Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/editor-adversarial-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/editor/src/commands/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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();
Expand Down
118 changes: 111 additions & 7 deletions packages/editor/src/commands/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -106,6 +114,12 @@ export const mergeBackCommand: Command<void> = {
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));
},
Expand All @@ -116,6 +130,33 @@ export const mergeBackCommand: Command<void> = {
},
};

/**
* 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);
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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<WmlRun>(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
Expand Down
53 changes: 53 additions & 0 deletions packages/editor/src/commands/text-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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],
]);
});
});
22 changes: 21 additions & 1 deletion packages/editor/src/dom-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 71 additions & 7 deletions packages/editor/src/range-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading