smartrte-core 0.2.1 → 1.0.0-beta.1

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 (329) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +23 -0
  3. package/dist/command.d.ts +8 -8
  4. package/dist/editor.d.ts +32 -0
  5. package/dist/editor.js +121 -0
  6. package/dist/foundation/annotations/index.d.ts +2 -0
  7. package/dist/foundation/annotations/index.js +2 -0
  8. package/dist/foundation/annotations/range.d.ts +54 -0
  9. package/dist/foundation/annotations/range.js +158 -0
  10. package/dist/foundation/annotations/types.d.ts +33 -0
  11. package/dist/foundation/annotations/types.js +1 -0
  12. package/dist/foundation/atom/commands.d.ts +11 -0
  13. package/dist/foundation/atom/commands.js +118 -0
  14. package/dist/foundation/atom/composition.d.ts +8 -0
  15. package/dist/foundation/atom/composition.js +22 -0
  16. package/dist/foundation/atom/declarations.d.ts +2 -0
  17. package/dist/foundation/atom/declarations.js +17 -0
  18. package/dist/foundation/atom/formats.d.ts +21 -0
  19. package/dist/foundation/atom/formats.js +77 -0
  20. package/dist/foundation/atom/index.d.ts +10 -0
  21. package/dist/foundation/atom/index.js +10 -0
  22. package/dist/foundation/atom/lifecycle.d.ts +12 -0
  23. package/dist/foundation/atom/lifecycle.js +41 -0
  24. package/dist/foundation/atom/persistence.d.ts +3 -0
  25. package/dist/foundation/atom/persistence.js +20 -0
  26. package/dist/foundation/atom/plugin.d.ts +20 -0
  27. package/dist/foundation/atom/plugin.js +55 -0
  28. package/dist/foundation/atom/schema.d.ts +3 -0
  29. package/dist/foundation/atom/schema.js +29 -0
  30. package/dist/foundation/atom/security.d.ts +10 -0
  31. package/dist/foundation/atom/security.js +20 -0
  32. package/dist/foundation/atom/selection.d.ts +3 -0
  33. package/dist/foundation/atom/selection.js +4 -0
  34. package/dist/foundation/atom/types.d.ts +47 -0
  35. package/dist/foundation/atom/types.js +1 -0
  36. package/dist/foundation/block/commands.d.ts +17 -0
  37. package/dist/foundation/block/commands.js +197 -0
  38. package/dist/foundation/block/formats.d.ts +33 -0
  39. package/dist/foundation/block/formats.js +57 -0
  40. package/dist/foundation/block/index.d.ts +5 -0
  41. package/dist/foundation/block/index.js +5 -0
  42. package/dist/foundation/block/input.d.ts +19 -0
  43. package/dist/foundation/block/input.js +67 -0
  44. package/dist/foundation/block/plugin.d.ts +26 -0
  45. package/dist/foundation/block/plugin.js +79 -0
  46. package/dist/foundation/block/schema.d.ts +49 -0
  47. package/dist/foundation/block/schema.js +27 -0
  48. package/dist/foundation/block/types.d.ts +40 -0
  49. package/dist/foundation/block/types.js +1 -0
  50. package/dist/foundation/boundaries.d.ts +11 -0
  51. package/dist/foundation/boundaries.js +117 -0
  52. package/dist/foundation/clipboard/detection.d.ts +3 -0
  53. package/dist/foundation/clipboard/detection.js +29 -0
  54. package/dist/foundation/clipboard/diagnostics.d.ts +24 -0
  55. package/dist/foundation/clipboard/diagnostics.js +59 -0
  56. package/dist/foundation/clipboard/index.d.ts +8 -0
  57. package/dist/foundation/clipboard/index.js +8 -0
  58. package/dist/foundation/clipboard/insertion.d.ts +22 -0
  59. package/dist/foundation/clipboard/insertion.js +210 -0
  60. package/dist/foundation/clipboard/normalizers.d.ts +8 -0
  61. package/dist/foundation/clipboard/normalizers.js +229 -0
  62. package/dist/foundation/clipboard/pipeline.d.ts +13 -0
  63. package/dist/foundation/clipboard/pipeline.js +58 -0
  64. package/dist/foundation/clipboard/sanitizer.d.ts +3 -0
  65. package/dist/foundation/clipboard/sanitizer.js +58 -0
  66. package/dist/foundation/clipboard/serialization.d.ts +5 -0
  67. package/dist/foundation/clipboard/serialization.js +33 -0
  68. package/dist/foundation/clipboard/transfer.d.ts +11 -0
  69. package/dist/foundation/clipboard/transfer.js +108 -0
  70. package/dist/foundation/clipboard/types.d.ts +65 -0
  71. package/dist/foundation/clipboard/types.js +1 -0
  72. package/dist/foundation/collab/index.d.ts +3 -0
  73. package/dist/foundation/collab/index.js +3 -0
  74. package/dist/foundation/collab/rebase.d.ts +28 -0
  75. package/dist/foundation/collab/rebase.js +82 -0
  76. package/dist/foundation/collab/transform.d.ts +73 -0
  77. package/dist/foundation/collab/transform.js +251 -0
  78. package/dist/foundation/collab/types.d.ts +52 -0
  79. package/dist/foundation/collab/types.js +1 -0
  80. package/dist/foundation/comments/fromSelection.d.ts +16 -0
  81. package/dist/foundation/comments/fromSelection.js +24 -0
  82. package/dist/foundation/comments/index.d.ts +3 -0
  83. package/dist/foundation/comments/index.js +3 -0
  84. package/dist/foundation/comments/rebase.d.ts +31 -0
  85. package/dist/foundation/comments/rebase.js +43 -0
  86. package/dist/foundation/comments/types.d.ts +37 -0
  87. package/dist/foundation/comments/types.js +1 -0
  88. package/dist/foundation/diff/documentDiff.d.ts +12 -0
  89. package/dist/foundation/diff/documentDiff.js +148 -0
  90. package/dist/foundation/diff/index.d.ts +3 -0
  91. package/dist/foundation/diff/index.js +3 -0
  92. package/dist/foundation/diff/textDiff.d.ts +12 -0
  93. package/dist/foundation/diff/textDiff.js +114 -0
  94. package/dist/foundation/diff/types.d.ts +74 -0
  95. package/dist/foundation/diff/types.js +1 -0
  96. package/dist/foundation/editor.d.ts +138 -0
  97. package/dist/foundation/editor.js +398 -0
  98. package/dist/foundation/formats/codec.d.ts +45 -0
  99. package/dist/foundation/formats/codec.js +1 -0
  100. package/dist/foundation/formats/docx/export.d.ts +4 -0
  101. package/dist/foundation/formats/docx/export.js +285 -0
  102. package/dist/foundation/formats/docx/import.d.ts +9 -0
  103. package/dist/foundation/formats/docx/import.js +37 -0
  104. package/dist/foundation/formats/docx/nestingGuard.d.ts +40 -0
  105. package/dist/foundation/formats/docx/nestingGuard.js +66 -0
  106. package/dist/foundation/formats/docx/portableAtoms.d.ts +4 -0
  107. package/dist/foundation/formats/docx/portableAtoms.js +66 -0
  108. package/dist/foundation/formats/docx/styledImport.d.ts +10 -0
  109. package/dist/foundation/formats/docx/styledImport.js +205 -0
  110. package/dist/foundation/formats/featureCodecs.d.ts +37 -0
  111. package/dist/foundation/formats/featureCodecs.js +69 -0
  112. package/dist/foundation/formats/fidelity.d.ts +31 -0
  113. package/dist/foundation/formats/fidelity.js +123 -0
  114. package/dist/foundation/formats/index.d.ts +7 -0
  115. package/dist/foundation/formats/index.js +7 -0
  116. package/dist/foundation/formats/pdf/format.d.ts +29 -0
  117. package/dist/foundation/formats/pdf/format.js +190 -0
  118. package/dist/foundation/history.d.ts +20 -0
  119. package/dist/foundation/history.js +121 -0
  120. package/dist/foundation/identity.d.ts +8 -0
  121. package/dist/foundation/identity.js +30 -0
  122. package/dist/foundation/index.d.ts +30 -0
  123. package/dist/foundation/index.js +30 -0
  124. package/dist/foundation/list/commands.d.ts +28 -0
  125. package/dist/foundation/list/commands.js +422 -0
  126. package/dist/foundation/list/formats.d.ts +20 -0
  127. package/dist/foundation/list/formats.js +795 -0
  128. package/dist/foundation/list/index.d.ts +8 -0
  129. package/dist/foundation/list/index.js +8 -0
  130. package/dist/foundation/list/input.d.ts +35 -0
  131. package/dist/foundation/list/input.js +320 -0
  132. package/dist/foundation/list/plugin.d.ts +21 -0
  133. package/dist/foundation/list/plugin.js +71 -0
  134. package/dist/foundation/list/presets.d.ts +16 -0
  135. package/dist/foundation/list/presets.js +21 -0
  136. package/dist/foundation/list/schema.d.ts +35 -0
  137. package/dist/foundation/list/schema.js +29 -0
  138. package/dist/foundation/list/shadow.d.ts +54 -0
  139. package/dist/foundation/list/shadow.js +75 -0
  140. package/dist/foundation/list/shadowCorpus.d.ts +10 -0
  141. package/dist/foundation/list/shadowCorpus.js +41 -0
  142. package/dist/foundation/list/types.d.ts +50 -0
  143. package/dist/foundation/list/types.js +1 -0
  144. package/dist/foundation/mapping.d.ts +11 -0
  145. package/dist/foundation/mapping.js +40 -0
  146. package/dist/foundation/marks/canonical.d.ts +9 -0
  147. package/dist/foundation/marks/canonical.js +72 -0
  148. package/dist/foundation/marks/commands.d.ts +21 -0
  149. package/dist/foundation/marks/commands.js +136 -0
  150. package/dist/foundation/marks/dom.d.ts +2 -0
  151. package/dist/foundation/marks/dom.js +46 -0
  152. package/dist/foundation/marks/editor.d.ts +8 -0
  153. package/dist/foundation/marks/editor.js +50 -0
  154. package/dist/foundation/marks/formats.d.ts +37 -0
  155. package/dist/foundation/marks/formats.js +49 -0
  156. package/dist/foundation/marks/hardBreak.d.ts +8 -0
  157. package/dist/foundation/marks/hardBreak.js +80 -0
  158. package/dist/foundation/marks/index.d.ts +11 -0
  159. package/dist/foundation/marks/index.js +11 -0
  160. package/dist/foundation/marks/normalization.d.ts +2 -0
  161. package/dist/foundation/marks/normalization.js +49 -0
  162. package/dist/foundation/marks/plugin.d.ts +14 -0
  163. package/dist/foundation/marks/plugin.js +63 -0
  164. package/dist/foundation/marks/resolveMarkRun.d.ts +8 -0
  165. package/dist/foundation/marks/resolveMarkRun.js +47 -0
  166. package/dist/foundation/marks/schema.d.ts +167 -0
  167. package/dist/foundation/marks/schema.js +50 -0
  168. package/dist/foundation/marks/stored.d.ts +2 -0
  169. package/dist/foundation/marks/stored.js +31 -0
  170. package/dist/foundation/marks/types.d.ts +40 -0
  171. package/dist/foundation/marks/types.js +1 -0
  172. package/dist/foundation/modelDom.d.ts +38 -0
  173. package/dist/foundation/modelDom.js +327 -0
  174. package/dist/foundation/normalization.d.ts +22 -0
  175. package/dist/foundation/normalization.js +132 -0
  176. package/dist/foundation/operations.d.ts +28 -0
  177. package/dist/foundation/operations.js +568 -0
  178. package/dist/foundation/plugin/builtins.d.ts +10 -0
  179. package/dist/foundation/plugin/builtins.js +27 -0
  180. package/dist/foundation/plugin/dispatch.d.ts +33 -0
  181. package/dist/foundation/plugin/dispatch.js +34 -0
  182. package/dist/foundation/plugin/index.d.ts +4 -0
  183. package/dist/foundation/plugin/index.js +4 -0
  184. package/dist/foundation/plugin/registry.d.ts +36 -0
  185. package/dist/foundation/plugin/registry.js +115 -0
  186. package/dist/foundation/plugin/types.d.ts +141 -0
  187. package/dist/foundation/plugin/types.js +13 -0
  188. package/dist/foundation/positions.d.ts +20 -0
  189. package/dist/foundation/positions.js +121 -0
  190. package/dist/foundation/schema.d.ts +57 -0
  191. package/dist/foundation/schema.js +371 -0
  192. package/dist/foundation/schemaBuilder.d.ts +41 -0
  193. package/dist/foundation/schemaBuilder.js +96 -0
  194. package/dist/foundation/scope/index.d.ts +2 -0
  195. package/dist/foundation/scope/index.js +2 -0
  196. package/dist/foundation/scope/resolveScope.d.ts +13 -0
  197. package/dist/foundation/scope/resolveScope.js +860 -0
  198. package/dist/foundation/scope/types.d.ts +122 -0
  199. package/dist/foundation/scope/types.js +1 -0
  200. package/dist/foundation/security/urlPolicy.d.ts +21 -0
  201. package/dist/foundation/security/urlPolicy.js +75 -0
  202. package/dist/foundation/structural/contentTarget.d.ts +21 -0
  203. package/dist/foundation/structural/contentTarget.js +51 -0
  204. package/dist/foundation/structural/index.d.ts +2 -0
  205. package/dist/foundation/structural/index.js +2 -0
  206. package/dist/foundation/structural/move.d.ts +7 -0
  207. package/dist/foundation/structural/move.js +35 -0
  208. package/dist/foundation/suggestions/index.d.ts +3 -0
  209. package/dist/foundation/suggestions/index.js +3 -0
  210. package/dist/foundation/suggestions/inline.d.ts +64 -0
  211. package/dist/foundation/suggestions/inline.js +116 -0
  212. package/dist/foundation/suggestions/structural.d.ts +40 -0
  213. package/dist/foundation/suggestions/structural.js +47 -0
  214. package/dist/foundation/suggestions/types.d.ts +19 -0
  215. package/dist/foundation/suggestions/types.js +1 -0
  216. package/dist/foundation/surface/index.d.ts +3 -0
  217. package/dist/foundation/surface/index.js +3 -0
  218. package/dist/foundation/surface/input.d.ts +127 -0
  219. package/dist/foundation/surface/input.js +1761 -0
  220. package/dist/foundation/surface/renderer.d.ts +84 -0
  221. package/dist/foundation/surface/renderer.js +974 -0
  222. package/dist/foundation/surface/types.d.ts +48 -0
  223. package/dist/foundation/surface/types.js +1 -0
  224. package/dist/foundation/table/commands.d.ts +31 -0
  225. package/dist/foundation/table/commands.js +470 -0
  226. package/dist/foundation/table/formats.d.ts +26 -0
  227. package/dist/foundation/table/formats.js +40 -0
  228. package/dist/foundation/table/grid.d.ts +8 -0
  229. package/dist/foundation/table/grid.js +178 -0
  230. package/dist/foundation/table/index.d.ts +6 -0
  231. package/dist/foundation/table/index.js +6 -0
  232. package/dist/foundation/table/plugin.d.ts +23 -0
  233. package/dist/foundation/table/plugin.js +119 -0
  234. package/dist/foundation/table/schema.d.ts +2 -0
  235. package/dist/foundation/table/schema.js +38 -0
  236. package/dist/foundation/table/selection.d.ts +7 -0
  237. package/dist/foundation/table/selection.js +35 -0
  238. package/dist/foundation/table/types.d.ts +109 -0
  239. package/dist/foundation/table/types.js +1 -0
  240. package/dist/foundation/transactions.d.ts +41 -0
  241. package/dist/foundation/transactions.js +90 -0
  242. package/dist/foundation/types.d.ts +261 -0
  243. package/dist/foundation/types.js +1 -0
  244. package/dist/foundation/versioning/diffVersions.d.ts +5 -0
  245. package/dist/foundation/versioning/diffVersions.js +3 -0
  246. package/dist/foundation/versioning/index.d.ts +2 -0
  247. package/dist/foundation/versioning/index.js +2 -0
  248. package/dist/foundation/versioning/types.d.ts +17 -0
  249. package/dist/foundation/versioning/types.js +1 -0
  250. package/dist/history.d.ts +20 -0
  251. package/dist/history.js +165 -0
  252. package/dist/html/compatibility.js +7 -1
  253. package/dist/index.d.ts +1 -14
  254. package/dist/index.js +9 -14
  255. package/dist/legacy/index.d.ts +45 -0
  256. package/dist/legacy/index.js +45 -0
  257. package/dist/legacyCommands/alignment.d.ts +20 -0
  258. package/dist/{commands → legacyCommands}/alignment.js +17 -0
  259. package/dist/legacyCommands/blocks.d.ts +14 -0
  260. package/dist/legacyCommands/blocks.js +105 -0
  261. package/dist/legacyCommands/checklist.d.ts +11 -0
  262. package/dist/legacyCommands/checklist.js +45 -0
  263. package/dist/legacyCommands/code.d.ts +15 -0
  264. package/dist/{commands → legacyCommands}/code.js +25 -1
  265. package/dist/legacyCommands/formula.d.ts +7 -0
  266. package/dist/legacyCommands/formula.js +67 -0
  267. package/dist/legacyCommands/inlineAtoms.d.ts +7 -0
  268. package/dist/legacyCommands/inlineAtoms.js +58 -0
  269. package/dist/legacyCommands/list.d.ts +38 -0
  270. package/dist/legacyCommands/list.js +477 -0
  271. package/dist/{commands → legacyCommands}/marks.d.ts +3 -0
  272. package/dist/{commands → legacyCommands}/marks.js +28 -11
  273. package/dist/legacyCommands/media.d.ts +31 -0
  274. package/dist/legacyCommands/media.js +205 -0
  275. package/dist/legacyCommands/move.d.ts +14 -0
  276. package/dist/legacyCommands/move.js +91 -0
  277. package/dist/legacyCommands/table.d.ts +91 -0
  278. package/dist/legacyCommands/table.js +547 -0
  279. package/dist/listPresets.d.ts +12 -0
  280. package/dist/listPresets.js +6 -0
  281. package/dist/listScope.d.ts +27 -0
  282. package/dist/listScope.js +104 -0
  283. package/dist/markdown/compatibility.d.ts +2 -0
  284. package/dist/markdown/compatibility.js +94 -2
  285. package/dist/marks.d.ts +5 -5
  286. package/dist/model.d.ts +65 -9
  287. package/dist/plugin.d.ts +15 -0
  288. package/dist/plugin.js +31 -0
  289. package/dist/plugins/alignment.d.ts +3 -0
  290. package/dist/plugins/alignment.js +6 -0
  291. package/dist/plugins/basicFormatting.d.ts +9 -0
  292. package/dist/plugins/basicFormatting.js +24 -0
  293. package/dist/plugins/blockType.d.ts +3 -0
  294. package/dist/plugins/blockType.js +6 -0
  295. package/dist/plugins/blockquote.d.ts +3 -0
  296. package/dist/plugins/blockquote.js +6 -0
  297. package/dist/plugins/checklist.d.ts +3 -0
  298. package/dist/plugins/checklist.js +10 -0
  299. package/dist/plugins/codeBlock.d.ts +3 -0
  300. package/dist/plugins/codeBlock.js +6 -0
  301. package/dist/plugins/formula.d.ts +3 -0
  302. package/dist/plugins/formula.js +9 -0
  303. package/dist/plugins/list.d.ts +3 -0
  304. package/dist/plugins/list.js +10 -0
  305. package/dist/plugins/media.d.ts +3 -0
  306. package/dist/plugins/media.js +12 -0
  307. package/dist/plugins/move.d.ts +3 -0
  308. package/dist/plugins/move.js +9 -0
  309. package/dist/plugins/table.d.ts +3 -0
  310. package/dist/plugins/table.js +22 -0
  311. package/dist/preset.d.ts +12 -0
  312. package/dist/preset.js +40 -0
  313. package/dist/schema.d.ts +22 -0
  314. package/dist/schema.js +333 -0
  315. package/dist/security/urlPolicy.d.ts +1 -17
  316. package/dist/security/urlPolicy.js +3 -69
  317. package/dist/selection.d.ts +1 -1
  318. package/dist/selectionMapping.d.ts +4 -0
  319. package/dist/selectionMapping.js +222 -0
  320. package/dist/table.d.ts +3 -3
  321. package/dist/transaction.d.ts +35 -12
  322. package/dist/transaction.js +174 -28
  323. package/dist/tree.d.ts +8 -2
  324. package/dist/tree.js +44 -0
  325. package/package.json +27 -8
  326. package/dist/commands/alignment.d.ts +0 -18
  327. package/dist/commands/code.d.ts +0 -13
  328. package/dist/commands/list.d.ts +0 -20
  329. package/dist/commands/list.js +0 -52
