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,72 @@
1
+ import { createSmartEditor, getNodeAtPath, mediaPlugin, } from "smartrte-core";
2
+ import { selectionFromDom } from "./domSelectionBridge.js";
3
+ import { smartDocumentFromEditorRoot } from "./domSmartDocument.js";
4
+ import { inlineAtomPathFromDom } from "./domInlineAtomCommandBridge.js";
5
+ /**
6
+ * Executes inline image insertion through core and projects only the resulting
7
+ * atom into the existing DOM range. Unrelated DOM and metadata stay untouched.
8
+ */
9
+ export const executeDomInlineImageCommand = (root, input, domSelection = root.ownerDocument.defaultView?.getSelection() || null) => {
10
+ if (!domSelection?.rangeCount || !domSelection.isCollapsed)
11
+ return null;
12
+ const modelSelection = selectionFromDom(root, domSelection);
13
+ if (!modelSelection)
14
+ return null;
15
+ const { document } = smartDocumentFromEditorRoot(root);
16
+ const editor = createSmartEditor({
17
+ state: { document, selection: modelSelection },
18
+ plugins: [mediaPlugin],
19
+ });
20
+ if (!editor.execute("image.insert-inline", input))
21
+ return null;
22
+ const selectionAfter = editor.state.selection;
23
+ if (selectionAfter.type !== "text")
24
+ return null;
25
+ const imagePath = [
26
+ ...selectionAfter.anchor.path.slice(0, -1),
27
+ selectionAfter.anchor.path[selectionAfter.anchor.path.length - 1] - 1,
28
+ ];
29
+ const atom = getNodeAtPath(editor.state.document, imagePath);
30
+ if (atom?.type !== "inlineImage")
31
+ return null;
32
+ const range = domSelection.getRangeAt(0);
33
+ const image = root.ownerDocument.createElement("img");
34
+ image.src = atom.src;
35
+ image.alt = atom.alt || "";
36
+ if (atom.title)
37
+ image.title = atom.title;
38
+ if (atom.width)
39
+ image.width = atom.width;
40
+ if (atom.height)
41
+ image.height = atom.height;
42
+ image.dataset.srteInline = "true";
43
+ range.insertNode(image);
44
+ range.setStartAfter(image);
45
+ range.collapse(true);
46
+ domSelection.removeAllRanges();
47
+ domSelection.addRange(range);
48
+ return image;
49
+ };
50
+ /** Validates an inline image attribute update through core, then mutates it in place. */
51
+ export const executeDomInlineImageUpdate = (root, image, input) => {
52
+ const path = inlineAtomPathFromDom(root, image);
53
+ if (!path)
54
+ return false;
55
+ const selection = selectionFromDom(root, root.ownerDocument.defaultView?.getSelection() || null) || { type: "node", path };
56
+ const { document } = smartDocumentFromEditorRoot(root);
57
+ const editor = createSmartEditor({
58
+ state: { document, selection },
59
+ plugins: [mediaPlugin],
60
+ });
61
+ if (!editor.execute("image.update-inline", { path, ...input }))
62
+ return false;
63
+ if (input.alt !== undefined)
64
+ image.alt = input.alt;
65
+ if (input.title !== undefined)
66
+ image.title = input.title;
67
+ if (input.width !== undefined)
68
+ image.width = input.width;
69
+ if (input.height !== undefined)
70
+ image.height = input.height;
71
+ return true;
72
+ };
@@ -0,0 +1,7 @@
1
+ export type DomMoveDirection = "up" | "down" | "left" | "right";
2
+ /**
3
+ * Executes model move/indent commands while preserving the existing DOM nodes.
4
+ * Null means the DOM shape is outside this adapter; false means a valid command
5
+ * was disabled at a boundary.
6
+ */
7
+ export declare const executeDomMoveCommand: (blocks: readonly HTMLElement[], direction: DomMoveDirection, beforeMutation?: () => void) => HTMLElement[] | false | null;
@@ -0,0 +1,54 @@
1
+ import { createSmartEditor, movePlugin } from "smartrte-core";
2
+ import { smartDocumentFromHtml } from "./domSmartDocument.js";
3
+ /**
4
+ * Executes model move/indent commands while preserving the existing DOM nodes.
5
+ * Null means the DOM shape is outside this adapter; false means a valid command
6
+ * was disabled at a boundary.
7
+ */
8
+ export const executeDomMoveCommand = (blocks, direction, beforeMutation) => {
9
+ if (!blocks.length || !blocks[0].parentElement)
10
+ return null;
11
+ const parent = blocks[0].parentElement;
12
+ if (!blocks.every((block) => block.parentElement === parent))
13
+ return null;
14
+ const siblings = Array.from(parent.children);
15
+ const indexes = blocks.map((block) => siblings.indexOf(block));
16
+ if (indexes.some((index, position) => index < 0 || (position > 0 && index !== indexes[position - 1] + 1))) {
17
+ return null;
18
+ }
19
+ const document = smartDocumentFromHtml(siblings.map((sibling) => sibling.outerHTML).join(""), parent.ownerDocument);
20
+ if (document.children.length !== siblings.length)
21
+ return null;
22
+ const editor = createSmartEditor({
23
+ state: { document, selection: { type: "all" } },
24
+ plugins: [movePlugin],
25
+ });
26
+ const commandId = direction === "up" || direction === "down" ? "block.move" : "block.indent";
27
+ const input = {
28
+ parentPath: [],
29
+ blockIndexes: indexes,
30
+ direction: direction === "left" ? "outdent" : direction === "right" ? "indent" : direction,
31
+ };
32
+ if (!editor.execute(commandId, input))
33
+ return false;
34
+ beforeMutation?.();
35
+ if (direction === "left" || direction === "right") {
36
+ indexes.forEach((index, position) => {
37
+ const indent = editor.state.document.children[index].indent || 0;
38
+ blocks[position].style.marginLeft = indent ? `${indent * 24}px` : "";
39
+ if (!blocks[position].getAttribute("style"))
40
+ blocks[position].removeAttribute("style");
41
+ });
42
+ return [...blocks];
43
+ }
44
+ if (direction === "up") {
45
+ const previous = siblings[indexes[0] - 1];
46
+ blocks.forEach((block) => parent.insertBefore(block, previous));
47
+ }
48
+ else {
49
+ const next = siblings[indexes[indexes.length - 1] + 1];
50
+ const reference = next.nextSibling;
51
+ blocks.forEach((block) => parent.insertBefore(block, reference));
52
+ }
53
+ return [...blocks];
54
+ };
@@ -1,5 +1,7 @@
1
- import type { SmartSelection } from "smartrte-core";
1
+ import type { Path, SmartSelection } from "smartrte-core";
2
2
  export declare const isEditorOnlyElement: (node: Element) => boolean;
