From 98fea9597d631122f42f4f7a069bc78dfd1e3e44 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Tue, 6 Oct 2026 02:41:31 +0900 Subject: [PATCH] fix(editor): keep a paragraph after a table that ends the document Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UAiVj14HzKtRDqK12BXTr6 --- .changeset/editor-final-paragraph.md | 5 +++++ packages/editor/src/history.test.ts | 21 +++++++++++++++++++-- packages/editor/src/model.ts | 13 ++++++++++++- packages/editor/src/range-edit.test.ts | 3 ++- 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .changeset/editor-final-paragraph.md diff --git a/.changeset/editor-final-paragraph.md b/.changeset/editor-final-paragraph.md new file mode 100644 index 0000000..845aa4e --- /dev/null +++ b/.changeset/editor-final-paragraph.md @@ -0,0 +1,5 @@ +--- +"@office-kit/docx-editor": patch +--- + +The editor keeps a paragraph after a table that ends the document, as Word does. Before, there was no way to type below such a table. This affected documents that end with a table, and tables inserted at the end of a document. diff --git a/packages/editor/src/history.test.ts b/packages/editor/src/history.test.ts index 2cff316..3311c85 100644 --- a/packages/editor/src/history.test.ts +++ b/packages/editor/src/history.test.ts @@ -27,6 +27,7 @@ import { openEditor, runCommand, } from "./index.js"; +import { caretAt } from "./selection.js"; function editorWith(texts: string[]): EditorModel { const model = editorFor(createDocx({ paragraphs: texts })); @@ -141,9 +142,25 @@ describe("replaceAllCommand", () => { expect(model.canUndo()).toBe(false); }); + it("keeps a paragraph after a table that ends the document", () => { + const doc = createDocx({ paragraphs: ["intro"] }); + addTable(doc, [["cell"]]); + const model = editorFor(doc); + expect(model.doc.document.body.blocks.map((b) => b.kind)).toEqual([ + "paragraph", + "table", + "paragraph", + ]); + // Inserting a table at the end leaves the caret's paragraph after it, too. + model.setSelection(caretAt({ block: 2, inline: 0, offset: 0 })); + runCommand(model, commands.insertTableCommand, { rows: 1, cols: 1 }); + expect(model.doc.document.body.blocks.at(-1)?.kind).toBe("paragraph"); + }); + it("replaces inside table cells in the same undo step, and redo re-applies it", () => { const doc = createDocx({ paragraphs: ["cat"] }); addTable(doc, [["cat cell"]]); + // The editor adds the paragraph Word keeps after a table that ends the body. const model = editorFor(doc); expect( runCommand(model, commands.replaceAllCommand, { query: "cat", replacement: "dog" }), @@ -151,10 +168,10 @@ describe("replaceAllCommand", () => { const cell = (): string => getTableCellText(tables(model.doc)[0]!, 0, 0); expect(cell()).toBe("dog cell"); model.undo(); - expect([texts(model), cell()]).toEqual([["cat"], "cat cell"]); + expect([texts(model), cell()]).toEqual([["cat", ""], "cat cell"]); expect(model.canUndo()).toBe(false); model.redo(); - expect([texts(model), cell()]).toEqual([["dog"], "dog cell"]); + expect([texts(model), cell()]).toEqual([["dog", ""], "dog cell"]); const reopened = openDocx(toUint8Array(model.doc)); expect(getTableCellText(tables(reopened)[0]!, 0, 0)).toBe("dog cell"); }); diff --git a/packages/editor/src/model.ts b/packages/editor/src/model.ts index c97d718..7d37d26 100644 --- a/packages/editor/src/model.ts +++ b/packages/editor/src/model.ts @@ -9,7 +9,7 @@ * do one thing"). */ -import { clone, type Docx, storyView } from "@office-kit/docx"; +import { appendParagraph, clone, type Docx, storyView } from "@office-kit/docx"; import type { DocPosition, Selection, StoryRef } from "./selection.js"; export interface EditorSnapshot { @@ -19,6 +19,15 @@ export interface EditorSnapshot { export type ChangeListener = (model: EditorModel) => void; +/** + * Word's body always ends with a paragraph: a document that ends with a table + * (or is empty) gets one on open, and an edit that leaves a table last gets + * one too. Without it there is nowhere to put the caret below the table. + */ +function ensureFinalParagraph(doc: Docx): void { + if (doc.document.body.blocks.at(-1)?.kind !== "paragraph") appendParagraph(doc, ""); +} + /** How deep the undo/redo history is allowed to grow. */ const DEFAULT_HISTORY_LIMIT = 200; @@ -42,6 +51,7 @@ export class EditorModel { constructor(doc: Docx, options: { historyLimit?: number } = {}) { this.docState = doc; this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT; + ensureFinalParagraph(doc); } /** @@ -113,6 +123,7 @@ export class EditorModel { this.pending = null; if (nextSelection !== undefined) this.setSelectionQuietly(nextSelection); this.editStory = null; + ensureFinalParagraph(this.docState); this.docState.dirty = true; this.emit(); } diff --git a/packages/editor/src/range-edit.test.ts b/packages/editor/src/range-edit.test.ts index 72579cb..3939884 100644 --- a/packages/editor/src/range-edit.test.ts +++ b/packages/editor/src/range-edit.test.ts @@ -189,10 +189,11 @@ describe("table cells", () => { it("Enter splits the cell paragraph instead of adding one after the table", () => { const model = cellModel(); + const blocks = model.doc.document.body.blocks.length; model.setSelection({ anchor: at(0, 3), focus: at(0, 3) }); runCommand(model, commands.splitParagraphCommand, undefined); expect(cellTexts(model)).toEqual(["abc", "def"]); - expect(model.doc.document.body.blocks).toHaveLength(2); + expect(model.doc.document.body.blocks).toHaveLength(blocks); expect(model.selection?.focus).toMatchObject({ block: 1, cell: { row: 0, col: 0 }, para: 1 }); expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); });