@@ -0,0 +1,974 @@
1
+ import katex from "katex";
2
+ // Registers \ce{...} for chemistry-equation notation (e.g. the formula
3
+ // library's "2 H2 + O2 -> 2 H2O" entry) - a side-effect import, required
4
+ // once per katex module instance for \ce to parse instead of throwing.
5
+ import "katex/contrib/mhchem";
6
+ import { isTextNode } from "../identity.js";
7
+ import { FoundationModelDomMapping, SMART_EMPTY_LINE_ATTRIBUTE, SMART_NODE_ID_ATTRIBUTE, SMART_PROJECTION_ATTRIBUTE, SMART_UI_ATTRIBUTE, } from "../modelDom.js";
8
+ import { renderMarkedText, stableValue } from "../marks/index.js";
9
+ import { occupancyGridFor, snapTableCellRect } from "../table/index.js";
10
+ import { sanitizeAtomSource } from "../atom/security.js";
11
+ /**
12
+ * Security-hardened per the retired legacy KaTeX config this replaces:
13
+ * trust:false blocks commands like \includegraphics/\href with adverse
14
+ * side effects; strict:"error" rejects non-standard LaTeX outright rather
15
+ * than silently tolerating it. Neither is relaxed here. `output` is left
16
+ * at KaTeX's own default (`htmlAndMathml`) deliberately - that default
17
+ * already emits MathML alongside the visual HTML, which is what makes
18
+ * this accessible to screen readers beyond the aria-label below.
19
+ */
20
+ const KATEX_OPTIONS = { trust: false, strict: "error" };
21
+ /** Genuinely invalid/incomplete LaTeX (a real possibility mid-typing) must not crash the whole document render. */
22
+ const renderFormulaInto = (element, source) => {
23
+ try {
24
+ katex.render(source, element, KATEX_OPTIONS);
25
+ }
26
+ catch {
27
+ element.textContent = source;
28
+ }
29
+ };
30
+ const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider"]);
31
+ /**
32
+ * atom/lifecycle.ts writes a specific upload-failure reason to
33
+ * attrs.error (the host MediaProvider's own message, or a generic
34
+ * fallback if none) - previously written but never read by this
35
+ * renderer (docs/bugs/atom-upload-error-reason-not-rendered.md), so
36
+ * every failed upload showed only this function's own generic fallback
37
+ * regardless of what the model actually recorded.
38
+ */
39
+ const atomErrorTitle = (node, fallback) => typeof node.attrs?.error === "string" && node.attrs.error ? node.attrs.error : fallback;
40
+ const emptyLineOwnerTypes = new Set(["paragraph", "heading", "code_block"]);
41
+ const tagForNode = (node) => {
42
+ if (node.type === "paragraph")
43
+ return "p";
44
+ if (node.type === "heading")
45
+ return `h${String(node.attrs?.level || 1)}`;
46
+ if (node.type === "blockquote")
47
+ return "blockquote";
48
+ if (node.type === "code_block")
49
+ return "pre";
50
+ if (node.type === "list") {
51
+ const marker = String(node.attrs?.style || node.attrs?.preset || "");
52
+ return /^(?:decimal|lower-|upper-|ordered)/.test(marker) || node.attrs?.start !== undefined ? "ol" : "ul";
53
+ }
54
+ if (node.type === "list_item")
55
+ return "li";
56
+ if (node.type === "hard_break")
57
+ return "br";
58
+ if (node.type === "table")
59
+ return "table";
60
+ if (node.type === "table_row")
61
+ return "tr";
62
+ if (node.type === "table_cell")
63
+ return node.attrs?.header === true ? "th" : "td";
64
+ if (node.type === "image" || node.type === "block_image")
65
+ return "img";
66
+ if (node.type === "formula")
67
+ return "span";
68
+ if (node.type === "block_formula")
69
+ return "div";
70
+ if (node.type === "video" || node.type === "audio")
71
+ return node.type;
72
+ if (node.type === "divider")
73
+ return "hr";
74
+ return "div";
75
+ };
76
+ export class FoundationSubtreeRenderer {
77
+ constructor(root, options = {}) {
78
+ this.root = root;
79
+ this.options = options;
80
+ this.mapping = new FoundationModelDomMapping();
81
+ this.current = null;
82
+ this.modelById = new Map();
83
+ this.compositionOwner = null;
84
+ this.writes = 0;
85
+ this.compositionWrites = 0;
86
+ this.liveRegion = null;
87
+ root.contentEditable = "true";
88
+ root.tabIndex = -1;
89
+ root.setAttribute("data-smart-canonical-surface", "true");
90
+ }
91
+ get composingNodeId() { return this.compositionOwner; }
92
+ get domWriteCount() { return this.writes; }
93
+ get composingDomWriteCount() { return this.compositionWrites; }
94
+ recordWrite(ownerId) {
95
+ this.writes += 1;
96
+ if (this.compositionOwner && ownerId === this.compositionOwner)
97
+ this.compositionWrites += 1;
98
+ }
99
+ setAttribute(element, name, value, ownerId) {
100
+ if (element.getAttribute(name) === value)
101
+ return;
102
+ element.setAttribute(name, value);
103
+ this.recordWrite(ownerId);
104
+ }
105
+ removeAttribute(element, name, ownerId) {
106
+ if (!element.hasAttribute(name))
107
+ return;
108
+ element.removeAttribute(name);
109
+ this.recordWrite(ownerId);
110
+ }
111
+ syncNodeAttributes(element, node, listDepth = 0) {
112
+ if (node.attrs?.align)
113
+ element.style.textAlign = String(node.attrs.align);
114
+ else if (element.style.textAlign)
115
+ element.style.removeProperty("text-align");
116
+ if (node.attrs?.indentLevel)
117
+ element.style.marginInlineStart = `${Number(node.attrs.indentLevel) * 2}em`;
118
+ else if (element.style.marginInlineStart)
119
+ element.style.removeProperty("margin-inline-start");
120
+ if (node.type === "code_block") {
121
+ const language = typeof node.attrs?.language === "string" && node.attrs.language.trim()
122
+ ? node.attrs.language.trim()
123
+ : null;
124
+ if (language)
125
+ this.setAttribute(element, "data-smart-language", language, node.id);
126
+ else
127
+ this.removeAttribute(element, "data-smart-language", node.id);
128
+ this.setAttribute(element, "aria-label", language ? `Code block, ${language}` : "Code block", node.id);
129
+ }
130
+ else {
131
+ this.removeAttribute(element, "data-smart-language", node.id);
132
+ }
133
+ if (node.type === "list") {
134
+ const preset = typeof node.attrs?.preset === "string" ? node.attrs.preset : null;
135
+ const style = typeof node.attrs?.style === "string" ? node.attrs.style : null;
136
+ if (preset) {
137
+ this.setAttribute(element, "data-smart-list-preset", preset, node.id);
138
+ this.setAttribute(element, "data-srte-list-preset", preset, node.id);
139
+ }
140
+ else {
141
+ this.removeAttribute(element, "data-smart-list-preset", node.id);
142
+ this.removeAttribute(element, "data-srte-list-preset", node.id);
143
+ }
144
+ if (style) {
145
+ this.setAttribute(element, "data-smart-list-style", style, node.id);
146
+ this.setAttribute(element, "data-srte-list-style", style, node.id);
147
+ }
148
+ else {
149
+ this.removeAttribute(element, "data-smart-list-style", node.id);
150
+ this.removeAttribute(element, "data-srte-list-style", node.id);
151
+ }
152
+ // The preset marker rules are depth-specific. The format serializer
153
+ // already emits this value; the live renderer must project it too or
154
+ // every non-default preset falls back to the browser's plain disc/
155
+ // decimal marker.
156
+ this.setAttribute(element, "data-smart-list-depth", String(listDepth), node.id);
157
+ this.setAttribute(element, "data-srte-list-depth", String(listDepth), node.id);
158
+ if (style && element.style.listStyleType !== style) {
159
+ element.style.listStyleType = style;
160
+ this.recordWrite(node.id);
161
+ }
162
+ else if (!style && element.style.listStyleType) {
163
+ element.style.removeProperty("list-style-type");
164
+ this.recordWrite(node.id);
165
+ }
166
+ if (node.attrs?.start !== undefined)
167
+ this.setAttribute(element, "start", String(node.attrs.start), node.id);
168
+ else
169
+ this.removeAttribute(element, "start", node.id);
170
+ this.setAttribute(element, "data-smart-checkable", node.attrs?.checkable === true ? "true" : "false", node.id);
171
+ if (node.attrs?.checkable === true)
172
+ this.setAttribute(element, "data-srte-checklist", "true", node.id);
173
+ else
174
+ this.removeAttribute(element, "data-srte-checklist", node.id);
175
+ }
176
+ else if (node.type === "list_item") {
177
+ const parent = element.parentElement;
178
+ const checkable = parent?.getAttribute("data-smart-checkable") === "true";
179
+ let control = element.querySelector(`:scope > [${SMART_UI_ATTRIBUTE}="check-control"]`);
180
+ if (checkable) {
181
+ if (!control) {
182
+ control = element.ownerDocument.createElement("button");
183
+ control.setAttribute("type", "button");
184
+ control.setAttribute(SMART_UI_ATTRIBUTE, "check-control");
185
+ control.setAttribute("data-srte-check", "true");
186
+ control.setAttribute("role", "checkbox");
187
+ control.setAttribute("aria-label", "Toggle checklist item");
188
+ control.contentEditable = "false";
189
+ element.prepend(control);
190
+ this.recordWrite(node.id);
191
+ }
192
+ const checked = node.attrs?.checked === true;
193
+ this.setAttribute(control, "aria-checked", checked ? "true" : "false", node.id);
194
+ this.setAttribute(control, "data-checked", checked ? "true" : "false", node.id);
195
+ this.setAttribute(control, "aria-label", checked ? "Mark incomplete" : "Mark complete", node.id);
196
+ this.setAttribute(element, "data-srte-checked", node.attrs?.checked === true ? "true" : "false", node.id);
197
+ this.removeAttribute(element, "role", node.id);
198
+ this.removeAttribute(element, "aria-checked", node.id);
199
+ this.removeAttribute(element, "tabindex", node.id);
200
+ }
201
+ else {
202
+ if (control) {
203
+ control.remove();
204
+ this.recordWrite(node.id);
205
+ }
206
+ this.removeAttribute(element, "data-srte-checked", node.id);
207
+ this.removeAttribute(element, "role", node.id);
208
+ this.removeAttribute(element, "aria-checked", node.id);
209
+ this.removeAttribute(element, "tabindex", node.id);
210
+ }
211
+ }
212
+ else if (node.type === "table") {
213
+ if (node.attrs?.layout) {
214
+ const layout = String(node.attrs.layout);
215
+ this.setAttribute(element, "data-smart-layout", layout, node.id);
216
+ if (element.style.tableLayout !== layout) {
217
+ element.style.tableLayout = layout;
218
+ this.recordWrite(node.id);
219
+ }
220
+ }
221
+ const caption = typeof node.attrs?.caption === "string" ? node.attrs.caption : "";
222
+ let captionElement = element.querySelector(`:scope > [${SMART_PROJECTION_ATTRIBUTE}="table-caption"]`);
223
+ if (caption) {
224
+ if (!captionElement) {
225
+ captionElement = element.ownerDocument.createElement("caption");
226
+ captionElement.setAttribute(SMART_PROJECTION_ATTRIBUTE, "table-caption");
227
+ captionElement.contentEditable = "false";
228
+ element.prepend(captionElement);
229
+ this.recordWrite(node.id);
230
+ }
231
+ if (captionElement.textContent !== caption) {
232
+ captionElement.textContent = caption;
233
+ this.recordWrite(node.id);
234
+ }
235
+ }
236
+ else if (captionElement) {
237
+ captionElement.remove();
238
+ this.recordWrite(node.id);
239
+ }
240
+ // table.attrs.columnWidths was, until this pass, written by
241
+ // setTableColumnWidthCommand but never read by this renderer at all -
242
+ // a real, until-now-invisible gap (docs/bugs/
243
+ // table-column-width-not-rendered.md). A <colgroup> is the standard
244
+ // HTML sizing mechanism, one <col> per grid column, positioned after
245
+ // <caption> (self-correcting on every render regardless of prior
246
+ // insertion order) and before row content, matching the legacy DOM
247
+ // bridge's existing colgroup approach for the same attribute.
248
+ const widths = Array.isArray(node.attrs?.columnWidths) ? node.attrs.columnWidths : null;
249
+ let colgroup = element.querySelector(`:scope > colgroup[${SMART_PROJECTION_ATTRIBUTE}="table-colgroup"]`);
250
+ if (widths && widths.length) {
251
+ const grid = occupancyGridFor(node);
252
+ if (!colgroup) {
253
+ colgroup = element.ownerDocument.createElement("colgroup");
254
+ colgroup.setAttribute(SMART_PROJECTION_ATTRIBUTE, "table-colgroup");
255
+ const afterCaption = element.querySelector(":scope > caption");
256
+ if (afterCaption)
257
+ afterCaption.after(colgroup);
258
+ else
259
+ element.prepend(colgroup);
260
+ this.recordWrite(node.id);
261
+ }
262
+ for (let index = 0; index < grid.columns; index += 1) {
263
+ let col = colgroup.children[index];
264
+ if (!col) {
265
+ col = element.ownerDocument.createElement("col");
266
+ colgroup.appendChild(col);
267
+ this.recordWrite(node.id);
268
+ }
269
+ const width = widths[index];
270
+ const value = Number.isFinite(width) && width > 0 ? `${width}px` : "";
271
+ if (col.style.width !== value) {
272
+ col.style.width = value;
273
+ this.recordWrite(node.id);
274
+ }
275
+ }
276
+ while (colgroup.children.length > grid.columns) {
277
+ colgroup.lastElementChild?.remove();
278
+ this.recordWrite(node.id);
279
+ }
280
+ // The stylesheet's default `table { width: 100% }` combined with
281
+ // `table-layout: fixed` makes every <col> width a *proportion* of
282
+ // the table's rendered width, not a literal pixel value - growing
283
+ // one column via colgroup shifts everyone else's proportional
284
+ // share too, moving columns the resize never touched (docs/bugs/
285
+ // table-resize-moves-unrelated-columns.md). Pinning the table's
286
+ // own inline width to the literal sum of columnWidths overrides
287
+ // that default so table-layout: fixed gives each column exactly
288
+ // its specified pixel width, and resizing one column changes the
289
+ // table's overall width instead of everyone else's.
290
+ const totalWidth = widths.slice(0, grid.columns).reduce((sum, width) => sum + (Number.isFinite(width) && width > 0 ? width : 0), 0);
291
+ const totalValue = totalWidth > 0 ? `${totalWidth}px` : "";
292
+ if (element.style.width !== totalValue) {
293
+ element.style.width = totalValue;
294
+ this.recordWrite(node.id);
295
+ }
296
+ }
297
+ else if (colgroup) {
298
+ colgroup.remove();
299
+ if (element.style.width) {
300
+ element.style.removeProperty("width");
301
+ this.recordWrite(node.id);
302
+ }
303
+ this.recordWrite(node.id);
304
+ }
305
+ }
306
+ else if (node.type === "table_row") {
307
+ const height = Number(node.attrs?.height);
308
+ if (Number.isFinite(height) && height > 0)
309
+ element.style.height = `${height}px`;
310
+ else
311
+ element.style.removeProperty("height");
312
+ }
313
+ else if (node.type === "table_cell") {
314
+ // Row height is canonical state on table_row. A cell-level height can
315
+ // survive a DOM subtree reuse (for example after a legacy resize or a
316
+ // merge) and makes the browser count the same row height once per
317
+ // covered cell. Clear that stale presentation at the renderer
318
+ // boundary; it is not part of the cell model and must never win over
319
+ // the row's height.
320
+ ["height", "min-height", "max-height"].forEach((property) => {
321
+ if (element.style.getPropertyValue(property)) {
322
+ element.style.removeProperty(property);
323
+ this.recordWrite(node.id);
324
+ }
325
+ });
326
+ this.removeAttribute(element, "height", node.id);
327
+ const rowspan = Math.max(1, Number(node.attrs?.rowspan) || 1);
328
+ const colspan = Math.max(1, Number(node.attrs?.colspan) || 1);
329
+ if (rowspan > 1)
330
+ this.setAttribute(element, "rowspan", String(rowspan), node.id);
331
+ else
332
+ this.removeAttribute(element, "rowspan", node.id);
333
+ if (colspan > 1)
334
+ this.setAttribute(element, "colspan", String(colspan), node.id);
335
+ else
336
+ this.removeAttribute(element, "colspan", node.id);
337
+ if (node.attrs?.background)
338
+ element.style.background = String(node.attrs.background);
339
+ else
340
+ element.style.removeProperty("background");
341
+ // Uniform border first (all 4 sides, or removed) - then any per-side
342
+ // override (borderTop/Right/Bottom/Left, the "which sides" control -
343
+ // docs/bugs/table-cell-border-color-width-no-ui.md's addendum) wins
344
+ // for that one side only, since setting a longhand like border-top
345
+ // after the border shorthand overrides just that side, leaving the
346
+ // shorthand's effect on the other three untouched.
347
+ if (node.attrs?.borders)
348
+ element.style.border = String(node.attrs.borders);
349
+ else
350
+ element.style.removeProperty("border");
351
+ ["Top", "Right", "Bottom", "Left"].forEach((side) => {
352
+ const value = node.attrs?.[`border${side}`];
353
+ if (value)
354
+ element.style.setProperty(`border-${side.toLowerCase()}`, String(value));
355
+ else
356
+ element.style.removeProperty(`border-${side.toLowerCase()}`);
357
+ });
358
+ if (node.attrs?.verticalAlign)
359
+ element.style.verticalAlign = String(node.attrs.verticalAlign);
360
+ else
361
+ element.style.removeProperty("vertical-align");
362
+ // table_cell.attrs.textColor was parsed from HTML import and settable
363
+ // via table.setCellAttributes, but never read by this renderer at all
364
+ // - the same "written, never rendered" gap columnWidths had (docs/
365
+ // bugs/table-column-width-not-rendered.md), found wiring the new
366
+ // "Cell text color" context menu item, which would otherwise have
367
+ // applied a value with zero visible effect.
368
+ if (node.attrs?.textColor)
369
+ element.style.color = String(node.attrs.textColor);
370
+ else
371
+ element.style.removeProperty("color");
372
+ }
373
+ else if (node.type === "image" || node.type === "block_image") {
374
+ const source = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: "image", allowBlobPreview: node.attrs?.status === "pending" });
375
+ if (source)
376
+ this.setAttribute(element, "src", source, node.id);
377
+ else
378
+ this.removeAttribute(element, "src", node.id);
379
+ this.setAttribute(element, "alt", typeof node.attrs?.alt === "string" ? node.attrs.alt : "", node.id);
380
+ if (node.attrs?.decorative === true)
381
+ this.setAttribute(element, "data-smart-decorative", "true", node.id);
382
+ else
383
+ this.removeAttribute(element, "data-smart-decorative", node.id);
384
+ if (node.attrs?.width)
385
+ this.setAttribute(element, "width", String(node.attrs.width), node.id);
386
+ else
387
+ this.removeAttribute(element, "width", node.id);
388
+ if (node.attrs?.height)
389
+ this.setAttribute(element, "height", String(node.attrs.height), node.id);
390
+ else
391
+ this.removeAttribute(element, "height", node.id);
392
+ this.setAttribute(element, "data-smart-status", String(node.attrs?.status || "ready"), node.id);
393
+ if (node.attrs?.status === "error")
394
+ this.setAttribute(element, "title", atomErrorTitle(node, "Image could not be loaded"), node.id);
395
+ else
396
+ this.removeAttribute(element, "title", node.id);
397
+ const imageAlign = node.attrs?.align;
398
+ if (imageAlign === "center") {
399
+ element.style.display = "block";
400
+ element.style.margin = "0 auto";
401
+ element.style.float = "none";
402
+ }
403
+ else if (imageAlign === "left" || imageAlign === "right") {
404
+ element.style.display = "inline";
405
+ element.style.float = imageAlign;
406
+ element.style.margin = imageAlign === "left" ? "0 8px 8px 0" : "0 0 8px 8px";
407
+ }
408
+ }
409
+ else if (node.type === "formula" || node.type === "block_formula") {
410
+ const source = String(node.attrs?.source || "");
411
+ const previousSource = element.getAttribute("data-smart-formula");
412
+ this.setAttribute(element, "role", "math", node.id);
413
+ this.setAttribute(element, "aria-label", `Mathematical formula: ${source}`, node.id);
414
+ this.setAttribute(element, "data-smart-formula", source, node.id);
415
+ // KaTeX owns the element's children once rendered (real HTML+MathML,
416
+ // not plain text); the dirty check moves to the source attribute
417
+ // since textContent no longer equals the raw source after rendering.
418
+ if (previousSource !== source || !element.hasChildNodes()) {
419
+ renderFormulaInto(element, source);
420
+ this.recordWrite(node.id);
421
+ }
422
+ }
423
+ else if (node.type === "video" || node.type === "audio") {
424
+ const source = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: node.type });
425
+ if (source)
426
+ this.setAttribute(element, "src", source, node.id);
427
+ else
428
+ this.removeAttribute(element, "src", node.id);
429
+ this.setAttribute(element, "controls", "", node.id);
430
+ this.setAttribute(element, "preload", "metadata", node.id);
431
+ if (node.type === "video")
432
+ this.setAttribute(element, "playsinline", "", node.id);
433
+ else
434
+ this.removeAttribute(element, "playsinline", node.id);
435
+ if (node.attrs?.width)
436
+ this.setAttribute(element, "width", String(node.attrs.width), node.id);
437
+ else
438
+ this.removeAttribute(element, "width", node.id);
439
+ if (node.attrs?.height)
440
+ this.setAttribute(element, "height", String(node.attrs.height), node.id);
441
+ else
442
+ this.removeAttribute(element, "height", node.id);
443
+ const extension = source?.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1]?.toLowerCase();
444
+ const mediaType = node.type === "audio"
445
+ ? extension === "mp3" ? "audio/mpeg" : extension === "wav" ? "audio/wav" : extension === "ogg" ? "audio/ogg" : extension === "webm" ? "audio/webm" : null
446
+ : extension === "mp4" ? "video/mp4" : extension === "webm" ? "video/webm" : extension === "ogv" ? "video/ogg" : null;
447
+ if (mediaType)
448
+ this.setAttribute(element, "type", mediaType, node.id);
449
+ else
450
+ this.removeAttribute(element, "type", node.id);
451
+ this.setAttribute(element, "aria-label", node.type === "video" ? "Video player" : "Audio player", node.id);
452
+ if (node.attrs?.status === "error") {
453
+ this.setAttribute(element, "title", atomErrorTitle(node, `${node.type === "video" ? "Video" : "Audio"} could not be loaded`), node.id);
454
+ }
455
+ else
456
+ this.removeAttribute(element, "title", node.id);
457
+ if (node.type === "video" && node.attrs?.poster) {
458
+ const poster = sanitizeAtomSource(String(node.attrs.poster), { kind: "image" });
459
+ if (poster)
460
+ this.setAttribute(element, "poster", poster, node.id);
461
+ else
462
+ this.removeAttribute(element, "poster", node.id);
463
+ }
464
+ }
465
+ }
466
+ /**
467
+ * Keep failed remote resources visible as model-backed atoms instead of
468
+ * leaving a silent broken-image/media control. The event only updates
469
+ * renderer diagnostics; it never mutates the canonical document. A later
470
+ * attribute transaction (retry, replacement, or upload completion) clears
471
+ * the state through the normal diff path.
472
+ */
473
+ installMediaDiagnostics(element, node) {
474
+ if (!atomTypes.has(node.type) || node.type === "formula" || node.type === "block_formula" || node.type === "divider")
475
+ return;
476
+ if (element.hasAttribute("data-smart-media-events"))
477
+ return;
478
+ element.setAttribute("data-smart-media-events", "true");
479
+ const clear = () => {
480
+ element.setAttribute("data-smart-media-state", "ready");
481
+ element.removeAttribute("title");
482
+ };
483
+ const failed = () => {
484
+ element.setAttribute("data-smart-media-state", "error");
485
+ element.setAttribute("title", atomErrorTitle(node, node.type === "block_image" || node.type === "image"
486
+ ? "Image could not be loaded"
487
+ : `${node.type === "video" ? "Video" : "Audio"} could not be loaded`));
488
+ };
489
+ element.addEventListener("load", clear);
490
+ element.addEventListener("canplay", clear);
491
+ element.addEventListener("error", failed);
492
+ }
493
+ syncTableAccessibility() {
494
+ this.root.querySelectorAll('[data-smart-type="table"]').forEach((tableElement) => {
495
+ const mapped = this.mapping.domToNode(tableElement)?.node;
496
+ if (!mapped || isTextNode(mapped) || mapped.type !== "table")
497
+ return;
498
+ const grid = occupancyGridFor(mapped);
499
+ let headerRows = 0;
500
+ while (headerRows < grid.rows && Array.from({ length: grid.columns }, (_, column) => grid.at(headerRows, column)?.node.attrs?.header === true).every(Boolean))
501
+ headerRows += 1;
502
+ let headerColumns = 0;
503
+ while (headerColumns < grid.columns && Array.from({ length: grid.rows }, (_, row) => grid.at(row, headerColumns)?.node.attrs?.header === true).every(Boolean))
504
+ headerColumns += 1;
505
+ const headerIds = new Map();
506
+ grid.anchors.filter((cell) => cell.node.attrs?.header === true).forEach((cell) => headerIds.set(cell.cellId, `smart-header-${cell.cellId}`));
507
+ grid.anchors.forEach((cell) => {
508
+ const element = this.mapping.nodeToDom(cell.cellId);
509
+ if (!(element instanceof HTMLElement))
510
+ return;
511
+ const id = headerIds.get(cell.cellId);
512
+ if (id) {
513
+ element.id = id;
514
+ element.setAttribute("scope", cell.left < headerColumns && cell.top >= headerRows ? "row" : "col");
515
+ element.removeAttribute("headers");
516
+ }
517
+ else {
518
+ element.removeAttribute("scope");
519
+ const related = grid.anchors.filter((header) => header.node.attrs?.header === true && (header.top < headerRows && header.left <= cell.left && header.right > cell.left
520
+ || header.left < headerColumns && header.top <= cell.top && header.bottom > cell.top))
521
+ .map((header) => headerIds.get(header.cellId)).filter((value) => Boolean(value));
522
+ if (related.length)
523
+ element.setAttribute("headers", related.join(" "));
524
+ else
525
+ element.removeAttribute("headers");
526
+ }
527
+ });
528
+ });
529
+ }
530
+ createNode(node, path, listDepth = 0) {
531
+ if (isTextNode(node)) {
532
+ this.recordWrite();
533
+ return renderMarkedText(node, this.root.ownerDocument);
534
+ }
535
+ const element = this.root.ownerDocument.createElement(tagForNode(node));
536
+ this.recordWrite(node.id);
537
+ element.setAttribute(SMART_NODE_ID_ATTRIBUTE, node.id);
538
+ element.setAttribute("data-smart-type", node.type);
539
+ this.syncNodeAttributes(element, node, listDepth);
540
+ this.installMediaDiagnostics(element, node);
541
+ if (node.type === "hard_break") {
542
+ element.setAttribute("data-smart-atomic", "true");
543
+ }
544
+ else if (node.type === "unknown" || node.attrs?.atomic === true || atomTypes.has(node.type)) {
545
+ element.contentEditable = "false";
546
+ element.setAttribute("data-smart-atomic", "true");
547
+ if (node.type === "unknown")
548
+ element.textContent = `[Unsupported: ${String(node.attrs?.originalType || "unknown")}]`;
549
+ }
550
+ else {
551
+ const childListDepth = node.type === "list" ? listDepth + 1 : listDepth;
552
+ node.children?.forEach((child, index) => element.appendChild(this.createNode(child, [...path, index], childListDepth)));
553
+ if (node.type === "list")
554
+ (node.children || []).forEach((child, index) => {
555
+ if (!isTextNode(child)) {
556
+ const childElement = this.modelChildren(element)[index];
557
+ if (childElement instanceof HTMLElement)
558
+ this.syncNodeAttributes(childElement, child);
559
+ }
560
+ });
561
+ this.syncEmptyLineProjection(element, node);
562
+ }
563
+ this.modelById.set(node.id, node);
564
+ this.mapping.track(node, path, element);
565
+ return element;
566
+ }
567
+ forget(node) {
568
+ if (isTextNode(node))
569
+ return;
570
+ this.modelById.delete(node.id);
571
+ this.mapping.forget(node.id);
572
+ node.children?.forEach((child) => this.forget(child));
573
+ }
574
+ modelChildren(element) {
575
+ return [...element.childNodes].filter((node) => {
576
+ const candidate = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
577
+ return !candidate?.closest(`[${SMART_UI_ATTRIBUTE}]`) && !candidate?.closest(`[${SMART_PROJECTION_ATTRIBUTE}]`);
578
+ });
579
+ }
580
+ /**
581
+ * Empty inline owners need a browser line box for an immediately visible
582
+ * caret. This <br> is renderer UI, not canonical document content.
583
+ */
584
+ syncEmptyLineProjection(element, node) {
585
+ const existing = element.querySelector(`:scope > [${SMART_EMPTY_LINE_ATTRIBUTE}]`);
586
+ const needsProjection = emptyLineOwnerTypes.has(node.type) && (node.children?.length || 0) === 0;
587
+ if (needsProjection && !existing) {
588
+ const line = element.ownerDocument.createElement("br");
589
+ line.setAttribute(SMART_EMPTY_LINE_ATTRIBUTE, "true");
590
+ line.setAttribute(SMART_UI_ATTRIBUTE, "empty-line");
591
+ element.setAttribute("data-srte-caret-boundary", "true");
592
+ element.appendChild(line);
593
+ this.recordWrite(node.id);
594
+ }
595
+ else if (!needsProjection && existing) {
596
+ existing.remove();
597
+ element.removeAttribute("data-srte-caret-boundary");
598
+ this.recordWrite(node.id);
599
+ }
600
+ }
601
+ diffElement(element, before, after, path, listDepth = 0) {
602
+ this.modelById.set(after.id, after);
603
+ this.mapping.track(after, path, element);
604
+ if (after.id === this.compositionOwner)
605
+ return false;
606
+ this.setAttribute(element, SMART_NODE_ID_ATTRIBUTE, after.id, after.id);
607
+ this.setAttribute(element, "data-smart-type", after.type, after.id);
608
+ this.syncNodeAttributes(element, after, listDepth);
609
+ this.installMediaDiagnostics(element, after);
610
+ // Atomic nodes render their payload as presentation (for example, a
611
+ // formula's accessible source text), not as model children. Once a
612
+ // stable-ID sibling is reused, recursing into that DOM subtree would
613
+ // mistake the presentation text for an unmodelled child and remove it.
614
+ // Keep the atom opaque after syncing its attributes; its model lifecycle
615
+ // is handled by the parent diff and its dedicated attribute renderer.
616
+ if (after.type === "unknown" || after.attrs?.atomic === true || atomTypes.has(after.type))
617
+ return false;
618
+ const beforeChildren = before.children || [];
619
+ const afterChildren = after.children || [];
620
+ // Remove the visual-only empty line before inserting real model children.
621
+ if (afterChildren.length)
622
+ this.syncEmptyLineProjection(element, after);
623
+ let structural = beforeChildren.length !== afterChildren.length
624
+ || after.type === "table_cell" && ["rowspan", "colspan", "header"].some((attribute) => before.attrs?.[attribute] !== after.attrs?.[attribute]);
625
+ for (let index = 0; index < afterChildren.length; index += 1) {
626
+ const next = afterChildren[index];
627
+ const previous = beforeChildren[index];
628
+ if (next === previous) {
629
+ if (!isTextNode(next) && atomTypes.has(next.type)) {
630
+ const atomElement = this.modelChildren(element)[index];
631
+ if (atomElement instanceof HTMLElement)
632
+ this.syncNodeAttributes(atomElement, next);
633
+ }
634
+ continue;
635
+ }
636
+ const domChildren = this.modelChildren(element);
637
+ let dom = domChildren[index] || null;
638
+ let old = previous;
639
+ // A sibling move can leave the DOM node already at this target index
640
+ // even though `previous` describes the node that occupied the index in
641
+ // the old model. Reconcile by stable node ID in that case. Treating the
642
+ // old positional node as the identity here recreates the moved element,
643
+ // drops its native caret, and can make the browser selection point at a
644
+ // detached range while the model has already moved on.
645
+ if (!isTextNode(next) && dom instanceof HTMLElement
646
+ && dom.getAttribute(SMART_NODE_ID_ATTRIBUTE) === next.id) {
647
+ // When the same ID remains at the same logical index, `previous` is
648
+ // the immediately-rendered version and is fresher than the historical
649
+ // ID cache. The cache is only needed for a node whose positional
650
+ // predecessor has a different ID (the actual sibling-move case).
651
+ old = Boolean(previous && !isTextNode(previous) && previous.id === next.id)
652
+ ? previous
653
+ : this.modelById.get(next.id) || next;
654
+ }
655
+ if (!isTextNode(next)) {
656
+ const existing = this.mapping.nodeToDom(next.id);
657
+ if (existing && existing.parentNode === element && existing !== dom) {
658
+ element.insertBefore(existing, dom);
659
+ this.recordWrite(after.id);
660
+ dom = existing;
661
+ old = this.modelById.get(next.id) || previous;
662
+ structural = true;
663
+ }
664
+ }
665
+ if (isTextNode(next)) {
666
+ const sameMarks = !old || isTextNode(old) && stableValue(old.marks || []) === stableValue(next.marks || []);
667
+ const textDom = dom?.nodeType === 3 ? dom : dom?.textContent !== undefined ? (() => {
668
+ const walker = element.ownerDocument.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
669
+ return walker.nextNode();
670
+ })() : null;
671
+ if (textDom?.nodeType === 3 && sameMarks) {
672
+ if (textDom.nodeValue !== next.text) {
673
+ textDom.nodeValue = next.text;
674
+ this.recordWrite(after.id);
675
+ }
676
+ }
677
+ else {
678
+ const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
679
+ const replacement = this.createNode(next, [...path, index], childListDepth);
680
+ if (dom)
681
+ element.replaceChild(replacement, dom);
682
+ else
683
+ element.appendChild(replacement);
684
+ this.recordWrite(after.id);
685
+ structural = true;
686
+ }
687
+ }
688
+ else if (dom?.nodeType === 1 && old && !isTextNode(old) && old.id === next.id && dom.tagName.toLowerCase() === tagForNode(next)) {
689
+ const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
690
+ structural = this.diffElement(dom, old, next, [...path, index], childListDepth) || structural;
691
+ }
692
+ else {
693
+ const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
694
+ const replacement = this.createNode(next, [...path, index], childListDepth);
695
+ const previousSurvives = Boolean(previous && afterChildren.slice(index + 1).includes(previous));
696
+ if (dom && previousSurvives)
697
+ element.insertBefore(replacement, dom);
698
+ else if (dom)
699
+ element.replaceChild(replacement, dom);
700
+ else
701
+ element.appendChild(replacement);
702
+ this.recordWrite(after.id);
703
+ if (old && !previousSurvives)
704
+ this.forget(old);
705
+ structural = true;
706
+ }
707
+ }
708
+ while (this.modelChildren(element).length > afterChildren.length) {
709
+ const domChildren = this.modelChildren(element);
710
+ const index = domChildren.length - 1;
711
+ const removed = beforeChildren[index];
712
+ element.removeChild(domChildren[index]);
713
+ this.recordWrite(after.id);
714
+ if (removed)
715
+ this.forget(removed);
716
+ structural = true;
717
+ }
718
+ this.syncEmptyLineProjection(element, after);
719
+ return structural;
720
+ }
721
+ selectionMatches(selection, anchor, head) {
722
+ return selection.anchorNode === anchor.node && selection.anchorOffset === anchor.offset
723
+ && selection.focusNode === head.node && selection.focusOffset === head.offset;
724
+ }
725
+ restoreSelection(model) {
726
+ const selection = this.root.ownerDocument.getSelection();
727
+ if (!selection)
728
+ return;
729
+ if (model.type === "none") {
730
+ if (selection.rangeCount)
731
+ selection.removeAllRanges();
732
+ return;
733
+ }
734
+ const anchor = this.mapping.posToDom(model.anchor);
735
+ const head = this.mapping.posToDom(model.head);
736
+ if (!anchor || !head)
737
+ return;
738
+ if (!this.selectionMatches(selection, anchor, head)) {
739
+ if (typeof selection.setBaseAndExtent === "function") {
740
+ selection.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
741
+ }
742
+ else {
743
+ const range = this.root.ownerDocument.createRange();
744
+ range.setStart(anchor.node, anchor.offset);
745
+ range.setEnd(head.node, head.offset);
746
+ selection.removeAllRanges();
747
+ selection.addRange(range);
748
+ }
749
+ }
750
+ this.revealSelectionHead(head.node);
751
+ }
752
+ /** Cell selections are projected as a DOM-only highlight; cells remain model nodes. */
753
+ syncCellSelectionProjection(selection) {
754
+ this.root.querySelectorAll("[data-smart-cell-selected]").forEach((cell) => cell.removeAttribute("data-smart-cell-selected"));
755
+ // restoreSelection still needs a real, non-collapsed native Range spanning
756
+ // anchor-to-head so Ctrl+C/native copy commands have something to act on
757
+ // (handleCopy overrides the actual clipboard payload from the model, so
758
+ // the Range's exact span never affects copied content) and so a later
759
+ // selectionchange still matches this cell selection's own anchor/head
760
+ // (see syncSelectionFromDom's "cell" preservation guard). But that Range
761
+ // necessarily passes through any cell that sits between the anchor and
762
+ // head cells in DOM order, and the browser highlights all of it - not
763
+ // just the two boundary cells - producing a native grey highlight on
764
+ // cells the [data-smart-cell-selected] rectangle above correctly leaves
765
+ // unmarked. Suppress the native highlight's rendering entirely while a
766
+ // cell selection is active; the attribute above is the real indicator.
767
+ if (selection.type === "cell")
768
+ this.root.setAttribute("data-smart-cell-selection-active", "true");
769
+ else
770
+ this.root.removeAttribute("data-smart-cell-selection-active");
771
+ if (selection.type !== "cell")
772
+ return;
773
+ const anchor = this.mapping.posToDom(selection.anchor)?.node;
774
+ const head = this.mapping.posToDom(selection.head)?.node;
775
+ const anchorCell = anchor instanceof Element ? anchor.closest('[data-smart-type="table_cell"]') : anchor?.parentElement?.closest('[data-smart-type="table_cell"]');
776
+ const headCell = head instanceof Element ? head.closest('[data-smart-type="table_cell"]') : head?.parentElement?.closest('[data-smart-type="table_cell"]');
777
+ if (!anchorCell || !headCell)
778
+ return;
779
+ const tableElement = anchorCell.closest('[data-smart-type="table"]');
780
+ const mappedTable = tableElement ? this.mapping.domToNode(tableElement) : null;
781
+ const mappedAnchor = this.mapping.domToNode(anchorCell);
782
+ const mappedHead = this.mapping.domToNode(headCell);
783
+ if (!mappedTable || !mappedAnchor || !mappedHead || isTextNode(mappedTable.node)
784
+ || isTextNode(mappedAnchor.node) || isTextNode(mappedHead.node))
785
+ return;
786
+ const grid = occupancyGridFor(mappedTable.node);
787
+ const anchorGridCell = grid.anchors.find((cell) => cell.cellId === mappedAnchor.nodeId);
788
+ const headGridCell = grid.anchors.find((cell) => cell.cellId === mappedHead.nodeId);
789
+ if (!anchorGridCell || !headGridCell)
790
+ return;
791
+ // Cell selections are logical rectangles, not DOM-order ranges. DOM order
792
+ // is row-major, so slicing it would select the cells between two vertical
793
+ // endpoints (for example three cells in a 2x2 table) rather than the two
794
+ // cells in the requested column. Snap through merged cells as the model
795
+ // resolver does, then project exactly the resulting anchor IDs.
796
+ const snapped = snapTableCellRect(mappedTable.node, {
797
+ top: Math.min(anchorGridCell.top, headGridCell.top),
798
+ left: Math.min(anchorGridCell.left, headGridCell.left),
799
+ bottom: Math.max(anchorGridCell.bottom, headGridCell.bottom),
800
+ right: Math.max(anchorGridCell.right, headGridCell.right),
801
+ });
802
+ const selected = new Set(snapped.cellIds);
803
+ this.root.querySelectorAll('[data-smart-type="table_cell"]').forEach((cell) => {
804
+ const mapped = this.mapping.domToNode(cell);
805
+ if (mapped && selected.has(mapped.nodeId))
806
+ cell.setAttribute("data-smart-cell-selected", "true");
807
+ });
808
+ }
809
+ /** Keep the active line visible without scrolling the surrounding page. */
810
+ revealSelectionHead(node) {
811
+ const target = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
812
+ if (!target || target === this.root || !this.root.contains(target))
813
+ return;
814
+ const rootRect = this.root.getBoundingClientRect();
815
+ const targetRect = target.getBoundingClientRect();
816
+ if (targetRect.bottom > rootRect.bottom) {
817
+ this.root.scrollTop += targetRect.bottom - rootRect.bottom + 8;
818
+ }
819
+ else if (targetRect.top < rootRect.top) {
820
+ this.root.scrollTop -= rootRect.top - targetRect.top + 8;
821
+ }
822
+ }
823
+ listItemDepths(document) {
824
+ const output = new Map();
825
+ const visit = (node, path, depth) => {
826
+ if (isTextNode(node))
827
+ return;
828
+ if (node.type === "list_item")
829
+ output.set(node.id, { depth: Math.max(0, depth - 1), path });
830
+ const nextDepth = node.type === "list" ? depth + 1 : depth;
831
+ node.children?.forEach((child, index) => visit(child, [...path, index], nextDepth));
832
+ };
833
+ visit(document, [], 0);
834
+ return output;
835
+ }
836
+ /**
837
+ * Checklist controls are renderer projections whose state depends on both
838
+ * the list parent and the item attributes. A list-type transition can keep
839
+ * the item object reference unchanged, so the normal subtree identity skip
840
+ * must not leave a stale checkbox projection behind. Reconcile these
841
+ * projections from the current model after every render; attribute writes
842
+ * remain idempotent.
843
+ */
844
+ syncListProjections(document) {
845
+ const visit = (node, listDepth = 0) => {
846
+ if (isTextNode(node))
847
+ return;
848
+ if (node.type === "list" || node.type === "list_item") {
849
+ const element = this.mapping.nodeToDom(node.id);
850
+ if (element)
851
+ this.syncNodeAttributes(element, node, listDepth);
852
+ }
853
+ const childListDepth = node.type === "list" ? listDepth + 1 : listDepth;
854
+ node.children?.forEach((child) => visit(child, childListDepth));
855
+ };
856
+ visit(document);
857
+ }
858
+ announceSelectedLevel(before, after, selection) {
859
+ const previous = this.listItemDepths(before);
860
+ const next = this.listItemDepths(after);
861
+ const active = [...next.entries()].filter(([, value]) => value.path.length <= selection.head.path.length
862
+ && value.path.every((part, index) => selection.head.path[index] === part))
863
+ .sort((left, right) => right[1].path.length - left[1].path.length)[0];
864
+ if (!active || previous.get(active[0])?.depth === active[1].depth)
865
+ return;
866
+ if (!this.liveRegion) {
867
+ const region = this.root.ownerDocument.createElement("div");
868
+ region.setAttribute(SMART_UI_ATTRIBUTE, "list-level-announcement");
869
+ region.setAttribute("aria-live", "polite");
870
+ region.setAttribute("aria-atomic", "true");
871
+ region.contentEditable = "false";
872
+ region.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap";
873
+ // Append outside the contenteditable root, not inside it. A
874
+ // contenteditable="false" island living as a child of a
875
+ // contenteditable="true" root - even a visually hidden one - makes
876
+ // Chromium and WebKit's native Ctrl/Cmd+A (select all) silently
877
+ // collapse to nothing instead of selecting the document, as soon as
878
+ // any list item's depth changes (creating the very first list item
879
+ // already qualifies). Firefox is unaffected, which is why this only
880
+ // surfaces in Chromium/WebKit-family browsers. The announcement has
881
+ // no accessibility reason to live inside the editable subtree - an
882
+ // aria-live region only needs to be present in the document, not
883
+ // physically nested under the content it describes.
884
+ (this.root.parentElement || this.root).appendChild(region);
885
+ this.liveRegion = region;
886
+ }
887
+ this.liveRegion.textContent = `List level ${active[1].depth + 1}`;
888
+ }
889
+ render(document, selection) {
890
+ if (!this.current) {
891
+ this.mapping.beginUpdate(document);
892
+ document.children.forEach((node, index) => this.root.appendChild(this.createNode(node, [index])));
893
+ this.current = document;
894
+ this.mapping.rebuild(this.root, document);
895
+ this.syncListProjections(document);
896
+ this.syncTableAccessibility();
897
+ this.modelById.set(document.id, document);
898
+ this.restoreSelection(selection);
899
+ this.syncCellSelectionProjection(selection);
900
+ return;
901
+ }
902
+ if (this.current === document) {
903
+ this.restoreSelection(selection);
904
+ this.syncCellSelectionProjection(selection);
905
+ this.syncContentVisibility(document, document, selection);
906
+ return;
907
+ }
908
+ const before = this.current;
909
+ this.mapping.beginUpdate(document);
910
+ const structural = this.diffElement(this.root, before, document, []);
911
+ this.modelById.set(document.id, document);
912
+ this.current = document;
913
+ if (structural)
914
+ this.mapping.rebuild(this.root, document);
915
+ this.syncListProjections(document);
916
+ if (structural)
917
+ this.syncTableAccessibility();
918
+ this.restoreSelection(selection);
919
+ this.syncCellSelectionProjection(selection);
920
+ this.syncContentVisibility(before, document, selection);
921
+ this.announceSelectedLevel(before, document, selection);
922
+ }
923
+ /**
924
+ * Renderer-integrated content-visibility (Phase 11 Tier 3): the naive
925
+ * per-block experiment (stamping content-visibility:auto on every
926
+ * top-level block unconditionally, including the one actively being
927
+ * typed into) measured 55-699ms against a 41-46ms baseline and was
928
+ * disproven - the active block's own containment-recalculation overhead
929
+ * on every keystroke outweighs any off-screen skip benefit. This keys off
930
+ * the same reference-identity check diffElement already computes
931
+ * (`next === previous`, line ~421): a top-level block only gets
932
+ * content-visibility when it was untouched THIS render pass AND isn't
933
+ * the selection's own block, so the actively-edited block never carries
934
+ * the extra containment cost. Opt-in via the `contentVisibility`
935
+ * constructor option - default behavior for every existing caller and
936
+ * test is completely unchanged.
937
+ */
938
+ syncContentVisibility(before, after, selection) {
939
+ if (!this.options.contentVisibility)
940
+ return;
941
+ const activeIndex = selection.head.path[0];
942
+ const domChildren = this.modelChildren(this.root);
943
+ after.children.forEach((child, index) => {
944
+ const dom = domChildren[index];
945
+ if (!(dom instanceof HTMLElement))
946
+ return;
947
+ const unchanged = before?.children[index] === child;
948
+ const eligible = unchanged && index !== activeIndex;
949
+ if (eligible) {
950
+ if (dom.style.contentVisibility !== "auto")
951
+ dom.style.contentVisibility = "auto";
952
+ if (!dom.style.containIntrinsicBlockSize)
953
+ dom.style.containIntrinsicBlockSize = "24px";
954
+ }
955
+ else {
956
+ if (dom.style.contentVisibility)
957
+ dom.style.removeProperty("content-visibility");
958
+ if (dom.style.containIntrinsicBlockSize)
959
+ dom.style.removeProperty("contain-intrinsic-block-size");
960
+ }
961
+ });
962
+ }
963
+ beginComposition(nodeId) { this.compositionOwner = nodeId; }
964
+ endComposition() { this.compositionOwner = null; }
965
+ resetWriteCounters() { this.writes = 0; this.compositionWrites = 0; }
966
+ destroy() {
967
+ this.compositionOwner = null;
968
+ this.current = null;
969
+ this.modelById.clear();
970
+ this.liveRegion?.remove();
971
+ this.liveRegion = null;
972
+ }
973
+ }
974
+ export const createSubtreeRenderer = (root, options) => new FoundationSubtreeRenderer(root, options);