/** * ProseMirror plugin that intercepts user input in "suggesting" mode * and converts edits into track-changes marks instead of direct mutations. * * In suggesting mode: * - Typed text → inserted with `suggestionInsert` mark (green underline) * - Backspace/Delete → text NOT deleted, marked with `suggestionDelete` (red strikethrough) * - Select + type → old text gets `suggestionDelete`, new text gets `suggestionInsert` * - Paste → same as select + type * * Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather * than filterTransaction for reliability. */ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; import type { Slice } from '@tiptap/pm/model'; import type { Editor } from '@tiptap/core'; const pluginKey = new PluginKey('suggestion-plugin'); function makeSuggestionId(): string { return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } /** * Create the suggestion mode ProseMirror plugin. * @param getSuggesting - callback that returns current suggesting mode state * @param getAuthor - callback that returns { authorId, authorName } */ export function createSuggestionPlugin( getSuggesting: () => boolean, getAuthor: () => { authorId: string; authorName: string }, ): Plugin { return new Plugin({ key: pluginKey, props: { /** Intercept typed text — insert with suggestionInsert mark. */ handleTextInput(view: EditorView, from: number, to: number, text: string): boolean { if (!getSuggesting()) return false; const { state } = view; const { authorId, authorName } = getAuthor(); const suggestionId = makeSuggestionId(); const tr = state.tr; // If there's a selection (replacement), mark the selected text as deleted if (from !== to) { // Check if selected text is all suggestionInsert from the same author // → if so, just replace it (editing your own suggestion) const ownInsert = isOwnSuggestionInsert(state, from, to, authorId); if (ownInsert) { tr.replaceWith(from, to, state.schema.text(text, [ state.schema.marks.suggestionInsert.create({ suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(), }), ])); tr.setMeta('suggestion-applied', true); view.dispatch(tr); return true; } const deleteMark = state.schema.marks.suggestionDelete.create({ suggestionId, authorId, authorName, createdAt: Date.now(), }); tr.addMark(from, to, deleteMark); } // Insert the new text with insert mark after the (marked-for-deletion) text const insertPos = to; const insertMark = state.schema.marks.suggestionInsert.create({ suggestionId, authorId, authorName, createdAt: Date.now(), }); tr.insert(insertPos, state.schema.text(text, [insertMark])); tr.setMeta('suggestion-applied', true); // Place cursor after the inserted text tr.setSelection(TextSelection.create(tr.doc, insertPos + text.length)); view.dispatch(tr); return true; }, /** Intercept Backspace/Delete — mark text as deleted instead of removing. */ handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { if (!getSuggesting()) return false; if (event.key !== 'Backspace' && event.key !== 'Delete') return false; const { state } = view; const { from, to, empty } = state.selection; const { authorId, authorName } = getAuthor(); let deleteFrom = from; let deleteTo = to; if (empty) { if (event.key === 'Backspace') { if (from === 0) return true; deleteFrom = from - 1; deleteTo = from; } else { if (from >= state.doc.content.size) return true; deleteFrom = from; deleteTo = from + 1; } // Don't cross block boundaries const $from = state.doc.resolve(deleteFrom); const $to = state.doc.resolve(deleteTo); if ($from.parent !== $to.parent) return true; } // Backspace/delete on own suggestionInsert → actually remove it const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId); if (ownInsert) { const tr = state.tr; tr.delete(deleteFrom, deleteTo); tr.setMeta('suggestion-applied', true); view.dispatch(tr); return true; } // Already marked as suggestionDelete → skip past it if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) { const tr = state.tr; const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo; tr.setSelection(TextSelection.create(state.doc, newPos)); view.dispatch(tr); return true; } // Mark the text as deleted const suggestionId = makeSuggestionId(); const tr = state.tr; const deleteMark = state.schema.marks.suggestionDelete.create({ suggestionId, authorId, authorName, createdAt: Date.now(), }); tr.addMark(deleteFrom, deleteTo, deleteMark); tr.setMeta('suggestion-applied', true); if (event.key === 'Backspace') { tr.setSelection(TextSelection.create(tr.doc, deleteFrom)); } else { tr.setSelection(TextSelection.create(tr.doc, deleteTo)); } view.dispatch(tr); return true; }, /** Intercept paste — insert pasted text as a suggestion. */ handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean { if (!getSuggesting()) return false; const { state } = view; const { from, to } = state.selection; const { authorId, authorName } = getAuthor(); const suggestionId = makeSuggestionId(); const tr = state.tr; // Mark selected text as deleted if (from !== to) { const deleteMark = state.schema.marks.suggestionDelete.create({ suggestionId, authorId, authorName, createdAt: Date.now(), }); tr.addMark(from, to, deleteMark); } // Extract text from slice and insert with mark let pastedText = ''; slice.content.forEach((node: any) => { if (pastedText) pastedText += '\n'; pastedText += node.textContent; }); if (pastedText) { const insertPos = to; const insertMark = state.schema.marks.suggestionInsert.create({ suggestionId, authorId, authorName, createdAt: Date.now(), }); tr.insert(insertPos, state.schema.text(pastedText, [insertMark])); tr.setMeta('suggestion-applied', true); tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length)); } view.dispatch(tr); return true; }, }, }); } /** Check if the range is entirely covered by suggestionInsert marks from the same author. */ function isOwnSuggestionInsert( state: { doc: any; schema: any }, from: number, to: number, authorId: string, ): string | null { let allOwn = true; let foundId: string | null = null; state.doc.nodesBetween(from, to, (node: any) => { if (!node.isText) return; const mark = node.marks.find( (m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId ); if (!mark) { allOwn = false; } else if (!foundId) { foundId = mark.attrs.suggestionId; } }); return allOwn && foundId ? foundId : null; } /** Check if the range is already entirely covered by suggestionDelete marks. */ function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean { let allDeleted = true; state.doc.nodesBetween(from, to, (node: any) => { if (!node.isText) return; if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false; }); return allDeleted; } /** * Accept a suggestion: insertions stay, deletions are removed. */ export function acceptSuggestion(editor: Editor, suggestionId: string) { const { state } = editor; const { tr } = state; // Collect ranges first, apply from end→start to preserve positions const deleteRanges: [number, number][] = []; const insertRanges: [number, number, any][] = []; state.doc.descendants((node: any, pos: number) => { if (!node.isText) return; const deleteMark = node.marks.find( (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId ); if (deleteMark) { deleteRanges.push([pos, pos + node.nodeSize]); return; } const insertMark = node.marks.find( (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId ); if (insertMark) { insertRanges.push([pos, pos + node.nodeSize, insertMark]); } }); for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) { tr.delete(from, to); } for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) { tr.removeMark(from, to, mark); } if (tr.docChanged) { tr.setMeta('suggestion-accept', true); editor.view.dispatch(tr); } } /** * Reject a suggestion: insertions are removed, deletions stay. */ export function rejectSuggestion(editor: Editor, suggestionId: string) { const { state } = editor; const { tr } = state; const insertRanges: [number, number][] = []; const deleteRanges: [number, number, any][] = []; state.doc.descendants((node: any, pos: number) => { if (!node.isText) return; const insertMark = node.marks.find( (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId ); if (insertMark) { insertRanges.push([pos, pos + node.nodeSize]); return; } const deleteMark = node.marks.find( (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId ); if (deleteMark) { deleteRanges.push([pos, pos + node.nodeSize, deleteMark]); } }); for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) { tr.delete(from, to); } for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) { tr.removeMark(from, to, mark); } if (tr.docChanged) { tr.setMeta('suggestion-reject', true); editor.view.dispatch(tr); } } /** Accept all suggestions in the document. */ export function acceptAllSuggestions(editor: Editor) { const ids = new Set(); editor.state.doc.descendants((node: any) => { if (!node.isText) return; for (const mark of node.marks) { if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { ids.add(mark.attrs.suggestionId); } } }); for (const id of ids) acceptSuggestion(editor, id); } /** Reject all suggestions in the document. */ export function rejectAllSuggestions(editor: Editor) { const ids = new Set(); editor.state.doc.descendants((node: any) => { if (!node.isText) return; for (const mark of node.marks) { if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { ids.add(mark.attrs.suggestionId); } } }); for (const id of ids) rejectSuggestion(editor, id); }