fix: a text box survives a paste from another window

The clipboard carries HTML, and in an HTML string any block tag inside a
<p> closes it — so a copied box arrived as loose paragraphs. It is now
serialized as an inline span carrying its blocks as JSON, read back by a
matching parse rule; its plain text stays in the span for readers that
cannot take the attribute back.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent 572e3a760d
commit f1f9a4df15
3 files changed
+86 -4

No files matched your search

+6 -2
View File
@@ -16,8 +16,12 @@ path reads the live DOM. The **vector print path** (`export/pdf.ts`) handles it:
`hoistTextBoxes` gives each box a paragraph of its own before serializing, and
`buildBodyHtml` drops the two empty paragraphs the split then leaves around it — so the
box prints as a block between whole paragraphs, which is how it printed as one.
An **AutoText** entry holding a box keeps the box's text and loses the frame; so does a
paste from another window.
The **clipboard** handles it the other way: `boxClipboardSerializer` (a
`clipboardSerializer` prop) writes each box as an inline `<span data-tbx>` carrying its
blocks as JSON, and the `span[data-tbx]` parse rule's `getContent` reads them back — so a
paste into another window keeps the box where it stood in the line, while a program that
cannot read the attribute still gets the span's plain text. An **AutoText** entry stores
the slice's nodes for the same reason (`storage/autoText.ts`).
## Clicking a float
+37 -2
View File
@@ -2,7 +2,8 @@ import { Node, mergeAttributes } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import TextAlign from '@tiptap/extension-text-align';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode, Slice } from '@tiptap/pm/model';
import { DOMSerializer, Fragment } from '@tiptap/pm/model';
import type { DOMOutputSpec, Node as PMNode, Schema, Slice } from '@tiptap/pm/model';
import { NodeSelection, Selection, TextSelection, Plugin } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
@@ -112,6 +113,33 @@ function isDrawnShape(a: TextBoxAttrs): boolean {
return !!SHAPES[a.shapeKind]?.points || !!a.shapePath || isLineKind(a.shapeKind);
}
// The blocks a copied box carries, read back off the span the clipboard form is.
function clipboardContent(dom: HTMLElement, schema: Schema): Fragment {
try {
return Fragment.fromJSON(schema, JSON.parse(dom.getAttribute('data-tbx') || 'null'));
} catch {
return Fragment.empty;
}
}
// A box holds blocks, and in an HTML string any block tag inside a `<p>` closes it — so
// a box copied to another window would arrive as loose paragraphs. It goes on the
// clipboard as an inline span instead, its blocks in an attribute as JSON, its text
// beside them for every reader that cannot take those back.
function boxClipboardSerializer(schema: Schema): DOMSerializer {
const base = DOMSerializer.fromSchema(schema);
const box = base.nodes.textBox;
const nodes = {
...base.nodes,
textBox: (node: PMNode) => {
const [, attrs] = box(node) as [string, Record<string, string>, 0];
return ['span', { ...attrs, 'data-tbx': JSON.stringify(node.content.toJSON()) },
node.textBetween(0, node.content.size, ' ')] as DOMOutputSpec;
},
};
return new DOMSerializer(nodes, base.marks);
}
export const TextBox = Node.create({
name: 'textBox',
// An inline node carrying block content, like the frame both formats write: it rides
@@ -230,7 +258,12 @@ export const TextBox = Node.create({
},
parseHTML() {
return [{ tag: 'div[data-textbox]' }];
return [
{ tag: 'div[data-textbox]' },
// The clipboard's inline form, whose blocks ride an attribute (see
// boxClipboardSerializer); its own text is what the attribute already holds.
{ tag: 'span[data-tbx]', getContent: (dom, schema) => clipboardContent(dom as HTMLElement, schema) },
];
},
renderHTML({ HTMLAttributes, node }) {
@@ -343,6 +376,7 @@ export const TextBox = Node.create({
// Show the resize frame + handles (via .textbox-active) whenever the caret sits
// inside a box, so a plain click into it reveals the editing frame like an image.
addProseMirrorPlugins() {
const clipboardSerializer = boxClipboardSerializer(this.editor.schema);
return [
new Plugin({
// A selection may not end inside a box it did not start in: deleting one
@@ -377,6 +411,7 @@ export const TextBox = Node.create({
return !carriesBox || !nestsBox(tr.doc);
},
props: {
clipboardSerializer,
decorations(state) {
const { from, to } = state.selection;
const decos: Decoration[] = [];
+43
View File
@@ -4,6 +4,7 @@
// 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';
@@ -214,3 +215,45 @@ describe('the frame as an atom', () => {
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();
});
});