All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 9s
A bubble menu on link selection offers "edit URL", "open in new tab", and "remove link"; Mod-k opens the same editor for the current selection (creating a link if there isn't one yet), and the toolbar button does the same. Invalid protocols (e.g. javascript:) show a localized inline error instead of silently no-oping. Pasting a URL over selected text links it instead of replacing the text. Links always render with target="_blank" so read mode opens them in a new tab by default; edit mode suppresses the resulting navigate-on- click (Mod-click still follows it), since a plain click there should place the cursor instead. Closes #29
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
import { isAllowedLinkHref } from '@dorfteich/shared';
|
|
import { Mark, markInputRule, markPasteRule } from '@tiptap/core';
|
|
import { Plugin } from '@tiptap/pm/state';
|
|
|
|
import { attributesFromSpec, markSpec } from './spec-utils';
|
|
|
|
declare module '@tiptap/core' {
|
|
interface Commands<ReturnType> {
|
|
documentMarks: {
|
|
toggleBold: () => ReturnType;
|
|
toggleItalic: () => ReturnType;
|
|
toggleCode: () => ReturnType;
|
|
toggleStrikethrough: () => ReturnType;
|
|
setLink: (href: string) => ReturnType;
|
|
unsetLink: () => ReturnType;
|
|
};
|
|
}
|
|
interface Storage {
|
|
link: { editRequestedAt: number };
|
|
}
|
|
}
|
|
|
|
const boldSpec = markSpec('bold');
|
|
export const Bold = Mark.create({
|
|
name: 'bold',
|
|
parseHTML: () => boldSpec.parseDOM,
|
|
renderHTML: ({ mark }) => boldSpec.toDOM!(mark, true),
|
|
addCommands() {
|
|
return {
|
|
toggleBold:
|
|
() =>
|
|
({ commands }) =>
|
|
commands.toggleMark(this.name),
|
|
};
|
|
},
|
|
addKeyboardShortcuts() {
|
|
return { 'Mod-b': () => this.editor.commands.toggleBold() };
|
|
},
|
|
addInputRules() {
|
|
return [markInputRule({ find: /(?:^|\s)(\*\*([^*]+)\*\*)$/, type: this.type })];
|
|
},
|
|
addPasteRules() {
|
|
return [markPasteRule({ find: /(?:^|\s)(\*\*([^*]+)\*\*)/g, type: this.type })];
|
|
},
|
|
});
|
|
|
|
const italicSpec = markSpec('italic');
|
|
export const Italic = Mark.create({
|
|
name: 'italic',
|
|
parseHTML: () => italicSpec.parseDOM,
|
|
renderHTML: ({ mark }) => italicSpec.toDOM!(mark, true),
|
|
addCommands() {
|
|
return {
|
|
toggleItalic:
|
|
() =>
|
|
({ commands }) =>
|
|
commands.toggleMark(this.name),
|
|
};
|
|
},
|
|
addKeyboardShortcuts() {
|
|
return { 'Mod-i': () => this.editor.commands.toggleItalic() };
|
|
},
|
|
addInputRules() {
|
|
return [markInputRule({ find: /(?:^|\s)(_([^_]+)_)$/, type: this.type })];
|
|
},
|
|
});
|
|
|
|
const codeSpec = markSpec('code');
|
|
export const CodeMark = Mark.create({
|
|
name: 'code',
|
|
excludes: '_',
|
|
code: true,
|
|
parseHTML: () => codeSpec.parseDOM,
|
|
renderHTML: ({ mark }) => codeSpec.toDOM!(mark, true),
|
|
addCommands() {
|
|
return {
|
|
toggleCode:
|
|
() =>
|
|
({ commands }) =>
|
|
commands.toggleMark(this.name),
|
|
};
|
|
},
|
|
addKeyboardShortcuts() {
|
|
return { 'Mod-e': () => this.editor.commands.toggleCode() };
|
|
},
|
|
addInputRules() {
|
|
return [markInputRule({ find: /(?:^|\s)(`([^`]+)`)$/, type: this.type })];
|
|
},
|
|
});
|
|
|
|
const strikethroughSpec = markSpec('strikethrough');
|
|
export const Strikethrough = Mark.create({
|
|
name: 'strikethrough',
|
|
parseHTML: () => strikethroughSpec.parseDOM,
|
|
renderHTML: ({ mark }) => strikethroughSpec.toDOM!(mark, true),
|
|
addCommands() {
|
|
return {
|
|
toggleStrikethrough:
|
|
() =>
|
|
({ commands }) =>
|
|
commands.toggleMark(this.name),
|
|
};
|
|
},
|
|
addKeyboardShortcuts() {
|
|
return { 'Mod-Shift-s': () => this.editor.commands.toggleStrikethrough() };
|
|
},
|
|
addInputRules() {
|
|
return [markInputRule({ find: /(?:^|\s)(~~([^~]+)~~)$/, type: this.type })];
|
|
},
|
|
});
|
|
|
|
/** Link mark + editing UX (issue #29): a bubble menu (`LinkMenu.tsx`) offers
|
|
* "edit URL"/"open in new tab"/"remove link" on selection; `Mod-k` opens the
|
|
* same menu for the current selection via the `editRequestedAt` storage
|
|
* ping (`LinkMenu` reacts to it through `useEditorState`, since a keyboard
|
|
* shortcut here has no direct handle on that component's React state).
|
|
* Reading (non-editable) documents always render links with
|
|
* `target="_blank"` (schema.ts `toDOM`), so "open in new tab" there is just
|
|
* default anchor behavior — no menu needed outside edit mode. */
|
|
const linkSpec = markSpec('link');
|
|
export const LinkMark = Mark.create({
|
|
name: 'link',
|
|
inclusive: false,
|
|
addAttributes() {
|
|
return attributesFromSpec(linkSpec);
|
|
},
|
|
parseHTML: () => linkSpec.parseDOM,
|
|
renderHTML: ({ mark }) => linkSpec.toDOM!(mark, true),
|
|
addStorage() {
|
|
return { editRequestedAt: 0 };
|
|
},
|
|
addCommands() {
|
|
return {
|
|
setLink:
|
|
(href: string) =>
|
|
({ commands }) =>
|
|
isAllowedLinkHref(href) && commands.setMark(this.name, { href }),
|
|
unsetLink:
|
|
() =>
|
|
({ commands }) =>
|
|
commands.unsetMark(this.name),
|
|
};
|
|
},
|
|
addKeyboardShortcuts() {
|
|
return {
|
|
'Mod-k': () => {
|
|
if (this.editor.state.selection.empty) return false;
|
|
this.storage.editRequestedAt = Date.now();
|
|
// An empty transaction still fires the editor's 'transaction' event,
|
|
// which is what `useEditorState` (LinkMenu.tsx) reacts to — plain
|
|
// storage mutations alone would otherwise go unnoticed by React.
|
|
this.editor.view.dispatch(this.editor.state.tr);
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
addProseMirrorPlugins() {
|
|
const linkType = this.type;
|
|
return [
|
|
new Plugin({
|
|
props: {
|
|
handleDOMEvents: {
|
|
// Links always carry target="_blank" (schema.ts, so read mode
|
|
// opens them in a new tab by default) — but a plain click on
|
|
// one while editing would then actually navigate instead of
|
|
// placing the cursor. Suppress that; Mod-click still follows
|
|
// the link, mirroring the bubble menu's "open in new tab".
|
|
click(view, event) {
|
|
if (!view.editable || event.metaKey || event.ctrlKey) return false;
|
|
if (!(event.target instanceof HTMLElement) || !event.target.closest('a'))
|
|
return false;
|
|
event.preventDefault();
|
|
return true;
|
|
},
|
|
},
|
|
// Pasting a URL over a text selection links the selected text
|
|
// instead of replacing it (issue #29 acceptance criterion).
|
|
handlePaste(view, event) {
|
|
const { selection } = view.state;
|
|
if (selection.empty) return false;
|
|
const text = event.clipboardData?.getData('text/plain')?.trim();
|
|
if (!text || !isAllowedLinkHref(text)) return false;
|
|
view.dispatch(
|
|
view.state.tr.addMark(selection.from, selection.to, linkType.create({ href: text })),
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|