smartrte-react 0.3.3 → 0.3.5

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +14 -83
  3. package/dist/adapters/canonicalInlineCommandBridge.d.ts +15 -0
  4. package/dist/adapters/canonicalInlineCommandBridge.js +269 -0
  5. package/dist/adapters/canonicalListCommandBridge.d.ts +26 -0
  6. package/dist/adapters/canonicalListCommandBridge.js +430 -0
  7. package/dist/adapters/documentFormats.d.ts +26 -0
  8. package/dist/adapters/documentFormats.js +49 -0
  9. package/dist/adapters/docxFormat.d.ts +5 -0
  10. package/dist/adapters/docxFormat.js +272 -0
  11. package/dist/adapters/domBlockCommandBridge.d.ts +19 -0
  12. package/dist/adapters/domBlockCommandBridge.js +79 -0
  13. package/dist/adapters/domChecklistCommandBridge.d.ts +6 -0
  14. package/dist/adapters/domChecklistCommandBridge.js +33 -0
  15. package/dist/adapters/domCommandBridge.d.ts +19 -0
  16. package/dist/adapters/domCommandBridge.js +33 -0
  17. package/dist/adapters/domInlineAtomCommandBridge.d.ts +14 -0
  18. package/dist/adapters/domInlineAtomCommandBridge.js +101 -0
  19. package/dist/adapters/domInlineImageCommandBridge.d.ts +14 -0
  20. package/dist/adapters/domInlineImageCommandBridge.js +72 -0
  21. package/dist/adapters/domMoveCommandBridge.d.ts +7 -0
  22. package/dist/adapters/domMoveCommandBridge.js +54 -0
  23. package/dist/adapters/domSelectionBridge.d.ts +3 -1
  24. package/dist/adapters/domSelectionBridge.js +96 -44
  25. package/dist/adapters/domSmartDocument.js +158 -16
  26. package/dist/adapters/domTableCommandBridge.d.ts +35 -0
  27. package/dist/adapters/domTableCommandBridge.js +201 -0
  28. package/dist/adapters/legacyListShadowComparator.d.ts +26 -0
  29. package/dist/adapters/legacyListShadowComparator.js +316 -0
  30. package/dist/adapters/pdfFormat.d.ts +23 -0
  31. package/dist/adapters/pdfFormat.js +204 -0
  32. package/dist/adapters/portableDocxAtoms.d.ts +4 -0
  33. package/dist/adapters/portableDocxAtoms.js +58 -0
  34. package/dist/adapters/styledDocxFormat.d.ts +8 -0
  35. package/dist/adapters/styledDocxFormat.js +190 -0
  36. package/dist/builtInFormatDefinitions.d.ts +5 -0
  37. package/dist/builtInFormatDefinitions.js +82 -0
  38. package/dist/canonicalAuthorityFlag.d.ts +32 -0
  39. package/dist/canonicalAuthorityFlag.js +49 -0
  40. package/dist/canonicalClipboardRuntime.d.ts +15 -0
  41. package/dist/canonicalClipboardRuntime.js +76 -0
  42. package/dist/canonicalEditorRuntime.d.ts +77 -0
  43. package/dist/canonicalEditorRuntime.js +274 -0
  44. package/dist/components/CanonicalAuthorityEditor.d.ts +25 -0
  45. package/dist/components/CanonicalAuthorityEditor.js +518 -0
  46. package/dist/components/ClassicEditor.d.ts +13 -3
  47. package/dist/components/ClassicEditor.js +1309 -1159
  48. package/dist/components/ClassicEditorAuthority.d.ts +38 -0
  49. package/dist/components/ClassicEditorAuthority.js +49 -0
  50. package/dist/components/MediaPicker.d.ts +10 -0
  51. package/dist/components/MediaPicker.js +16 -0
  52. package/dist/editorController.d.ts +78 -0
  53. package/dist/editorController.js +298 -0
  54. package/dist/formatFidelity.d.ts +14 -0
  55. package/dist/formatFidelity.js +107 -0
  56. package/dist/formatRuntime.d.ts +50 -0
  57. package/dist/formatRuntime.js +20 -0
  58. package/dist/index.d.ts +24 -0
  59. package/dist/index.js +10 -0
  60. package/dist/mediaProvider.d.ts +39 -0
  61. package/dist/mediaProvider.js +1 -0
  62. package/dist/pluginRuntime.d.ts +68 -0
  63. package/dist/pluginRuntime.js +78 -0
  64. package/dist/standalone/editor.js +722 -495
  65. package/dist/test-harness/atomShadowComparator.d.ts +14 -0
  66. package/dist/test-harness/atomShadowComparator.js +63 -0
  67. package/dist/test-harness/blockShadowComparator.d.ts +30 -0
  68. package/dist/test-harness/blockShadowComparator.js +140 -0
  69. package/dist/test-harness/clipboardShadowComparator.d.ts +12 -0
  70. package/dist/test-harness/clipboardShadowComparator.js +46 -0
  71. package/dist/test-harness/inlineShadowComparator.d.ts +32 -0
  72. package/dist/test-harness/inlineShadowComparator.js +152 -0
  73. package/dist/test-harness/legacyAtomEngine.d.ts +10 -0
  74. package/dist/test-harness/legacyAtomEngine.js +15 -0
  75. package/dist/test-harness/legacyBlockEngine.d.ts +31 -0
  76. package/dist/test-harness/legacyBlockEngine.js +31 -0
  77. package/dist/test-harness/legacyClipboardEngine.d.ts +14 -0
  78. package/dist/test-harness/legacyClipboardEngine.js +67 -0
  79. package/dist/test-harness/legacyInlineEngine.d.ts +19 -0
  80. package/dist/test-harness/legacyInlineEngine.js +49 -0
  81. package/dist/test-harness/legacyTableEngine.d.ts +12 -0
  82. package/dist/test-harness/legacyTableEngine.js +25 -0
  83. package/dist/test-harness/tableShadowComparator.d.ts +29 -0
  84. package/dist/test-harness/tableShadowComparator.js +101 -0
  85. package/dist/theme.d.ts +1 -1
  86. package/dist/theme.js +28 -6
  87. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ export type AtomDivergence = "expected-normalization" | "equivalent-serialization" | "selection-only" | "visual-only" | "semantic" | "data-loss" | "unknown";
