Operand

engine, you in?

gram: docs

> ./packages/core/src/prosemirror/plugins/suggestionMode/index.ts

/**
* Suggestion Mode Plugin
*
* When active, intercepts all text insertions and deletions, wrapping
* them in tracked-change marks (insertion/deletion) instead of modifying
* the document directly.
*
* - Typed text is marked as insertion (green underline)
* - Deleted text is NOT removed — it's marked as deletion (red strikethrough)
* - Text already marked as insertion by the current author is deleted
* normally (retracting your own suggestion)
*
* The implementation is split across this directory for readability:
* - `state.ts` — plugin key, meta constants, shared types
* - `markAttrs.ts` — fresh-attr minting + projection
* - `adjacency.ts` — coalescing lookups (sibling, cross-block, cellMarker)
* - `handlers/` — keyboard / input handlers (delete, insert, structural)
* - `commands.ts` — toggle / set / isActive
* - this file — `createSuggestionModePlugin` + public re-exports
*/
import { isHistoryTransaction } from 'prosemirror-history';
import { Plugin } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import { findAdjacentRevisionForRange } from './adjacency';
import { applySuggestionInsert, markRangeAsInserted } from './handlers/insert';
import { handleSuggestionDelete } from './handlers/delete';
import { applySuggestionPaste } from './handlers/paste';
import {
handleSuggestionBackspaceAtStart,
handleSuggestionDeleteAtEnd,
handleSuggestionEnter,
} from './handlers/structural';
import { makeMarkAttrs } from './markAttrs';
import {
suggestionModeKey,
SUGGESTION_BYPASS_META,
SUGGESTION_META,
type SuggestionModeState,
} from './state';
/**
* Create the suggestion-mode ProseMirror plugin. **Must be mounted on
* the editor view for `setSuggestionMode` and `toggleSuggestionMode`
* to do anything** — both adapters (`@eigenpal/docx-editor-react`,
* `@eigenpal/docx-editor-vue`) auto-mount this inside the `DocxEditor`
* component, so consumers using the bundled components don't need to
* register it themselves.
*
* When active, typed text gets the `insertion` mark, deleted text gets
* the `deletion` mark (text stays in the doc; the painter strikes it
* through), Enter sets `pPrIns` on the originating paragraph, and
* Backspace at paragraph start sets `pPrDel` on the previous paragraph.
* Author + adjacent same-author marks coalesce into one tracked change.
*
* @param initialActive - Whether suggesting mode starts on. Default `false`.
* @param author - Author name attached to every minted revision. Default `'User'`.
*
* @example
* ```ts
* import { createSuggestionModePlugin } from '@eigenpal/docx-editor-core/prosemirror/plugins';
*
* const plugin = createSuggestionModePlugin(false, 'Jane');
* EditorState.create({ doc, plugins: [plugin, ...other] });
* ```
*/
export function createSuggestionModePlugin(initialActive = false, author = 'User'): Plugin {
return new Plugin({
key: suggestionModeKey,
state: {
init(): SuggestionModeState {
return { active: initialActive, author };
},
apply(tr, state): SuggestionModeState {
const meta = tr.getMeta(suggestionModeKey);
if (meta) {
return { ...state, ...meta };
}
return state;
},
},
props: {
handleDOMEvents: {
// Intercept text input at the DOM level. ProseMirror's handleTextInput
// is NOT reliably called when the hidden PM has complex mark structures
// (it requires the change to span exactly one text node). By handling
// beforeinput directly, we ensure suggestion mode always processes input.
beforeinput(view: EditorView, event: InputEvent) {
const pluginState = suggestionModeKey.getState(view.state);
if (!pluginState?.active) return false;
if (event.inputType === 'insertText' && event.data) {
event.preventDefault();
const { from, to } = view.state.selection;
return applySuggestionInsert(view, from, to, event.data, pluginState);
}
return false;
},
},
// Paste over a non-empty text selection is a replace: mark the old text
// deleted and the pasted text inserted, in one transaction, so both
// sides of the replacement are tracked. A plain paste at a collapsed
// cursor returns false and is marked by the append-transaction catch-all.
handlePaste(view, _event, slice) {
const pluginState = suggestionModeKey.getState(view.state);
if (!pluginState?.active) return false;
return applySuggestionPaste(view, slice, pluginState);
},
// Intercept Backspace and Delete to mark as deletion.
// Enter splits the paragraph and marks the FIRST paragraph's pPrIns.
handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
const pluginState = suggestionModeKey.getState(view.state);
if (!pluginState?.active) return false;
if (event.key === 'Enter' && !event.shiftKey) {
return handleSuggestionEnter(view.state, view.dispatch);
}
if (event.key === 'Backspace') {
// At paragraph start (non-first paragraph), track the pilcrow
// deletion instead of joining or deleting a character.
if (handleSuggestionBackspaceAtStart(view.state, view.dispatch)) return true;
return handleSuggestionDelete(view.state, view.dispatch, 'backward');
}
if (event.key === 'Delete') {
if (handleSuggestionDeleteAtEnd(view.state, view.dispatch)) return true;
return handleSuggestionDelete(view.state, view.dispatch, 'forward');
}
return false;
},
// Backup: also handle via PM's handleTextInput for simple cases
handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
const pluginState = suggestionModeKey.getState(view.state);
if (!pluginState?.active) return false;
return applySuggestionInsert(view, from, to, text, pluginState);
},
},
// Catch-all: mark any unhandled new content (e.g. paste) as insertion
appendTransaction(transactions, _oldState, newState) {
const pluginState = suggestionModeKey.getState(newState);
if (!pluginState?.active) return null;
// Skip the catch-all mark-as-insertion path for:
// - transactions we've already authored (`SUGGESTION_META`)
// - accept/reject command transactions (`SUGGESTION_BYPASS_META`)
// - undo/redo (`isHistoryTransaction`)
// The bypass meta is set by `resolveById` so structural-revision joins
// (e.g. `pPrIns` reject → `tr.split` + `tr.setNodeMarkup`) aren't
// re-wrapped as user insertions.
// History transactions are skipped because a tracked edit and its marks
// are recorded in one history event, so undo/redo already restores them;
// re-running the catch-all over the replayed steps mis-stamps an insertion
// on the boundary character. See eigenpal/docx-editor#633.
const userTr = transactions.find(
(tr) =>
tr.docChanged &&
!tr.getMeta(SUGGESTION_META) &&
!tr.getMeta(SUGGESTION_BYPASS_META) &&
!isHistoryTransaction(tr)
);
if (!userTr) return null;
const insertionType = newState.schema.marks.insertion;
if (!insertionType) return null;
const tr = newState.tr;
tr.setMeta(SUGGESTION_META, true);
const deletionType = newState.schema.marks.deletion;
userTr.steps.forEach((step) => {
const stepMap = step.getMap();
stepMap.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
if (newTo > newFrom) {
// Reuse an adjacent same-author insertion when present so a
// paste right after typed text coalesces into one tracked change.
const markAttrs =
findAdjacentRevisionForRange(
newState.doc,
newFrom,
newTo,
'insertion',
pluginState.author
) ?? makeMarkAttrs(pluginState);
// Mark text AND inline atoms (image, shape) that don't already
// carry a tracked-change mark, so a pasted/dropped picture becomes
// a tracked insertion just like typed text.
markRangeAsInserted(
tr,
newState.doc,
newFrom,
newTo,
insertionType,
deletionType,
markAttrs
);
}
});
});
return tr.steps.length > 0 ? tr : null;
},
});
}
// Public surface — keep import paths stable for external consumers.
export { suggestionModeKey, SUGGESTION_BYPASS_META } from './state';
export { toggleSuggestionMode, setSuggestionMode, isSuggestionModeActive } from './commands';