Files
edentext/src/lib/editor/extensions/trailingNode.ts
T
Steffen Becker de7f63f269 fix: Enter frees the caret from a leading table
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.
2026-08-20 14:26:57 +02:00

61 lines
2.5 KiB
TypeScript

import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
// Keep an empty paragraph after a trailing table: a table is isolating, so at the very
// end the cursor gets trapped with nowhere to type below. Appending a paragraph fixes
// it and self-terminates (once the last child is a paragraph the check no-ops).
const trailingNodeKey = new PluginKey('trailingNode');
// Block types that can't sit at the document end without trapping the cursor.
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({
key: trailingNodeKey,
appendTransaction(_transactions, _oldState, newState) {
// The note section is not body text and sits past it, so the block that needs
// a paragraph under it is the last one before the section.
const doc = newState.doc;
const notes = doc.lastChild?.type.name === 'noteSection' ? doc.lastChild : null;
const end = doc.content.size - (notes?.nodeSize ?? 0);
const last = notes ? doc.childBefore(end).node : doc.lastChild;
if (!last || !NEEDS_TRAILING_AFTER.has(last.type.name)) return null;
const paragraph = newState.schema.nodes.paragraph;
if (!paragraph) return null;
return newState.tr.insert(end, paragraph.create());
},
}),
];
},
});