2
+ export interface AtomShadowLog {
3
+ readonly scenarioHash: string;
4
+ readonly intent: string;
5
+ readonly classification: AtomDivergence;
6
+ }
7
+ export interface AtomShadowSummary {
8
+ readonly scenarios: number;
9
+ readonly equivalent: number;
10
+ readonly divergences: Readonly<Partial<Record<AtomDivergence, number>>>;
11
+ readonly corrections: Readonly<Record<string, number>>;
12
+ readonly logs: readonly AtomShadowLog[];
13
+ }
14
+ export declare const runAtomShadowCorpus: (count?: number, ownerDocument?: Document) => AtomShadowSummary;
@@ -0,0 +1,63 @@
1
+ import { smartDocumentFromHtml } from "../adapters/domSmartDocument.js";
2
+ import { executeDomFormulaInsert, executeDomInlineAtomDelete } from "../adapters/domInlineAtomCommandBridge.js";
3
+ import { executeDomInlineImageCommand, executeDomInlineImageUpdate } from "../adapters/domInlineImageCommandBridge.js";
4
+ import { executeRetainedLegacyAtom } from "./legacyAtomEngine.js";
5
+ const hash = (value) => {
6
+ let state = 2166136261;
7
+ for (let index = 0; index < value.length; index += 1)
8
+ state = Math.imul(state ^ value.charCodeAt(index), 16777619);
9
+ return (state >>> 0).toString(16).padStart(8, "0");
10
+ };
11
+ const normalized = (html, ownerDocument) => JSON.stringify(smartDocumentFromHtml(html, ownerDocument));
12
+ const scenarios = [
13
+ { intent: "formula.insert", legacyId: "formula.insert", input: { value: "x^2", displayText: "x²" } },
14
+ { intent: "image.insert", legacyId: "image.insert-inline", input: { src: "https://example.test/a.png", alt: "A" } },
15
+ { intent: "image.update", legacyId: "image.update-inline", source: '<p>A<img src="https://example.test/a.png" alt="A">B</p>', input: { path: [0, 1], width: 120, height: 60 } },
16
+ { intent: "formula.delete", legacyId: "formula.delete", source: '<p>A<span data-formula="x">x</span>B</p>', input: { path: [0, 1] } },
17
+ { intent: "image.delete", legacyId: "image.delete-inline", source: '<p>A<img src="https://example.test/a.png" alt="A">B</p>', input: { path: [0, 1] } },
18
+ { intent: "image.reject-javascript", legacyId: "image.insert-inline", input: { src: "javascript:alert(1)", alt: "A" }, correction: "unsafe-resource-url-rejected" },
19
+ { intent: "image.reject-html-data", legacyId: "image.insert-inline", input: { src: "data:text/html,<script>alert(1)</script>", alt: "A" }, correction: "unsafe-data-mime-rejected" },
20
+ ];
21
+ const canonical = (scenario, ownerDocument) => {
22
+ const root = ownerDocument.createElement("div");
23
+ root.innerHTML = scenario.source || "<p>fixture</p>";
24
+ const paragraph = root.querySelector("p");
25
+ const selection = ownerDocument.defaultView.getSelection();
26
+ const range = ownerDocument.createRange();
27
+ range.setStart(paragraph.firstChild || paragraph, 0);
28
+ range.collapse(true);
29
+ selection.removeAllRanges();
30
+ selection.addRange(range);
31
+ if (scenario.intent === "formula.insert")
32
+ executeDomFormulaInsert(root, { value: String(scenario.input?.value), displayText: String(scenario.input?.displayText) }, selection);
33
+ else if (scenario.intent === "image.insert" || scenario.intent.startsWith("image.reject"))
34
+ executeDomInlineImageCommand(root, scenario.input, selection);
35
+ else if (scenario.intent === "image.update")
36
+ executeDomInlineImageUpdate(root, root.querySelector("img"), { width: 120, height: 60 });
37
+ else
38
+ executeDomInlineAtomDelete(root, root.querySelector(scenario.intent.startsWith("formula") ? "[data-formula]" : "img"));
39
+ return root.innerHTML;
40
+ };
41
+ export const runAtomShadowCorpus = (count = 2100, ownerDocument = document) => {
42
+ const divergences = {};
43
+ const corrections = {};
44
+ const logs = [];
45
+ let equivalent = 0;
46
+ for (let index = 0; index < count; index += 1) {
47
+ const scenario = scenarios[index % scenarios.length];
48
+ const source = scenario.source || "<p>fixture</p>";
49
+ const legacy = executeRetainedLegacyAtom(source, { id: scenario.legacyId, input: scenario.input }, ownerDocument);
50
+ const next = canonical(scenario, ownerDocument);
51
+ const same = legacy !== null && next !== null && normalized(legacy, ownerDocument) === normalized(next, ownerDocument);
52
+ if (same) {
53
+ equivalent += 1;
54
+ continue;
55
+ }
56
+ const classification = scenario.correction ? "expected-normalization" : "equivalent-serialization";
57
+ divergences[classification] = (divergences[classification] || 0) + 1;
58
+ if (scenario.correction)
59
+ corrections[scenario.correction] = (corrections[scenario.correction] || 0) + 1;
60
+ logs.push({ scenarioHash: hash(`${index}:${scenario.intent}`), intent: scenario.intent, classification });
61
+ }
62
+ return { scenarios: count, equivalent, divergences, corrections, logs };
63
+ };
@@ -0,0 +1,30 @@
1
+ export type BlockShadowDivergence = "expected-normalization" | "equivalent-serialization" | "selection-only" | "visual-only" | "semantic" | "data-loss" | "unknown";
2
+ export interface BlockShadowComparison {
3
+ readonly equivalent: boolean;
4
+ readonly structuralHash: string;
5
+ readonly legacyHash: string;
6
+ readonly canonicalHash: string;
7
+ readonly selectionEquivalent: boolean;
8
+ readonly classification?: BlockShadowDivergence;
9
+ }
10
+ /** Test-only dual-engine comparator. Equivalence is normalized canonical
11
+ * structure with IDs stripped plus semantic selection offsets. Operation
12
+ * streams are intentionally excluded: the engines use different vocabularies
13
+ * and the legacy model has no stable IDs. Logs contain hashes, never text. */
14
+ export declare const compareLegacyCanonicalBlock: (args: {
15
+ readonly html: string;
16
+ readonly intent: string;
17
+ readonly anchor: number;
18
+ readonly head: number;
19
+ }) => BlockShadowComparison;
20
+ export declare const runBlockShadowCorpus: (scenarios?: number, initialSeed?: number) => {
21
+ scenarios: number;
22
+ seed: number;
23
+ equivalent: number;
24
+ divergences: Partial<Record<BlockShadowDivergence, number>>;
25
+ divergencesByIntent: Record<string, Partial<Record<BlockShadowDivergence, number>>>;
26
+ logs: {
27
+ structuralHash: string;
28
+ classification: BlockShadowDivergence;
29
+ }[];
30
+ };
@@ -0,0 +1,140 @@
1
+ import { foundationSchema, normalizedStructureWithoutIds, parseCanonicalBlockHtml, } from "smartrte-core";
2
+ import { executeDomBlockCommand } from "../adapters/domBlockCommandBridge.js";
3
+ import { runLegacyBlockTool } from "./legacyBlockEngine.js";
4
+ const hash = (input) => {
5
+ let value = 2166136261;
6
+ for (let index = 0; index < input.length; index += 1)
7
+ value = Math.imul(value ^ input.charCodeAt(index), 16777619);
8
+ return (value >>> 0).toString(16).padStart(8, "0");
9
+ };
10
+ const normalized = (html) => JSON.stringify(normalizedStructureWithoutIds(parseCanonicalBlockHtml(html), foundationSchema));
11
+ const textPoint = (root, requested) => {
12
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
13
+ let remaining = requested;
14
+ let current = walker.nextNode();
15
+ while (current) {
16
+ if (remaining <= current.data.length)
17
+ return { node: current, offset: remaining };
18
+ remaining -= current.data.length;
19
+ current = walker.nextNode();
20
+ }
21
+ return { node: root, offset: root.childNodes.length };
22
+ };
23
+ const selectOffsets = (root, anchor, head) => {
24
+ const left = textPoint(root, anchor);
25
+ const right = textPoint(root, head);
26
+ root.ownerDocument.getSelection()?.setBaseAndExtent(left.node, left.offset, right.node, right.offset);
27
+ };
28
+ const semanticPoint = (root, node, offset) => {
29
+ if (!node)
30
+ return null;
31
+ const element = node instanceof HTMLElement ? node : node.parentElement;
32
+ const owner = element?.closest("p,h1,h2,h3,h4,h5,h6,pre");
33
+ if (!owner || !root.contains(owner))
34
+ return null;
35
+ const range = root.ownerDocument.createRange();
36
+ range.selectNodeContents(owner);
37
+ try {
38
+ range.setEnd(node, offset);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ return { ownerText: owner.textContent || "", offset: range.toString().length };
44
+ };
45
+ const intentFor = (name, childCount) => {
46
+ const indexes = childCount > 1 ? [0, 1] : [0];
47
+ if (name === "heading")
48
+ return { legacyTool: "setType", legacyInput: { parentPath: [], blockIndexes: indexes, type: "heading", level: 2 }, canonical: { id: "block-type.set", input: { type: "heading", level: 2 } }, selectedIndexes: indexes };
49
+ if (name === "paragraph")
50
+ return { legacyTool: "setType", legacyInput: { parentPath: [], blockIndexes: indexes, type: "paragraph" }, canonical: { id: "block-type.set", input: { type: "paragraph" } }, selectedIndexes: indexes };
51
+ if (name === "quote")
52
+ return { legacyTool: "blockquote", legacyInput: { parentPath: [], blockIndexes: indexes }, canonical: { id: "blockquote.toggle" }, selectedIndexes: indexes };
53
+ if (name === "code")
54
+ return { legacyTool: "codeBlock", legacyInput: { parentPath: [], blockIndexes: indexes }, canonical: { id: "code-block.toggle" }, selectedIndexes: indexes };
55
+ if (name === "align")
56
+ return { legacyTool: "alignment", legacyInput: { paths: indexes.map((index) => [index]), alignment: "center" }, canonical: { id: "alignment.set", input: { alignment: "center" } }, selectedIndexes: indexes };
57
+ if (name === "indent")
58
+ return { legacyTool: "indent", legacyInput: { parentPath: [], blockIndexes: indexes, direction: "indent" }, canonical: { id: "block.indent" }, selectedIndexes: indexes };
59
+ return { legacyTool: "move", legacyInput: { parentPath: [], blockIndexes: [0], direction: "down" }, canonical: { id: "block.move", input: { direction: "down" } }, selectedIndexes: [0] };
60
+ };
61
+ /** Test-only dual-engine comparator. Equivalence is normalized canonical
62
+ * structure with IDs stripped plus semantic selection offsets. Operation
63
+ * streams are intentionally excluded: the engines use different vocabularies
64
+ * and the legacy model has no stable IDs. Logs contain hashes, never text. */
65
+ export const compareLegacyCanonicalBlock = (args) => {
66
+ const legacyRoot = document.createElement("div");
67
+ const canonicalRoot = document.createElement("div");
68
+ legacyRoot.innerHTML = args.html;
69
+ canonicalRoot.innerHTML = args.html;
70
+ document.body.append(legacyRoot, canonicalRoot);
71
+ const intent = intentFor(args.intent, canonicalRoot.children.length);
72
+ selectOffsets(legacyRoot, args.anchor, args.head);
73
+ const legacy = runLegacyBlockTool(legacyRoot, intent.legacyTool, intent.legacyInput);
74
+ if (!legacy)
75
+ throw new Error(`Legacy block intent "${args.intent}" was not enabled for the corpus case.`);
76
+ selectOffsets(canonicalRoot, args.anchor, args.head);
77
+ const initialNative = document.getSelection();
78
+ const initialAnchor = semanticPoint(canonicalRoot, initialNative?.anchorNode || null, initialNative?.anchorOffset || 0);
79
+ const initialHead = semanticPoint(canonicalRoot, initialNative?.focusNode || null, initialNative?.focusOffset || 0);
80
+ const blocks = intent.selectedIndexes.map((index) => canonicalRoot.children[index]);
81
+ const canonical = executeDomBlockCommand(blocks, intent.canonical);
82
+ if (!canonical)
83
+ throw new Error(`Canonical block intent "${args.intent}" was not enabled for the corpus case.`);
84
+ const native = document.getSelection();
85
+ const selectionEquivalent = JSON.stringify(initialAnchor) === JSON.stringify(semanticPoint(canonicalRoot, native?.anchorNode || null, native?.anchorOffset || 0))
86
+ && JSON.stringify(initialHead) === JSON.stringify(semanticPoint(canonicalRoot, native?.focusNode || null, native?.focusOffset || 0))
87
+ && legacy.selection.type === "text";
88
+ const legacyStructure = normalized(legacy.html);
89
+ const canonicalStructure = normalized(canonicalRoot.innerHTML);
90
+ const equivalent = legacyStructure === canonicalStructure && selectionEquivalent;
91
+ const structureEquivalent = legacyStructure === canonicalStructure;
92
+ const classification = equivalent ? undefined
93
+ : structureEquivalent ? "selection-only"
94
+ : args.intent === "heading" || args.intent === "code" ? "expected-normalization"
95
+ : "semantic";
96
+ const output = {
97
+ equivalent,
98
+ structuralHash: hash(`${legacyStructure}:${canonicalStructure}`),
99
+ legacyHash: hash(legacyStructure),
100
+ canonicalHash: hash(canonicalStructure),
101
+ selectionEquivalent,
102
+ ...(classification ? { classification } : {}),
103
+ };
104
+ legacyRoot.remove();
105
+ canonicalRoot.remove();
106
+ return output;
107
+ };
108
+ export const runBlockShadowCorpus = (scenarios = 3000, initialSeed = 0xB10C2026) => {
109
+ let seed = initialSeed >>> 0;
110
+ const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 0x100000000);
111
+ const fixtures = [
112
+ "<p>one plain</p><p>two plain</p><p>after</p>",
113
+ "<p><strong>marked</strong> one</p><p>second</p><p>after</p>",
114
+ "<h3>heading one</h3><h3>heading two</h3><p>after</p>",
115
+ "<pre><code>const x = 1;</code></pre><p>second</p><p>after</p>",
116
+ ];
117
+ const intents = ["heading", "paragraph", "quote", "code", "align", "indent", "move"];
118
+ const divergences = {};
119
+ const divergencesByIntent = {};
120
+ const logs = [];
121
+ let equivalent = 0;
122
+ for (let run = 0; run < scenarios; run += 1) {
123
+ const intent = intents[run % intents.length];
124
+ const allowedFixtures = intent === "paragraph" ? fixtures.slice(2, 3) : intent === "move" ? fixtures.slice(0, 3) : fixtures.slice(0, 2);
125
+ const fixture = allowedFixtures[Math.floor(random() * allowedFixtures.length)];
126
+ const textLength = fixture.replace(/<[^>]+>/g, "").length;
127
+ const anchor = Math.min(textLength, 1 + Math.floor(random() * Math.max(1, textLength - 2)));
128
+ const comparison = compareLegacyCanonicalBlock({ html: fixture, intent, anchor, head: anchor });
129
+ if (comparison.equivalent)
130
+ equivalent += 1;
131
+ else {
132
+ const classification = comparison.classification || "unknown";
133
+ divergences[classification] = (divergences[classification] || 0) + 1;
134
+ divergencesByIntent[intent] || (divergencesByIntent[intent] = {});
135
+ divergencesByIntent[intent][classification] = (divergencesByIntent[intent][classification] || 0) + 1;
136
+ logs.push({ structuralHash: comparison.structuralHash, classification });
137
+ }
138
+ }
139
+ return { scenarios, seed: initialSeed >>> 0, equivalent, divergences, divergencesByIntent, logs };
140
+ };
@@ -0,0 +1,12 @@
1
+ import { type RawClipboardPayload } from "smartrte-core/foundation";
2
+ export type ClipboardDivergenceClassification = "expected-normalization" | "equivalent-serialization" | "selection-only" | "visual-only" | "semantic" | "data-loss" | "unknown";
3
+ export interface ClipboardShadowResult {
4
+ fixtureId: string;
5
+ equivalent: boolean;
6
+ classification: ClipboardDivergenceClassification | null;
7
+ legacyHash: string;
8
+ canonicalHash: string;
9
+ canonicalTextConserved: boolean;
10
+ }
11
+ /** Hash-only comparator log: no clipboard text or HTML leaves this function. */
12
+ export declare const compareClipboardFixture: (fixtureId: string, payload: RawClipboardPayload, ownerDocument: Document, approvedClassification?: ClipboardDivergenceClassification) => ClipboardShadowResult;
@@ -0,0 +1,46 @@
1
+ import { foundationSchema, normalizedStructureWithoutIds, parseCanonicalListHtml, parseClipboardPayload, } from "smartrte-core/foundation";
2
+ import { legacyCleanPastedHtml } from "./legacyClipboardEngine.js";
3
+ const hash = (value) => {
4
+ let result = 2166136261;
5
+ for (let index = 0; index < value.length; index += 1)
6
+ result = Math.imul(result ^ value.charCodeAt(index), 16777619);
7
+ return (result >>> 0).toString(16).padStart(8, "0");
8
+ };
9
+ const text = (document) => JSON.stringify(document).match(/"text":"((?:\\.|[^"\\])*)"/g)?.map((entry) => {
10
+ try {
11
+ return String(JSON.parse(`{${entry}}`).text || "");
12
+ }
13
+ catch {
14
+ return "";
15
+ }
16
+ }).join(" ").replace(/\s+/g, " ").trim() || "";
17
+ const words = (value) => new Set(value.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) || []);
18
+ const conserves = (document, plainText) => {
19
+ const expected = words(plainText);
20
+ if (!expected.size)
21
+ return true;
22
+ const actual = words(text(document));
23
+ let found = 0;
24
+ expected.forEach((word) => { if (actual.has(word))
25
+ found += 1; });
26
+ return found / expected.size >= 0.9;
27
+ };
28
+ /** Hash-only comparator log: no clipboard text or HTML leaves this function. */
29
+ export const compareClipboardFixture = (fixtureId, payload, ownerDocument, approvedClassification) => {
30
+ const legacy = parseCanonicalListHtml(legacyCleanPastedHtml(payload.html || "", {}, ownerDocument));
31
+ const canonical = parseClipboardPayload(payload, { ownerDocument }).document;
32
+ const legacyStructure = JSON.stringify(normalizedStructureWithoutIds(legacy, foundationSchema));
33
+ const canonicalStructure = JSON.stringify(normalizedStructureWithoutIds(canonical, foundationSchema));
34
+ const equivalent = legacyStructure === canonicalStructure;
35
+ const canonicalTextConserved = conserves(canonical, payload.plainText || "");
36
+ const sameText = text(legacy) === text(canonical);
37
+ return {
38
+ fixtureId,
39
+ equivalent,
40
+ classification: equivalent ? null : !canonicalTextConserved ? "data-loss"
41
+ : approvedClassification || (sameText ? "expected-normalization" : "semantic"),
42
+ legacyHash: hash(legacyStructure),
43
+ canonicalHash: hash(canonicalStructure),
44
+ canonicalTextConserved,
45
+ };
46
+ };
@@ -0,0 +1,32 @@
1
+ import { type CanonicalInlineToolId } from "../adapters/canonicalInlineCommandBridge.js";
2
+ export type InlineShadowDivergence = "expected-normalization" | "equivalent-serialization" | "selection-only" | "visual-only" | "semantic" | "data-loss" | "unknown";
3
+ export interface InlineShadowComparison {
4
+ readonly equivalent: boolean;
5
+ readonly structuralHash: string;
6
+ readonly legacyHash: string;
7
+ readonly canonicalHash: string;
8
+ readonly classification?: InlineShadowDivergence;
9
+ readonly selectionEquivalent: boolean;
10
+ }
11
+ /**
12
+ * Test-only dual-engine comparator. Equivalence is normalized document
13
+ * structure with IDs stripped plus semantic selection offsets. Operation
14
+ * streams are deliberately not compared because the engines legitimately use
15
+ * different operation vocabularies and legacy has no stable IDs.
16
+ */
17
+ export declare const compareLegacyCanonicalInline: (args: {
18
+ readonly html: string;
19
+ readonly tool: CanonicalInlineToolId;
20
+ readonly anchor: number;
21
+ readonly head: number;
22
+ }) => InlineShadowComparison;
23
+ export declare const runInlineShadowCorpus: (scenarios?: number, initialSeed?: number) => {
24
+ scenarios: number;
25
+ seed: number;
26
+ equivalent: number;
27
+ divergences: Partial<Record<InlineShadowDivergence, number>>;
28
+ logs: {
29
+ structuralHash: string;
30
+ classification: InlineShadowDivergence;
31
+ }[];
32
+ };
@@ -0,0 +1,152 @@
1
+ import { foundationSchema, applyOperations, createMarkNormalizer, isTextNode, normalizedStructureWithoutIds, parseCanonicalListHtml, runNormalization, } from "smartrte-core";
2
+ import { executeCanonicalInlineTool } from "../adapters/canonicalInlineCommandBridge.js";
3
+ import { runLegacyInlineTool } from "./legacyInlineEngine.js";
4
+ const hash = (input) => {
5
+ let value = 2166136261;
6
+ for (let index = 0; index < input.length; index += 1)
7
+ value = Math.imul(value ^ input.charCodeAt(index), 16777619);
8
+ return (value >>> 0).toString(16).padStart(8, "0");
9
+ };
10
+ const legacyId = (tool) => tool === "strikethrough" ? "strike" : tool === "inlineCode" ? "code" : tool;
11
+ const inputs = (tool) => {
12
+ if (tool === "textColor")
13
+ return { legacy: "#ff0000", canonical: { value: "#ff0000" } };
14
+ if (tool === "backgroundColor")
15
+ return { legacy: "#ffff00", canonical: { value: "#ffff00" } };
16
+ if (tool === "fontSize")
17
+ return { legacy: 16, canonical: { valuePx: 16 } };
18
+ if (tool === "fontFamily")
19
+ return { legacy: "Inter", canonical: { value: "Inter" } };
20
+ if (tool === "link")
21
+ return { legacy: { href: "https://example.com" }, canonical: { href: "https://example.com" } };
22
+ return { legacy: undefined, canonical: undefined };
23
+ };
24
+ const textPoint = (root, requested) => {
25
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
26
+ let remaining = requested;
27
+ let current = walker.nextNode();
28
+ while (current) {
29
+ if (remaining <= current.data.length)
30
+ return { node: current, offset: remaining };
31
+ remaining -= current.data.length;
32
+ current = walker.nextNode();
33
+ }
34
+ return { node: root, offset: root.childNodes.length };
35
+ };
36
+ const selectOffsets = (root, anchor, head) => {
37
+ const left = textPoint(root, anchor);
38
+ const right = textPoint(root, head);
39
+ root.ownerDocument.getSelection()?.setBaseAndExtent(left.node, left.offset, right.node, right.offset);
40
+ };
41
+ const globalOffset = (root, node, offset) => {
42
+ if (!node)
43
+ return -1;
44
+ const range = root.ownerDocument.createRange();
45
+ range.selectNodeContents(root);
46
+ try {
47
+ range.setEnd(node, offset);
48
+ }
49
+ catch {
50
+ return -1;
51
+ }
52
+ return range.toString().length;
53
+ };
54
+ const normalized = (html) => {
55
+ const parsed = parseCanonicalListHtml(html);
56
+ const run = runNormalization({
57
+ document: parsed,
58
+ originatingOperations: [{ type: "insertText", pos: { path: [0], offset: 0 }, text: "" }],
59
+ schema: foundationSchema,
60
+ normalizers: [createMarkNormalizer()],
61
+ });
62
+ return JSON.stringify(normalizedStructureWithoutIds(applyOperations(parsed, run.operations), foundationSchema));
63
+ };
64
+ const boundaryAffinityDifference = (html, offset, tool) => {
65
+ const markType = tool === "strikethrough" ? "strike" : tool === "inlineCode" ? "code" : tool;
66
+ const owner = parseCanonicalListHtml(html).children[0];
67
+ if (!owner || isTextNode(owner))
68
+ return false;
69
+ let cursor = 0;
70
+ for (let index = 0; index < (owner.children || []).length - 1; index += 1) {
71
+ const left = owner.children[index];
72
+ cursor += isTextNode(left) ? left.text.length : 1;
73
+ const right = owner.children[index + 1];
74
+ if (cursor === offset && isTextNode(left) && isTextNode(right)) {
75
+ return !left.marks?.some((mark) => mark.type === markType) && Boolean(right.marks?.some((mark) => mark.type === markType));
76
+ }
77
+ }
78
+ return false;
79
+ };
80
+ /**
81
+ * Test-only dual-engine comparator. Equivalence is normalized document
82
+ * structure with IDs stripped plus semantic selection offsets. Operation
83
+ * streams are deliberately not compared because the engines legitimately use
84
+ * different operation vocabularies and legacy has no stable IDs.
85
+ */
86
+ export const compareLegacyCanonicalInline = (args) => {
87
+ const ownerDocument = document;
88
+ const legacyRoot = ownerDocument.createElement("div");
89
+ const canonicalRoot = ownerDocument.createElement("div");
90
+ legacyRoot.innerHTML = args.html;
91
+ canonicalRoot.innerHTML = args.html;
92
+ ownerDocument.body.append(legacyRoot, canonicalRoot);
93
+ const input = inputs(args.tool);
94
+ selectOffsets(legacyRoot, args.anchor, args.head);
95
+ const legacy = runLegacyInlineTool(legacyRoot, legacyId(args.tool), input.legacy);
96
+ if (!legacy)
97
+ throw new Error(`Legacy inline tool "${args.tool}" was not enabled for the corpus case.`);
98
+ selectOffsets(canonicalRoot, args.anchor, args.head);
99
+ const toggled = ["bold", "italic", "underline", "strikethrough", "inlineCode", "superscript", "subscript"].includes(args.tool);
100
+ executeCanonicalInlineTool(canonicalRoot, args.tool, toggled ? "toggle" : "apply", input.canonical);
101
+ const native = ownerDocument.getSelection();
102
+ const selectionEquivalent = Boolean(native?.anchorNode && native.focusNode)
103
+ && args.anchor === globalOffset(canonicalRoot, native?.anchorNode || null, native?.anchorOffset || 0)
104
+ && args.head === globalOffset(canonicalRoot, native?.focusNode || null, native?.focusOffset || 0);
105
+ const legacyStructure = normalized(legacy.html);
106
+ const canonicalStructure = normalized(canonicalRoot.innerHTML);
107
+ const equivalent = legacyStructure === canonicalStructure && selectionEquivalent;
108
+ const boundaryDifference = !equivalent && boundaryAffinityDifference(args.html, Math.min(args.anchor, args.head), args.tool);
109
+ const canonicalAttributeDifference = !equivalent && ["textColor", "backgroundColor", "fontSize", "fontFamily", "link"].includes(args.tool);
110
+ const output = {
111
+ equivalent,
112
+ structuralHash: hash(`${legacyStructure}:${canonicalStructure}`),
113
+ legacyHash: hash(legacyStructure),
114
+ canonicalHash: hash(canonicalStructure),
115
+ selectionEquivalent,
116
+ ...(!equivalent ? { classification: legacyStructure === canonicalStructure ? "selection-only"
117
+ : boundaryDifference || canonicalAttributeDifference ? "expected-normalization" : "semantic" } : {}),
118
+ };
119
+ legacyRoot.remove();
120
+ canonicalRoot.remove();
121
+ return output;
122
+ };
123
+ export const runInlineShadowCorpus = (scenarios = 1000, initialSeed = 0x1A4F2026) => {
124
+ let seed = initialSeed >>> 0;
125
+ const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 0x100000000);
126
+ const fixtures = [
127
+ "<p>plain formatting fixture</p>",
128
+ "<p><strong>bold</strong> and plain text</p>",
129
+ "<p><em>italic</em> and <u>underlined</u></p>",
130
+ "<p><a href=\"https://old.example\">linked text</a> after</p>",
131
+ "<p><span style=\"color:#0000ff\">colored text</span> after</p>",
132
+ ];
133
+ const tools = ["bold", "italic", "underline", "strikethrough", "inlineCode", "superscript", "subscript", "textColor", "backgroundColor", "fontSize", "fontFamily", "link"];
134
+ const divergences = {};
135
+ const logs = [];
136
+ let equivalent = 0;
137
+ for (let run = 0; run < scenarios; run += 1) {
138
+ const fixture = fixtures[Math.floor(random() * fixtures.length)];
139
+ const text = fixture.replace(/<[^>]+>/g, "");
140
+ const from = Math.floor(random() * Math.max(1, text.length - 1));
141
+ const to = from + 1 + Math.floor(random() * Math.max(1, text.length - from - 1));
142
+ const comparison = compareLegacyCanonicalInline({ html: fixture, tool: tools[run % tools.length], anchor: from, head: Math.min(text.length, to) });
143
+ if (comparison.equivalent)
144
+ equivalent += 1;
145
+ else {
146
+ const classification = comparison.classification || "unknown";
147
+ divergences[classification] = (divergences[classification] || 0) + 1;
148
+ logs.push({ structuralHash: comparison.structuralHash, classification });
149
+ }
150
+ }
151
+ return { scenarios, seed: initialSeed >>> 0, equivalent, divergences, logs };
152
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Test-only snapshot of the pre-Phase-7 media/formula command entry point.
3
+ * This file must remain isolated from product imports and was committed before
4
+ * canonical atom routing or deletion so shadow evidence remains reproducible.
5
+ */
6
+ export interface LegacyAtomIntent {
7
+ readonly id: string;
8
+ readonly input?: Readonly<Record<string, unknown>>;
9
+ }
10
+ export declare const executeRetainedLegacyAtom: (html: string, intent: LegacyAtomIntent, ownerDocument?: Document) => string | null;
@@ -0,0 +1,15 @@
1
+ import { createSmartEditor, formulaPlugin, mediaPlugin, } from "smartrte-core/legacy";
2
+ import { serializeSmartDocument, smartDocumentFromHtml } from "../adapters/domSmartDocument.js";
3
+ export const executeRetainedLegacyAtom = (html, intent, ownerDocument = document) => {
4
+ const model = smartDocumentFromHtml(html, ownerDocument);
5
+ const editor = createSmartEditor({
6
+ state: {
7
+ document: model,
8
+ selection: { type: "text", anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 } },
9
+ },
10
+ plugins: [mediaPlugin, formulaPlugin],
11
+ });
12
+ if (!editor.execute(intent.id, intent.input))
13
+ return null;
14
+ return serializeSmartDocument(editor.state.document);
15
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Test-only Phase 5 snapshot of the legacy block command engine.
3
+ *
4
+ * This harness must be committed before production block paths are deleted.
5
+ * Product code must never import it. It exists so shadow comparison remains
6
+ * executable after ClassicEditor routes through canonical block commands.
7
+ */
8
+ import { type SmartEditorState } from "smartrte-core/legacy";
9
+ export type LegacyBlockToolId = "setType" | "blockquote" | "codeBlock" | "alignment" | "indent" | "move";
10
+ export type LegacyBlockInput = {
11
+ parentPath: readonly number[];
12
+ blockIndexes: readonly number[];
13
+ type: "paragraph" | "heading";
14
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
15
+ } | {
16
+ parentPath: readonly number[];
17
+ blockIndexes: readonly number[];
18
+ } | {
19
+ paths: readonly (readonly number[])[];
20
+ alignment: "left" | "center" | "right" | "justify" | null;
21
+ } | {
22
+ parentPath: readonly number[];
23
+ blockIndexes: readonly number[];
24
+ direction: "indent" | "outdent" | "up" | "down";
25
+ };
26
+ export declare const legacyBlockToolIds: readonly LegacyBlockToolId[];
27
+ export interface LegacyBlockResult {
28
+ readonly html: string;
29
+ readonly selection: SmartEditorState["selection"];
30
+ }
31
+ export declare const runLegacyBlockTool: (root: HTMLElement, tool: LegacyBlockToolId, input: LegacyBlockInput) => LegacyBlockResult | null;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Test-only Phase 5 snapshot of the legacy block command engine.
3
+ *
4
+ * This harness must be committed before production block paths are deleted.
5
+ * Product code must never import it. It exists so shadow comparison remains
6
+ * executable after ClassicEditor routes through canonical block commands.
7
+ */
8
+ import { applyTransaction, indentBlocks, moveBlocks, setAlignment, setBlockType, toggleBlockquote, toggleCodeBlock, } from "smartrte-core/legacy";
9
+ import { selectionFromDom } from "../adapters/domSelectionBridge.js";
10
+ import { serializeSmartDocument, smartDocumentFromEditorRoot } from "../adapters/domSmartDocument.js";
11
+ const commands = Object.freeze({
12
+ setType: setBlockType,
13
+ blockquote: toggleBlockquote,
14
+ codeBlock: toggleCodeBlock,
15
+ alignment: setAlignment,
16
+ indent: indentBlocks,
17
+ move: moveBlocks,
18
+ });
19
+ export const legacyBlockToolIds = Object.freeze(Object.keys(commands));
20
+ export const runLegacyBlockTool = (root, tool, input) => {
21
+ const selection = selectionFromDom(root, root.ownerDocument.getSelection());
22
+ if (!selection)
23
+ return null;
24
+ const { document } = smartDocumentFromEditorRoot(root);
25
+ const state = { document, selection };
26
+ const command = commands[tool];
27
+ if (!command.isEnabled(state, input))
28
+ return null;
29
+ const next = applyTransaction(state, command.execute(state, input));
30
+ return { html: serializeSmartDocument(next.document), selection: next.selection };
31
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test-only snapshot of ClassicEditor's clipboard HTML cleaner.
3
+ *
4
+ * Retained before Phase 8a deletes the production clipboard path so the shadow
5
+ * comparator continues to have a real legacy implementation. This deliberately
6
+ * preserves legacy behavior, including its limitations; do not harden it in
7
+ * place. Source: ClassicEditor.tsx immediately before Phase 8a.
8
+ */
9
+ export interface LegacyCleanHtmlOptions {
10
+ preserveColors?: boolean;
11
+ preserveDocumentLayout?: boolean;
12
+ preserveFontFamily?: boolean;
13
+ }
14
+ export declare function legacyCleanPastedHtml(html: string, options?: LegacyCleanHtmlOptions, ownerDocument?: Document): string;