diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md
index 300af5d..d3d0b9e 100644
--- a/docs/architecture/frames.md
+++ b/docs/architecture/frames.md
@@ -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 `` 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
diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts
index 8fcdd56..f124670 100644
--- a/src/lib/editor/extensions/textBox.ts
+++ b/src/lib/editor/extensions/textBox.ts
@@ -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 ` ` 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 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