Files
edentext/tests/unit/text-box-guards.test.ts
Steffen Becker fd82080115 feat: a floating table opens as the frame that holds it
Word's w:tblpPr takes a table out of the flow; the editor kept it in, so
whatever followed — and every frame anchored to it — sat as far down as the
table is tall. It now arrives as the box whose content a table may be, which
is the shape LibreOffice keeps such a table in, and both exporters write it
back as the file's own floating one. A drawing in one of its cells is
ordinary table text, so the box-in-box guard lets it stand.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
2026-09-11 20:06:22 +02:00

277 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A text box is an inline node with block content, so two things the schema alone
// cannot say have to be enforced: a box never nests in another, and a selection never
// ends inside a box it did not start in (deleting one would dissolve the frame and
// spill its text into the body).
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import { __parseFromClipboard } from 'prosemirror-view';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Heading from '@tiptap/extension-heading';
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
import ListItem from '@tiptap/extension-list-item';
import { TextBox, TextAlignInFrames } from '../../src/lib/editor/extensions/textBox';
// The box's content takes a table (a floating table rides one), so the schema needs it.
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
type N = any;
// jsdom has none, and the node view refits its wrapper through one.
(globalThis as N).ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
};
const box = (text: string): N => ({
type: 'textBox',
attrs: { width: 160, height: 60 },
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
function makeEditor(...content: N[]) {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({
element: el,
extensions: [Document, Paragraph, Text, Heading, BulletList, OrderedList, ListItem, TextBox,
Table, ResizableTableRow, TableHeader, TableCell,
TextAlignInFrames.configure({ types: ['paragraph', 'heading'] })],
content: { type: 'doc', content },
});
}
const boxCount = (ed: Editor): number => {
let n = 0;
ed.state.doc.descendants((node) => { if (node.type.name === 'textBox') n += 1; });
return n;
};
// The depth the caret sits at inside a box, 0 when it is not in one.
const boxDepth = (ed: Editor): number => {
const $p = ed.state.selection.$from;
for (let d = 1; d <= $p.depth; d++) if ($p.node(d).type.name === 'textBox') return d;
return 0;
};
const boxPos = (ed: Editor): number => {
let pos = -1;
ed.state.doc.descendants((node, p) => { if (pos < 0 && node.type.name === 'textBox') pos = p; });
return pos;
};
const doc = (): N[] => [
{ type: 'paragraph', content: [{ type: 'text', text: 'vor AAA ' }, box('KASTEN'), { type: 'text', text: ' nach BBB' }] },
];
describe('a text box in the line', () => {
it('lives in the paragraph, not beside it', () => {
const ed = makeEditor(...doc());
const para = ed.state.doc.child(0);
expect(para.type.name).toBe('paragraph');
expect(para.content.child(1).type.name).toBe('textBox');
expect(ed.state.schema.nodes.textBox.isInline).toBe(true);
ed.destroy();
});
it('refuses a box inside a box', () => {
const ed = makeEditor(...doc());
const s = ed.state.schema;
const nested = s.nodes.textBox.create({ width: 60, height: 30 }, s.nodes.paragraph.create(null, s.text('INNEN')));
// Position 2 inside the box's own first paragraph.
ed.view.dispatch(ed.state.tr.insert(boxPos(ed) + 2, nested));
expect(boxCount(ed)).toBe(1);
ed.destroy();
});
it('keeps a drawing in a floating table’s cell', () => {
// The frame around a table is the file's floating table, so what its cells hold is
// anchored in ordinary table text — not a frame inside a frame.
const inner = box('IM FELD');
const cell = { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [180] },
content: [{ type: 'paragraph', content: [inner] }] };
const floating = { type: 'textBox', attrs: { width: 200, wrap: 'left' },
content: [{ type: 'table', content: [{ type: 'tableRow', content: [cell] }] }] };
const ed = makeEditor({ type: 'paragraph', content: [floating] });
expect(boxCount(ed)).toBe(2);
ed.destroy();
});
it('grows a selection that reaches into the box to the whole box', () => {
const ed = makeEditor(...doc());
const pos = boxPos(ed);
ed.commands.setTextSelection({ from: pos - 4, to: pos + 4 });
const sel = ed.state.selection;
expect(sel.from).toBe(pos - 4);
expect(sel.to).toBe(pos + ed.state.doc.nodeAt(pos)!.nodeSize);
ed.destroy();
});
it('deletes such a selection whole instead of spilling the box text', () => {
const ed = makeEditor(...doc());
const pos = boxPos(ed);
ed.commands.setTextSelection({ from: pos - 4, to: pos + 4 });
ed.commands.deleteSelection();
expect(boxCount(ed)).toBe(0);
expect(ed.state.doc.textBetween(0, ed.state.doc.content.size)).toBe('vor nach BBB');
ed.destroy();
});
it('leaves a selection inside one box alone', () => {
const ed = makeEditor(...doc());
const pos = boxPos(ed);
ed.commands.setTextSelection({ from: pos + 2, to: pos + 5 });
expect(ed.state.selection.from).toBe(pos + 2);
expect(ed.state.selection.to).toBe(pos + 5);
ed.destroy();
});
});
describe('inserting a text box', () => {
it('puts it at the caret, cursor inside', () => {
const ed = makeEditor({ type: 'paragraph', content: [{ type: 'text', text: 'abcd' }] });
ed.commands.setTextSelection(3);
ed.commands.insertTextBox();
const para = ed.state.doc.child(0);
expect(para.content.child(1).type.name).toBe('textBox');
expect(ed.state.selection.$from.parent.type.name).toBe('paragraph');
expect(boxDepth(ed)).toBeGreaterThan(0);
ed.destroy();
});
it('puts a second one after the block when the caret is inside one', () => {
const ed = makeEditor(...doc());
ed.commands.setTextSelection(boxPos(ed) + 2);
ed.commands.insertTextBox();
expect(boxCount(ed)).toBe(2);
// The new box rides a paragraph of its own, next to the one holding the first.
expect(ed.state.doc.childCount).toBe(2);
expect(ed.state.doc.child(1).content.child(0).type.name).toBe('textBox');
ed.destroy();
});
});
describe('aligning a selected box', () => {
it('moves the block it sits in, not the text inside it', () => {
const ed = makeEditor(...doc());
ed.commands.setNodeSelection(boxPos(ed));
ed.commands.setTextAlign('right');
const outer = ed.state.doc.child(0);
const inner = outer.content.child(1).content.child(0);
expect(outer.attrs.textAlign).toBe('right');
expect(inner.attrs.textAlign).toBeNull();
ed.destroy();
});
it('aligns the text inside when the caret is in the box', () => {
const ed = makeEditor(...doc());
ed.commands.setTextSelection(boxPos(ed) + 2);
ed.commands.setTextAlign('center');
const outer = ed.state.doc.child(0);
expect(outer.attrs.textAlign).toBeNull();
expect(outer.content.child(1).content.child(0).attrs.textAlign).toBe('center');
ed.destroy();
});
});
// The frame holds editable blocks, so at its own first and last text position the
// browser has no neighbouring one to walk to; without a step out the caret is stuck.
describe('leaving a box that is alone in its paragraph', () => {
const press = (ed: Editor, key: string): void => {
const ev = new KeyboardEvent('keydown', { key, bubbles: true });
ed.view.someProp('handleKeyDown', f => f(ed.view, ev));
};
const alone = (): N[] => [{ type: 'paragraph', content: [box('KASTEN')] }];
it('steps before the box on ArrowLeft at its start', () => {
const ed = makeEditor(...alone());
ed.commands.setTextSelection(boxPos(ed) + 2);
press(ed, 'ArrowLeft');
expect(ed.state.selection.from).toBe(boxPos(ed));
expect(boxDepth(ed)).toBe(0);
ed.destroy();
});
it('steps after the box on ArrowRight at its end', () => {
const ed = makeEditor(...alone());
const pos = boxPos(ed);
ed.commands.setTextSelection(pos + 2 + 'KASTEN'.length);
press(ed, 'ArrowRight');
expect(ed.state.selection.from).toBe(pos + ed.state.doc.nodeAt(pos)!.nodeSize);
expect(boxDepth(ed)).toBe(0);
ed.destroy();
});
it('leaves the caret alone inside the text', () => {
const ed = makeEditor(...alone());
ed.commands.setTextSelection(boxPos(ed) + 4);
press(ed, 'ArrowLeft');
expect(ed.state.selection.from).toBe(boxPos(ed) + 4);
ed.destroy();
});
});
// A box that starts its paragraph has no text position beside it, so an editable frame
// swallows the caret meant for the box's own place in the line.
describe('the frame as an atom', () => {
const frame = (ed: Editor): HTMLElement => ed.view.dom.querySelector('.textbox-node')!;
it('is not editable while nobody edits it', () => {
const ed = makeEditor(...doc());
ed.commands.setTextSelection(1);
expect(frame(ed).getAttribute('contenteditable')).toBe('false');
ed.destroy();
});
it('turns editable, and shows its frame, with the caret in its text', () => {
const ed = makeEditor(...doc());
ed.commands.setTextSelection(boxPos(ed) + 2);
expect(frame(ed).getAttribute('contenteditable')).toBeNull();
expect(frame(ed).classList.contains('textbox-active')).toBe(true);
ed.destroy();
});
});
// Copying to another window goes through an HTML string, where any block tag inside a
// <p> closes it — so the box travels as an inline span carrying its blocks as JSON.
describe('a box on the clipboard', () => {
const copyPaste = (src: Editor, dst: Editor): void => {
const html = (src.view.serializeForClipboard(src.state.doc.slice(0, src.state.doc.content.size)) as
{ dom: HTMLElement }).dom.innerHTML;
const slice = __parseFromClipboard(dst.view, '', html, false, dst.state.doc.resolve(1));
if (slice) dst.view.dispatch(dst.state.tr.replaceSelection(slice));
};
it('arrives in another editor as a box in the line', () => {
const src = makeEditor(...doc());
const dst = makeEditor({ type: 'paragraph' });
copyPaste(src, dst);
expect(dst.state.doc.childCount).toBe(1);
const para = dst.state.doc.child(0);
expect(para.content.child(1).type.name).toBe('textBox');
expect(para.content.child(1).textContent).toBe('KASTEN');
expect(para.textContent).toBe('vor AAA KASTEN nach BBB');
src.destroy();
dst.destroy();
});
it('keeps the frame\u2019s own size', () => {
const src = makeEditor(...doc());
const dst = makeEditor({ type: 'paragraph' });
copyPaste(src, dst);
expect(dst.state.doc.child(0).content.child(1).attrs).toMatchObject({ width: 160, height: 60 });
src.destroy();
dst.destroy();
});
it('reads as its plain text where the attribute cannot be taken back', () => {
const src = makeEditor(...doc());
const html = (src.view.serializeForClipboard(src.state.doc.slice(0, src.state.doc.content.size)) as
{ dom: HTMLElement }).dom.innerHTML;
expect(html).toContain('KASTEN</span>');
expect(html).not.toContain('<div');
src.destroy();
});
});