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, 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[] = []; diff --git a/tests/unit/text-box-guards.test.ts b/tests/unit/text-box-guards.test.ts index 52786c7..7d3c18a 100644 --- a/tests/unit/text-box-guards.test.ts +++ b/tests/unit/text-box-guards.test.ts @@ -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 +//

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'); + expect(html).not.toContain('