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
- const parentPath = selected.from.path.slice(0, -1);
49
- if (!samePath(parentPath, selected.to.path.slice(0, -1)))
50
- throw new Error("Clipboard copy is clamped to one structural parent.");
51
- const parent = nodeAtPath(document, parentPath);
52
- const toOwner = element(document, selected.to);
53
- if (!parent || isTextNode(parent) || !parent.children)
54
- throw new Error("Clipboard selection parent is invalid.");
55
- const fromIndex = selected.from.path[selected.from.path.length - 1];
56
- const toIndex = selected.to.path[selected.to.path.length - 1];
57
- const [, first] = split(fromOwner.children || [], selected.from.offset);
58
- const [last] = split(toOwner.children || [], selected.to.offset);
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 (!samePath(parentPath, selected.to.path.slice(0, -1)) || !parent || isTextNode(parent) || !parent.children) {
97
- throw new Error("Clipboard deletion is clamped to one structural parent.");
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
- const fromIndex = selected.from.path[selected.from.path.length - 1];
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
- this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
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
- this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
611
- const deletion = deleteClipboardSelection(this.editor.document, this.editor.selection, this.editor.positions);
612
- this.commitClipboard({ ...deletion, definingAncestorId: null }, "cut");
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
- const fragment = sliceClipboardSelection(this.editor.document, this.editor.selection);
639
- this.internalDrag = { selection: this.editor.selection, fragment };
640
- this.writeTransfer(event.dataTransfer, fragment);
641
- event.dataTransfer.effectAllowed = "move";
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-core",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.7",
4
4
  "description": "Framework-agnostic document model and editing primitives for Smart RTE",
5
5
  "repository": {
6
6
  "type": "git",