rspace-online/modules/rnotes/components/suggestion-plugin.ts

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