3
+ /** Returns the semantic model path for a DOM element at the adapter boundary. */
4
+ export declare const pathForDomElement: (element: Element, editor: HTMLElement) => Path | null;
3
5
  /** Converts browser selection into a core selection without exposing editor UI nodes. */
4
6
  export declare const selectionFromDom: (editor: HTMLElement, selection: Selection | null) => SmartSelection | null;
5
7
  /** Restores a text selection from core paths after the editor DOM is rebuilt. */
@@ -1,7 +1,10 @@
1
1
  const LEAF_TAGS = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6"]);
2
2
  const CONTAINER_TAGS = new Set(["blockquote", "ul", "ol", "li", "table", "tr", "td", "th"]);
3
3
  export const isEditorOnlyElement = (node) => node.getAttribute("data-table-wrapper") === "true" ||
4
- Array.from(node.attributes).some((attribute) => attribute.name.startsWith("data-srte-")) ||
4
+ node.hasAttribute("data-srte-selection-marker") ||
5
+ node.hasAttribute("data-srte-resize-overlay") ||
6
+ node.hasAttribute("data-srte-drag-handle") ||
7
+ node.hasAttribute("data-srte-check") ||
5
8
  node.matches(".srte-table-resize-handle, .srte-table-resize-overlay, .srte-drag-handle");
6
9
  const isSemanticElement = (node) => LEAF_TAGS.has(node.tagName.toLowerCase()) || CONTAINER_TAGS.has(node.tagName.toLowerCase());
7
10
  const hasSemanticChild = (node) => Array.from(node.children).some((child) => !isEditorOnlyElement(child) && isSemanticElement(child));
@@ -59,38 +62,51 @@ const pathForElement = (element, editor) => {
59
62
  };
60
63
  return findPath(editor, []);
61
64
  };
