smartrte-core 1.0.0-beta.5 → 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,13 @@
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
+
7
+ ## 1.0.0-beta.6
8
+
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.
10
+
3
11
  ## 1.0.0-beta.5
4
12
 
5
13
  - Add a per-block `lineHeight` attribute (paragraph, heading, blockquote, code block) — a unitless multiplier matching CSS `line-height`'s own unitless mode (`1`, `1.5`, `2`, ...), consistent with how `align`/`indentLevel` are represented. Absence means no override (the browser/font's own natural line-height), not a forced `1`. Full HTML round-trip fidelity via a dedicated `data-smart-line-height` marker — deliberately does **not** fall back to reading a bare CSS `line-height` value on import (unlike `align`/`indentLevel`'s own CSS fallbacks): real captured clipboard fixtures show `line-height` is frequently an ambient, whole-document default baked onto every paragraph, not a deliberate per-paragraph choice, and recognizing it would have silently misattributed that default as an explicit override on every pasted document. Real Word line spacing (`w:spacing`/`w:line`/`w:lineRule="auto"`) on DOCX export; declared `lossy` there (confirmed empirically that DOCX reimport via mammoth drops it) and `unsupported` (content preserved) in Markdown. Inherits `full` PDF fidelity for free, since this package's "Save as PDF" prints the same HTML export.
@@ -2,6 +2,20 @@ import { parseCanonicalListMarkdown, serializeCanonicalListHtml } from "../list/
2
2
  const listTags = new Set(["UL", "OL"]);
3
3
  const blockSelector = ":scope > p, :scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6, :scope > ul, :scope > ol, :scope > blockquote, :scope > pre, :scope > table, :scope > div";
4
4
  const msoListPattern = /(?:^|;)\s*mso-list\s*:\s*([^\s;]+)\s+level(\d+)\s+([^\s;]+)/i;
5
+ /**
6
+ * Elements whose HTML content model is inline-only - a real HTML parser
7
+ * never lets a <p>/<ul>/<ol>/<div> nest inside one of these (an outer <p>
8
+ * auto-closes first), so a nested Office mso-list paragraph or a
9
+ * declared-level <ul>/<ol> can never appear inside one. normalizeMsoLists/
10
+ * normalizeDeclaredLevelLists below recurse looking for exactly those two
11
+ * things - recursing into one of these tags is always wasted work. See
12
+ * docs/bugs/clipboard-paste-normalizer-quadratic-blowup-on-large-documents.md:
13
+ * on a real document with thousands of top-level paragraphs (the common
14
+ * case for a long pasted document with no lists at all), skipping this
15
+ * recursion is what turns an O(n) visit count with an accidentally
16
+ * document-size-scaled per-call cost back into genuinely linear work.
17
+ */
18
+ const INLINE_LEAF_TAGS = new Set(["P", "SPAN", "B", "STRONG", "I", "EM", "U", "S", "A", "CODE", "BR", "IMG", "SUP", "SUB"]);
5
19
  const unwrap = (element) => element.replaceWith(...Array.from(element.childNodes));
6
20
  const normalizeTransparentContainers = (document) => {
7
21
  const docsWrapper = document.body.querySelector('[id^="docs-internal-guid-"]');
@@ -71,7 +85,8 @@ const normalizeMsoLists = (document) => {
71
85
  for (let index = 0; index < children.length;) {
72
86
  const first = msoListEntry(children[index]);
73
87
  if (!first) {
74
- visit(children[index]);
88
+ if (!INLINE_LEAF_TAGS.has(children[index].tagName))
89
+ visit(children[index]);
75
90
  index += 1;
76
91
  continue;
77
92
  }
@@ -167,7 +182,8 @@ const normalizeDeclaredLevelLists = (document) => {
167
182
  run[0].replaceWith(...roots);
168
183
  run.slice(1).forEach((list) => list.remove());
169
184
  }
170
- Array.from(parent.children).forEach((child) => visit(child));
185
+ Array.from(parent.children).forEach((child) => { if (!INLINE_LEAF_TAGS.has(child.tagName))
186
+ visit(child); });
171
187
  };
172
188
  visit(document.body);
173
189
  };
@@ -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.5",
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",