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
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
// 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();
|
||
});
|
||
});
|