diff --git a/apps/client/src/features/editor/extensions/custom-typography.ts b/apps/client/src/features/editor/extensions/custom-typography.ts new file mode 100644 index 00000000..33186aa3 --- /dev/null +++ b/apps/client/src/features/editor/extensions/custom-typography.ts @@ -0,0 +1,186 @@ +import { InputRule } from "@tiptap/core"; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; +import { Typography } from "@tiptap/extension-typography"; +import { isChangeOrigin } from "@tiptap/extension-collaboration"; +import { ySyncPluginKey } from "@tiptap/y-tiptap"; + +// Region restored by the latest undo — while it is intact, typography +// input rules overlapping it must not fire again. +interface UndoGuardRange { + from: number; + to: number; +} + +const undoGuardKey = new PluginKey( + "typographyUndoGuard", +); + +// prosemirror-history does not export its plugin key, so template-editor +// undo/redo is detected via the stable stringified key. Only one +// PluginKey("history") exists in the dependency tree, so "history$" is stable. +const HISTORY_META = "history$"; + +const isUndoRedoTransaction = (tr: Transaction): boolean => { + if (tr.getMeta(HISTORY_META)) { + return true; + } + // Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not + // a fragile stringified key), which y-tiptap sets on Y.UndoManager changes. + const ySyncMeta = tr.getMeta(ySyncPluginKey) as + | { isUndoRedoOperation?: boolean } + | undefined; + return !!ySyncMeta?.isUndoRedoOperation; +}; + +interface DocChange { + from: number; + oldTo: number; + newTo: number; +} + +// Compute the minimal changed region between two docs. yjs undo/redo (and any +// remote change) arrives as a whole-document replace step, so the transaction +// step maps are useless — diff the docs to recover the real minimal change. +// Returns null when the docs are identical. +const findChangedRange = ( + oldState: EditorState, + newState: EditorState, +): DocChange | null => { + const start = oldState.doc.content.findDiffStart(newState.doc.content); + const end = oldState.doc.content.findDiffEnd(newState.doc.content); + if (start == null || end == null) { + return null; + } + let { a: oldTo, b: newTo } = end; + // Normalize overlapping diff bounds (repeated-content edge case). + if (oldTo < start) { + newTo += start - oldTo; + oldTo = start; + } + return { from: start, oldTo, newTo }; +}; + +// Map an armed guard range across a single document change described by a diff. +// Returns null when the change touches the guarded text itself (the restored +// substitution was edited, so the guard must be released). +const mapRangeThroughChange = ( + range: UndoGuardRange, + change: DocChange, +): UndoGuardRange | null => { + // Strict intersection: an edit exactly at a guard boundary (e.g. the user + // typing the suppressed space right after the restored text, or deleting it) + // must NOT drop the guard. + if (change.from < range.to && change.oldTo > range.from) { + return null; + } + // Change fully before the guard: shift the guard by the length delta. + if (change.oldTo <= range.from) { + const delta = change.newTo - change.oldTo; + return { from: range.from + delta, to: range.to + delta }; + } + // Change fully after the guard: positions are unaffected. + return range; +}; + +// Detect history/remote transactions that may arrive as a whole-document +// replace step: prosemirror-history undo/redo, or any yjs remote-origin change +// (isChangeOrigin is the canonical predicate already used across the app). +const isHistoryOrRemoteTransaction = (tr: Transaction): boolean => + !!tr.getMeta(HISTORY_META) || isChangeOrigin(tr); + +export const CustomTypography = Typography.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + key: undoGuardKey, + state: { + init: () => null, + apply(tr, prev, oldState, newState): UndoGuardRange | null { + if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) { + const change = findChangedRange(oldState, newState); + if (change == null) { + // Attribute-only or otherwise content-neutral change: keep the + // guard. + return prev; + } + // Arm the guard only when the LOCAL user's undo/redo REPLACED text + // (deleted + inserted) — the signature of reverting an input-rule + // substitution. Pure insertions/deletions and remote peer edits + // must not arm it. + if ( + isUndoRedoTransaction(tr) && + change.oldTo > change.from && + change.newTo > change.from + ) { + return { from: change.from, to: change.newTo }; + } + // Non-arming history/remote change: map the existing guard through + // the real diff instead of the (whole-document) step map. + if (!prev) { + return null; + } + return mapRangeThroughChange(prev, change); + } + if (!prev) { + return null; + } + if (!tr.docChanged) { + return prev; + } + // Ordinary local edit: minimal step maps are accurate and cheap. + let range: UndoGuardRange | null = prev; + for (const stepMap of tr.mapping.maps) { + const { from: rangeFrom, to: rangeTo } = range; + let touched = false; + stepMap.forEach((fromA, toA) => { + if (fromA < rangeTo && toA > rangeFrom) { + touched = true; + } + }); + if (touched) { + range = null; + break; + } + range = { + from: stepMap.map(rangeFrom, 1), + to: stepMap.map(rangeTo, -1), + }; + } + return range && range.to > range.from ? range : null; + }, + }, + }), + ]; + }, + + addInputRules() { + // Wrap every typography rule: skip it when its match overlaps the text + // just restored by undo, so an undone substitution is not re-applied. + return (this.parent?.() ?? []).map( + (rule) => + new InputRule({ + find: rule.find, + undoable: rule.undoable, + handler: (props) => { + const guard = undoGuardKey.getState(props.state); + if ( + guard && + props.range.from < guard.to && + props.range.to > guard.from + ) { + // Returning null skips this rule and lets the typed character + // be inserted as plain text. + return null; + } + return rule.handler(props); + }, + }), + ); + }, +}); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index d09821d0..4232307c 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list"; import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions"; import { Superscript } from "@tiptap/extension-superscript"; import SubScript from "@tiptap/extension-subscript"; -import { Typography } from "@tiptap/extension-typography"; +import { CustomTypography } from "./custom-typography"; import { TextStyle } from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import { Youtube } from "@tiptap/extension-youtube"; @@ -245,7 +245,9 @@ export const mainExtensions = [ return ReactMarkViewRenderer(SpoilerView); }, }), - Typography, + // Typography with an undo guard: does not re-apply a substitution the user + // just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space). + CustomTypography, TrailingNode, GlobalDragHandle.configure({ customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],