From de7f63f269b14e570387440115dd323ecb0dad14 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 20 Aug 2026 14:26:57 +0200 Subject: [PATCH] fix: Enter frees the caret from a leading table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the very first position of a table with no block before it, Enter inserts a paragraph above the table and moves the caret there, as both word processors do — previously the caret could never get before such a table. Generic first-child check, so it also covers a table leading a cell, text box or columns section. --- src/lib/editor/extensions/CLAUDE.md | 2 +- src/lib/editor/extensions/trailingNode.ts | 26 +++++++++ tests/unit/leading-table-enter.test.ts | 65 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/unit/leading-table-enter.test.ts diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index a91f5b2..50617a1 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -40,7 +40,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale. - **`bibliographyEntry.ts`** — one citation, an inline **atom** carrying the whole source record (`identifier`, `type`, and every remaining ODF field in one `fields` record, so a file's fields we have no UI for still round-trip). ODF puts that record on the mark itself, which is what lets the document travel without a database; Word keeps it in a custom-XML part and cites it by tag, so the same attrs feed both. The **citation style** (`utils/citationStyle.ts` — LibreOffice's cite-by-key, numbered, APA, MLA, Chicago) rides the bibliography index's node, which is where both formats keep it: a row is a token list, exactly what an ODF entry template is, so LibreOffice regenerates the same rows (it fills the types the document does not cite with its own default, so the import takes the first template reading as anything but `key`). Word gets the style as `b:Sources StyleName`. Each citation's label is derived, kept true by an `appendTransaction` off the undo stack like the caption numbers. `bibliographyRows` is the list: one row per source, **in document order** — LibreOffice's own default is document position, not alphabetical — reading "key: author, title, year", the shape its entry templates produce. `DOCX_SOURCE_TYPE`/`DOCX_BIB_FIELD` map onto Word's smaller set, and the fields it has no slot for survive only the ODF leg. - **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family that **never** has a page number or a leader — a bibliography row is the source and nothing else, which is how both word processors print one. Any other family drops both where the file says so (`pageNumbers: false` — Word's `TOC \n`, an ODF entry template naming no ``; the export writes the stop out with the number, which is the pair LibreOffice's own dialog switches together). -- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). +- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). Also the trap at the start: Enter at the very first position of a table with nothing before it inserts a paragraph above and moves the caret there. - **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table. - **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as ``; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one). - **`autoCorrect.ts`** — AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options): typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization and TWo INitials. One `handleTextInput` plugin, listed **last** in `extensions.ts` so every input rule (list markers, the rule line) still fires first; `autoCorrectFix(before, opts, lang)` is pure — the whole table is `tests/unit/autocorrect.test.ts`. URL recognition and auto-list are TipTap's own (`Link autolink` — gated by the same options — and the list input rules). diff --git a/src/lib/editor/extensions/trailingNode.ts b/src/lib/editor/extensions/trailingNode.ts index a7b293d..fbc47c4 100644 --- a/src/lib/editor/extensions/trailingNode.ts +++ b/src/lib/editor/extensions/trailingNode.ts @@ -12,6 +12,32 @@ const NEEDS_TRAILING_AFTER = new Set(['table', 'textBox', 'columns']); export const TrailingNode = Extension.create({ name: 'trailingNode', + // The trap mirrored at the start: nothing can sit before a leading table, so Enter + // at the table's very first position inserts a paragraph above it and moves the + // caret there — the only way to get text before such a table. + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parentOffset !== 0) return false; + let depth = 0; + for (let d = $from.depth; d > 0 && !depth; d--) { + if ($from.node(d).type.name === 'table') depth = d; + } + if (!depth) return false; + // First row, first cell, first block — anything else is a normal Enter. + for (let d = depth; d < $from.depth; d++) if ($from.index(d) !== 0) return false; + const tablePos = $from.before(depth); + if (editor.state.doc.resolve(tablePos).index() !== 0) return false; + return editor + .chain() + .insertContentAt(tablePos, { type: 'paragraph' }) + .setTextSelection(tablePos + 1) + .run(); + }, + }; + }, + addProseMirrorPlugins() { return [ new Plugin({ diff --git a/tests/unit/leading-table-enter.test.ts b/tests/unit/leading-table-enter.test.ts new file mode 100644 index 0000000..be7b2b3 --- /dev/null +++ b/tests/unit/leading-table-enter.test.ts @@ -0,0 +1,65 @@ +// Enter at the very first position of a table with nothing before it inserts a +// paragraph above the table and moves the caret there — the only way to get text +// before such a table. Anywhere else in the table Enter stays a normal split. +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 { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table'; +import { TrailingNode } from '../../src/lib/editor/extensions/trailingNode'; + +type N = any; + +const cell = (text: string): N => ({ + type: 'tableCell', + content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }], +}); +const table = (...rows: N[][]): N => ({ + type: 'table', + content: rows.map((cells) => ({ type: 'tableRow', content: cells })), +}); + +function makeEditor(...content: N[]) { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ + element: el, + extensions: [Document, Paragraph, Text, Table, TableRow, TableHeader, TableCell, TrailingNode], + content: { type: 'doc', content }, + }); +} +const pressEnter = (editor: Editor) => + editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + +describe('Enter before a leading table', () => { + it('inserts a paragraph above and moves the caret into it', () => { + const editor = makeEditor(table([cell('a'), cell('b')])); + editor.commands.setTextSelection(4); // start of the first cell's text + pressEnter(editor); + const json = editor.getJSON().content as N[]; + expect(json[0].type).toBe('paragraph'); + expect(json[1].type).toBe('table'); + expect(editor.state.selection.from).toBe(1); + editor.destroy(); + }); + + it('stays a normal split when a paragraph already precedes the table', () => { + const editor = makeEditor({ type: 'paragraph' }, table([cell('a')])); + editor.commands.setTextSelection(6); // start of the first cell's text + pressEnter(editor); + const json = editor.getJSON().content as N[]; + expect(json.filter((n) => n.type === 'paragraph' && json.indexOf(n) === 0).length).toBe(1); + expect(json[1].content[0].content[0].content.length).toBe(2); // the cell split instead + editor.destroy(); + }); + + it('stays a normal split away from the first position', () => { + const editor = makeEditor(table([cell('a'), cell('b')])); + editor.commands.setTextSelection(9); // second cell + pressEnter(editor); + const json = editor.getJSON().content as N[]; + expect(json[0].type).toBe('table'); + editor.destroy(); + }); +});