62
- const textNodesIn = (root) => {
63
- const nodes = [];
64
- const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
65
- acceptNode(node) {
66
- let parent = node.parentElement;
67
- while (parent && parent !== root) {
68
- if (isEditorOnlyElement(parent))
69
- return NodeFilter.FILTER_REJECT;
70
- parent = parent.parentElement;
71
- }
72
- return NodeFilter.FILTER_ACCEPT;
73
- },
74
- });
75
- let node = walker.nextNode();
76
- while (node) {
77
- nodes.push(node);
78
- node = walker.nextNode();
79
- }
80
- return nodes;
65
+ /** Returns the semantic model path for a DOM element at the adapter boundary. */
66
+ export const pathForDomElement = (element, editor) => pathForElement(element, editor);
67
+ const isInlineAtom = (element) => element.tagName.toLowerCase() === "img" || element.hasAttribute("data-formula");
68
+ const inlineUnitsIn = (root) => {
69
+ const units = [];
70
+ const visit = (node) => {
71
+ if (node.nodeType === Node.TEXT_NODE) {
72
+ if (node.textContent)
73
+ units.push({ type: "text", node: node });
74
+ return;
75
+ }
76
+ if (!(node instanceof Element) || isEditorOnlyElement(node))
77
+ return;
78
+ if (isInlineAtom(node)) {
79
+ units.push({ type: "atom", node });
80
+ return;
81
+ }
82
+ Array.from(node.childNodes).forEach(visit);
83
+ };
84
+ Array.from(root.childNodes).forEach(visit);
85
+ return units;
81
86
  };
