smartrte-core 1.0.0-beta.6 → 1.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.7
|
|
4
|
+
|
|
5
|
+
- Fix copy, cut, and native drag throwing an uncaught error ("Clipboard copy is clamped to one structural parent.") for any selection whose two endpoints weren't immediate siblings — e.g. starting in a plain paragraph and dragging into a nested list item, or spanning two list items at different nesting depths. This was not an exotic shape: any selection reaching into a list, table, or blockquote from outside it hit this, and the failure was a silent no-op (nothing copied) with a console error, not a graceful fallback. See `docs/bugs/clipboard-copy-cut-throws-across-differently-nested-endpoints.md`.
|
|
6
|
+
|
|
3
7
|
## 1.0.0-beta.6
|
|
4
8
|
|
|
5
9
|
- Fix pasting a long, list-free document (many short paragraphs, no images) being slow enough to look broken — the Office/Google Docs list normalizers recursed into every paragraph hunting for nested lists a `<p>` can never structurally contain. Pasting a few thousand blocks now completes in well under a second instead of multiple seconds to tens of seconds. See `docs/bugs/clipboard-paste-slow-on-large-list-free-documents.md` for the full investigation, including a known remaining limitation for very large (10,000+ block) pastes.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { isTextNode } from "../identity.js";
|
|
2
2
|
import { comparePos, nodeAtPath } from "../positions.js";
|
|
3
3
|
const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
4
|
+
const commonPrefixLength = (left, right) => {
|
|
5
|
+
let index = 0;
|
|
6
|
+
while (index < left.length && index < right.length && left[index] === right[index])
|
|
7
|
+
index += 1;
|
|
8
|
+
return index;
|
|
9
|
+
};
|
|
4
10
|
const size = (node) => isTextNode(node) ? node.text.length : 1;
|
|
5
11
|
const split = (children, offset) => {
|
|
6
12
|
const before = [];
|
|
@@ -31,6 +37,42 @@ const element = (document, pos) => {
|
|
|
31
37
|
throw new Error("Clipboard selection owner is invalid.");
|
|
32
38
|
return node;
|
|
33
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* `node`'s content, keeping only what lies at-or-after (path, offset) within it -
|
|
42
|
+
* fully-included later siblings at every level, and a recursively-trimmed branch
|
|
43
|
+
* for the one that actually contains the position. Used for the tail (or "from")
|
|
44
|
+
* endpoint of a selection that crosses into a different structural ancestor than
|
|
45
|
+
* its other endpoint (e.g. from a plain paragraph into a nested list item) - a
|
|
46
|
+
* selection shape that isn't naturally isTextNode/same-parent, but is still a
|
|
47
|
+
* perfectly ordinary drag-selection a user can make.
|
|
48
|
+
*/
|
|
49
|
+
const afterSlice = (node, path, offset) => {
|
|
50
|
+
const children = node.children || [];
|
|
51
|
+
if (path.length === 0) {
|
|
52
|
+
const [, tail] = split(children, offset);
|
|
53
|
+
return { ...node, children: tail };
|
|
54
|
+
}
|
|
55
|
+
const index = path[0];
|
|
56
|
+
const child = children[index];
|
|
57
|
+
if (!child || isTextNode(child))
|
|
58
|
+
throw new Error("Clipboard selection path is malformed.");
|
|
59
|
+
const rest = children.slice(index + 1).map((sibling) => structuredClone(sibling));
|
|
60
|
+
return { ...node, children: [afterSlice(child, path.slice(1), offset), ...rest] };
|
|
61
|
+
};
|
|
62
|
+
/** Mirror of {@link afterSlice}: keeps only what lies strictly before (path, offset). */
|
|
63
|
+
const beforeSlice = (node, path, offset) => {
|
|
64
|
+
const children = node.children || [];
|
|
65
|
+
if (path.length === 0) {
|
|
66
|
+
const [before] = split(children, offset);
|
|
67
|
+
return { ...node, children: before };
|
|
68
|
+
}
|
|
69
|
+
const index = path[0];
|
|
70
|
+
const child = children[index];
|
|
71
|
+
if (!child || isTextNode(child))
|
|
72
|
+
throw new Error("Clipboard selection path is malformed.");
|
|
73
|
+
const rest = children.slice(0, index).map((sibling) => structuredClone(sibling));
|
|
74
|
+
return { ...node, children: [...rest, beforeSlice(child, path.slice(1), offset)] };
|
|
75
|
+
};
|
|
34
76
|
export const sliceClipboardSelection = (document, selection) => {
|
|
35
77
|
const selected = range(selection);
|
|
36
78
|
if (selection.type === "node" || selection.type === "cell") {
|
|
@@ -45,24 +87,27 @@ export const sliceClipboardSelection = (document, selection) => {
|
|
|
45
87
|
const [content] = split(tail, selected.to.offset - selected.from.offset);
|
|
46
88
|
return { type: "doc", id: document.id, children: [{ ...fromOwner, children: content }] };
|
|
47
89
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
90
|
+
// The two endpoints don't share an immediate parent - they may still share a
|
|
91
|
+
// deeper common ancestor (e.g. one endpoint is a plain paragraph and the other
|
|
92
|
+
// is nested inside a list item or table cell several levels down, an entirely
|
|
93
|
+
// ordinary drag-selection). Slice each endpoint's own top-level branch under
|
|
94
|
+
// that ancestor recursively, rather than assuming a flat sibling relationship.
|
|
95
|
+
const commonLength = commonPrefixLength(selected.from.path, selected.to.path);
|
|
96
|
+
const ancestorPath = selected.from.path.slice(0, commonLength);
|
|
97
|
+
const ancestor = nodeAtPath(document, ancestorPath);
|
|
98
|
+
if (!ancestor || isTextNode(ancestor) || !ancestor.children)
|
|
99
|
+
throw new Error("Clipboard selection ancestor is invalid.");
|
|
100
|
+
const fromIndex = selected.from.path[commonLength];
|
|
101
|
+
const toIndex = selected.to.path[commonLength];
|
|
102
|
+
const fromBranch = ancestor.children[fromIndex];
|
|
103
|
+
const toBranch = ancestor.children[toIndex];
|
|
104
|
+
if (!fromBranch || !toBranch || isTextNode(fromBranch) || isTextNode(toBranch))
|
|
105
|
+
throw new Error("Clipboard selection endpoint is invalid.");
|
|
106
|
+
const first = afterSlice(fromBranch, selected.from.path.slice(commonLength + 1), selected.from.offset);
|
|
107
|
+
const last = beforeSlice(toBranch, selected.to.path.slice(commonLength + 1), selected.to.offset);
|
|
59
108
|
return {
|
|
60
109
|
type: "doc", id: document.id,
|
|
61
|
-
children: [
|
|
62
|
-
{ ...fromOwner, children: first },
|
|
63
|
-
...parent.children.slice(fromIndex + 1, toIndex).map((node) => structuredClone(node)),
|
|
64
|
-
{ ...toOwner, children: last },
|
|
65
|
-
],
|
|
110
|
+
children: [first, ...ancestor.children.slice(fromIndex + 1, toIndex).map((node) => structuredClone(node)), last],
|
|
66
111
|
};
|
|
67
112
|
};
|
|
68
113
|
export const deleteClipboardSelection = (document, selection, positions) => {
|
|
@@ -91,18 +136,45 @@ export const deleteClipboardSelection = (document, selection, positions) => {
|
|
|
91
136
|
};
|
|
92
137
|
}
|
|
93
138
|
const parentPath = selected.from.path.slice(0, -1);
|
|
94
|
-
const parent = nodeAtPath(document, parentPath);
|
|
95
139
|
const toOwner = element(document, selected.to);
|
|
96
|
-
if (
|
|
97
|
-
|
|
140
|
+
if (samePath(parentPath, selected.to.path.slice(0, -1))) {
|
|
141
|
+
const parent = nodeAtPath(document, parentPath);
|
|
142
|
+
if (!parent || isTextNode(parent) || !parent.children)
|
|
143
|
+
throw new Error("Clipboard deletion parent is invalid.");
|
|
144
|
+
const fromIndex = selected.from.path[selected.from.path.length - 1];
|
|
145
|
+
const toIndex = selected.to.path[selected.to.path.length - 1];
|
|
146
|
+
const [before] = split(owner.children || [], selected.from.offset);
|
|
147
|
+
const [, after] = split(toOwner.children || [], selected.to.offset);
|
|
148
|
+
const operations = [];
|
|
149
|
+
for (let index = toIndex; index > fromIndex; index -= 1)
|
|
150
|
+
operations.push({ type: "removeNode", pos: { path: [...parentPath], offset: index }, node: parent.children[index] });
|
|
151
|
+
operations.push({ type: "replaceNode", pos: ownerPosition.pos, before: owner, after: { ...owner, children: [...before, ...after] } });
|
|
152
|
+
return { operations, selectionTarget: { ownerId: owner.id, offset: selected.from.offset } };
|
|
153
|
+
}
|
|
154
|
+
// Endpoints live under different immediate parents (e.g. cutting from a plain
|
|
155
|
+
// paragraph into a nested list item) - too structurally different to merge
|
|
156
|
+
// into one owner the way same-parent siblings do above, but still coherent:
|
|
157
|
+
// trim each endpoint's own top-level branch down to what's unselected, remove
|
|
158
|
+
// any branch fully between them, and leave both trimmed branches in place.
|
|
159
|
+
const commonLength = commonPrefixLength(selected.from.path, selected.to.path);
|
|
160
|
+
const ancestorPath = selected.from.path.slice(0, commonLength);
|
|
161
|
+
const ancestor = nodeAtPath(document, ancestorPath);
|
|
162
|
+
if (!ancestor || isTextNode(ancestor) || !ancestor.children)
|
|
163
|
+
throw new Error("Clipboard deletion ancestor is invalid.");
|
|
164
|
+
const fromIndex = selected.from.path[commonLength];
|
|
165
|
+
const toIndex = selected.to.path[commonLength];
|
|
166
|
+
const fromBranch = ancestor.children[fromIndex];
|
|
167
|
+
const toBranch = ancestor.children[toIndex];
|
|
168
|
+
if (!fromBranch || !toBranch || isTextNode(fromBranch) || isTextNode(toBranch))
|
|
169
|
+
throw new Error("Clipboard deletion endpoint is invalid.");
|
|
170
|
+
const trimmedFrom = beforeSlice(fromBranch, selected.from.path.slice(commonLength + 1), selected.from.offset);
|
|
171
|
+
const trimmedTo = afterSlice(toBranch, selected.to.path.slice(commonLength + 1), selected.to.offset);
|
|
172
|
+
const operations = [
|
|
173
|
+
{ type: "replaceNode", pos: { path: [...ancestorPath], offset: toIndex }, before: toBranch, after: trimmedTo },
|
|
174
|
+
];
|
|
175
|
+
for (let index = toIndex - 1; index > fromIndex; index -= 1) {
|
|
176
|
+
operations.push({ type: "removeNode", pos: { path: [...ancestorPath], offset: index }, node: ancestor.children[index] });
|
|
98
177
|
}
|
|
99
|
-
|
|
100
|
-
const toIndex = selected.to.path[selected.to.path.length - 1];
|
|
101
|
-
const [before] = split(owner.children || [], selected.from.offset);
|
|
102
|
-
const [, after] = split(toOwner.children || [], selected.to.offset);
|
|
103
|
-
const operations = [];
|
|
104
|
-
for (let index = toIndex; index > fromIndex; index -= 1)
|
|
105
|
-
operations.push({ type: "removeNode", pos: { path: [...parentPath], offset: index }, node: parent.children[index] });
|
|
106
|
-
operations.push({ type: "replaceNode", pos: ownerPosition.pos, before: owner, after: { ...owner, children: [...before, ...after] } });
|
|
178
|
+
operations.push({ type: "replaceNode", pos: { path: [...ancestorPath], offset: fromIndex }, before: fromBranch, after: trimmedFrom });
|
|
107
179
|
return { operations, selectionTarget: { ownerId: owner.id, offset: selected.from.offset } };
|
|
108
180
|
};
|
|
@@ -601,15 +601,25 @@ export class FoundationInputPipeline {
|
|
|
601
601
|
if (!event.clipboardData || collapsed(this.editor.selection))
|
|
602
602
|
return;
|
|
603
603
|
event.preventDefault();
|
|
604
|
-
|
|
604
|
+
try {
|
|
605
|
+
this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
this.unhandled.push("copy-rejected");
|
|
609
|
+
}
|
|
605
610
|
}
|
|
606
611
|
handleCut(event) {
|
|
607
612
|
if (!event.clipboardData || collapsed(this.editor.selection))
|
|
608
613
|
return;
|
|
609
614
|
event.preventDefault();
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
615
|
+
try {
|
|
616
|
+
this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
|
|
617
|
+
const deletion = deleteClipboardSelection(this.editor.document, this.editor.selection, this.editor.positions);
|
|
618
|
+
this.commitClipboard({ ...deletion, definingAncestorId: null }, "cut");
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
this.unhandled.push("cut-rejected");
|
|
622
|
+
}
|
|
613
623
|
}
|
|
614
624
|
handlePaste(event) {
|
|
615
625
|
event.preventDefault();
|
|
@@ -635,10 +645,15 @@ export class FoundationInputPipeline {
|
|
|
635
645
|
handleDragStart(event) {
|
|
636
646
|
if (!event.dataTransfer || collapsed(this.editor.selection))
|
|
637
647
|
return;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
648
|
+
try {
|
|
649
|
+
const fragment = sliceClipboardSelection(this.editor.document, this.editor.selection);
|
|
650
|
+
this.internalDrag = { selection: this.editor.selection, fragment };
|
|
651
|
+
this.writeTransfer(event.dataTransfer, fragment);
|
|
652
|
+
event.dataTransfer.effectAllowed = "move";
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
this.unhandled.push("dragstart-rejected");
|
|
656
|
+
}
|
|
642
657
|
}
|
|
643
658
|
handleDrop(event) {
|
|
644
659
|
event.preventDefault();
|