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