82
- const offsetBeforePoint = (leaf, container, offset) => {
83
- if (!leaf.contains(container) && leaf !== container)
87
+ const pointRelativeTo = (container, offset, unit) => {
88
+ const point = container.ownerDocument?.createRange();
89
+ const unitRange = container.ownerDocument?.createRange();
90
+ if (!point || !unitRange)
84
91
  return null;
85
- const range = leaf.ownerDocument.createRange();
86
- range.selectNodeContents(leaf);
87
92
  try {
88
- range.setEnd(container, offset);
93
+ point.setStart(container, offset);
94
+ point.collapse(true);
95
+ if (unit.type === "text")
96
+ unitRange.selectNodeContents(unit.node);
97
+ else
98
+ unitRange.selectNode(unit.node);
99
+ const fromStart = point.compareBoundaryPoints(Range.START_TO_START, unitRange);
100
+ const fromEnd = point.compareBoundaryPoints(Range.START_TO_END, unitRange);
101
+ if (fromStart <= 0)
102
+ return "before";
103
+ if (fromEnd >= 0)
104
+ return "after";
105
+ return "inside";
89
106
  }
90
107
  catch {
91
108
  return null;
92
109
  }
93
- return range.toString().length;
94
110
  };
95
111
  const pointForDomPoint = (editor, container, offset) => {
96
112
  const leaf = closestLeaf(container, editor);
@@ -99,25 +115,47 @@ const pointForDomPoint = (editor, container, offset) => {
99
115
  const leafPath = pathForElement(leaf, editor);
100
116
  if (!leafPath)
101
117
  return null;
102
- const textNodes = textNodesIn(leaf);
103
- if (textNodes.length === 0)
118
+ const units = inlineUnitsIn(leaf);
119
+ if (units.length === 0)
104
120
  return { path: [...leafPath, 0], offset: 0 };
105
- const absoluteOffset = offsetBeforePoint(leaf, container, offset);
106
- if (absoluteOffset == null)
107
- return null;
108
- let remaining = absoluteOffset;
109
- for (let index = 0; index < textNodes.length; index += 1) {
110
- const length = textNodes[index].data.length;
111
- if (remaining <= length)
112
- return { path: [...leafPath, index], offset: remaining };
113
- remaining -= length;
121
+ const directTextIndex = units.findIndex((unit) => unit.type === "text" && unit.node === container);
122
+ if (directTextIndex >= 0) {
123
+ const text = units[directTextIndex];
124
+ return {
125
+ path: [...leafPath, directTextIndex],
126
+ offset: Math.max(0, Math.min(offset, text.node.data.length)),
127
+ };
128
+ }
129
+ let previousText = null;
130
+ for (let index = 0; index < units.length; index += 1) {
131
+ const unit = units[index];
132
+ const relative = pointRelativeTo(container, offset, unit);
133
+ if (relative === "before") {
134
+ if (unit.type === "text")
135
+ return { path: [...leafPath, index], offset: 0 };
136
+ const nextTextIndex = units.slice(index + 1).findIndex((candidate) => candidate.type === "text");
137
+ if (nextTextIndex >= 0)
138
+ return { path: [...leafPath, index + 1 + nextTextIndex], offset: 0 };
139
+ break;
140
+ }
141
+ if (unit.type === "text")
142
+ previousText = { index, node: unit.node };
143
+ }
144
+ if (previousText) {
145
+ return { path: [...leafPath, previousText.index], offset: previousText.node.data.length };
114
146
  }
115
- const last = textNodes.length - 1;
116
- return { path: [...leafPath, last], offset: textNodes[last].data.length };
147
+ return null;
117
148
  };
118
149
  const nodeSelectionFromRange = (editor, range) => {
119
- const nodes = Array.from(editor.querySelectorAll("img, [data-srte-node-selection='true']"));
120
- const selected = nodes.find((node) => range.intersectsNode(node));
150
+ const nodes = Array.from(editor.querySelectorAll("img, [data-formula], [data-srte-node-selection='true']"));
151
+ const selected = nodes.find((node) => {
152
+ if (range.collapsed)
153
+ return node === range.startContainer || node.contains(range.startContainer);
154
+ const nodeRange = editor.ownerDocument.createRange();
155
+ nodeRange.selectNode(node);
156
+ return range.compareBoundaryPoints(Range.START_TO_START, nodeRange) === 0 &&
157
+ range.compareBoundaryPoints(Range.END_TO_END, nodeRange) === 0;
158
+ });
121
159
  if (!selected)
122
160
  return null;
123
161
  const path = pathForElement(selected, editor);
@@ -155,13 +193,27 @@ const domPointForSmartPoint = (editor, point) => {
155
193
  const leaf = elementAtPath(editor, point.path.slice(0, -1));
156
194
  if (!leaf)
157
195
  return null;
158
- const text = textNodesIn(leaf)[point.path[point.path.length - 1]];
159
- if (!text || point.offset < 0 || point.offset > text.data.length)
196
+ const unit = inlineUnitsIn(leaf)[point.path[point.path.length - 1]];
197
+ if (unit?.type !== "text" || point.offset < 0 || point.offset > unit.node.data.length)
160
198
  return null;
161
- return { node: text, offset: point.offset };
199
+ return { node: unit.node, offset: point.offset };
162
200
  };
163
201
  /** Restores a text selection from core paths after the editor DOM is rebuilt. */
164
202
  export const restoreSelectionToDom = (editor, smartSelection) => {
203
+ if (smartSelection.type === "node") {
204
+ const element = elementAtPath(editor, smartSelection.path);
205
+ if (!element)
206
+ return false;
207
+ const range = editor.ownerDocument.createRange();
208
+ range.selectNodeContents(element);
209
+ range.collapse(false);
210
+ const selection = editor.ownerDocument.defaultView?.getSelection();
211
+ if (!selection)
212
+ return false;
213
+ selection.removeAllRanges();
214
+ selection.addRange(range);
215
+ return true;
216
+ }
165
217
  if (smartSelection.type !== "text")
166
218
  return false;
167
219
  const anchor = domPointForSmartPoint(editor, smartSelection.anchor);
@@ -1,6 +1,7 @@
1
1
  import { normalizeCompatibilityHtml, sanitizeLinkAttrs, } from "smartrte-core";
2
+ import { isSmartListPreset } from "smartrte-core";
2
3
  import { isEditorOnlyElement } from "./domSelectionBridge.js";
3
- const blockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"]);
4
+ const blockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table", "img", "video", "audio"]);
4
5
  const unwrap = (element) => {
5
6
  const parent = element.parentNode;
6
7
  if (!parent)
@@ -40,6 +41,9 @@ const marksFor = (element, inherited) => {
40
41
  marks = addMark(marks, { type: "subscript" });
41
42
  if (tag === "code")
42
43
  marks = addMark(marks, { type: "code" });
44
+ if (element.hasAttribute("data-formula")) {
45
+ marks = addMark(marks, { type: "formula", value: element.getAttribute("data-formula") || "" });
46
+ }
43
47
  if (tag === "a") {
44
48
  const safeLink = sanitizeLinkAttrs({
45
49
  href: element.getAttribute("href") || "",
@@ -51,6 +55,7 @@ const marksFor = (element, inherited) => {
51
55
  const color = element.getAttribute("color") || element.style.color;
52
56
  const backgroundColor = element.style.backgroundColor;
53
57
  const fontSize = element.style.fontSize;
58
+ const fontFamily = element.style.fontFamily;
54
59
  if (color)
55
60
  marks = addMark(marks, { type: "textColor", value: color });
56
61
  if (backgroundColor)
@@ -64,6 +69,8 @@ const marksFor = (element, inherited) => {
64
69
  marks = addMark(marks, { type: "fontSize", valuePx });
65
70
  }
66
71
  }
72
+ if (fontFamily)
73
+ marks = addMark(marks, { type: "fontFamily", value: fontFamily });
67
74
  return marks;
68
75
  };
69
76
  const inlineNodes = (nodes, inherited = []) => {
@@ -76,6 +83,27 @@ const inlineNodes = (nodes, inherited = []) => {
76
83
  }
77
84
  if (!(node instanceof Element))
78
85
  return;
86
+ if (node.hasAttribute("data-formula")) {
87
+ result.push({
88
+ type: "formula",
89
+ value: node.getAttribute("data-formula") || "",
90
+ ...(node.textContent && node.textContent !== node.getAttribute("data-formula")
91
+ ? { displayText: node.textContent }
92
+ : {}),
93
+ });
94
+ return;
95
+ }
96
+ if (node.tagName.toLowerCase() === "img") {
97
+ result.push({
98
+ type: "inlineImage",
99
+ src: node.getAttribute("src") || "",
100
+ alt: node.getAttribute("alt") || undefined,
101
+ title: node.getAttribute("title") || undefined,
102
+ width: Number(node.getAttribute("width")) || undefined,
103
+ height: Number(node.getAttribute("height")) || undefined,
104
+ });
105
+ return;
106
+ }
79
107
  if (node.tagName.toLowerCase() === "br") {
80
108
  result.push({ type: "text", text: "\n", marks: inherited.length ? inherited : undefined });
81
109
  return;
@@ -103,9 +131,19 @@ const alignmentFor = (element) => {
103
131
  const value = (element.style.textAlign || element.getAttribute("align") || "").toLowerCase();
104
132
  return value === "center" || value === "right" || value === "justify" ? value : undefined;
105
133
  };
134
+ const indentFor = (element) => {
135
+ const margin = Number.parseFloat(element.style.marginLeft || "0");
136
+ return Number.isFinite(margin) && margin > 0 ? Math.min(Math.round(margin / 24), 10) : undefined;
137
+ };
106
138
  const parseBlocks = (parent) => directBlockChildren(parent).flatMap(parseBlock);
107
139
  const parseList = (element) => {
108
- const style = element.tagName.toLowerCase() === "ol" ? "decimal" : "disc";
140
+ const requestedStyle = element.style.listStyleType;
141
+ const supportedStyles = new Set([
142
+ "disc", "circle", "square", "decimal", "decimal-leading-zero", "lower-alpha", "upper-alpha",
143
+ "lower-roman", "upper-roman",
144
+ ]);
145
+ const fallbackStyle = element.tagName.toLowerCase() === "ol" ? "decimal" : "disc";
146
+ const style = (supportedStyles.has(requestedStyle) ? requestedStyle : fallbackStyle);
109
147
  const items = Array.from(element.children)
110
148
  .filter((child) => child.tagName.toLowerCase() === "li")
111
149
  .map((item) => {
@@ -113,9 +151,28 @@ const parseList = (element) => {
113
151
  const nested = Array.from(item.children)
114
152
  .filter((child) => ["ul", "ol", "blockquote", "pre", "table", "p", "h1", "h2", "h3", "h4", "h5", "h6"].includes(child.tagName.toLowerCase()))
115
153
  .flatMap(parseBlock);
116
- return { type: "listItem", alignment: alignmentFor(item), children: [...(content ? [content] : []), ...nested] };
154
+ return {
155
+ type: "listItem",
156
+ alignment: alignmentFor(item),
157
+ ...(item.hasAttribute("data-checked") || item.hasAttribute("data-srte-checked")
158
+ ? { checked: (item.getAttribute("data-checked") || item.getAttribute("data-srte-checked")) === "true" }
159
+ : {}),
160
+ children: [...(content ? [content] : []), ...nested],
161
+ };
117
162
  });
118
- return { type: "list", style, children: items };
163
+ const checklist = element.getAttribute("data-srte-checklist") === "true";
164
+ const requestedPreset = element.getAttribute("data-srte-list-preset");
165
+ return {
166
+ type: "list",
167
+ indent: indentFor(element),
168
+ style,
169
+ ...(isSmartListPreset(requestedPreset) ? { preset: requestedPreset } : {}),
170
+ ...(checklist ? { checklist: true } : {}),
171
+ ...(checklist && element.getAttribute("data-srte-checklist-strike") === "true"
172
+ ? { strikeCompleted: true }
173
+ : {}),
174
+ children: items,
175
+ };
119
176
  };
120
177
  const tableRows = (table) => {
121
178
  const rows = [];
@@ -128,16 +185,38 @@ const tableRows = (table) => {
128
185
  });
129
186
  return rows;
130
187
  };
188
+ const borderFor = (element) => {
189
+ const style = element.style;
190
+ if (style.border)
191
+ return style.border;
192
+ const declared = element.getAttribute("style")?.match(/(?:^|;)\s*border\s*:\s*([^;]+)/i)?.[1]?.trim();
193
+ if (declared)
194
+ return declared;
195
+ if (style.borderTopStyle === "none" &&
196
+ style.borderRightStyle === "none" &&
197
+ style.borderBottomStyle === "none" &&
198
+ style.borderLeftStyle === "none")
199
+ return "none";
200
+ return undefined;
201
+ };
131
202
  const parseTable = (element) => ({
132
203
  type: "table",
204
+ indent: indentFor(element),
205
+ columnWidths: Array.from(element.querySelectorAll(":scope > colgroup > col"))
206
+ .map((column) => Number.parseFloat(column.style.width || column.getAttribute("width") || ""))
207
+ .filter((width) => Number.isFinite(width) && width > 0),
133
208
  children: tableRows(element).map((row) => ({
134
209
  type: "tableRow",
210
+ heightPx: Number.parseFloat(row.style.height || "") || undefined,
135
211
  children: Array.from(row.children)
136
212
  .filter((cell) => ["td", "th"].includes(cell.tagName.toLowerCase()))
137
213
  .map((cell) => ({
138
214
  type: cell.tagName.toLowerCase() === "th" ? "tableHeaderCell" : "tableCell",
139
215
  colspan: Number(cell.getAttribute("colspan")) || undefined,
140
216
  rowspan: Number(cell.getAttribute("rowspan")) || undefined,
217
+ backgroundColor: cell.style.backgroundColor || undefined,
218
+ textColor: cell.style.color || undefined,
219
+ border: borderFor(cell),
141
220
  children: parseBlocks(cell).length ? parseBlocks(cell) : [{ type: "paragraph", alignment: alignmentFor(cell), children: inlineNodes(cell.childNodes) }],
142
221
  })),
143
222
  })),
@@ -145,15 +224,34 @@ const parseTable = (element) => ({
145
224
  const parseBlock = (element) => {
146
225
  const tag = element.tagName.toLowerCase();
147
226
  if (tag === "p")
148
- return [{ type: "paragraph", alignment: alignmentFor(element), children: inlineNodes(element.childNodes) }];
227
+ return [{ type: "paragraph", alignment: alignmentFor(element), indent: indentFor(element), children: inlineNodes(element.childNodes) }];
149
228
  if (/^h[1-6]$/.test(tag))
150
- return [{ type: "heading", level: Number(tag.slice(1)), alignment: alignmentFor(element), children: inlineNodes(element.childNodes) }];
229
+ return [{ type: "heading", level: Number(tag.slice(1)), alignment: alignmentFor(element), indent: indentFor(element), children: inlineNodes(element.childNodes) }];
151
230
  if (tag === "ul" || tag === "ol")
152
231
  return [parseList(element)];
153
232
  if (tag === "blockquote")
154
- return [{ type: "blockquote", alignment: alignmentFor(element), children: parseBlocks(element) }];
233
+ return [{ type: "blockquote", alignment: alignmentFor(element), indent: indentFor(element), children: parseBlocks(element) }];
155
234
  if (tag === "pre")
156
- return [{ type: "codeBlock", alignment: alignmentFor(element), text: element.textContent || "", language: element.querySelector("code")?.className.replace(/^language-/, "") || undefined }];
235
+ return [{ type: "codeBlock", alignment: alignmentFor(element), indent: indentFor(element), text: element.textContent || "", language: element.querySelector("code")?.className.replace(/^language-/, "") || undefined }];
236
+ if (tag === "img")
237
+ return [{
238
+ type: "image",
239
+ indent: indentFor(element),
240
+ src: element.getAttribute("src") || "",
241
+ alt: element.getAttribute("alt") || undefined,
242
+ title: element.getAttribute("title") || undefined,
243
+ width: Number(element.getAttribute("width")) || undefined,
244
+ height: Number(element.getAttribute("height")) || undefined,
245
+ }];
246
+ if (tag === "video" || tag === "audio")
247
+ return [{
248
+ type: "media",
249
+ indent: indentFor(element),
250
+ src: element.getAttribute("src") || element.querySelector("source")?.getAttribute("src") || "",
251
+ mediaType: tag,
252
+ title: element.getAttribute("title") || undefined,
253
+ mimeType: element.getAttribute("type") || element.querySelector("source")?.getAttribute("type") || undefined,
254
+ }];
157
255
  if (tag === "table")
158
256
  return [parseTable(element)];
159
257
  return [];
@@ -191,26 +289,70 @@ const serializeText = (node) => {
191
289
  html = `<span style="background-color:${escapeHtml(mark.value)}">${html}</span>`;
192
290
  if (mark.type === "fontSize")
193
291
  html = `<span style="font-size:${mark.valuePx}px">${html}</span>`;
292
+ if (mark.type === "fontFamily")
293
+ html = `<span style="font-family:${escapeHtml(mark.value)}">${html}</span>`;
294
+ if (mark.type === "formula")
295
+ html = `<span data-formula="${escapeHtml(mark.value)}">${html}</span>`;
194
296
  if (mark.type === "link")
195
297
  html = `<a href="${escapeHtml(mark.href)}"${mark.target ? ` target="${escapeHtml(mark.target)}"` : ""}>${html}</a>`;
196
298
  });
197
299
  return html;
198
300
  };
199
- const alignmentAttribute = (alignment) => alignment ? ` style="text-align:${alignment}"` : "";
301
+ const serializeInline = (node) => {
302
+ if (node.type === "text")
303
+ return serializeText(node);
304
+ if (node.type === "formula") {
305
+ return `<span data-formula="${escapeHtml(node.value)}">${escapeHtml(node.displayText ?? node.value)}</span>`;
306
+ }
307
+ return `<img data-srte-inline="true" src="${escapeHtml(node.src)}"${node.alt ? ` alt="${escapeHtml(node.alt)}"` : ""}${node.title ? ` title="${escapeHtml(node.title)}"` : ""}${node.width ? ` width="${node.width}"` : ""}${node.height ? ` height="${node.height}"` : ""}>`;
308
+ };
309
+ const blockStyleAttribute = (block, extra) => {
310
+ const declarations = [
311
+ block.alignment ? `text-align:${block.alignment}` : "",
312
+ block.indent ? `margin-left:${block.indent * 24}px` : "",
313
+ extra || "",
314
+ ].filter(Boolean);
315
+ return declarations.length ? ` style="${declarations.join(";")}"` : "";
316
+ };
317
+ const alignmentAttribute = (alignment) => blockStyleAttribute({ alignment });
200
318
  const serializeBlock = (block) => {
201
319
  if (block.type === "paragraph")
202
- return `<p${alignmentAttribute(block.alignment)}>${block.children.map(serializeText).join("")}</p>`;
320
+ return `<p${blockStyleAttribute(block)}>${block.children.map(serializeInline).join("")}</p>`;
203
321
  if (block.type === "heading")
204
- return `<h${block.level}${alignmentAttribute(block.alignment)}>${block.children.map(serializeText).join("")}</h${block.level}>`;
322
+ return `<h${block.level}${blockStyleAttribute(block)}>${block.children.map(serializeInline).join("")}</h${block.level}>`;
205
323
  if (block.type === "blockquote")
206
- return `<blockquote${alignmentAttribute(block.alignment)}>${block.children.map(serializeBlock).join("")}</blockquote>`;
324
+ return `<blockquote${blockStyleAttribute(block)}>${block.children.map(serializeBlock).join("")}</blockquote>`;
207
325
  if (block.type === "codeBlock")
208
- return `<pre${alignmentAttribute(block.alignment)}><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.text)}</code></pre>`;
326
+ return `<pre${blockStyleAttribute(block)}><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.text)}</code></pre>`;
327
+ if (block.type === "image")
328
+ return `<img${blockStyleAttribute(block)} src="${escapeHtml(block.src)}"${block.alt ? ` alt="${escapeHtml(block.alt)}"` : ""}${block.title ? ` title="${escapeHtml(block.title)}"` : ""}${block.width ? ` width="${block.width}"` : ""}${block.height ? ` height="${block.height}"` : ""}>`;
329
+ if (block.type === "media") {
330
+ const tag = block.mediaType === "audio" ? "audio" : "video";
331
+ return `<${tag}${blockStyleAttribute(block)} controls src="${escapeHtml(block.src)}"${block.title ? ` title="${escapeHtml(block.title)}"` : ""}${block.mimeType ? ` type="${escapeHtml(block.mimeType)}"` : ""}></${tag}>`;
332
+ }
209
333
  if (block.type === "list") {
210
- const tag = block.style === "decimal" ? "ol" : "ul";
211
- return `<${tag}>${block.children.map((item) => `<li${alignmentAttribute(item.alignment)}>${item.children.map(serializeBlock).join("")}</li>`).join("")}</${tag}>`;
334
+ const ordered = ["decimal", "decimal-leading-zero", "lower-alpha", "upper-alpha", "lower-roman", "upper-roman"].includes(block.style);
335
+ const tag = ordered ? "ol" : "ul";
336
+ const style = blockStyleAttribute(block, `list-style-type:${block.style}`);
337
+ const checklist = block.checklist
338
+ ? ` data-srte-checklist="true" data-srte-checklist-strike="${block.strikeCompleted ? "true" : "false"}"`
339
+ : "";
340
+ const preset = block.preset ? ` data-srte-list-preset="${block.preset}"` : "";
341
+ return `<${tag}${style}${preset}${checklist}>${block.children.map((item) => `<li${item.checked !== undefined ? ` data-srte-checked="${item.checked ? "true" : "false"}"` : ""}${alignmentAttribute(item.alignment)}>${item.children.map(serializeBlock).join("")}</li>`).join("")}</${tag}>`;
212
342
  }
213
- return `<table><tbody>${block.children.map((row) => `<tr>${row.children.map((cell) => `<${cell.type === "tableHeaderCell" ? "th" : "td"}>${cell.children.map(serializeBlock).join("")}</${cell.type === "tableHeaderCell" ? "th" : "td"}>`).join("")}</tr>`).join("")}</tbody></table>`;
343
+ const columns = block.columnWidths?.length
344
+ ? `<colgroup>${block.columnWidths.map((width) => `<col style="width:${width}px">`).join("")}</colgroup>`
345
+ : "";
346
+ return `<table${blockStyleAttribute(block)}>${columns}<tbody>${block.children.map((row) => `<tr${row.heightPx ? ` style="height:${row.heightPx}px"` : ""}>${row.children.map((cell) => {
347
+ const tag = cell.type === "tableHeaderCell" ? "th" : "td";
348
+ const spans = `${cell.colspan && cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ""}${cell.rowspan && cell.rowspan > 1 ? ` rowspan="${cell.rowspan}"` : ""}`;
349
+ const style = [
350
+ cell.backgroundColor ? `background-color:${escapeHtml(cell.backgroundColor)}` : "",
351
+ cell.textColor ? `color:${escapeHtml(cell.textColor)}` : "",
352
+ cell.border ? `border:${escapeHtml(cell.border)}` : "",
353
+ ].filter(Boolean);
354
+ return `<${tag}${spans}${style.length ? ` style="${style.join(";")}"` : ""}>${cell.children.map(serializeBlock).join("")}</${tag}>`;
355
+ }).join("")}</tr>`).join("")}</tbody></table>`;
214
356
  };
215
357
  /** Serializes the shadow-only model. It is not used for persisted editor HTML. */
216
358
  export const serializeSmartDocument = (document) => document.children.map(serializeBlock).join("");
@@ -0,0 +1,35 @@
1
+ import { type MergeTableCellsInput, type SetTableCellStyleInput, type SetTableColumnWidthInput, type SetTableRowHeightInput, type TableHeaderInput, type SplitTableCellInput, type TableColumnInput, type TableRowInput, type ToggleTableCellBorderInput } from "smartrte-core";
2
+ export type DomTableCommand = {
3
+ id: "table.row.add" | "table.row.remove";
4
+ input: Omit<TableRowInput, "tablePath">;
5
+ } | {
6
+ id: "table.column.add" | "table.column.remove";
7
+ input: Omit<TableColumnInput, "tablePath">;
8
+ } | {
9
+ id: "table.cell.merge";
10
+ input: Omit<MergeTableCellsInput, "tablePath">;
11
+ } | {
12
+ id: "table.cell.split";
13
+ input: Omit<SplitTableCellInput, "tablePath">;
14
+ } | {
15
+ id: "table.header.cell.toggle" | "table.header.row.toggle" | "table.header.column.toggle";
16
+ input: Omit<TableHeaderInput, "tablePath">;
17
+ } | {
18
+ id: "table.cell.style.set";
19
+ input: Omit<SetTableCellStyleInput, "tablePath">;
20
+ } | {
21
+ id: "table.column.width.set";
22
+ input: Omit<SetTableColumnWidthInput, "tablePath">;
23
+ } | {
24
+ id: "table.row.height.set";
25
+ input: Omit<SetTableRowHeightInput, "tablePath">;
26
+ } | {
27
+ id: "table.cell.border.toggle";
28
+ input: Omit<ToggleTableCellBorderInput, "tablePath">;
29
+ };
30
+ /**
31
+ * Executes a core table command while replacing only the affected table.
32
+ * Visual table/cell attributes are transferred by logical grid position.
33
+ */
34
+ export declare const executeDomTableCommand: (table: HTMLTableElement, command: DomTableCommand) => HTMLTableElement | null;
35
+ export declare const executeDomTableRemoval: (table: HTMLTableElement) => boolean;