From 2a50fb4a93b108ba7ca3860036d718bd0bcdef79 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Tue, 6 Oct 2026 03:18:08 +0900 Subject: [PATCH] fix(editor): edits that lost text or acted on the wrong place Fixes found by adversarial testing of the editor canvas: - A key pressed right after a click acted on the previous caret. - Typing into an empty paragraph saved by Word () or a new table row was lost. - Cmd+A, Cmd+Up/Down, Cmd+Home/End left the caret outside every paragraph. - Word / line deletes (Option/Cmd+Backspace), Enter during IME composition, spelling suggestions and browser-menu undo let the browser restructure the DOM, so the screen and the saved document diverged. - Deleting a selection that crosses table cells did nothing. - Backspace after a table joined the paragraph to the one above the table. - Enter / Backspace in an empty list item now leave the list as in Word. - Caret formatting (Cmd+B with no selection) formats the word or the text typed next instead of the whole run. - A click in the header / footer margin moves the caret to the nearest line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UAiVj14HzKtRDqK12BXTr6 --- .changeset/editor-adversarial-fixes.md | 11 + packages/editor/src/commands/commands.test.ts | 9 + packages/editor/src/commands/structure.ts | 118 +++++++- .../src/commands/text-selection.test.ts | 53 ++++ packages/editor/src/dom-selection.ts | 22 +- packages/editor/src/index.ts | 2 +- packages/editor/src/range-edit.test.ts | 78 ++++- packages/editor/src/selection-runs.ts | 105 ++++++- packages/editor/src/text-edit.ts | 12 +- site/src/lib/editor/EditorCanvas.svelte | 266 ++++++++++++++++-- site/src/lib/editor/canvas/caret.ts | 77 +++++ 11 files changed, 701 insertions(+), 52 deletions(-) create mode 100644 .changeset/editor-adversarial-fixes.md diff --git a/.changeset/editor-adversarial-fixes.md b/.changeset/editor-adversarial-fixes.md new file mode 100644 index 0000000..930d257 --- /dev/null +++ b/.changeset/editor-adversarial-fixes.md @@ -0,0 +1,11 @@ +--- +"@office-kit/docx-editor": patch +--- + +Editing fixes found by adversarial testing: + +- Deleting or typing over a selection that crosses table cells no longer fails silently: within one table the covered cells are emptied, and a selection that leaves the table deletes the rows it touches, as in Word. +- Backspace at the start of a paragraph after a table no longer joins it to the paragraph above the table. An empty paragraph there is removed and the caret moves to the table's last cell. +- Bold, italic, font and other character formatting with a caret (no selection) now behaves like Word: inside a word it formats the whole word (Japanese words included); elsewhere it applies to the text typed next instead of the whole run. +- `runPoint` places a caret after the placeholder of an empty run, so text typed there lands in that run. +- New `isEmptyParagraph` export. diff --git a/packages/editor/src/commands/commands.test.ts b/packages/editor/src/commands/commands.test.ts index bf55262..5f58698 100644 --- a/packages/editor/src/commands/commands.test.ts +++ b/packages/editor/src/commands/commands.test.ts @@ -39,6 +39,13 @@ function editorWith(texts: string[]): EditorModel { return model; } +function selectAllOf(model: EditorModel, block: number, text: string): void { + model.setSelection({ + anchor: { block, inline: 0, offset: 0 }, + focus: { block, inline: 0, offset: text.length }, + }); +} + /** Serialize → reopen and return the fresh Docx (proves the edit persisted). */ function roundTrip(model: EditorModel) { const bytes = toUint8Array(model.doc); @@ -57,6 +64,7 @@ function firstRun(model: EditorModel): WmlRun { describe("text commands", () => { it("toggles bold and it survives round-trip", () => { const model = editorWith(["Hello world"]); + selectAllOf(model, 0, "Hello world"); runCommand(model, toggleBoldCommand, undefined); expect(getRunFormat(firstRun(model)).bold).toBe(true); const reopened = openDocx(toUint8Array(model.doc)); @@ -148,6 +156,7 @@ describe("review commands", () => { describe("history", () => { it("undo/redo restores document state", () => { const model = editorWith(["Hello"]); + selectAllOf(model, 0, "Hello"); runCommand(model, toggleBoldCommand, undefined); expect(getRunFormat(firstRun(model)).bold).toBe(true); model.undo(); diff --git a/packages/editor/src/commands/structure.ts b/packages/editor/src/commands/structure.ts index ea0ed4c..2d75962 100644 --- a/packages/editor/src/commands/structure.ts +++ b/packages/editor/src/commands/structure.ts @@ -21,14 +21,22 @@ import { type WmlInline, type WmlParagraph, type WmlRun, + type WmlTable, + type WmlTableCell, type XmlElement, } from "@office-kit/docx"; import { absoluteOffset, positionAtOffset } from "../char-offset.js"; -import { blockAt, cellAt, paragraphAt } from "../doc-access.js"; +import { blockAt, bodyOf, cellAt, paragraphAt } from "../doc-access.js"; import type { EditorModel } from "../model.js"; import { caretAt, type DocPosition, orderSelection } from "../selection.js"; import { WML_NS } from "../wml-ns.js"; -import { isPlainTextRun, plainRunText, runAtPath, setSimpleRunText } from "../text-edit.js"; +import { + isEmptyParagraph, + isPlainTextRun, + plainRunText, + runAtPath, + setSimpleRunText, +} from "../text-edit.js"; import { isTrackingRevisions, recordSplit, @@ -106,6 +114,12 @@ export const mergeBackCommand: Command = { model.setSelection(caretAt(caret)); return; } + const blocks = bodyOf(model.doc, pos).blocks; + const prev = blocks[pos.block - 1]; + if (prev?.kind === "table") { + backIntoTable(model, pos, prev); + return; + } const joined = mergeParagraphIntoPrevious(model.doc, pos.block); if (joined) model.setSelection(caretAt(joined)); }, @@ -116,6 +130,33 @@ export const mergeBackCommand: Command = { }, }; +/** + * Backspace at the start of a paragraph that follows a table, as in Word: the + * text never joins a cell. An empty paragraph is removed (unless it is the + * last block, which the body must keep) and the caret moves to the end of the + * table's last cell; a paragraph with text stays as it is. + */ +function backIntoTable(model: EditorModel, pos: DocPosition, table: WmlTable): void { + const blocks = bodyOf(model.doc, pos).blocks; + const cur = blocks[pos.block]; + if (cur?.kind !== "paragraph" || !isEmptyParagraph(cur)) return; + const row = table.rows.length - 1; + const cells = table.rows[row]?.cells ?? []; + const col = cells.length - 1; + const paras = cells[col]?.paragraphs ?? []; + const last = paras.at(-1); + if (!last) return; + if (pos.block < blocks.length - 1) blocks.splice(pos.block, 1); + const { story } = pos; + const at = { + ...(story ? { story } : {}), + block: pos.block - 1, + cell: { row, col }, + para: paras.length - 1, + }; + model.setSelection(caretAt(positionAtOffset(last, Number.POSITIVE_INFINITY, at))); +} + /** Insert `text` at `pos` (a run boundary or inside a text run); returns the caret after it. */ function insertIntoRun(model: EditorModel, pos: DocPosition, text: string): DocPosition { const para = paragraphAt(model.doc, pos); @@ -256,11 +297,7 @@ function deleteRange(model: EditorModel, start: DocPosition, end: DocPosition): start.block === end.block && start.cell?.row === end.cell?.row && start.cell?.col === end.cell?.col; - // A range spanning several cells (or leaving the table) would need - // per-cell clearing and row/column structure decisions; refuse atomically. - if (!sameCell) { - throw new Error("Deleting a selection that crosses table cells is not supported yet."); - } + if (!sameCell) return deleteAcrossCells(model, start, end); const first = cell.paragraphs[start.para ?? 0]; const last = cell.paragraphs[end.para ?? 0]; if (!first || !last) throw new Error("The selection is not inside the cell's paragraphs."); @@ -303,6 +340,73 @@ function deleteRange(model: EditorModel, start: DocPosition, end: DocPosition): return joined; } +/** + * Delete a range with an end inside a table, as Word does once a selection + * leaves a cell: within one table the covered cells are emptied; across the + * table's edge the rows the range touches go (a table losing every row goes + * with them), and the text outside the table is cut up to the table. + */ +function deleteAcrossCells(model: EditorModel, start: DocPosition, end: DocPosition): DocPosition { + const blocks = model.doc.document.body.blocks; + const startBlock = blocks[start.block]; + const first = start.cell; + const last = end.cell; + if (start.block === end.block && first && last && startBlock?.kind === "table") { + startBlock.rows.forEach((row, r) => + row.cells.forEach((cell, c) => { + const afterFirst = r > first.row || (r === first.row && c >= first.col); + const beforeLast = r < last.row || (r === last.row && c <= last.col); + if (afterFirst && beforeLast) clearCell(cell); + }), + ); + return { block: start.block, cell: first, para: 0, inline: 0, offset: 0 }; + } + // Work from the end backwards so earlier block indices stay valid. + const endBlock = blocks[end.block]; + if (last && endBlock?.kind === "table") { + endBlock.rows.splice(0, last.row + 1); + if (endBlock.rows.length === 0) blocks.splice(end.block, 1); + } else if (endBlock?.kind === "paragraph") { + cutParagraph(endBlock, 0, absoluteOffset(endBlock, end)); + } + blocks.splice(start.block + 1, end.block - start.block - 1); + if (first && startBlock?.kind === "table") { + startBlock.rows.splice(first.row); + if (startBlock.rows.length > 0) return startOfBlock(model, start.block + 1); + blocks.splice(start.block, 1); + return startOfBlock(model, start.block); + } + if (startBlock?.kind !== "paragraph") + throw new Error("The selection does not start in a paragraph."); + const from = absoluteOffset(startBlock, start); + cutParagraph(startBlock, from, paragraphLength(startBlock)); + return positionAtOffset(startBlock, from, { ...start }); +} + +function clearCell(cell: WmlTableCell): void { + const [first] = cell.paragraphs; + if (!first) return; + first.children = ensureRun([]); + cell.paragraphs = [first]; +} + +function cutParagraph(para: WmlParagraph, from: number, to: number): void { + const removed = new Set(isolateParagraphRunRange(para, from, to)); + para.children = ensureRun(para.children.filter((c) => !(c.kind === "run" && removed.has(c)))); +} + +function paragraphLength(para: WmlParagraph): number { + return para.children.reduce((n, c) => n + (c.kind === "run" ? runTextLength(c) : 0), 0); +} + +/** The first caret position in a top-level block (its first cell for a table). */ +function startOfBlock(model: EditorModel, index: number): DocPosition { + const block = model.doc.document.body.blocks[index]; + return block?.kind === "table" + ? { block: index, cell: { row: 0, col: 0 }, para: 0, inline: 0, offset: 0 } + : { block: index, inline: 0, offset: 0 }; +} + /** * The caret an insertion should use: the focus for a collapsed selection, or — * as in Word, where typing / Enter over a range replaces it — the caret left diff --git a/packages/editor/src/commands/text-selection.test.ts b/packages/editor/src/commands/text-selection.test.ts index 8c2e389..8344ecf 100644 --- a/packages/editor/src/commands/text-selection.test.ts +++ b/packages/editor/src/commands/text-selection.test.ts @@ -16,6 +16,7 @@ import { import { describe, expect, it } from "vitest"; import { editorFor } from "../index.js"; import type { EditorModel } from "../model.js"; +import { insertTextCommand } from "./structure.js"; import { toggleBoldCommand, toggleItalicCommand } from "./text.js"; import { runCommand } from "./types.js"; @@ -129,3 +130,55 @@ describe("selection-aware bold", () => { ]); }); }); + +function caret(model: EditorModel, inline: number, offset: number): void { + const pos = { block: 0, inline, offset }; + model.setSelection({ anchor: pos, focus: pos }); +} + +describe("character formatting at a caret (Word)", () => { + it("formats the whole word the caret is inside", () => { + const model = editorFor(createDocx({ paragraphs: ["Hello world foo"] })); + caret(model, 0, 8); // wo|rld + runCommand(model, toggleBoldCommand, undefined); + expect(runProfile(model)).toEqual([ + ["Hello ", false], + ["world", true], + [" foo", false], + ]); + expect(model.selection?.focus).toMatchObject({ inline: 1, offset: 2 }); + }); + + it("finds Japanese words too", () => { + const model = editorFor(createDocx({ paragraphs: ["今日は晴れ"] })); + caret(model, 0, 1); // 今|日 + runCommand(model, toggleBoldCommand, undefined); + expect(runProfile(model).filter(([, bold]) => bold)).toEqual([["今日", true]]); + }); + + it("at a word edge formats only what is typed next", () => { + const model = editorFor(createDocx({ paragraphs: ["Hello"] })); + caret(model, 0, 5); + runCommand(model, toggleBoldCommand, undefined); + runCommand(model, insertTextCommand, { text: " bold" }); + runCommand(model, toggleBoldCommand, undefined); + runCommand(model, insertTextCommand, { text: " plain" }); + expect(runProfile(model)).toEqual([ + ["Hello", false], + [" bold", true], + [" plain", false], + ]); + expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); + }); + + it("pressing Bold twice at a caret turns the pending formatting back off", () => { + const model = editorFor(createDocx({ paragraphs: ["Hello"] })); + caret(model, 0, 5); + runCommand(model, toggleBoldCommand, undefined); + runCommand(model, toggleBoldCommand, undefined); + expect(runProfile(model)).toEqual([ + ["Hello", false], + ["", false], + ]); + }); +}); diff --git a/packages/editor/src/dom-selection.ts b/packages/editor/src/dom-selection.ts index a60dd92..23e8f60 100644 --- a/packages/editor/src/dom-selection.ts +++ b/packages/editor/src/dom-selection.ts @@ -41,6 +41,25 @@ function textBefore(span: Element, node: Node, offset: number): number { return range.toString().replace(/\u200b/g, "").length; } +/** + * The offset in a text node's data after `chars` visible characters. A + * zero-width placeholder there is stepped over: a caret before the one an + * empty run renders would type into the previous run instead, since the + * browser moves an insertion point at the start of an inline element to the + * end of the text before it. + */ +function domOffset(data: string, chars: number): number { + let seen = 0; + for (let i = 0; i < data.length; i++) { + if (seen === chars) { + while (data[i] === "\u200b") i++; + return i; + } + if (data[i] !== "\u200b") seen++; + } + return data.length; +} + /** * The DOM point `offset` characters into a run span. The span holds text * nodes and tab elements; a point next to a tab is placed in the text beside @@ -54,7 +73,8 @@ export function runPoint(span: Element, offset: number): { node: Node; offset: n if (!node) continue; const length = (node.textContent ?? "").replace(/\u200b/g, "").length; if (node.nodeType === Node.TEXT_NODE) { - if (remaining <= length) return { node, offset: remaining }; + if (remaining <= length) + return { node, offset: domOffset(node.textContent ?? "", remaining) }; } else if (remaining === 0) { return { node: span, offset: i }; } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 34309a0..59e95e1 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -84,7 +84,7 @@ export { } from "./table-format.js"; export { type TablePreviewCell, tableStylePreviews } from "./table-preview.js"; export { type TablePropertiesSnapshot, tablePropertiesSnapshot } from "./table-properties.js"; -export { runAtPath, setSimpleRunText } from "./text-edit.js"; +export { isEmptyParagraph, runAtPath, setSimpleRunText } from "./text-edit.js"; export { isTrackingRevisions, type Reviewer, diff --git a/packages/editor/src/range-edit.test.ts b/packages/editor/src/range-edit.test.ts index 3939884..793c270 100644 --- a/packages/editor/src/range-edit.test.ts +++ b/packages/editor/src/range-edit.test.ts @@ -223,15 +223,79 @@ describe("table cells", () => { expect(cellTexts(model)).toEqual(["abkl"]); }); - it("refuses a range across cells atomically", () => { + it("empties every cell a range across cells covers", () => { const model = cellModel(); - model.setSelection({ anchor: at(0, 1), focus: at(0, 0, 1) }); - expect(() => runCommand(model, commands.deleteSelectionCommand, undefined)).toThrow( - /crosses table cells/, - ); + model.setSelection({ anchor: at(0, 0, 1), focus: at(0, 0, 1) }); + runCommand(model, commands.insertTextCommand, { text: "xyz" }); + model.setSelection({ anchor: at(0, 1, 1), focus: at(0, 3) }); + runCommand(model, commands.deleteSelectionCommand, undefined); + expect([cellTexts(model, 0), cellTexts(model, 1)]).toEqual([[""], [""]]); + expect(model.selection?.focus).toEqual(at(0, 0)); + expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); + }); + + it("deletes the rows a range from a paragraph into the table touches", () => { + const model = cellModel(); + model.setSelection({ anchor: { block: 0, inline: 0, offset: 2 }, focus: at(0, 3) }); + runCommand(model, commands.deleteSelectionCommand, undefined); + expect(model.doc.document.body.blocks.map((b) => b.kind)).toEqual(["paragraph", "paragraph"]); + expect(texts(model)).toEqual(["be", ""]); + expect(model.selection?.focus).toMatchObject({ block: 0, offset: 2 }); + expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); + model.undo(); + expect(cellTexts(model)).toEqual(["abcdef"]); + }); + + it("deletes the rows and the text a range from the table out of it covers", () => { + const model = cellModel(); + model.setSelection({ anchor: { block: 2 }, focus: { block: 2 } }); + runCommand(model, commands.insertTextCommand, { text: "after" }); + model.setSelection({ anchor: at(0, 1), focus: { block: 2, inline: 0, offset: 2 } }); + runCommand(model, commands.insertTextCommand, { text: "X" }); + expect(texts(model)).toEqual(["before", "Xter"]); + expect(model.selection?.focus).toMatchObject({ block: 1, offset: 1 }); + expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); + }); + + it("Backspace after a table never joins text into it", () => { + const model = cellModel(); + model.setSelection({ anchor: { block: 2 }, focus: { block: 2 } }); + runCommand(model, commands.insertTextCommand, { text: "after" }); + model.setSelection({ + anchor: { block: 2, inline: 0, offset: 0 }, + focus: { block: 2, inline: 0, offset: 0 }, + }); + runCommand(model, commands.mergeBackCommand, undefined); + expect(texts(model)).toEqual(["before", "after"]); expect(cellTexts(model)).toEqual(["abcdef"]); - expect(model.selection).toEqual({ anchor: at(0, 1), focus: at(0, 0, 1) }); - expect(model.canUndo()).toBe(true); // only the setup commands remain + }); + + it("Backspace in an empty paragraph after a table removes it and enters the last cell", () => { + const model = cellModel(); + model.setSelection({ anchor: { block: 2 }, focus: { block: 2 } }); + runCommand(model, commands.insertTextCommand, { text: "\nend" }); + model.setSelection({ + anchor: { block: 2, inline: 0, offset: 0 }, + focus: { block: 2, inline: 0, offset: 0 }, + }); + runCommand(model, commands.mergeBackCommand, undefined); + expect(texts(model)).toEqual(["before", "end"]); + expect(model.selection?.focus).toEqual(at(0, 0, 1)); + }); + + it("Backspace in the document's last paragraph after a table only enters the table", () => { + const model = cellModel(); + model.setSelection({ + anchor: { block: 2, inline: 0, offset: 0 }, + focus: { block: 2, inline: 0, offset: 0 }, + }); + runCommand(model, commands.mergeBackCommand, undefined); + expect(model.doc.document.body.blocks.map((b) => b.kind)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + expect(model.selection?.focus).toEqual(at(0, 0, 1)); }); it("an IME-style replacement of a range is one undo step", () => { diff --git a/packages/editor/src/selection-runs.ts b/packages/editor/src/selection-runs.ts index 7589491..d7682b8 100644 --- a/packages/editor/src/selection-runs.ts +++ b/packages/editor/src/selection-runs.ts @@ -23,9 +23,11 @@ import { withRunFormatTracking } from "./track-changes.js"; import { type CellCoord, type DocPosition, + caretAt, type OrderedSelection, orderSelection, } from "./selection.js"; +import { positionAtOffset } from "./char-offset.js"; function paragraphRuns(para: WmlParagraph): WmlRun[] { return para.children.filter((c): c is WmlRun => c.kind === "run"); @@ -173,6 +175,104 @@ export function overlappingSelectionRunRefs(model: EditorModel): RunRef[] { return runRefsCovered(selectionWindows(model)); } +/** + * Character formatting at a caret, as in Word: inside a word it formats the + * whole word; anywhere else it formats what is typed next, held by an empty + * run at the caret (the caret's own run when that is already empty, as after + * pressing Bold twice). + */ +function applyAtCaret( + model: EditorModel, + caret: DocPosition, + applyFn: (run: WmlRun, para: WmlParagraph) => void, +): void { + const para = paragraphAt(model.doc, caret); + if (!para) return; + const runs = paragraphRuns(para); + const own = runs[caret.inline ?? 0]; + if (own && runTextLength(own) === 0) { + withRunFormatTracking(model, own, () => applyFn(own, para)); + return; + } + const at = absoluteChar(runs, caret.inline ?? 0, caret.offset ?? 0); + const word = wordAround(paragraphCharText(runs), at); + if (word) { + for (const run of isolateParagraphRunRange(para, word[0], word[1])) { + withRunFormatTracking(model, run, () => applyFn(run, para)); + } + model.setSelection(caretAt(positionAtOffset(para, at, caret))); + return; + } + const pending = insertEmptyRunAt(para, at); + if (!pending) { + // The caret sits inside a run that cannot be split (a tab or field in + // it): format that run, as before there was a pending run. + if (own) withRunFormatTracking(model, own, () => applyFn(own, para)); + return; + } + withRunFormatTracking(model, pending, () => applyFn(pending, para)); + const inline = paragraphRuns(para).indexOf(pending); + const pos = { ...caret, inline, offset: 0 }; + model.setSelection({ anchor: pos, focus: pos }); +} + +/** A paragraph's run text, one character per unit `runTextLength` counts. */ +function paragraphCharText(runs: WmlRun[]): string { + return runs + .flatMap((run) => + run.pieces.map((p) => { + if (p.kind === "text") return p.value; + // Tabs, breaks and hyphens count one character and never join a word. + return runTextLength({ kind: "run", pieces: [p], extras: [] }) ? " " : ""; + }), + ) + .join(""); +} + +/** The word the character offset `at` falls strictly inside, if any. */ +function wordAround(text: string, at: number): [number, number] | undefined { + for (const seg of new Intl.Segmenter(undefined, { granularity: "word" }).segment(text)) { + const start = seg.index; + const end = start + seg.segment.length; + if (start >= at) return undefined; + if (at < end) return seg.isWordLike ? [start, end] : undefined; + } + return undefined; +} + +/** + * Split the runs at character `at` and put an empty run there carrying the + * formatting of the text before it (after it at the paragraph start), the + * formatting typing at that point would take. `undefined` when `at` falls + * inside a run that cannot be split. + */ +function insertEmptyRunAt(para: WmlParagraph, at: number): WmlRun | undefined { + isolateParagraphRunRange(para, 0, at); + let cursor = 0; + let index = 0; + let source: WmlRun | undefined; + for (const [i, child] of para.children.entries()) { + if (child.kind !== "run") continue; + if (cursor === at) { + index = i; + source ??= child; + break; + } + cursor += runTextLength(child); + if (cursor > at) return undefined; + index = i + 1; + source = child; + } + const run: WmlRun = { + kind: "run", + ...(source?.rPr ? { rPr: structuredClone(source.rPr) } : {}), + pieces: [], + extras: [], + }; + para.children.splice(index, 0, run); + return run; +} + /** * Apply `applyFn` to exactly the runs covering the selection, splitting runs at * the selection boundaries first so a partial selection formats only the @@ -186,11 +286,8 @@ export function applyToSelectionRuns( if (!sel) return; const { start, end } = orderSelection(sel); - // Collapsed caret: apply to the whole caret run (there is no range to isolate). if (isCollapsed(start, end)) { - for (const { run, para } of overlappingSelectionRunRefs(model)) { - withRunFormatTracking(model, run, () => applyFn(run, para)); - } + applyAtCaret(model, start, applyFn); return; } diff --git a/packages/editor/src/text-edit.ts b/packages/editor/src/text-edit.ts index bbbcf67..1675d1c 100644 --- a/packages/editor/src/text-edit.ts +++ b/packages/editor/src/text-edit.ts @@ -4,7 +4,7 @@ * the span's text and writes it to the matching run here. */ -import type { Docx, WmlRun, WmlRunPiece } from "@office-kit/docx"; +import type { Docx, WmlParagraph, WmlRun, WmlRunPiece } from "@office-kit/docx"; import { paragraphAt } from "./doc-access.js"; import type { DocPosition } from "./selection.js"; @@ -44,3 +44,13 @@ export function setSimpleRunText(run: WmlRun, text: string): boolean { run.pieces = pieces; return true; } + +/** + * Whether a paragraph holds nothing but empty text: no characters, and no + * picture, field or other inline that would be lost with the paragraph. + */ +export function isEmptyParagraph(para: WmlParagraph): boolean { + return para.children.every( + (c) => c.kind === "run" && c.pieces.every((p) => p.kind === "text" && p.value === ""), + ); +} diff --git a/site/src/lib/editor/EditorCanvas.svelte b/site/src/lib/editor/EditorCanvas.svelte index facee4f..8883228 100644 --- a/site/src/lib/editor/EditorCanvas.svelte +++ b/site/src/lib/editor/EditorCanvas.svelte @@ -11,6 +11,7 @@ paragraphAt, positionFromDom, readDomSelection, + isEmptyParagraph, runAtPath, setSimpleRunText, runCommand, @@ -25,7 +26,7 @@ import { t } from './i18n/index.svelte'; import { PageLayout } from './canvas/page-layout'; import { decorateTableSelection, handleTableKey, tableCanvasStyle, tablePointerDown, tablePointerUp, tableToolAttr } from './table-canvas'; - import { copiesOf, domPoint, focusPage, focusParagraph, mirrorParagraph, paragraphSelector } from './canvas/caret'; + import { copiesOf, domPoint, edgePoint, focusPage, focusParagraph, mirrorParagraph, paragraphJumpPoint, paragraphSelector, storyRange, type ParagraphJump } from './canvas/caret'; import ObjectSelection from './picture/ObjectSelection.svelte'; interface Props { @@ -88,8 +89,9 @@ composing = true; // A new composition right after the previous one: apply that one first. flushImeFinalize(); - // While tracking, a composition at a caret must also become one tracked insert. - if (!hasRangeSelection() && !isTrackingRevisions(model)) return; + // While tracking, a composition at a caret must also become one tracked + // insert; so must one where native typing could not be synced. + if (!hasRangeSelection() && !isTrackingRevisions(model) && caretInModelRun()) return; endTyping(); imeRange = model.selection; } @@ -300,6 +302,10 @@ function onSelChange(): void { if (imeRange) return; const sel = readDomSelection(document); + // A caret the browser left between paragraphs (on a page box, between + // pages) has no document position, and typing there would be lost: put + // it back where the model has it. + if (!sel && caretOutsideParagraphs()) restoreCaret(); if (sel) { // A selection never spans two stories (body and a footnote, say): // keep the end that moved. @@ -311,6 +317,28 @@ session.tick++; } + function caretOutsideParagraphs(): boolean { + const domSel = window.getSelection(); + const node = domSel?.focusNode; + if (!domSel?.isCollapsed || !node || !canvas?.contains(node)) return false; + const el = node instanceof HTMLElement ? node : node.parentElement; + return !el?.closest('.wk-p'); + } + + /** + * Enter in an empty list item steps out of the list as in Word: a nested + * item moves up a level, a top-level one stops being a list item. + */ + function leaveEmptyListItem(): boolean { + const sel = model.selection; + const para = sel && orderSelection(sel).collapsed ? paragraphAt(model.doc, sel.focus) : undefined; + const list = para && getParagraphNumbering(para); + if (!para || !list || !isEmptyParagraph(para)) return false; + if (list.ilvl > 0) exec(commands.setListLevelCommand, { ilvl: list.ilvl - 1 }); + else exec(commands.removeListCommand, undefined); + return true; + } + /** Whether the caret is a collapsed cursor at the very start of its paragraph. */ function caretAtParagraphStart(): boolean { const sel = model.selection; @@ -348,7 +376,11 @@ if (e.isComposing || e.keyCode === 229) return; // A key after a range composition must act on the applied result. flushImeFinalize(); + // selectionchange fires asynchronously: a key pressed right after a click + // or an arrow key would otherwise act on the previous caret. + onSelChange(); if (CARET_KEYS.has(e.key)) endTyping(); + if (jumpCaret(e)) return; if (handleTableKey(e, model, exec)) { rerender(); session.tick++; @@ -365,21 +397,12 @@ // Undo / redo. if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); - // Typed text is already in the AST (onInput syncs every keystroke), so - // undo/redo can act on the model directly. - endTyping(); - if (e.shiftKey) model.redo(); - else model.undo(); - rerender(); - session.edited(); + history(e.shiftKey ? 'redo' : 'undo'); return; } if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); - endTyping(); - model.redo(); - rerender(); - session.edited(); + history('redo'); return; } @@ -388,8 +411,6 @@ // item down a level (Shift+Tab up), as in Word. if (e.key === 'Tab' && !mod && !e.altKey) { e.preventDefault(); - // selectionchange may not have run since the last keystroke. - onSelChange(); const focus = model.selection?.focus; const para = focus && paragraphAt(model.doc, focus); if (para && getParagraphNumbering(para) && caretAtParagraphStart()) { @@ -403,18 +424,18 @@ if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) exec(commands.insertLineBreakCommand, { kind: 'line' }); - else exec(commands.splitParagraphCommand, undefined); + else if (!leaveEmptyListItem()) exec(commands.splitParagraphCommand, undefined); return; } - // Backspace at paragraph start merges into the previous paragraph. - if ( - e.key === 'Backspace' && - caretAtParagraphStart() && - (commands.mergeBackCommand.isEnabled?.(model) ?? false) - ) { + // Backspace at paragraph start: a list item first loses its number (as in + // Word), anything else merges into the previous paragraph. Never native: + // the browser would join DOM nodes (across cells, into a table) on its own. + if (e.key === 'Backspace' && caretAtParagraphStart()) { e.preventDefault(); - exec(commands.mergeBackCommand, undefined); + const para = model.selection && paragraphAt(model.doc, model.selection.focus); + if (para && getParagraphNumbering(para)) exec(commands.removeListCommand, undefined); + else if (commands.mergeBackCommand.isEnabled?.(model) ?? false) exec(commands.mergeBackCommand, undefined); return; } @@ -444,6 +465,9 @@ return; } } + // Before a table (or at the end of the document) there is nothing to + // pull up, as in Word. + e.preventDefault(); } // Formatting shortcuts. @@ -459,20 +483,166 @@ } } + // Word's caret jumps (⌘ on the Mac, Ctrl elsewhere). Left to the browser, + // ⌘↑ / ⌘↓ put the caret outside every paragraph, where typing is lost. + const JUMPS: Readonly> = { + ArrowUp: 'previous', + ArrowDown: 'next', + Home: 'first', + End: 'last', + }; + + function jumpCaret(e: KeyboardEvent): boolean { + // Only the platform's own modifier: on the Mac, Ctrl+A is the line-start + // key the browser already handles. + const mod = (/Mac|iPhone|iPad/.test(navigator.platform) ? e.metaKey : e.ctrlKey) && !e.altKey; + const domSel = window.getSelection(); + const from = focusParagraph(); + if (!mod || !domSel?.focusNode || !from || !canvas) return false; + // The browser's Select All takes the whole page box, which maps to no + // document position. + if (e.key.toLowerCase() === 'a' && !e.shiftKey) { + const all = storyRange(canvas, from); + e.preventDefault(); + if (all) domSel.setBaseAndExtent(all.start.node, all.start.offset, all.end.node, all.end.offset); + onSelChange(); + return true; + } + const jump = JUMPS[e.key]; + if (!jump) return false; + const to = paragraphJumpPoint(canvas, from, { node: domSel.focusNode, offset: domSel.focusOffset }, jump); + e.preventDefault(); + if (!to) return true; + if (e.shiftKey) domSel.extend(to.node, to.offset); + else domSel.collapse(to.node, to.offset); + (to.node instanceof HTMLElement ? to.node : to.node.parentElement)?.scrollIntoView({ block: 'nearest' }); + onSelChange(); + return true; + } + + /** + * Whether native typing at the caret lands in a rendered run the model has, + * the only text syncFromDom reads back. An empty paragraph saved by Word + * (``) has no run, so its typing must go through a command. + */ + function caretInModelRun(): boolean { + const node = window.getSelection()?.focusNode; + const el = node instanceof HTMLElement ? node : node?.parentElement; + const span = el?.closest('.wk-run[data-wk-block]'); + const pos = span ? positionFromDom(span, 0) : null; + return !!pos && !!runAtPath(model.doc, pos); + } + function hasRangeSelection(): boolean { const sel = model.selection; return !!sel && !orderSelection(sel).collapsed; } + function history(step: 'undo' | 'redo'): void { + // Typed text is already in the AST (onInput syncs every keystroke), so + // undo/redo can act on the model directly. + endTyping(); + if (step === 'undo') model.undo(); + else model.redo(); + rerender(); + session.edited(); + } + + // The edits the browser may make itself: plain typing, IME composition, and + // a one-character delete (when it stays inside one run, see nativeDeleteFits). + const NATIVE_INPUTS: ReadonlySet = new Set([ + 'insertText', + 'insertCompositionText', + 'deleteCompositionText', + 'insertFromComposition', + 'deleteContentBackward', + 'deleteContentForward', + ]); + /** - * Typing / deleting over a *range* is routed to commands: the browser's own + * Every other input — Enter from a virtual keyboard or voice input, a word + * or line delete (⌥⌫, ⌘⌫), a spelling suggestion, a text drop, Undo from + * the browser's menu — becomes the matching command: the browser's own * handling would restructure the DOM across runs and paragraphs in ways the - * per-run text sync cannot map back. Collapsed-caret typing stays native - * (reconciled by onInput) so the caret and IME behave normally. + * per-run text sync cannot map back. + */ + function applyInput(e: InputEvent): void { + onSelChange(); + switch (e.inputType) { + case 'insertParagraph': + if (!leaveEmptyListItem()) exec(commands.splitParagraphCommand, undefined); + return; + case 'insertLineBreak': + exec(commands.insertLineBreakCommand, { kind: 'line' }); + return; + case 'historyUndo': + history('undo'); + return; + case 'historyRedo': + history('redo'); + return; + case 'insertReplacementText': + case 'insertFromDrop': + case 'insertFromYank': { + const text = e.dataTransfer?.getData('text/plain') ?? e.data; + if (text && selectTargetRange(e)) exec(commands.insertTextCommand, { text }); + return; + } + } + if (e.inputType.startsWith('delete') && selectTargetRange(e) && hasRangeSelection()) { + exec(commands.deleteSelectionCommand, undefined); + } + } + + /** Put the model selection on the range an input event is about to change. */ + function selectTargetRange(e: InputEvent): boolean { + const [range] = e.getTargetRanges(); + if (!range) return true; + const anchor = positionFromDom(range.startContainer, range.startOffset); + const focus = positionFromDom(range.endContainer, range.endOffset); + if (!anchor || !focus) return false; + model.setSelection({ anchor, focus }); + return true; + } + + /** + * Whether the browser may make a one-character delete itself: it removes + * text from one text node of a run the model has, and leaves text there + * (a run emptied by the browser loses its span, and with it the sync). + */ + function nativeDeleteFits(e: InputEvent): boolean { + const [range] = e.getTargetRanges(); + const node = range?.startContainer; + if (!range || !(node instanceof Text) || range.endContainer !== node || !caretInModelRun()) return false; + const span = node.parentElement?.closest('.wk-run'); + const left = (span?.textContent ?? '').replace(/\u200b/g, '').length - (range.endOffset - range.startOffset); + return left > 0; + } + + /** + * Typing / deleting over a *range* is routed to commands, like every input + * outside NATIVE_INPUTS. Collapsed-caret typing stays native (reconciled by + * onInput) so the caret and IME behave normally. */ function onBeforeInput(e: InputEvent): void { + if (!NATIVE_INPUTS.has(e.inputType)) { + e.preventDefault(); + // Enter confirming a composition is the IME's, not a paragraph break. + if (!composing && !imeRange && !e.isComposing) applyInput(e); + return; + } // While tracking changes every edit is recorded by a command, never synced natively. - if (imeRange || e.isComposing || (!hasRangeSelection() && !isTrackingRevisions(model))) return; + if (imeRange || e.isComposing) return; + // Mouse selections reach here without a keydown to sync them. + onSelChange(); + // A selection the browser placed outside every paragraph (on a page box) + // has no document position: editing it natively would wreck the pages. + if (!readDomSelection(document)) { + e.preventDefault(); + return; + } + const nativeOk = e.inputType.startsWith('delete') ? nativeDeleteFits(e) : caretInModelRun(); + if (!hasRangeSelection() && !isTrackingRevisions(model) && nativeOk) return; if (e.inputType === 'insertText') { e.preventDefault(); // The replacement's undo snapshot (taken by runCommand) is the burst's @@ -483,8 +653,11 @@ } } else if (e.inputType.startsWith('delete')) { e.preventDefault(); - if (hasRangeSelection()) exec(commands.deleteSelectionCommand, undefined); - else exec(commands.trackedDeleteCommand, { direction: e.inputType.endsWith('Forward') ? 1 : -1 }); + if (isTrackingRevisions(model) && !hasRangeSelection()) { + exec(commands.trackedDeleteCommand, { direction: e.inputType.endsWith('Forward') ? 1 : -1 }); + } else if (hasRangeSelection() || (selectTargetRange(e) && hasRangeSelection())) { + exec(commands.deleteSelectionCommand, undefined); + } } } @@ -519,6 +692,37 @@ return undefined; } + /** + * A single click in the header or footer margin while editing the body puts + * the caret on the nearest body line, as in Word. The header is not + * editable then, so the browser would leave the caret where it was. + */ + function onMouseDown(e: MouseEvent): void { + endTyping(); + if (session.viewMode !== 'print' || editingHeaderFooter || e.button !== 0 || e.shiftKey) return; + // Body content placed in the margin (a picture) takes its own click. + if ((e.target as Element).closest('.wk-body')) return; + const zone = marginZoneAt(e); + const body = (e.target as Element).closest('.wk-pagebox')?.querySelector('.wk-body'); + const lines = body ? [...body.querySelectorAll('.wk-p')] : []; + const line = zone?.kind === 'header' ? lines[0] : lines.at(-1); + if (!zone || !line) return; + const rect = line.getBoundingClientRect(); + const y = Math.min(Math.max(e.clientY, rect.top + 1), rect.bottom - 1); + const x = Math.min(Math.max(e.clientX, rect.left + 1), rect.right - 1); + const hit = document.caretPositionFromPoint(x, y); + // Off screen (a long page scrolled to its foot) the line has no hit + // point: take its end nearest the click instead. + const at = + hit && line.contains(hit.offsetNode) + ? { node: hit.offsetNode, offset: hit.offset } + : edgePoint(line, zone.kind === 'footer'); + e.preventDefault(); + canvas?.focus({ preventScroll: true }); + window.getSelection()?.collapse(at.node, at.offset); + onSelChange(); + } + /** Double-click in a header/footer area opens it; in the body, closes it (Word). */ function onDblClick(e: MouseEvent): void { if (session.viewMode !== 'print') return; @@ -593,7 +797,7 @@ aria-label={t('canvas.label')} oninput={onInput} onkeydown={onKeydown} - onmousedown={endTyping} + onmousedown={onMouseDown} onpointerdown={(e) => tablePointerDown(e, model, exec)} onpointerup={(e) => tablePointerUp(e, model, exec)} onblur={endTyping} diff --git a/site/src/lib/editor/canvas/caret.ts b/site/src/lib/editor/canvas/caret.ts index 66fae0b..23b8f53 100644 --- a/site/src/lib/editor/canvas/caret.ts +++ b/site/src/lib/editor/canvas/caret.ts @@ -119,3 +119,80 @@ export function focusPage(): number | undefined { const page = el?.closest(".wk-pagebox")?.dataset.page; return page === undefined ? undefined : Number(page); } + +/** + * The paragraphs a caret can move through from `from`, in reading order: the + * header / footer / note it is in (as shown on that page), or else the body + * without the second window a split paragraph shows. + */ +function storyParagraphs(root: HTMLElement, from: HTMLElement): HTMLElement[] { + const story = from.closest(STORY_SELECTOR); + if (story) return [...story.querySelectorAll(".wk-p")]; + return [...root.querySelectorAll(".wk-p")].filter( + (p) => !p.closest("[data-wk-clone]") && !p.closest(STORY_SELECTOR), + ); +} + +/** The first or last caret point inside a paragraph's own text. */ +export function edgePoint(p: HTMLElement, end: boolean): DomPoint { + const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, { + // List labels and other generated text are not editable. + acceptNode: (n) => + n.parentElement?.closest('[contenteditable="false"]') + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT, + }); + let first: Text | null = null; + let last: Text | null = null; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + if (!(n instanceof Text)) continue; + first ??= n; + last = n; + } + const node = end ? last : first; + if (!node) return { node: p, offset: end ? p.childNodes.length : 0 }; + return { node, offset: end ? node.length : 0 }; +} + +const STORY_SELECTOR = ".wk-hf, .wk-note, .wk-endnotes"; + +export type ParagraphJump = "previous" | "next" | "first" | "last"; + +/** Select All (⌘A): the whole story `from` belongs to, first paragraph to last. */ +export function storyRange( + root: HTMLElement, + from: HTMLElement, +): { start: DomPoint; end: DomPoint } | null { + const paragraphs = storyParagraphs(root, from); + const first = paragraphs[0]; + const last = paragraphs.at(-1); + return first && last ? { start: edgePoint(first, false), end: edgePoint(last, true) } : null; +} + +/** + * Word's paragraph and document jumps (⌘↑ / ⌘↓, ⌘Home / ⌘End): the caret + * point to move to from the paragraph `from` holding `at`. ⌘↑ inside a + * paragraph goes to its own start first, as in Word. + */ +export function paragraphJumpPoint( + root: HTMLElement, + from: HTMLElement, + at: DomPoint, + jump: ParagraphJump, +): DomPoint | null { + const paragraphs = storyParagraphs(root, from); + const index = paragraphs.indexOf(from); + if (jump === "first") return paragraphs[0] ? edgePoint(paragraphs[0], false) : null; + if (jump === "last") { + const last = paragraphs.at(-1); + return last ? edgePoint(last, true) : null; + } + if (jump === "previous") { + const start = edgePoint(from, false); + const atStart = start.node === at.node && start.offset === at.offset; + const target = atStart ? paragraphs[index - 1] : from; + return target ? edgePoint(target, false) : null; + } + const next = paragraphs[index + 1]; + return next ? edgePoint(next, false) : edgePoint(from, true); +}