diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 4232307c..c2eb30fe 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -45,6 +45,7 @@ import { TiptapPdf, PageBreak, SearchAndReplace, + MultiCursor, Mention, TableDndExtension, TableHandleCommandsExtension, @@ -447,6 +448,10 @@ export const mainExtensions = [ }; }, }).configure(), + // Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into + // all at once. Does not depend on collaboration, so it lives in mainExtensions + // (available in both the plain and collaborative editors). + MultiCursor, Columns, Column, AutoJoiner.configure({ diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 030c0180..6db59aee 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -1,5 +1,6 @@ @import "./core.css"; @import "./collaboration.css"; +@import "./multi-cursor.css"; @import "./task-list.css"; @import "./placeholder.css"; @import "./drag-handle.css"; diff --git a/apps/client/src/features/editor/styles/multi-cursor.css b/apps/client/src/features/editor/styles/multi-cursor.css new file mode 100644 index 00000000..fa0b712e --- /dev/null +++ b/apps/client/src/features/editor/styles/multi-cursor.css @@ -0,0 +1,60 @@ +/* + * Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration + * carets (collaboration.css) so a user never confuses their own multi-cursors + * with a co-author's caret: solid accent-blue carets + a translucent blue + * range highlight, versus the thin dark collaboration caret with a name label. + */ + +/* A secondary caret rendered as a Decoration.widget at each cursor position. */ +.multi-cursor__caret { + position: relative; + display: inline-block; + width: 0; + height: 1em; + vertical-align: text-bottom; + pointer-events: none; +} + +.multi-cursor__caret::after { + content: ""; + position: absolute; + left: -1px; + top: 0; + bottom: 0; + width: 2px; + background: #2b6cb0; + animation: multi-cursor-blink 1s steps(1) infinite; +} + +/* Optional label class reserved for future per-cursor annotations. */ +.multi-cursor__label { + position: absolute; + top: -1.4em; + left: -1px; + font-size: 0.7rem; + line-height: normal; + padding: 0.05rem 0.25rem; + border-radius: 3px 3px 3px 0; + background: #2b6cb0; + color: #fff; + white-space: nowrap; + user-select: none; + pointer-events: none; +} + +/* Inline highlight for a multi-cursor RANGE (from < to). */ +.multi-cursor__selection { + background: rgba(43, 108, 176, 0.28); + border-radius: 2px; +} + +@keyframes multi-cursor-blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index a2f1d0eb..e197f78b 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -20,6 +20,7 @@ export * from "./lib/html-embed/html-embed"; export * from "./lib/mention"; export * from "./lib/markdown"; export * from "./lib/search-and-replace"; +export * from "./lib/multi-cursor"; export * from "./lib/embed-provider"; export * from "./lib/subpages"; export * from "./lib/transclusion"; diff --git a/packages/editor-ext/src/lib/multi-cursor/index.ts b/packages/editor-ext/src/lib/multi-cursor/index.ts new file mode 100644 index 00000000..98b931f3 --- /dev/null +++ b/packages/editor-ext/src/lib/multi-cursor/index.ts @@ -0,0 +1,3 @@ +import { MultiCursor } from "./multi-cursor"; +export * from "./multi-cursor"; +export default MultiCursor; diff --git a/packages/editor-ext/src/lib/multi-cursor/multi-cursor.test.ts b/packages/editor-ext/src/lib/multi-cursor/multi-cursor.test.ts new file mode 100644 index 00000000..20d55ac5 --- /dev/null +++ b/packages/editor-ext/src/lib/multi-cursor/multi-cursor.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Bold } from "@tiptap/extension-bold"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor"; +import { findOccurrences } from "../search-and-replace/find-occurrences"; + +const extensions = [Document, Paragraph, Text, Bold, MultiCursor]; + +function makeEditor(content?: any) { + return new Editor({ + extensions, + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + }); +} + +function doc(...paragraphs: string[]) { + return { + type: "doc", + content: paragraphs.map((text) => ({ + type: "paragraph", + content: text ? [{ type: "text", text }] : [], + })), + }; +} + +function paraTexts(d: PMNode): string[] { + const out: string[] = []; + d.forEach((node) => { + if (node.type.name === "paragraph") out.push(node.textContent); + }); + return out; +} + +function cursors(editor: Editor) { + return multiCursorPluginKey.getState(editor.state)!.cursors; +} + +// Simulate typing a character through the real handleTextInput routing (the +// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling +// the view's input handler directly. +function typeText(editor: Editor, text: string) { + const { from, to } = editor.state.selection; + // props.handleTextInput is what ProseMirror calls on beforeinput/keypress. + const handled = editor.view.someProp( + "handleTextInput", + (fn) => fn(editor.view, from, to, text) || false, + ); + if (!handled) { + // Fall back to a normal insertion (no active multi-cursor set). + editor.view.dispatch(editor.state.tr.insertText(text, from, to)); + } +} + +function pressKey(editor: Editor, key: string) { + editor.view.someProp("handleKeyDown", (fn) => + fn(editor.view, new KeyboardEvent("keydown", { key })), + ); +} + +describe("multi-cursor: selectAllOccurrences", () => { + it("finds EVERY occurrence of a repeated word under the cursor", () => { + const editor = makeEditor(doc("foo bar foo baz foo")); + // Cursor inside the first "foo". + editor.commands.setTextSelection(2); + expect(editor.commands.selectAllOccurrences()).toBe(true); + + const cs = cursors(editor); + expect(cs.length).toBe(3); + // Every cursor spans a "foo". + for (const c of cs) { + expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo"); + } + editor.destroy(); + }); + + it("uses the current non-empty selection as the term", () => { + const editor = makeEditor(doc("ab abc ab abcd ab")); + // Select the first "ab". + editor.commands.setTextSelection({ from: 1, to: 3 }); + expect(editor.state.doc.textBetween(1, 3)).toBe("ab"); + editor.commands.selectAllOccurrences(); + // Literal substring match (selection is not whole-word), so every "ab" + // including those inside "abc"/"abcd" is matched: 5 total. + const cs = cursors(editor); + expect(cs.length).toBe(5); + editor.destroy(); + }); + + it("whole-word matching from a word cursor does not match substrings", () => { + const editor = makeEditor(doc("cat category cat scatter cat")); + editor.commands.setTextSelection(2); // inside first "cat" + editor.commands.selectAllOccurrences(); + // Only the three standalone "cat" words, not "category"/"scatter". + expect(cursors(editor).length).toBe(3); + editor.destroy(); + }); +}); + +describe("multi-cursor: mass typing (single transaction)", () => { + it("types text into N carets at once", () => { + const editor = makeEditor(doc("foo foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(3); + + // Typing replaces each selected "foo" with "X". + typeText(editor, "X"); + expect(paraTexts(editor.state.doc)).toEqual(["X X X"]); + + // The cursors are now carets right after each inserted "X". + const cs = cursors(editor); + expect(cs.length).toBe(3); + for (const c of cs) expect(c.from).toBe(c.to); + editor.destroy(); + }); + + it("continues typing at the resulting carets (append semantics)", () => { + const editor = makeEditor(doc("a a a")); + editor.commands.setTextSelection(1); + editor.commands.selectAllOccurrences(); + typeText(editor, "b"); // each "a" -> "b" + typeText(editor, "c"); // append at each caret -> "bc" + expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]); + editor.destroy(); + }); + + it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => { + // "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in + // ONE transaction (history groups by transaction). @tiptap/extension-history + // is not a dependency here, so rather than exercise undo we assert the + // property that guarantees it: typing into N cursors is exactly ONE dispatch. + const editor = makeEditor(doc("foo foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(3); + + const orig = editor.view.dispatch.bind(editor.view); + let dispatches = 0; + editor.view.dispatch = (tr) => { + dispatches += 1; + return orig(tr); + }; + typeText(editor, "Z"); + editor.view.dispatch = orig; + + expect(dispatches).toBe(1); // all three edits share one transaction + expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]); + editor.destroy(); + }); + + it("off-by-one guard: reverse-order iteration keeps every position valid", () => { + // If the mass edit iterated FORWARD, inserting at an earlier cursor would + // shift every later cursor and corrupt the result. Different-length + // replacement makes such a bug visible. + const editor = makeEditor(doc("x x x x")); + editor.commands.setTextSelection(1); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(4); + typeText(editor, "LONG"); + expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]); + editor.destroy(); + }); +}); + +describe("multi-cursor: mass Backspace / Delete", () => { + it("Backspace removes one char before each caret", () => { + const editor = makeEditor(doc("foo foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + // Collapse selections to carets at the END of each "foo" by typing then + // removing is complex; instead type to convert ranges into carets first. + typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab" + expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]); + pressKey(editor, "Backspace"); // remove the trailing "b" at each caret + expect(paraTexts(editor.state.doc)).toEqual(["a a a"]); + editor.destroy(); + }); + + it("Delete removes one char after each caret", () => { + const editor = makeEditor(doc("fooX fooX")); + // Literal (selection) match of "foo" -> both occurrences inside "fooX". + editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo" + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X" + expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]); + pressKey(editor, "Delete"); // remove the "X" after each caret + expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]); + editor.destroy(); + }); + + it("Backspace at a block-start caret is a no-op for that cursor", () => { + const editor = makeEditor(doc("ab", "ab")); + // Select both "ab" then convert to carets at start by replacing with "". + editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab" + editor.commands.selectAllOccurrences(); + // Move carets to block start: type "" is not possible; instead delete range. + pressKey(editor, "Backspace"); // deletes each selected "ab" + expect(paraTexts(editor.state.doc)).toEqual(["", ""]); + // Carets are now at each block start; another Backspace must not throw and + // must not merge blocks (still two empty paragraphs). + pressKey(editor, "Backspace"); + expect(paraTexts(editor.state.doc)).toEqual(["", ""]); + editor.destroy(); + }); +}); + +describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => { + it("first press selects the current word, next press adds the next", () => { + const editor = makeEditor(doc("go go go")); + editor.commands.setTextSelection(2); // inside first "go" + editor.commands.addNextOccurrence(); + expect(cursors(editor).length).toBe(1); + editor.commands.addNextOccurrence(); + expect(cursors(editor).length).toBe(2); + editor.commands.addNextOccurrence(); + expect(cursors(editor).length).toBe(3); + // Nothing left to add — stays at 3. + editor.commands.addNextOccurrence(); + expect(cursors(editor).length).toBe(3); + for (const c of cursors(editor)) { + expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go"); + } + editor.destroy(); + }); +}); + +describe("multi-cursor: position remapping", () => { + it("remaps cursors after a LOCAL edit before them", () => { + const editor = makeEditor(doc("foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + const before = cursors(editor).map((c) => ({ ...c })); + + // Insert unrelated text at the very start (pos 1), shifting everything +5. + editor.view.dispatch(editor.state.tr.insertText("HELLO", 1)); + + const after = cursors(editor); + expect(after.length).toBe(before.length); + for (let i = 0; i < after.length; i += 1) { + expect(after[i].from).toBe(before[i].from + 5); + expect(after[i].to).toBe(before[i].to + 5); + // And they still point at "foo". + expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe( + "foo", + ); + } + editor.destroy(); + }); + + it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => { + const editor = makeEditor(doc("foo bar foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + const before = cursors(editor).map((c) => ({ ...c })); + expect(before.length).toBe(2); + + // y-prosemirror applies remote changes as ordinary transactions. Emulate a + // remote insertion between the two "foo"s (inside "bar", pos 6) with a tr + // that carries NO multi-cursor meta — exactly like a collaborator's edit. + const tr = editor.state.tr.insertText("ZZ", 6); + editor.view.dispatch(tr); + + const after = cursors(editor); + // The first "foo" (before the insertion) is unchanged; the second shifts +2. + expect(after[0].from).toBe(before[0].from); + expect(after[1].from).toBe(before[1].from + 2); + for (const c of after) { + expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo"); + } + editor.destroy(); + }); + + it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => { + // The riskiest remap path: a collaborator deletes the very text one cursor + // spans. Both edges map with assoc +1 and there is no drop logic, so the + // deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the + // deletion point (from === to) and STAYS in the set — it is not removed. + // Untouched cursors keep spanning their occurrence. Pinning this makes the + // collapse-not-drop choice explicit (review #372 F2). + const editor = makeEditor(doc("foo bar foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + const before = cursors(editor).map((c) => ({ ...c })); + expect(before.length).toBe(2); + + // Remote (no multi-cursor meta) delete of the FIRST "foo" range. + const tr = editor.state.tr.delete(before[0].from, before[0].to); + editor.view.dispatch(tr); + + const after = cursors(editor); + // Still two cursors — the deleted-over one is NOT dropped. + expect(after.length).toBe(2); + // The first collapsed to a caret at the deletion point. + expect(after[0].from).toBe(after[0].to); + expect(after[0].from).toBe(before[0].from); + // The second still spans "foo" (shifted left by the 3 removed chars). + expect(after[1].from).toBe(before[1].from - 3); + expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo"); + // Sanity: the document now reads " bar foo". + expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]); + editor.destroy(); + }); +}); + +describe("multi-cursor: collapse / exit", () => { + it("exitMultiCursor clears the set", () => { + const editor = makeEditor(doc("foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + editor.commands.exitMultiCursor(); + expect(cursors(editor).length).toBe(0); + editor.destroy(); + }); + + it("an arrow key collapses the set", () => { + const editor = makeEditor(doc("foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + pressKey(editor, "ArrowRight"); + expect(cursors(editor).length).toBe(0); + editor.destroy(); + }); +}); + +describe("multi-cursor: collapse on composition / mousedown", () => { + // Invoke a plugin handleDOMEvents handler through the real prop plumbing. + function fireDOM(editor: Editor, name: string): void { + editor.view.someProp("handleDOMEvents", (handlers: any) => { + const h = handlers && handlers[name]; + if (h) h(editor.view, new Event(name)); + return false; + }); + } + + it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => { + const editor = makeEditor(doc("foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + fireDOM(editor, "compositionstart"); + expect(cursors(editor).length).toBe(0); + editor.destroy(); + }); + + it("collapses the set on a plain mousedown (VS Code behaviour)", () => { + const editor = makeEditor(doc("foo foo")); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + fireDOM(editor, "mousedown"); + expect(cursors(editor).length).toBe(0); + editor.destroy(); + }); +}); + +describe("multi-cursor: hard cap", () => { + it("never activates more than MAX_CURSORS cursors", () => { + const many = new Array(MAX_CURSORS + 20).fill("w").join(" "); + const editor = makeEditor(doc(many)); + editor.commands.setTextSelection(2); + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(MAX_CURSORS); + editor.destroy(); + }); +}); + +describe("multi-cursor: marks are carried across a mass edit", () => { + it("preserves marks spanning each replaced range", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a " }, + { type: "text", marks: [{ type: "bold" }], text: "key" }, + { type: "text", text: " b " }, + { type: "text", marks: [{ type: "bold" }], text: "key" }, + ], + }, + ], + }); + editor.commands.setTextSelection(3); // inside first bold "key" + editor.commands.selectAllOccurrences(); + expect(cursors(editor).length).toBe(2); + typeText(editor, "NEW"); + + // Both replacements keep the bold mark. + let boldRuns = 0; + editor.state.doc.descendants((node) => { + if ( + node.isText && + node.text === "NEW" && + node.marks.some((m) => m.type.name === "bold") + ) { + boldRuns += 1; + } + }); + expect(boldRuns).toBe(2); + editor.destroy(); + }); +}); + +// The extracted find-occurrences util must return the SAME occurrences that the +// old inline walk produced (and that search-and-replace still relies on). +describe("find-occurrences util", () => { + it("finds all matches of a literal regex across text nodes", () => { + const editor = makeEditor(doc("foo foofoo foo")); + const results = findOccurrences(editor.state.doc, /foo/gu); + // 4 occurrences: two standalone + two inside "foofoo". + expect(results.length).toBe(4); + for (const r of results) { + expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo"); + } + editor.destroy(); + }); + + it("ignores whitespace-only matches and empty regex", () => { + const editor = makeEditor(doc("a b c")); + expect(findOccurrences(editor.state.doc, null as any).length).toBe(0); + // A whitespace regex yields no results (matches are trimmed away). + expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0); + editor.destroy(); + }); + + it("finds a match spanning two differently-marked contiguous text nodes", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "wo" }, + { type: "text", marks: [{ type: "bold" }], text: "rd" }, + ], + }, + ], + }); + const results = findOccurrences(editor.state.doc, /word/gu); + expect(results.length).toBe(1); + expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe( + "word", + ); + editor.destroy(); + }); +}); diff --git a/packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts b/packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts new file mode 100644 index 00000000..dc251843 --- /dev/null +++ b/packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts @@ -0,0 +1,545 @@ +import { Extension, Range } from "@tiptap/core"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; +import { + Plugin, + PluginKey, + TextSelection, + type EditorState, +} from "@tiptap/pm/state"; +import { Mark } from "@tiptap/pm/model"; +import { findOccurrences } from "../search-and-replace/find-occurrences"; + +/** + * Multi-cursor editing — MVP (issue #196, "Variant A"). + * + * VS Code-style multi-cursor limited to "select all occurrences of a word (or + * the current selection) and type into all of them at once", built ON TOP OF + * the search-and-replace mass-transaction machinery: + * + * - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the + * current non-empty selection) -> ALL its occurrences become active cursors. + * - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term. + * - Typing / Backspace / Delete apply to EVERY active cursor in ONE + * transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit). + * - Esc (exitMultiCursor): collapse back to a single cursor. + * + * The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in + * search-and-replace.ts: we iterate cursors from the END of the document to the + * START so an earlier edit never invalidates a later position, carrying the + * marks that span each range. + * + * CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT + * built here): + * - Alt+Click arbitrary carets and Alt+drag column selection. + * - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line". + * - Simultaneous IME / composition input into multiple positions — on + * `compositionstart` we collapse back to a single cursor. + * - Cursors spanning different schema nodes in one edit. + * + * NOT out of scope, but worth stating precisely: there is NO schema-aware or + * structural cursor. Occurrences are found by a plain text-node walk + * (`findOccurrences`), so a term that appears inside a table cell, code block or + * callout DOES get a cursor there and IS edited — as plain text, exactly like + * `replaceAll`. There is no special table/code handling; the per-cursor try/catch + * only SKIPS a cursor whose edit would violate the schema (never applied + * half-way), it does not exclude those node types from matching. + */ + +interface MultiCursorState { + // Each active cursor: a caret when from === to, a range when from < to. + cursors: Range[]; +} + +export const multiCursorPluginKey = new PluginKey( + "multiCursor", +); + +// Hard safety cap on simultaneously-active cursors — stop adding past it. +export const MAX_CURSORS = 100; + +export interface MultiCursorStorage { + // Whether the active term matches whole words only. Set to true when the set + // was seeded from a bare cursor (word under caret), false when seeded from an + // explicit selection (literal substring match, like VS Code). Remembered so + // addNextOccurrence keeps matching the same way as selectAllOccurrences. + wholeWord: boolean; +} + +declare module "@tiptap/core" { + interface Storage { + multiCursor: MultiCursorStorage; + } + interface Commands { + multiCursor: { + /** Select all occurrences of the word/selection as active cursors. */ + selectAllOccurrences: () => ReturnType; + /** Add the next occurrence of the current term to the cursor set. */ + addNextOccurrence: () => ReturnType; + /** Collapse the multi-cursor set back to a single cursor. */ + exitMultiCursor: () => ReturnType; + }; + } +} + +// --------------------------------------------------------------------------- +// Term helpers +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// A "word" is a run of letters/numbers/underscore; those get whole-word +// matching (\b…\b) so a term never matches inside a larger word. Anything else +// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code. +function isWordTerm(s: string): boolean { + return /^[\p{L}\p{N}_]+$/u.test(s); +} + +// wholeWord uses \b…\b so the term never matches inside a larger word; it only +// applies to word-like terms (a term containing punctuation cannot be +// whole-word-bounded meaningfully). Otherwise the term is matched literally. +function buildTermRegex(term: string, wholeWord: boolean): RegExp { + const esc = escapeRegExp(term); + return wholeWord && isWordTerm(term) + ? new RegExp(`\\b${esc}\\b`, "gu") + : new RegExp(esc, "gu"); +} + +// Word under a position: returns the exact { from, to } range and its text, or +// null if the position is not inside a word in a textblock. +function getWordAt( + state: EditorState, + pos: number, +): { from: number; to: number; text: string } | null { + const $pos = state.doc.resolve(pos); + const parent = $pos.parent; + if (!parent.isTextblock) return null; + + const text = parent.textContent; + const offset = $pos.parentOffset; + const start = $pos.start(); + const wordRe = /[\p{L}\p{N}_]+/gu; + + let m: RegExpExecArray | null; + while ((m = wordRe.exec(text)) !== null) { + const s = m.index; + const e = m.index + m[0].length; + if (offset >= s && offset <= e) { + return { from: start + s, to: start + e, text: m[0] }; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Plugin-state access +// --------------------------------------------------------------------------- + +function getCursors(state: EditorState): Range[] { + const st = multiCursorPluginKey.getState(state); + return st ? st.cursors : []; +} + +function setCursors(view: EditorView, cursors: Range[]): void { + view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors)); +} + +function collapse(view: EditorView): void { + setCursors(view, []); +} + +// --------------------------------------------------------------------------- +// The single-transaction, reverse-order mass edit (mirrors replaceAll) +// --------------------------------------------------------------------------- + +interface EditOp { + from: number; + to: number; + // Text to insert at `from` after deleting [from, to); "" for a pure delete. + text: string; +} + +/** + * Apply one edit per cursor in ONE transaction. Ops are processed from the END + * of the document to the START so an earlier edit never shifts a later position + * (mirrors `replaceAll`). Each cursor is wrapped independently: a schema + * violation SKIPS that one cursor instead of throwing away the whole + * transaction, so the document is never left half-applied. + * + * After building the transaction the new cursor positions are recomputed by + * mapping each op's original anchor through `tr.mapping` (which also remaps any + * concurrent changes), so carets land right after their inserted text. + */ +function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean { + if (!ops.length) return false; + + const { state } = view; + const tr = state.tr; + const schema = state.schema; + + // Ascending by `from`; iterate reverse so earlier positions stay valid. + const sorted = [...ops].sort((a, b) => a.from - b.from); + const appliedLen: number[] = new Array(sorted.length).fill(0); + + for (let i = sorted.length - 1; i >= 0; i -= 1) { + const { from, to, text } = sorted[i]; + try { + let marks: readonly Mark[] = []; + if (text) { + if (to > from) { + // Carry all marks spanning the replaced range. + const set = new Set(); + tr.doc.nodesBetween(from, to, (node) => { + if (node.isText && node.marks) { + node.marks.forEach((mk) => set.add(mk)); + } + }); + marks = Array.from(set); + } else { + // Caret: continue the marks active at the insertion point. + marks = state.storedMarks || state.doc.resolve(from).marks(); + } + } + + // ONE atomic step per cursor: replaceWith covers both insert (from === to) + // and replace (to > from); a pure delete (empty text) uses delete. This + // can never leave a cursor half-applied (deleted but not re-inserted) the + // way a separate delete-then-insert pair could if the insert step threw. + if (text) { + tr.replaceWith(from, to, schema.text(text, marks as Mark[])); + } else if (to > from) { + tr.delete(from, to); + } + + appliedLen[i] = text.length; + } catch { + // Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the + // rest of the transaction intact. + appliedLen[i] = 0; + } + } + + if (!tr.docChanged) return false; + + // Recompute cursor carets from the ORIGINAL op anchors through the full map. + const newCursors: Range[] = sorted.map((op, i) => { + const start = tr.mapping.map(op.from, -1); + const caret = start + appliedLen[i]; + return { from: caret, to: caret }; + }); + + tr.setMeta(multiCursorPluginKey, newCursors); + + // Park the native selection on the last caret so the browser draws exactly + // one real caret; the rest are our decoration widgets. + const last = newCursors[newCursors.length - 1]; + tr.setSelection(TextSelection.create(tr.doc, last.from)); + + view.dispatch(tr); + return true; +} + +function buildDeleteOps( + state: EditorState, + cursors: Range[], + forward: boolean, +): EditOp[] { + return cursors.map((c) => { + // A selected range: Backspace/Delete removes the whole range. + if (c.to > c.from) return { from: c.from, to: c.to, text: "" }; + + const $pos = state.doc.resolve(c.from); + if (forward) { + // Delete: at the end of a textblock there is nothing to remove (a no-op; + // MVP does not merge blocks across a multi-cursor set). + if ($pos.parentOffset >= $pos.parent.content.size) { + return { from: c.from, to: c.from, text: "" }; + } + return { from: c.from, to: c.from + 1, text: "" }; + } + // Backspace: at the start of a textblock there is nothing to remove. + if ($pos.parentOffset <= 0) { + return { from: c.from, to: c.from, text: "" }; + } + return { from: c.from - 1, to: c.from, text: "" }; + }); +} + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + +export const MultiCursor = Extension.create({ + name: "multiCursor", + + addStorage() { + return { wholeWord: true }; + }, + + addCommands() { + return { + selectAllOccurrences: + () => + ({ editor, state, tr, dispatch }) => { + let term: string; + // A bare cursor expands to the whole word; an explicit selection is + // matched literally (VS Code semantics). + const wholeWord = state.selection.empty; + if (wholeWord) { + const word = getWordAt(state, state.selection.from); + if (!word) return false; + term = word.text; + } else { + term = state.doc.textBetween( + state.selection.from, + state.selection.to, + ); + } + if (!term.trim()) return false; + editor.storage.multiCursor.wholeWord = wholeWord; + + const results = findOccurrences( + state.doc, + buildTermRegex(term, wholeWord), + ).slice(0, MAX_CURSORS); + if (!results.length) return false; + + if (dispatch) { + tr.setMeta(multiCursorPluginKey, results); + const last = results[results.length - 1]; + tr.setSelection(TextSelection.create(tr.doc, last.from, last.to)); + dispatch(tr); + } + return true; + }, + + addNextOccurrence: + () => + ({ editor, state, tr, dispatch }) => { + const existing = getCursors(state); + let cursors: Range[]; + + if (!existing.length) { + // First press: turn the current word/selection into the one cursor. + let range: Range; + const wholeWord = state.selection.empty; + if (wholeWord) { + const word = getWordAt(state, state.selection.from); + if (!word) return false; + range = { from: word.from, to: word.to }; + } else { + range = { from: state.selection.from, to: state.selection.to }; + } + editor.storage.multiCursor.wholeWord = wholeWord; + cursors = [range]; + } else { + // Subsequent press: add the next unselected occurrence of the term, + // matched the SAME way (whole-word vs literal) the set was seeded. + if (existing.length >= MAX_CURSORS) return true; + + const first = existing[0]; + const term = state.doc.textBetween(first.from, first.to); + if (!term.trim()) return false; + + const results = findOccurrences( + state.doc, + buildTermRegex(term, editor.storage.multiCursor.wholeWord), + ); + const keys = new Set(existing.map((c) => `${c.from}:${c.to}`)); + const notSelected = results.filter( + (r) => !keys.has(`${r.from}:${r.to}`), + ); + if (!notSelected.length) return true; // all occurrences selected + + const maxTo = Math.max(...existing.map((c) => c.to)); + const next = + notSelected.find((r) => r.from >= maxTo) || notSelected[0]; + cursors = [...existing, next]; + } + + if (dispatch) { + tr.setMeta(multiCursorPluginKey, cursors); + const last = cursors[cursors.length - 1]; + tr.setSelection(TextSelection.create(tr.doc, last.from, last.to)); + dispatch(tr); + } + return true; + }, + + exitMultiCursor: + () => + ({ tr, dispatch }) => { + if (dispatch) { + tr.setMeta(multiCursorPluginKey, []); + dispatch(tr); + } + return true; + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-Shift-l": () => { + this.editor.commands.selectAllOccurrences(); + // Always consume so the browser's default is prevented. + return true; + }, + "Mod-d": () => { + this.editor.commands.addNextOccurrence(); + // Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark. + return true; + }, + Escape: () => { + // Only swallow Escape while a multi-cursor set is active; otherwise let + // Escape keep its other behaviours (e.g. closing dialogs). + if (!getCursors(this.editor.state).length) return false; + return this.editor.commands.exitMultiCursor(); + }, + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: multiCursorPluginKey, + + state: { + init: () => ({ cursors: [] }), + apply(tr, value): MultiCursorState { + // A command (or a mass edit) can set/clear the cursor set directly. + // Its cursors are already in the post-transaction coordinate space, + // so they take priority over remapping. + const meta = tr.getMeta(multiCursorPluginKey) as + | Range[] + | undefined; + if (meta !== undefined) { + return { cursors: meta.slice(0, MAX_CURSORS) }; + } + + if (!value.cursors.length) return value; + + // Remap surviving cursors across ANY doc change — this covers both + // local edits and REMOTE Yjs edits (y-prosemirror applies remote + // changes as ordinary transactions, so mapping them here keeps every + // multi-cursor correctly positioned without special-casing collab). + if (tr.docChanged) { + // Map both edges with the SAME association (+1) so content + // inserted at a boundary shifts the whole cursor right and a caret + // (from === to) can never invert into a range. + const cursors = value.cursors.map((c) => ({ + from: tr.mapping.map(c.from, 1), + to: tr.mapping.map(c.to, 1), + })); + return { cursors }; + } + + return value; + }, + }, + + props: { + decorations(state) { + const st = multiCursorPluginKey.getState(state); + if (!st || !st.cursors.length) return DecorationSet.empty; + + const decorations: Decoration[] = []; + st.cursors.forEach((c, i) => { + if (c.from === c.to) { + decorations.push( + Decoration.widget( + c.from, + () => { + const el = document.createElement("span"); + el.className = "multi-cursor__caret"; + return el; + }, + { side: 0, key: `mc-caret-${i}` }, + ), + ); + } else { + decorations.push( + Decoration.inline(c.from, c.to, { + class: "multi-cursor__selection", + }), + ); + } + }); + + return DecorationSet.create(state.doc, decorations); + }, + + handleTextInput(view, _from, _to, text) { + const cursors = getCursors(view.state); + if (!cursors.length) return false; + + // Insert `text` at EVERY cursor in one transaction. Returning true + // prevents ProseMirror's own single-position insert at the native + // selection, so there is no double-insert there. + const ops = cursors.map((c) => ({ + from: c.from, + to: c.to, + text, + })); + return dispatchMassEdit(view, ops); + }, + + handleKeyDown(view, event) { + const cursors = getCursors(view.state); + if (!cursors.length) return false; + + if (event.key === "Backspace") { + dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false)); + return true; + } + if (event.key === "Delete") { + dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true)); + return true; + } + + // Let modifier combinations (our own shortcuts, copy, etc.) through + // WITHOUT collapsing the set. + if (event.metaKey || event.ctrlKey || event.altKey) return false; + + // Navigation / block keys collapse back to a single cursor, then let + // ProseMirror handle the movement on the native selection. + const COLLAPSE_KEYS = [ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + "Enter", + "Tab", + ]; + if (COLLAPSE_KEYS.includes(event.key)) { + collapse(view); + return false; + } + + return false; + }, + + handleDOMEvents: { + // A plain click exits multi-cursor (VS Code behaviour). + mousedown: (view) => { + if (getCursors(view.state).length) collapse(view); + return false; + }, + // MVP does not drive multi-position IME — collapse on composition. + compositionstart: (view) => { + if (getCursors(view.state).length) collapse(view); + return false; + }, + }, + }, + }), + ]; + }, +}); + +export default MultiCursor; diff --git a/packages/editor-ext/src/lib/search-and-replace/find-occurrences.ts b/packages/editor-ext/src/lib/search-and-replace/find-occurrences.ts new file mode 100644 index 00000000..295c2547 --- /dev/null +++ b/packages/editor-ext/src/lib/search-and-replace/find-occurrences.ts @@ -0,0 +1,69 @@ +import { Range } from "@tiptap/core"; +import { Node as PMNode } from "@tiptap/pm/model"; + +interface TextNodesWithPosition { + text: string; + pos: number; +} + +/** + * Shared "find all occurrences of a term in the doc" primitive. + * + * Walks every text node of the document and returns each regex match as a + * `{ from, to }` range. Contiguous text nodes (which may differ only by marks) + * are concatenated into a single run, so a match that spans e.g. "wo" + bold + * "rd" is still found; runs are split by any non-text node, so a match never + * crosses a node boundary. Whitespace-only matches are ignored. + * + * This is used by BOTH search-and-replace (highlight/replace) and multi-cursor + * (turn occurrences into active cursors) so the two stay behaviourally in sync. + * Extracted verbatim from the original `processSearches` walk. + */ +export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] { + const results: Range[] = []; + + if (!searchTerm) return results; + + let textNodesWithPosition: TextNodesWithPosition[] = []; + let index = 0; + + doc?.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos, + }; + } else { + textNodesWithPosition[index] = { + text: `${node.text}`, + pos, + }; + } + } else { + index += 1; + } + }); + + textNodesWithPosition = textNodesWithPosition.filter(Boolean); + + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter( + ([matchText]) => matchText.trim(), + ); + + for (const m of matches) { + if (m[0] === "") break; + + if (m.index !== undefined) { + results.push({ + from: pos + m.index, + to: pos + m.index + m[0].length, + }); + } + } + } + + return results; +} diff --git a/packages/editor-ext/src/lib/search-and-replace/index.ts b/packages/editor-ext/src/lib/search-and-replace/index.ts index d082e4f8..b13c4f39 100644 --- a/packages/editor-ext/src/lib/search-and-replace/index.ts +++ b/packages/editor-ext/src/lib/search-and-replace/index.ts @@ -1,3 +1,4 @@ import { SearchAndReplace } from './search-and-replace' export * from './search-and-replace' +export * from './find-occurrences' export default SearchAndReplace \ No newline at end of file diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts index 2326a50a..3c8aee97 100644 --- a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -29,6 +29,7 @@ import { type Transaction, } from "@tiptap/pm/state"; import { Node as PMNode, Mark } from "@tiptap/pm/model"; +import { findOccurrences } from "./find-occurrences"; declare module "@tiptap/core" { interface Storage { @@ -76,11 +77,6 @@ declare module "@tiptap/core" { } } -interface TextNodesWithPosition { - text: string; - pos: number; -} - const getRegex = ( s: string, disableRegex: boolean, @@ -104,10 +100,6 @@ function processSearches( resultIndex: number, ): ProcessedSearches { const decorations: Decoration[] = []; - const results: Range[] = []; - - let textNodesWithPosition: TextNodesWithPosition[] = []; - let index = 0; if (!searchTerm) { return { @@ -116,43 +108,8 @@ function processSearches( }; } - doc?.descendants((node, pos) => { - if (node.isText) { - if (textNodesWithPosition[index]) { - textNodesWithPosition[index] = { - text: textNodesWithPosition[index].text + node.text, - pos: textNodesWithPosition[index].pos, - }; - } else { - textNodesWithPosition[index] = { - text: `${node.text}`, - pos, - }; - } - } else { - index += 1; - } - }); - - textNodesWithPosition = textNodesWithPosition.filter(Boolean); - - for (const element of textNodesWithPosition) { - const { text, pos } = element; - const matches = Array.from(text.matchAll(searchTerm)).filter( - ([matchText]) => matchText.trim(), - ); - - for (const m of matches) { - if (m[0] === "") break; - - if (m.index !== undefined) { - results.push({ - from: pos + m.index, - to: pos + m.index + m[0].length, - }); - } - } - } + // Shared find-all-occurrences primitive (also used by multi-cursor). + const results: Range[] = findOccurrences(doc, searchTerm); for (let i = 0; i < results.length; i += 1) { const r = results[i];