336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
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<string>();
|
|
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);
|
|
}
|