dorfteich/apps/web/src/editor/marks.ts
Claude Sonnet 5 b5cc4c34b8
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
Add link UX: edit URL and open in new tab (#29)
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
2026-07-08 11:50:34 +02:00

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;
},
},
}),
];
},
});