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:
1 parent
572e3a760d
commit
f1f9a4df15
3 files changed
+86
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user