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,1761 @@
1
+ import { createNodeId, isTextNode } from "../identity.js";
2
+ import { backspaceAtListItemStart, deleteAtListItemEnd, enterInList, listItemAt, setListChecked, } from "../list/index.js";
3
+ import { resolveShortcut } from "../plugin/dispatch.js";
4
+ import { applyOperations } from "../operations.js";
5
+ import { nodeAtPath, comparePos, inlineGraphemeBoundaries } from "../positions.js";
6
+ import { createScopeIndex } from "../scope/index.js";
7
+ import { TransactionBuilder } from "../editor.js";
8
+ import { executeMarkTool, canonicalMarkOrder, inlineToolDeclarations, insertHardBreak, markKey, marksAtInsertion, } from "../marks/index.js";
9
+ import { exitCodeBlock, insertCodeBlockNewline, } from "../block/index.js";
10
+ import { tokenizeCompositionOwner } from "../atom/index.js";
11
+ import { deleteClipboardSelection, insertClipboardFragment, parseClipboardPayload, reportParsedClipboard, reportRejectedClipboard, remintClipboardFragmentIds, serializeClipboardRepresentations, sliceClipboardSelection, } from "../clipboard/index.js";
12
+ import { cellSelectionFromIds } from "../table/selection.js";
13
+ import { occupancyGridFor } from "../table/grid.js";
14
+ import { insertTableRowCommand } from "../table/commands.js";
15
+ import { FoundationTransactionMap } from "../mapping.js";
16
+ import { resolvePrecedingContentTarget } from "../structural/contentTarget.js";
17
+ import { SMART_UI_ATTRIBUTE } from "../modelDom.js";
18
+ import { suggestDeleteRangeOperation, suggestInsertOperation } from "../suggestions/index.js";
19
+ const samePos = (left, right) => comparePos(left, right) === 0;
20
+ const normalizedRange = (selection) => comparePos(selection.anchor, selection.head) <= 0
21
+ ? { from: selection.anchor, to: selection.head }
22
+ : { from: selection.head, to: selection.anchor };
23
+ const collapsed = (selection) => samePos(selection.anchor, selection.head);
24
+ const inlineText = (owner) => (owner.children || []).map((node) => isTextNode(node) ? node.text : "\uFFFC").join("");
25
+ const tokenUnits = (tokens) => {
26
+ const units = [];
27
+ tokens.forEach((token) => {
28
+ if (token.kind === "atom")
29
+ units.push({ char: "\uFFFC", marks: [], key: `atom:${token.nodeId}`, kind: "atom" });
30
+ else
31
+ for (let index = 0; index < token.text.length; index += 1) {
32
+ units.push({ char: token.text[index], marks: [...token.marks], key: token.marks.map(markKey).join("|"), kind: "text" });
33
+ }
34
+ });
35
+ return units;
36
+ };
37
+ const ownerAt = (editor, pos) => {
38
+ const node = nodeAtPath(editor.document, pos.path);
39
+ if (!node || isTextNode(node))
40
+ throw new Error("Input position must resolve to an inline owner.");
41
+ return node;
42
+ };
43
+ /**
44
+ * Returns editable inline owners in logical document order. This is used only
45
+ * when a deletion crosses a structural boundary; ordinary character deletion
46
+ * remains path-local. Structural containers (lists, quotes, cells) are
47
+ * intentionally transparent here, while atomic nodes remain opaque.
48
+ */
49
+ const editableOwners = (node, path, schema, output = []) => {
50
+ if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
51
+ return output;
52
+ if (isInlineOwnerNode(node, schema)) {
53
+ output.push({ node, path: [...path] });
54
+ return output;
55
+ }
56
+ node.children?.forEach((child, index) => editableOwners(child, [...path, index], schema, output));
57
+ return output;
58
+ };
59
+ const emptyParagraph = (id) => ({ type: "paragraph", id, children: [] });
60
+ const isInlineOwnerNode = (node, schema) => {
61
+ if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
62
+ return false;
63
+ if (["paragraph", "heading", "code_block"].includes(node.type))
64
+ return true;
65
+ return /(?:^|[|(\s])(?:inline|text)(?:[+*?]|$)/.test(schema.nodes[node.type]?.content || "");
66
+ };
67
+ /** Finds the nearest editable inline owner inside a structural sibling. */
68
+ const editableOwnerPosition = (node, path, direction, schema) => {
69
+ if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
70
+ return null;
71
+ if (isInlineOwnerNode(node, schema))
72
+ return {
73
+ path: [...path],
74
+ offset: direction > 0 ? 0 : inlineText(node).length,
75
+ };
76
+ const children = node.children || [];
77
+ const indexes = Array.from({ length: children.length }, (_, index) => index);
78
+ if (direction < 0)
79
+ indexes.reverse();
80
+ for (const index of indexes) {
81
+ const found = editableOwnerPosition(children[index], [...path, index], direction, schema);
82
+ if (found)
83
+ return found;
84
+ }
85
+ return null;
86
+ };
87
+ /**
88
+ * DOM selections can legally collapse on a structural element boundary (for
89
+ * example, the editable root at child offset `children.length`). Structural
90
+ * positions are useful for operations, but they are not caret owners. Resolve
91
+ * those boundary points to the nearest inline owner before they enter the
92
+ * canonical selection state. Direct editable siblings are preferred over an
93
+ * owner nested inside a structural sibling so a caret between quotes lands in
94
+ * the boundary paragraph rather than inside the preceding quote.
95
+ */
96
+ const editablePositionForStructuralBoundary = (editor, position, bias) => {
97
+ const node = nodeAtPath(editor.document, position.path);
98
+ if (!node || isTextNode(node) || isInlineOwnerNode(node, editor.schema))
99
+ return position;
100
+ const children = node.children || [];
101
+ const offset = Math.max(0, Math.min(position.offset, children.length));
102
+ const direct = (index, direction) => {
103
+ const child = children[index];
104
+ if (!child || isTextNode(child) || !isInlineOwnerNode(child, editor.schema))
105
+ return null;
106
+ return {
107
+ path: [...position.path, index],
108
+ offset: direction < 0 ? inlineText(child).length : 0,
109
+ };
110
+ };
111
+ const directOrder = bias < 0 ? [offset - 1, offset] : [offset, offset - 1];
112
+ for (const index of directOrder) {
113
+ const found = direct(index, bias);
114
+ if (found)
115
+ return found;
116
+ }
117
+ const nestedOrder = bias < 0 ? [offset - 1, offset] : [offset, offset - 1];
118
+ for (const index of nestedOrder) {
119
+ const child = children[index];
120
+ if (!child || isTextNode(child))
121
+ continue;
122
+ const found = editableOwnerPosition(child, [...position.path, index], bias, editor.schema);
123
+ if (found)
124
+ return found;
125
+ }
126
+ return null;
127
+ };
128
+ const textSegments = (owner) => {
129
+ let offset = 0;
130
+ return (owner.children || []).map((node, index) => {
131
+ const from = offset;
132
+ offset += isTextNode(node) ? node.text.length : 1;
133
+ return { node, index, from, to: offset };
134
+ });
135
+ };
136
+ /**
137
+ * Split an inline owner without flattening its children. Enter is a block
138
+ * operation, but the content crossing the split still belongs to the same
139
+ * inline runs: marks, hard breaks, and inline atoms must survive byte-for-byte
140
+ * on the new block. Text children are copied only when they are cut in two;
141
+ * unsplit children retain their identity until the operation boundary clones
142
+ * the inserted node.
143
+ */
144
+ const splitInlineChildren = (children, offset) => {
145
+ const before = [];
146
+ const after = [];
147
+ let consumed = 0;
148
+ let split = false;
149
+ for (const child of children) {
150
+ if (split) {
151
+ after.push(child);
152
+ continue;
153
+ }
154
+ const width = isTextNode(child) ? child.text.length : 1;
155
+ const local = offset - consumed;
156
+ if (local >= width) {
157
+ before.push(child);
158
+ consumed += width;
159
+ continue;
160
+ }
161
+ if (isTextNode(child)) {
162
+ if (local > 0)
163
+ before.push({ ...child, text: child.text.slice(0, local) });
164
+ if (local < width)
165
+ after.push({ ...child, text: child.text.slice(local) });
166
+ }
167
+ else if (local === 0) {
168
+ after.push(child);
169
+ }
170
+ else {
171
+ // Inline atoms occupy one indivisible unit. A valid position cannot be
172
+ // inside one; this branch is only the defensive side of that contract.
173
+ before.push(child);
174
+ }
175
+ split = true;
176
+ consumed += width;
177
+ }
178
+ return [before, after];
179
+ };
180
+ const queueInlineDeletion = (builder, ownerPath, owner, from, to) => {
181
+ const segments = textSegments(owner).filter((segment) => segment.from < to && segment.to > from).reverse();
182
+ segments.forEach((segment) => {
183
+ if (isTextNode(segment.node)) {
184
+ const localFrom = Math.max(from, segment.from) - segment.from;
185
+ const localTo = Math.min(to, segment.to) - segment.from;
186
+ const text = segment.node.text.slice(localFrom, localTo);
187
+ if (text)
188
+ builder.operations.push({
189
+ type: "deleteText",
190
+ pos: { path: [...ownerPath], offset: segment.from + localFrom },
191
+ text,
192
+ ...(segment.node.marks?.length ? { marks: [...segment.node.marks] } : {}),
193
+ });
194
+ }
195
+ else if (from <= segment.from && to >= segment.to) {
196
+ builder.operations.push({ type: "removeNode", pos: { path: [...ownerPath], offset: segment.index }, node: segment.node });
197
+ }
198
+ });
199
+ };
200
+ const isDocumentStart = (editor, pos) => {
201
+ if (pos.offset !== 0)
202
+ return false;
203
+ let node = editor.document;
204
+ for (const index of pos.path) {
205
+ if (index !== 0 || isTextNode(node) || !node.children?.[index])
206
+ return false;
207
+ node = node.children[index];
208
+ }
209
+ return true;
210
+ };
211
+ const isDocumentEnd = (editor, pos) => {
212
+ let node = editor.document;
213
+ if (pos.path.length === 0)
214
+ return pos.offset === editor.document.children.length;
215
+ for (const index of pos.path) {
216
+ if (isTextNode(node) || !node.children?.[index] || index !== node.children.length - 1)
217
+ return false;
218
+ node = node.children[index];
219
+ }
220
+ const limit = isTextNode(node) ? 0 : isInlineOwnerNode(node, editor.schema)
221
+ ? inlineText(node).length
222
+ : node.children?.length || 0;
223
+ return pos.offset === limit;
224
+ };
225
+ const isWholeDocumentRange = (editor, range) => {
226
+ return isDocumentStart(editor, range.from) && isDocumentEnd(editor, range.to);
227
+ };
228
+ /**
229
+ * Clearing a whole-document selection must still leave the schema-valid
230
+ * editable anchor the browser expects. Keep the operation explicit rather
231
+ * than relying on repair(), because transaction validation deliberately runs
232
+ * before repair and a doc node is required to contain at least one block.
233
+ */
234
+ const queueWholeDocumentDeletion = (editor, builder) => {
235
+ const children = editor.document.children || [];
236
+ for (let index = children.length - 1; index >= 0; index -= 1) {
237
+ builder.operations.push({ type: "removeNode", pos: { path: [], offset: index }, node: children[index] });
238
+ }
239
+ const paragraph = emptyParagraph(createNodeId());
240
+ builder.operations.push({ type: "insertNode", pos: { path: [], offset: 0 }, node: paragraph });
241
+ return { path: [0], offset: 0 };
242
+ };
243
+ const queueRangeDeletion = (editor, builder, range) => {
244
+ const fromOwner = ownerAt(editor, range.from);
245
+ const toOwner = ownerAt(editor, range.to);
246
+ if (range.from.path.length === range.to.path.length && range.from.path.every((part, index) => part === range.to.path[index])) {
247
+ queueInlineDeletion(builder, range.from.path, fromOwner, range.from.offset, range.to.offset);
248
+ return { path: [...range.from.path], offset: range.from.offset };
249
+ }
250
+ const parentPath = range.from.path.slice(0, -1);
251
+ const toParentPath = range.to.path.slice(0, -1);
252
+ if (parentPath.length !== toParentPath.length || parentPath.some((part, index) => part !== toParentPath[index])) {
253
+ throw new Error("Cross-parent text deletion is outside the canonical test surface contract.");
254
+ }
255
+ const fromIndex = range.from.path[range.from.path.length - 1];
256
+ const toIndex = range.to.path[range.to.path.length - 1];
257
+ if (fromOwner.type !== toOwner.type || fromIndex >= toIndex)
258
+ throw new Error("Cross-block deletion requires compatible forward sibling blocks.");
259
+ queueInlineDeletion(builder, range.to.path, toOwner, 0, range.to.offset);
260
+ queueInlineDeletion(builder, range.from.path, fromOwner, range.from.offset, inlineText(fromOwner).length);
261
+ const parent = nodeAtPath(editor.document, parentPath);
262
+ if (!parent || isTextNode(parent) || !parent.children)
263
+ throw new Error("Cross-block deletion parent is invalid.");
264
+ for (let index = toIndex - 1; index > fromIndex; index -= 1) {
265
+ builder.operations.push({ type: "removeNode", pos: { path: [...parentPath], offset: index }, node: parent.children[index] });
266
+ }
267
+ builder.operations.push({
268
+ type: "mergeNode",
269
+ pos: { path: [...parentPath], offset: fromIndex + 1 },
270
+ depth: 0,
271
+ retiredId: toOwner.id,
272
+ splitOffset: textSegments(fromOwner).filter((segment) => segment.from < range.from.offset).length,
273
+ });
274
+ return { path: [...range.from.path], offset: range.from.offset };
275
+ };
276
+ const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
277
+ const pathStartsWith = (path, prefix) => prefix.length < path.length && prefix.every((part, index) => path[index] === part);
278
+ const firstEditableOwnerId = (node, schema) => {
279
+ if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
280
+ return null;
281
+ if (isInlineOwnerNode(node, schema))
282
+ return node.id;
283
+ for (const child of node.children || []) {
284
+ const found = firstEditableOwnerId(child, schema);
285
+ if (found)
286
+ return found;
287
+ }
288
+ return null;
289
+ };
290
+ const scopeTargetIds = (scope) => {
291
+ if (scope.kind === "list-selection")
292
+ return scope.items.map((item) => item.itemId);
293
+ if (scope.kind === "block-range")
294
+ return [...scope.blockIds];
295
+ if (scope.kind === "container-tree")
296
+ return [scope.rootId];
297
+ if (scope.kind === "atomic-node")
298
+ return [scope.nodeId];
299
+ if (scope.kind === "mixed")
300
+ return scope.parts.flatMap(scopeTargetIds);
301
+ return [];
302
+ };
303
+ /**
304
+ * Deletes a structural selection by IDs rather than by assuming both inline
305
+ * endpoints share one parent. The old range path was intentionally narrow
306
+ * for character deletion; it threw for list items and for node selections of
307
+ * blockquote/code nodes, which the browser quite legitimately reports as
308
+ * different scope kinds.
309
+ */
310
+ const structuralDeletionPlan = (editor, selection, range) => {
311
+ const ids = [];
312
+ if (selection.type === "node" || selection.type === "cell") {
313
+ const parent = nodeAtPath(editor.document, range.from.path);
314
+ if (!parent || isTextNode(parent) || !parent.children)
315
+ return null;
316
+ for (let index = range.from.offset; index < range.to.offset; index += 1) {
317
+ const child = parent.children[index];
318
+ if (child && !isTextNode(child))
319
+ ids.push(child.id);
320
+ }
321
+ }
322
+ else {
323
+ const scope = editor.resolveScope({ want: "list-selection" }, selection);
324
+ if ("kind" in scope)
325
+ ids.push(...scopeTargetIds(scope));
326
+ if (!ids.length) {
327
+ const blocks = editor.resolveScope({ want: "block-range" }, selection);
328
+ if ("kind" in blocks)
329
+ ids.push(...scopeTargetIds(blocks));
330
+ }
331
+ if (!ids.length) {
332
+ const containers = editor.resolveScope({ want: "container-tree" }, selection);
333
+ if ("kind" in containers)
334
+ ids.push(...scopeTargetIds(containers));
335
+ }
336
+ }
337
+ const uniqueIds = [...new Set(ids)];
338
+ if (!uniqueIds.length)
339
+ return null;
340
+ const located = uniqueIds.flatMap((id) => {
341
+ const position = editor.positions.positionOf(id);
342
+ if (!position)
343
+ return [];
344
+ return [{ id, position, node: position.parent.children?.[position.pos.offset] }];
345
+ }).filter((entry) => Boolean(entry.node && !isTextNode(entry.node)));
346
+ if (!located.length)
347
+ return null;
348
+ // If a parent itself is selected, descendants are already removed with it;
349
+ // emitting both would address stale paths in one transaction.
350
+ const targetPaths = located.map((entry) => ({ ...entry, path: [...entry.position.pos.path, entry.position.pos.offset] }));
351
+ const targets = targetPaths.filter((entry) => !targetPaths.some((candidate) => candidate !== entry && pathStartsWith(entry.path, candidate.path)));
352
+ const byParent = new Map();
353
+ targets.forEach((entry) => {
354
+ const key = entry.position.pos.path.join("/");
355
+ byParent.set(key, [...(byParent.get(key) || []), entry]);
356
+ });
357
+ // Select-all is a structural range at the document root. Keep the explicit
358
+ // empty-document anchor instead of leaving repair to invent one later.
359
+ const root = nodeAtPath(editor.document, []);
360
+ const rootGroup = byParent.get("");
361
+ if (root && !isTextNode(root) && rootGroup && rootGroup.length === (root.children || []).length
362
+ && rootGroup.every((entry) => entry.position.pos.path.length === 0)) {
363
+ const operations = [];
364
+ for (let index = (root.children || []).length - 1; index >= 0; index -= 1) {
365
+ operations.push({ type: "removeNode", pos: { path: [], offset: index }, node: root.children[index] });
366
+ }
367
+ const paragraph = emptyParagraph(createNodeId());
368
+ operations.push({ type: "insertNode", pos: { path: [], offset: 0 }, node: paragraph });
369
+ return { operations, preferredOwnerIds: [paragraph.id] };
370
+ }
371
+ const operations = [];
372
+ const preferredOwnerIds = [];
373
+ byParent.forEach((entries) => {
374
+ const parent = entries[0].position.parent;
375
+ const children = parent.children || [];
376
+ const indexes = entries.map((entry) => entry.position.pos.offset).sort((left, right) => left - right);
377
+ const allChildren = indexes.length === children.length && indexes.every((value, index) => value === index);
378
+ if (allChildren && parent.type === "list") {
379
+ const listPosition = editor.positions.positionOf(parent.id);
380
+ if (listPosition) {
381
+ const paragraph = emptyParagraph(createNodeId());
382
+ operations.push({ type: "replaceNode", pos: listPosition.pos, before: parent, after: paragraph });
383
+ preferredOwnerIds.push(paragraph.id);
384
+ return;
385
+ }
386
+ }
387
+ if (allChildren && (parent.type === "list_item" || parent.type === "blockquote" || parent.type === "table_cell")) {
388
+ const parentPosition = editor.positions.positionOf(parent.id);
389
+ if (parentPosition) {
390
+ const paragraph = emptyParagraph(createNodeId());
391
+ operations.push({ type: "replaceNode", pos: parentPosition.pos, before: parent, after: { ...parent, children: [paragraph] } });
392
+ preferredOwnerIds.push(paragraph.id);
393
+ return;
394
+ }
395
+ }
396
+ // Descending offsets keep every remaining path stable when several
397
+ // siblings are deleted from the same list/table/document container.
398
+ [...entries].sort((left, right) => right.position.pos.offset - left.position.pos.offset).forEach((entry) => {
399
+ operations.push({ type: "removeNode", pos: entry.position.pos, node: entry.node });
400
+ });
401
+ const next = children[indexes[indexes.length - 1] + 1] || children[indexes[0] - 1];
402
+ if (next && !isTextNode(next)) {
403
+ const owner = firstEditableOwnerId(next, editor.schema);
404
+ if (owner)
405
+ preferredOwnerIds.push(owner);
406
+ }
407
+ });
408
+ if (!operations.length)
409
+ return null;
410
+ return { operations, preferredOwnerIds };
411
+ };
412
+ const previousWord = (text, offset) => {
413
+ const prefix = text.slice(0, offset);
414
+ const match = prefix.match(/(?:\s+|[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)$/u);
415
+ return Math.max(0, offset - (match?.[0].length || 1));
416
+ };
417
+ const nextWord = (text, offset) => {
418
+ const match = text.slice(offset).match(/^(?:\s+|[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)/u);
419
+ return Math.min(text.length, offset + (match?.[0].length || 1));
420
+ };
421
+ export class FoundationInputPipeline {
422
+ constructor(editor, renderer, root, options = {}) {
423
+ this.editor = editor;
424
+ this.renderer = renderer;
425
+ this.root = root;
426
+ this.options = options;
427
+ this.composition = null;
428
+ this.unhandled = [];
429
+ this.destroyed = false;
430
+ this.internalDrag = null;
431
+ this.tableDragAnchor = null;
432
+ this.trackChanges = { enabled: false, authorId: "anonymous" };
433
+ this.beforeInputListener = (event) => this.handleBeforeInput(event);
434
+ this.keyDownListener = (event) => this.handleKeyDown(event);
435
+ this.compositionStartListener = (event) => this.handleCompositionStart(event);
436
+ this.compositionUpdateListener = (event) => this.handleCompositionUpdate(event);
437
+ this.compositionEndListener = (event) => this.handleCompositionEnd(event);
438
+ this.tableMouseDownListener = (event) => {
439
+ const target = event.target instanceof Element ? event.target.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`) : null;
440
+ if (target) {
441
+ // A projected checkbox must not move the editable selection to the
442
+ // button itself. The click handler below keeps the existing model
443
+ // selection while toggling the item.
444
+ event.preventDefault();
445
+ this.root.focus();
446
+ return;
447
+ }
448
+ if (this.composition)
449
+ return;
450
+ // Only the primary (left) button starts a drag-selection gesture. A
451
+ // right-click's mousedown/mouseup pair (button 2) also reaches this
452
+ // same native listener - without this guard, a real-world imprecise
453
+ // right-click whose mousedown and mouseup landed in different cells
454
+ // (easy near a shared cell border, where a human hand's natural
455
+ // few-pixel wobble between press and release is enough) silently
456
+ // formed a genuine multi-cell selection the user never intended to
457
+ // make, rendered as a highlight spanning well past what a single
458
+ // right-click should ever select.
459
+ if (event.button !== 0) {
460
+ this.tableDragAnchor = null;
461
+ return;
462
+ }
463
+ const cell = this.cellFromNode(event.target);
464
+ this.tableDragAnchor = cell;
465
+ };
466
+ this.tableMouseUpListener = (event) => {
467
+ const anchor = this.tableDragAnchor;
468
+ this.tableDragAnchor = null;
469
+ if (!anchor || this.composition)
470
+ return;
471
+ const head = this.cellFromNode(event.target);
472
+ if (!head || head.tableId !== anchor.tableId || head.id === anchor.id)
473
+ return;
474
+ const selection = cellSelectionFromIds(anchor.id, head.id, this.editor.positions);
475
+ if (!selection)
476
+ return;
477
+ event.preventDefault();
478
+ this.editor.setSelection(selection, { source: "api" });
479
+ this.renderer.render(this.editor.document, selection);
480
+ };
481
+ this.clickListener = (event) => {
482
+ const checkControl = event.target instanceof Element
483
+ ? event.target.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`)
484
+ : null;
485
+ if (checkControl) {
486
+ event.preventDefault();
487
+ const itemElement = checkControl.closest('[data-smart-type="list_item"]');
488
+ const mapped = itemElement ? this.renderer.mapping.domToNode(itemElement) : null;
489
+ if (!mapped || isTextNode(mapped.node) || mapped.node.type !== "list_item")
490
+ return;
491
+ const itemPosition = this.editor.positions.positionOf(mapped.nodeId);
492
+ const list = itemPosition?.parent;
493
+ if (!itemPosition || !list || list.type !== "list" || list.attrs?.checkable !== true)
494
+ return;
495
+ const ownerId = firstEditableOwnerId(mapped.node, this.editor.schema);
496
+ const content = ownerId ? this.editor.positions.contentRangeOf(ownerId) : null;
497
+ if (!content)
498
+ return;
499
+ const point = content.from;
500
+ const scope = this.editor.resolveScope({ want: "list-selection" }, { type: "text", anchor: point, head: point });
501
+ if (!("kind" in scope) || (scope.kind !== "list-selection" && scope.kind !== "mixed"))
502
+ return;
503
+ const operations = setListChecked(this.editor.document, scope, { checked: mapped.node.attrs?.checked !== true }, this.commandContext());
504
+ if (!operations.length)
505
+ return;
506
+ const selection = this.editor.selection;
507
+ this.editor.transact((builder) => {
508
+ builder.operations.push(...operations);
509
+ builder.setSelection(selection);
510
+ }, { source: "input", addToHistory: true });
511
+ this.renderer.render(this.editor.document, this.editor.selection);
512
+ return;
513
+ }
514
+ const target = event.target instanceof Element ? event.target.closest("[data-smart-atomic]") : null;
515
+ const mapped = target ? this.renderer.mapping.domToNode(target) : null;
516
+ if (!mapped || isTextNode(mapped.node) || this.editor.schema.nodes[mapped.node.type]?.selectable !== true)
517
+ return;
518
+ const range = this.editor.positions.rangeOf(mapped.nodeId);
519
+ if (!range)
520
+ return;
521
+ this.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
522
+ this.renderer.render(this.editor.document, this.editor.selection);
523
+ };
524
+ this.selectionChangeListener = () => this.syncSelectionFromDom();
525
+ this.pasteListener = (event) => this.handlePaste(event);
526
+ this.copyListener = (event) => this.handleCopy(event);
527
+ this.cutListener = (event) => this.handleCut(event);
528
+ this.dragStartListener = (event) => this.handleDragStart(event);
529
+ this.dropListener = (event) => this.handleDrop(event);
530
+ this.ownerDocument = root.ownerDocument;
531
+ root.addEventListener("beforeinput", this.beforeInputListener);
532
+ root.addEventListener("keydown", this.keyDownListener);
533
+ root.addEventListener("compositionstart", this.compositionStartListener);
534
+ root.addEventListener("compositionupdate", this.compositionUpdateListener);
535
+ root.addEventListener("compositionend", this.compositionEndListener);
536
+ root.addEventListener("click", this.clickListener);
537
+ root.addEventListener("mousedown", this.tableMouseDownListener);
538
+ root.addEventListener("mouseup", this.tableMouseUpListener);
539
+ this.ownerDocument.addEventListener("selectionchange", this.selectionChangeListener);
540
+ root.addEventListener("paste", this.pasteListener);
541
+ root.addEventListener("copy", this.copyListener);
542
+ root.addEventListener("cut", this.cutListener);
543
+ root.addEventListener("dragstart", this.dragStartListener);
544
+ root.addEventListener("drop", this.dropListener);
545
+ renderer.render(editor.document, editor.selection);
546
+ editor.resolveScope({ want: "describe" });
547
+ }
548
+ get unhandledInputTypes() { return this.unhandled; }
549
+ cellFromNode(input) {
550
+ const node = input instanceof Node ? input : null;
551
+ const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
552
+ const cell = element?.closest('[data-smart-type="table_cell"]');
553
+ if (!cell)
554
+ return null;
555
+ const table = cell.closest('[data-smart-type="table"]');
556
+ const mappedCell = this.renderer.mapping.domToNode(cell);
557
+ const mappedTable = table ? this.renderer.mapping.domToNode(table) : null;
558
+ if (!mappedCell || isTextNode(mappedCell.node) || !mappedTable || isTextNode(mappedTable.node))
559
+ return null;
560
+ return { id: mappedCell.nodeId, tableId: mappedTable.nodeId };
561
+ }
562
+ payloadFromTransfer(transfer) {
563
+ const representations = Object.fromEntries(Array.from(transfer.types).filter((type) => type !== "Files").map((type) => [type, transfer.getData(type)]));
564
+ return {
565
+ html: representations["text/html"], plainText: representations["text/plain"],
566
+ native: representations["application/x-smart-rte+json"], types: Array.from(transfer.types), representations,
567
+ };
568
+ }
569
+ writeTransfer(transfer, document) {
570
+ const representations = serializeClipboardRepresentations(document);
571
+ Object.entries(representations).forEach(([type, value]) => transfer.setData(type, value));
572
+ }
573
+ selectionForPoint(event) {
574
+ const pointDocument = this.ownerDocument;
575
+ const caret = pointDocument.caretPositionFromPoint?.(event.clientX, event.clientY);
576
+ const fallback = !caret ? pointDocument.caretRangeFromPoint?.(event.clientX, event.clientY) : null;
577
+ const pos = caret ? this.renderer.mapping.domToPos(caret.offsetNode, caret.offset)
578
+ : fallback ? this.renderer.mapping.domToPos(fallback.startContainer, fallback.startOffset) : null;
579
+ const target = pos || this.editor.selection.head;
580
+ return { type: "text", anchor: target, head: target };
581
+ }
582
+ commitClipboard(result, source) {
583
+ if (!result.operations.length)
584
+ return;
585
+ const preview = applyOperations(this.editor.document, result.operations);
586
+ const lookup = createScopeIndex().positions(preview, this.editor.schema);
587
+ const content = lookup.contentRangeOf(result.selectionTarget.ownerId);
588
+ const position = lookup.positionOf(result.selectionTarget.ownerId);
589
+ const base = content?.from || position?.pos || this.editor.selection.head;
590
+ const target = content && content.from.path.length === content.to.path.length
591
+ && content.from.path.every((part, index) => part === content.to.path[index])
592
+ ? { path: [...content.from.path], offset: Math.min(result.selectionTarget.offset, content.to.offset) }
593
+ : { path: [...base.path], offset: base.offset };
594
+ this.editor.transact((builder) => {
595
+ builder.operations.push(...result.operations);
596
+ builder.setSelection({ type: "text", anchor: target, head: target });
597
+ }, { source, addToHistory: true });
598
+ this.renderer.render(this.editor.document, this.editor.selection);
599
+ }
600
+ handleCopy(event) {
601
+ if (!event.clipboardData || collapsed(this.editor.selection))
602
+ return;
603
+ event.preventDefault();
604
+ this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
605
+ }
606
+ handleCut(event) {
607
+ if (!event.clipboardData || collapsed(this.editor.selection))
608
+ return;
609
+ event.preventDefault();
610
+ this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
611
+ const deletion = deleteClipboardSelection(this.editor.document, this.editor.selection, this.editor.positions);
612
+ this.commitClipboard({ ...deletion, definingAncestorId: null }, "cut");
613
+ }
614
+ handlePaste(event) {
615
+ event.preventDefault();
616
+ if (!event.clipboardData) {
617
+ this.unhandled.push("paste-without-clipboard-data");
618
+ return;
619
+ }
620
+ const payload = this.payloadFromTransfer(event.clipboardData);
621
+ try {
622
+ const parsed = parseClipboardPayload(payload, { ownerDocument: this.ownerDocument, schema: this.editor.schema });
623
+ this.options.onClipboardDiagnostic?.(reportParsedClipboard(payload, parsed));
624
+ const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
625
+ const result = insertClipboardFragment(this.editor.document, this.editor.selection, fragment, {
626
+ schema: this.editor.schema, positions: this.editor.positions, idFactory: createNodeId,
627
+ });
628
+ this.commitClipboard(result, "paste");
629
+ }
630
+ catch (error) {
631
+ this.options.onClipboardDiagnostic?.(reportRejectedClipboard(payload, error));
632
+ this.unhandled.push("paste-rejected");
633
+ }
634
+ }
635
+ handleDragStart(event) {
636
+ if (!event.dataTransfer || collapsed(this.editor.selection))
637
+ return;
638
+ const fragment = sliceClipboardSelection(this.editor.document, this.editor.selection);
639
+ this.internalDrag = { selection: this.editor.selection, fragment };
640
+ this.writeTransfer(event.dataTransfer, fragment);
641
+ event.dataTransfer.effectAllowed = "move";
642
+ }
643
+ handleDrop(event) {
644
+ event.preventDefault();
645
+ if (!event.dataTransfer) {
646
+ this.unhandled.push("drop-without-data-transfer");
647
+ return;
648
+ }
649
+ const targetSelection = this.selectionForPoint(event);
650
+ const files = Array.from(event.dataTransfer.files || []);
651
+ if (files.length) {
652
+ this.options.onFiles?.(files, targetSelection);
653
+ return;
654
+ }
655
+ if (this.internalDrag) {
656
+ const deletion = deleteClipboardSelection(this.editor.document, this.internalDrag.selection, this.editor.positions);
657
+ const preview = applyOperations(this.editor.document, deletion.operations);
658
+ const mappedSelection = new FoundationTransactionMap(deletion.operations).mapSelection(targetSelection);
659
+ const lookup = createScopeIndex().positions(preview, this.editor.schema);
660
+ if (this.internalDrag.selection.type === "node" && this.internalDrag.fragment.children.length === 1) {
661
+ const moving = this.internalDrag.fragment.children[0];
662
+ const targetOwner = nodeAtPath(preview, mappedSelection.head.path);
663
+ if (targetOwner && !isTextNode(targetOwner) && mappedSelection.head.path.length) {
664
+ const parentPath = mappedSelection.head.path.slice(0, -1);
665
+ const ownerIndex = mappedSelection.head.path[mappedSelection.head.path.length - 1];
666
+ const insertionOffset = ownerIndex + (mappedSelection.head.offset > 0 ? 1 : 0);
667
+ this.internalDrag = null;
668
+ this.commitClipboard({
669
+ operations: [...deletion.operations, { type: "insertNode", pos: { path: parentPath, offset: insertionOffset }, node: moving }],
670
+ selectionTarget: { ownerId: isTextNode(moving) ? targetOwner.id : moving.id, offset: 0 },
671
+ definingAncestorId: null,
672
+ }, "drop");
673
+ return;
674
+ }
675
+ }
676
+ const insertion = insertClipboardFragment(preview, mappedSelection, this.internalDrag.fragment, {
677
+ schema: this.editor.schema, positions: lookup, idFactory: createNodeId,
678
+ });
679
+ this.internalDrag = null;
680
+ this.commitClipboard({ ...insertion, operations: [...deletion.operations, ...insertion.operations] }, "drop");
681
+ return;
682
+ }
683
+ const parsed = parseClipboardPayload(this.payloadFromTransfer(event.dataTransfer), { ownerDocument: this.ownerDocument, schema: this.editor.schema });
684
+ const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
685
+ const result = insertClipboardFragment(this.editor.document, targetSelection, fragment, {
686
+ schema: this.editor.schema, positions: this.editor.positions, idFactory: createNodeId,
687
+ });
688
+ this.commitClipboard(result, "drop");
689
+ }
690
+ commit(builderFn, options = {}) {
691
+ this.editor.transact((builder) => builder.setSelection(builderFn(builder)), {
692
+ source: "input",
693
+ addToHistory: true,
694
+ ...(options.compositionId ? { compositionId: options.compositionId } : {}),
695
+ });
696
+ this.renderer.render(this.editor.document, this.editor.selection);
697
+ }
698
+ commandContext() {
699
+ return { schema: this.editor.schema, positions: this.editor.positions };
700
+ }
701
+ commitStructuralResult(result, source = "input") {
702
+ if (!result.operations.length)
703
+ return;
704
+ const preview = applyOperations(this.editor.document, result.operations);
705
+ const lookup = createScopeIndex().positions(preview, this.editor.schema);
706
+ const content = lookup.contentRangeOf(result.selectionTarget.ownerId);
707
+ if (!content)
708
+ throw new Error(`List input selection owner "${result.selectionTarget.ownerId}" was not preserved.`);
709
+ const target = { path: [...content.from.path], offset: result.selectionTarget.offset };
710
+ this.editor.transact((builder) => {
711
+ builder.operations.push(...result.operations);
712
+ builder.setSelection({ type: "text", anchor: target, head: target });
713
+ }, { source, addToHistory: true });
714
+ this.renderer.render(this.editor.document, this.editor.selection);
715
+ }
716
+ /** First content-bearing child of a table cell, or the cell itself if somehow childless. */
717
+ firstOwnerOf(cell) {
718
+ const first = cell.children?.[0];
719
+ return first && !isTextNode(first) ? first.id : cell.id;
720
+ }
721
+ /**
722
+ * Tab/Shift+Tab cell-to-cell navigation (Phase 11 Tier 2 - previously
723
+ * "Tables own Tab navigation" was just a bare `return`, a genuinely
724
+ * missing feature, not a missing test). `grid.anchors` is already in
725
+ * reading order, so stepping ±1 through it is next/previous cell;
726
+ * stepping past the last cell appends a row via insertTableRowCommand and
727
+ * lands in its first cell, matching the reference precedent Tab already
728
+ * uses for a list item with no legal indent - always claim the key.
729
+ */
730
+ handleTableTab(tableId, currentCellId, backward) {
731
+ const resolvedTable = this.editor.positions.positionOf(tableId);
732
+ const tableNode = resolvedTable?.parent.children?.[resolvedTable.pos.offset];
733
+ if (!tableNode || isTextNode(tableNode) || tableNode.id !== tableId)
734
+ return;
735
+ const grid = occupancyGridFor(tableNode);
736
+ const index = grid.anchors.findIndex((cell) => cell.cellId === currentCellId);
737
+ if (index === -1)
738
+ return;
739
+ if (backward) {
740
+ if (index === 0)
741
+ return;
742
+ const target = grid.anchors[index - 1];
743
+ const ownerId = this.firstOwnerOf(target.node);
744
+ const content = this.editor.positions.contentRangeOf(ownerId);
745
+ if (content) {
746
+ this.editor.setSelection({ type: "text", anchor: content.from, head: content.from }, { source: "keyboard" });
747
+ this.renderer.render(this.editor.document, this.editor.selection);
748
+ }
749
+ return;
750
+ }
751
+ if (index < grid.anchors.length - 1) {
752
+ const target = grid.anchors[index + 1];
753
+ const ownerId = this.firstOwnerOf(target.node);
754
+ const content = this.editor.positions.contentRangeOf(ownerId);
755
+ if (content) {
756
+ this.editor.setSelection({ type: "text", anchor: content.from, head: content.from }, { source: "keyboard" });
757
+ this.renderer.render(this.editor.document, this.editor.selection);
758
+ }
759
+ return;
760
+ }
761
+ // Past the last cell: append a row (no crossing rowspans at the bottom
762
+ // edge, so cellIds[0]/paragraphIds[0] are guaranteed the new row's
763
+ // first column-0 cell/paragraph).
764
+ const cellIds = Array.from({ length: grid.columns }, () => createNodeId());
765
+ const paragraphIds = Array.from({ length: grid.columns }, () => createNodeId());
766
+ const tableGridScope = this.editor.resolveScope({ want: "table-grid" });
767
+ if (!("kind" in tableGridScope) || tableGridScope.kind !== "table-grid")
768
+ return;
769
+ const operations = insertTableRowCommand(this.editor.document, tableGridScope, { position: "after", rowId: createNodeId(), cellIds, paragraphIds }, this.commandContext());
770
+ if (!operations.length)
771
+ return;
772
+ const preview = applyOperations(this.editor.document, operations);
773
+ const lookup = createScopeIndex().positions(preview, this.editor.schema);
774
+ const content = lookup.contentRangeOf(paragraphIds[0]);
775
+ if (!content)
776
+ return;
777
+ this.editor.transact((builder) => {
778
+ builder.operations.push(...operations);
779
+ builder.setSelection({ type: "text", anchor: content.from, head: content.from });
780
+ }, { source: "keyboard", addToHistory: true });
781
+ this.renderer.render(this.editor.document, this.editor.selection);
782
+ }
783
+ /**
784
+ * Every prefix of a valid SmartPos.path addresses a real ancestor node
785
+ * (the same property ownerAt already relies on), so walking path prefixes
786
+ * from longest to shortest finds the nearest enclosing table_cell/table
787
+ * without any DOM lookup - the model-level equivalent of cellFromNode,
788
+ * usable from a plain SmartPos rather than a DOM event target.
789
+ */
790
+ tableAncestorsAt(pos) {
791
+ let cellId = null;
792
+ for (let length = pos.path.length; length >= 0; length -= 1) {
793
+ const node = nodeAtPath(this.editor.document, pos.path.slice(0, length));
794
+ if (!node || isTextNode(node))
795
+ continue;
796
+ if (!cellId && node.type === "table_cell")
797
+ cellId = node.id;
798
+ if (node.type === "table")
799
+ return cellId ? { tableId: node.id, cellId } : null;
800
+ }
801
+ return null;
802
+ }
803
+ /**
804
+ * Shift+Arrow rectangular cell-selection expansion (Phase 11 Tier 2 -
805
+ * previously no table-grid branch existed anywhere in arrow-key handling
806
+ * at all). Returns false when not applicable (anchor/head aren't both in
807
+ * the same table), letting the caller fall through to ordinary text-range
808
+ * arrow handling unchanged. Steps from the head cell's own top-left
809
+ * anchor corner - a reasonable v1 simplification for merged-cell edges,
810
+ * matching the existing renderer projection's own merge-snapping
811
+ * (surface/renderer.ts's syncCellSelectionProjection already snaps
812
+ * whatever rect the model selection implies through merged cells on
813
+ * render, so this does not need to duplicate that snapping here).
814
+ */
815
+ handleTableShiftArrow(rowDelta, colDelta) {
816
+ const selection = this.editor.selection;
817
+ const anchorInfo = this.tableAncestorsAt(selection.anchor);
818
+ const headInfo = this.tableAncestorsAt(selection.head);
819
+ if (!anchorInfo || !headInfo || anchorInfo.tableId !== headInfo.tableId)
820
+ return false;
821
+ const resolvedTable = this.editor.positions.positionOf(anchorInfo.tableId);
822
+ const tableNode = resolvedTable?.parent.children?.[resolvedTable.pos.offset];
823
+ if (!tableNode || isTextNode(tableNode) || tableNode.id !== anchorInfo.tableId)
824
+ return false;
825
+ const grid = occupancyGridFor(tableNode);
826
+ const headCell = grid.anchors.find((cell) => cell.cellId === headInfo.cellId);
827
+ if (!headCell)
828
+ return false;
829
+ const newRow = Math.min(grid.rows - 1, Math.max(0, headCell.top + rowDelta));
830
+ const newCol = Math.min(grid.columns - 1, Math.max(0, headCell.left + colDelta));
831
+ const target = grid.at(newRow, newCol);
832
+ if (!target)
833
+ return false;
834
+ const selectionResult = cellSelectionFromIds(anchorInfo.cellId, target.cellId, this.editor.positions);
835
+ if (!selectionResult)
836
+ return false;
837
+ this.editor.setSelection(selectionResult, { source: "keyboard" });
838
+ this.renderer.render(this.editor.document, this.editor.selection);
839
+ return true;
840
+ }
841
+ replaceSelection(text) {
842
+ const selection = this.editor.selection;
843
+ this.commit((builder) => {
844
+ if (this.trackChanges.enabled) {
845
+ const suggested = this.suggestReplaceSelection(builder, selection, text);
846
+ if (suggested)
847
+ return suggested;
848
+ }
849
+ const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
850
+ const marks = this.editor.storedMarks || marksAtInsertion(this.editor.document, caret, this.editor.schema);
851
+ if (text)
852
+ builder.operations.push({ type: "insertText", pos: caret, text, ...(marks.length ? { marks: [...marks] } : {}) });
853
+ const next = { path: [...caret.path], offset: caret.offset + text.length };
854
+ return { type: "text", anchor: next, head: next };
855
+ });
856
+ }
857
+ /**
858
+ * Ambient track-changes mode (Phase 12a §2.3 follow-up, 2026-08-25):
859
+ * typed text becomes a real, live "insert"-kind suggestion mark instead
860
+ * of a plain insertText, and content it replaces is marked "delete"
861
+ * instead of actually removed - the same reviewable-suggestion contract
862
+ * as the explicit Suggest deletion/Suggest insertion toolbar actions,
863
+ * just applied automatically to ordinary typing. Scoped to a selection
864
+ * that's collapsed or fully within one inline owner; returns null for a
865
+ * selection crossing multiple paragraphs, so the caller falls through to
866
+ * the real (non-suggested) path - proposing a delete that spans several
867
+ * paragraphs plus an insert needs per-owner handling this pass doesn't
868
+ * build (see docs/PHASE_ROADMAP_8B_12B.md's Phase 12a status note).
869
+ */
870
+ suggestReplaceSelection(builder, selection, text) {
871
+ const range = normalizedRange(selection);
872
+ if (!collapsed(selection) && !samePath(range.from.path, range.to.path))
873
+ return null;
874
+ const insertAt = collapsed(selection) ? selection.head : range.to;
875
+ if (!collapsed(selection))
876
+ builder.operations.push(suggestDeleteRangeOperation(range, { authorId: this.trackChanges.authorId }));
877
+ const marks = this.editor.storedMarks || marksAtInsertion(this.editor.document, insertAt, this.editor.schema);
878
+ if (text)
879
+ builder.operations.push(suggestInsertOperation(insertAt, text, { authorId: this.trackChanges.authorId }, marks));
880
+ const next = { path: [...insertAt.path], offset: insertAt.offset + text.length };
881
+ return { type: "text", anchor: next, head: next };
882
+ }
883
+ /**
884
+ * True exactly when queueRangeDeletion's own same-parent forward-sibling
885
+ * path can handle this range (see its cross-parent/type/order checks,
886
+ * which this mirrors) - i.e. the two endpoints are different children of
887
+ * the very same parent, with compatible owner types, in forward order.
888
+ * Deliberately does not attempt to be more permissive than
889
+ * queueRangeDeletion itself; anything this returns false for keeps
890
+ * falling through to structuralDeletionPlan exactly as before.
891
+ */
892
+ canMergeAsSiblings(range) {
893
+ const fromParent = range.from.path.slice(0, -1);
894
+ const toParent = range.to.path.slice(0, -1);
895
+ if (!samePath(fromParent, toParent))
896
+ return false;
897
+ const fromIndex = range.from.path[range.from.path.length - 1];
898
+ const toIndex = range.to.path[range.to.path.length - 1];
899
+ if (fromIndex >= toIndex)
900
+ return false;
901
+ let fromOwner;
902
+ let toOwner;
903
+ try {
904
+ fromOwner = ownerAt(this.editor, range.from);
905
+ toOwner = ownerAt(this.editor, range.to);
906
+ }
907
+ catch {
908
+ return false;
909
+ }
910
+ return fromOwner.type === toOwner.type;
911
+ }
912
+ deleteRange(range) {
913
+ if (samePos(range.from, range.to))
914
+ return;
915
+ this.commit((builder) => {
916
+ if (isWholeDocumentRange(this.editor, range)) {
917
+ const caret = queueWholeDocumentDeletion(this.editor, builder);
918
+ return { type: "text", anchor: caret, head: caret };
919
+ }
920
+ // A node/cell selection, or a text selection genuinely crossing
921
+ // structural parents (different list items, table cells, etc.), is
922
+ // not an inline range - resolve its semantic scope and remove the
923
+ // selected IDs; otherwise queueRangeDeletion would either silently do
924
+ // nothing (node selection) or throw on a legitimate nested list.
925
+ //
926
+ // A text selection whose endpoints are merely different SIBLINGS
927
+ // under the same parent (the ordinary "select from partway into one
928
+ // paragraph through partway into the next" gesture) is NOT such a
929
+ // case - queueRangeDeletion already handles it correctly (trims each
930
+ // end, removes any fully-covered siblings between them, merges the
931
+ // remainder). Routing it here instead was a real bug: block-range/
932
+ // container-tree scope resolution treats any block the selection
933
+ // merely touches as fully "in scope" (correct for a property-setting
934
+ // command like "make these headings," wrong for deletion, where
935
+ // partial coverage must mean trim-and-merge, not whole-block
936
+ // removal) - structuralDeletionPlan would then delete both siblings
937
+ // in their entirety, silently destroying the unselected prefix of
938
+ // the first and unselected suffix of the last. canMergeAsSiblings
939
+ // excludes exactly this case from the structural path.
940
+ const structural = (this.editor.selection.type !== "text"
941
+ || (!samePath(range.from.path, range.to.path) && !this.canMergeAsSiblings(range)))
942
+ ? structuralDeletionPlan(this.editor, this.editor.selection, range)
943
+ : null;
944
+ if (structural) {
945
+ builder.operations.push(...structural.operations);
946
+ const preview = applyOperations(this.editor.document, structural.operations);
947
+ const lookup = createScopeIndex().positions(preview, this.editor.schema);
948
+ const ownerId = structural.preferredOwnerIds.find((id) => lookup.exists(id))
949
+ || firstEditableOwnerId(preview, this.editor.schema);
950
+ const content = ownerId ? lookup.contentRangeOf(ownerId) : null;
951
+ const caret = content?.from || { path: [], offset: 0 };
952
+ return { type: "text", anchor: caret, head: caret };
953
+ }
954
+ // Ambient track-changes mode: a same-owner range (the overwhelming
955
+ // majority of real Backspace/Delete presses - one character or word
956
+ // within one paragraph) is marked "delete" instead of removed. The
957
+ // multi-owner case above (structural) and the cross-block merge in
958
+ // deleteAcrossBlock are unaffected - see suggestReplaceSelection's
959
+ // doc comment for why that scope reduction is deliberate.
960
+ if (this.trackChanges.enabled && samePath(range.from.path, range.to.path)) {
961
+ builder.operations.push(suggestDeleteRangeOperation(range, { authorId: this.trackChanges.authorId }));
962
+ return { type: "text", anchor: range.from, head: range.from };
963
+ }
964
+ const caret = queueRangeDeletion(this.editor, builder, range);
965
+ return { type: "text", anchor: caret, head: caret };
966
+ });
967
+ }
968
+ /**
969
+ * Block atoms have no legal internal caret. When the browser reports a
970
+ * structural boundary beside one (including a node selection's head), move
971
+ * to the nearest editable block instead of manufacturing a position inside
972
+ * the atom. If the atom is at a container edge, create the empty paragraph
973
+ * that gives the user a real line to continue typing on.
974
+ */
975
+ moveFromStructuralBoundary(path, offset, direction) {
976
+ const parent = nodeAtPath(this.editor.document, path);
977
+ if (!parent || isTextNode(parent) || isInlineOwnerNode(parent, this.editor.schema))
978
+ return false;
979
+ const children = parent.children || [];
980
+ let cursor = direction < 0 ? offset - 1 : offset;
981
+ while (cursor >= 0 && cursor < children.length) {
982
+ const child = children[cursor];
983
+ if (!isTextNode(child) && this.editor.schema.nodes[child.type]?.atomic === true) {
984
+ cursor += direction;
985
+ continue;
986
+ }
987
+ const target = editableOwnerPosition(child, [...path, cursor], direction, this.editor.schema);
988
+ if (target) {
989
+ const model = { type: "text", anchor: target, head: target };
990
+ this.editor.setSelection(model, { source: "keyboard" });
991
+ this.renderer.render(this.editor.document, model);
992
+ return true;
993
+ }
994
+ cursor += direction;
995
+ }
996
+ // Only containers whose schema accepts blocks may receive the fallback
997
+ // paragraph. This keeps the operation valid inside a document, list item,
998
+ // quote, or table cell while refusing to put content inside an atom.
999
+ const content = this.editor.schema.nodes[parent.type]?.content || "";
1000
+ if (parent.type !== "doc" && !/(?:^|[|(\s])block(?:[+*?]|$)/.test(content))
1001
+ return false;
1002
+ const insertionIndex = direction > 0
1003
+ ? Math.max(0, Math.min(children.length, cursor))
1004
+ : Math.max(0, Math.min(children.length, cursor + 1));
1005
+ const paragraph = { type: "paragraph", id: createNodeId(), children: [] };
1006
+ const target = { path: [...path, insertionIndex], offset: 0 };
1007
+ this.editor.transact((builder) => {
1008
+ builder.operations.push({ type: "insertNode", pos: { path: [...path], offset: insertionIndex }, node: paragraph });
1009
+ builder.setSelection({ type: "text", anchor: target, head: target });
1010
+ }, { source: "keyboard", addToHistory: true });
1011
+ this.renderer.render(this.editor.document, this.editor.selection);
1012
+ return true;
1013
+ }
1014
+ insertParagraph() {
1015
+ const selection = this.editor.selection;
1016
+ if (collapsed(selection)) {
1017
+ const listResult = enterInList(this.editor.document, selection.head, {
1018
+ itemId: createNodeId(), blockId: createNodeId(), emptyBlockId: createNodeId(), splitListId: createNodeId(),
1019
+ }, this.commandContext());
1020
+ if (listResult)
1021
+ return this.commitStructuralResult(listResult);
1022
+ const codeResult = insertCodeBlockNewline(this.editor.document, selection.head, {
1023
+ exitOnTrailingEmptyLine: true,
1024
+ paragraphId: createNodeId(),
1025
+ });
1026
+ if (codeResult)
1027
+ return this.commitStructuralResult(codeResult);
1028
+ }
1029
+ this.commit((builder) => {
1030
+ const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
1031
+ const owner = ownerAt(this.editor, caret);
1032
+ const parentPath = caret.path.slice(0, -1);
1033
+ const index = caret.path[caret.path.length - 1];
1034
+ const text = inlineText(owner);
1035
+ const tail = text.slice(caret.offset);
1036
+ const [, tailChildren] = splitInlineChildren(owner.children || [], caret.offset);
1037
+ const activeMarks = marksAtInsertion(this.editor.document, caret, this.editor.schema);
1038
+ queueInlineDeletion(builder, caret.path, owner, caret.offset, text.length);
1039
+ const nextNode = {
1040
+ type: owner.type,
1041
+ id: createNodeId(),
1042
+ ...(owner.attrs ? { attrs: structuredClone(owner.attrs) } : {}),
1043
+ children: tail ? tailChildren : [],
1044
+ };
1045
+ builder.operations.push({ type: "insertNode", pos: { path: parentPath, offset: index + 1 }, node: nextNode });
1046
+ const next = { path: [...parentPath, index + 1], offset: 0 };
1047
+ // An empty split still needs the marks active at the caret for the next
1048
+ // insertion. The transaction would otherwise clear stored marks as a
1049
+ // side effect of its structural operations.
1050
+ builder.setStoredMarks(activeMarks.length ? activeMarks : undefined);
1051
+ return { type: "text", anchor: next, head: next };
1052
+ });
1053
+ }
1054
+ insertLineBreak() {
1055
+ const selection = this.editor.selection;
1056
+ if (collapsed(selection)) {
1057
+ const codeResult = insertCodeBlockNewline(this.editor.document, selection.head);
1058
+ if (codeResult)
1059
+ return this.commitStructuralResult(codeResult);
1060
+ }
1061
+ const operations = [];
1062
+ const builder = new TransactionBuilder(selection, this.editor.storedMarks ? [...this.editor.storedMarks] : undefined);
1063
+ const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
1064
+ operations.push(...builder.operations);
1065
+ const preview = operations.length ? applyOperations(this.editor.document, operations) : this.editor.document;
1066
+ operations.push(...insertHardBreak(preview, caret));
1067
+ const next = { path: [...caret.path], offset: caret.offset + 1 };
1068
+ this.editor.transact((transaction) => {
1069
+ transaction.operations.push(...operations);
1070
+ transaction.setSelection({ type: "text", anchor: next, head: next });
1071
+ }, { source: "input", addToHistory: true });
1072
+ this.renderer.render(this.editor.document, this.editor.selection);
1073
+ }
1074
+ deleteByInputType(inputType) {
1075
+ const selection = this.editor.selection;
1076
+ if (!collapsed(selection))
1077
+ return this.deleteRange(normalizedRange(selection));
1078
+ const owner = ownerAt(this.editor, selection.head);
1079
+ const text = inlineText(owner);
1080
+ const offset = selection.head.offset;
1081
+ if (inputType === "deleteContentBackward" || inputType === "deleteContentForward") {
1082
+ const structural = inputType.endsWith("Backward")
1083
+ ? backspaceAtListItemStart(this.editor.document, selection.head, this.commandContext())
1084
+ : deleteAtListItemEnd(this.editor.document, selection.head, this.commandContext());
1085
+ if (structural)
1086
+ return this.commitStructuralResult(structural);
1087
+ const boundaries = inlineGraphemeBoundaries(owner.children || []);
1088
+ const target = inputType.endsWith("Backward")
1089
+ ? [...boundaries].reverse().find((boundary) => boundary < offset)
1090
+ : boundaries.find((boundary) => boundary > offset);
1091
+ if (target !== undefined)
1092
+ return this.deleteRange(inputType.endsWith("Backward")
1093
+ ? { from: { ...selection.head, offset: target }, to: selection.head }
1094
+ : { from: selection.head, to: { ...selection.head, offset: target } });
1095
+ return this.deleteAcrossBlock(inputType.endsWith("Backward") ? -1 : 1);
1096
+ }
1097
+ const backward = inputType.endsWith("Backward");
1098
+ const target = inputType === "deleteSoftLineBackward" ? 0 : backward ? previousWord(text, offset) : nextWord(text, offset);
1099
+ this.deleteRange(backward
1100
+ ? { from: { ...selection.head, offset: target }, to: selection.head }
1101
+ : { from: selection.head, to: { ...selection.head, offset: target } });
1102
+ }
1103
+ deleteAcrossBlock(direction) {
1104
+ const position = this.editor.selection.head;
1105
+ const current = nodeAtPath(this.editor.document, position.path);
1106
+ if (!current || isTextNode(current) || !isInlineOwnerNode(current, this.editor.schema))
1107
+ return;
1108
+ // Preserve the existing atom selection affordance for a directly adjacent
1109
+ // block atom. A block atom is selected first and deleted on the next key,
1110
+ // rather than being silently consumed by a cross-container merge.
1111
+ if (position.path.length) {
1112
+ const parentPath = position.path.slice(0, -1);
1113
+ const index = position.path[position.path.length - 1];
1114
+ const parent = nodeAtPath(this.editor.document, parentPath);
1115
+ const neighbor = parent && !isTextNode(parent) ? parent.children?.[index + direction] : undefined;
1116
+ if (neighbor && !isTextNode(neighbor) && this.editor.schema.nodes[neighbor.type]?.atomic === true) {
1117
+ const range = this.editor.positions.rangeOf(neighbor.id);
1118
+ if (range) {
1119
+ this.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "keyboard" });
1120
+ this.renderer.render(this.editor.document, this.editor.selection);
1121
+ }
1122
+ return;
1123
+ }
1124
+ }
1125
+ const owners = editableOwners(this.editor.document, [], this.editor.schema);
1126
+ const currentIndex = owners.findIndex((entry) => entry.node.id === current.id);
1127
+ const adjacent = currentIndex >= 0 ? owners[currentIndex + direction] : undefined;
1128
+ if (!adjacent)
1129
+ return;
1130
+ const target = direction < 0
1131
+ ? resolvePrecedingContentTarget(this.editor.document, current.id, this.commandContext())
1132
+ : null;
1133
+ const targetOwner = direction < 0
1134
+ ? target && nodeAtPath(this.editor.document, this.editor.positions.contentRangeOf(target.ownerId)?.from.path || [])
1135
+ : adjacent.node;
1136
+ const targetId = direction < 0 ? target?.ownerId : adjacent.node.id;
1137
+ if (!targetOwner || isTextNode(targetOwner) || !targetId || targetOwner.id === current.id)
1138
+ return;
1139
+ const currentPosition = this.editor.positions.positionOf(current.id);
1140
+ const targetPosition = this.editor.positions.positionOf(targetId);
1141
+ if (!currentPosition || !targetPosition)
1142
+ return;
1143
+ // A blank line at a structural boundary is removed as a block, leaving
1144
+ // the caret at the nearest visible content. This is the path exercised
1145
+ // after Enter twice exits a depth-zero list. Do not merge an empty quote
1146
+ // paragraph with the outside boundary paragraph: that would re-enter the
1147
+ // quote's content instead of exiting its scope.
1148
+ if (inlineText(current) === "") {
1149
+ const targetRange = this.editor.positions.contentRangeOf(targetId);
1150
+ if (!targetRange)
1151
+ return;
1152
+ const targetSelection = {
1153
+ type: "text",
1154
+ anchor: direction < 0 ? targetRange.to : targetRange.from,
1155
+ head: direction < 0 ? targetRange.to : targetRange.from,
1156
+ };
1157
+ const operations = [{ type: "removeNode", pos: currentPosition.pos, node: current }];
1158
+ const mappedSelection = new FoundationTransactionMap(operations).mapSelection(targetSelection);
1159
+ this.editor.transact((builder) => {
1160
+ builder.operations.push(...operations);
1161
+ builder.setSelection(mappedSelection);
1162
+ }, { source: "input", addToHistory: true });
1163
+ this.renderer.render(this.editor.document, this.editor.selection);
1164
+ return;
1165
+ }
1166
+ if (targetOwner.type !== current.type)
1167
+ return;
1168
+ const targetChildren = direction < 0
1169
+ ? [...(targetOwner.children || []), ...(current.children || []).map((node) => structuredClone(node))]
1170
+ : [...(current.children || []), ...(targetOwner.children || []).map((node) => structuredClone(node))];
1171
+ // Backward deletion keeps the preceding owner; forward deletion keeps the
1172
+ // current owner. Keeping the owner at the caret is the identity contract
1173
+ // used by ordinary cross-block deletion and prevents annotations on the
1174
+ // active block from being retired merely because its content was joined.
1175
+ const survivingOwner = direction < 0 ? targetOwner : current;
1176
+ const removedOwner = direction < 0 ? current : targetOwner;
1177
+ const survivingPosition = direction < 0 ? targetPosition : currentPosition;
1178
+ const removedPosition = direction < 0 ? currentPosition : targetPosition;
1179
+ const merged = { ...survivingOwner, children: targetChildren };
1180
+ const survivingContent = this.editor.positions.contentRangeOf(survivingOwner.id);
1181
+ if (!survivingContent)
1182
+ return;
1183
+ const selectionOffset = direction < 0 ? survivingContent.to.offset : inlineText(current).length;
1184
+ const targetSelection = {
1185
+ type: "text",
1186
+ anchor: direction < 0
1187
+ ? { path: [...survivingContent.to.path], offset: selectionOffset }
1188
+ : { path: [...survivingContent.from.path], offset: selectionOffset },
1189
+ head: direction < 0
1190
+ ? { path: [...survivingContent.to.path], offset: selectionOffset }
1191
+ : { path: [...survivingContent.from.path], offset: selectionOffset },
1192
+ };
1193
+ // The removed owner's content was just absorbed into survivingOwner
1194
+ // (targetChildren, above) - mark it so an AnnotationRange anchored
1195
+ // inside it (e.g. a comment) snaps to the survivor instead of silently
1196
+ // failing to resolve. This is the real cross-block Backspace/Delete
1197
+ // merge path (collapsed caret at a block boundary) - distinct from
1198
+ // queueRangeDeletion's mergeNode operation, which only fires for a
1199
+ // non-collapsed selection spanning multiple blocks. Same pattern as
1200
+ // mergeTableCellsCommand/mergeItems.
1201
+ const operations = [
1202
+ { type: "replaceNode", pos: survivingPosition.pos, before: survivingOwner, after: merged },
1203
+ { type: "removeNode", pos: removedPosition.pos, node: removedOwner, mergedInto: survivingOwner.id },
1204
+ ];
1205
+ // Forward deletion removes the current owner before the target's old
1206
+ // path. Map the selection through both operations instead of retaining a
1207
+ // stale path (the exact failure after Enter twice exits a list in a
1208
+ // quote). Backward deletion uses the same mapping for symmetry.
1209
+ const mappedSelection = new FoundationTransactionMap(operations).mapSelection(targetSelection);
1210
+ this.editor.transact((builder) => {
1211
+ builder.operations.push(...operations);
1212
+ builder.setSelection(mappedSelection);
1213
+ }, { source: "input", addToHistory: true });
1214
+ this.renderer.render(this.editor.document, this.editor.selection);
1215
+ }
1216
+ handleBeforeInput(event) {
1217
+ if (this.destroyed)
1218
+ return;
1219
+ const type = event.inputType;
1220
+ // Browsers may dispatch beforeinput for a keyboard selection mutation
1221
+ // before the asynchronous selectionchange event reaches the document.
1222
+ // Refresh the model from the native selection first, otherwise Ctrl/Cmd+A
1223
+ // followed immediately by Backspace/Delete is mistaken for a collapsed
1224
+ // caret at the old location.
1225
+ // A canonical node selection is already the semantic selection for the
1226
+ // upcoming replacement/deletion. The renderer projects it as a native
1227
+ // range over the node's contents, which is intentionally not a text range
1228
+ // to be re-imported here (doing so turns a selected quote/code node into a
1229
+ // paragraph selection and leaves the node behind).
1230
+ const preserveNodeSelection = this.editor.selection.type === "node"
1231
+ && (type === "deleteContentBackward" || type === "deleteContentForward"
1232
+ || type === "insertText" || type === "insertReplacementText");
1233
+ if (!this.composition && !preserveNodeSelection)
1234
+ this.syncSelectionFromDom();
1235
+ if (this.composition && (type === "insertCompositionText" || type === "deleteCompositionText"))
1236
+ return;
1237
+ if (this.editor.selection.type === "node" && (type === "insertText" || type === "insertReplacementText"
1238
+ || type === "deleteContentBackward" || type === "deleteContentForward")) {
1239
+ const scope = this.editor.resolveScope({ want: "atomic-node" });
1240
+ if ("kind" in scope && scope.kind === "atomic-node") {
1241
+ event.preventDefault();
1242
+ const resolved = this.editor.positions.positionOf(scope.nodeId);
1243
+ const node = resolved?.parent.children?.[resolved.pos.offset];
1244
+ if (!resolved || !node || isTextNode(node))
1245
+ return;
1246
+ const group = this.editor.schema.nodes[node.type]?.group;
1247
+ const text = type === "insertText" || type === "insertReplacementText" ? event.data || "" : "";
1248
+ this.editor.transact((builder) => {
1249
+ if (group === "inline") {
1250
+ builder.operations.push({ type: "removeNode", pos: resolved.pos, node });
1251
+ if (text)
1252
+ builder.operations.push({ type: "insertText", pos: scope.range.from, text });
1253
+ const caret = { path: [...scope.range.from.path], offset: scope.range.from.offset + text.length };
1254
+ builder.setSelection({ type: "text", anchor: caret, head: caret });
1255
+ }
1256
+ else {
1257
+ const replacement = { type: "paragraph", id: createNodeId(), children: text ? [{ type: "text", text }] : [] };
1258
+ builder.operations.push({ type: "replaceNode", pos: resolved.pos, before: node, after: replacement });
1259
+ const caret = { path: [...resolved.pos.path, resolved.pos.offset], offset: text.length };
1260
+ builder.setSelection({ type: "text", anchor: caret, head: caret });
1261
+ }
1262
+ }, { source: "input", addToHistory: true });
1263
+ this.renderer.render(this.editor.document, this.editor.selection);
1264
+ return;
1265
+ }
1266
+ }
1267
+ if (type === "insertText" || type === "insertReplacementText") {
1268
+ event.preventDefault();
1269
+ this.replaceSelection(event.data || "");
1270
+ }
1271
+ else if (type === "insertParagraph") {
1272
+ event.preventDefault();
1273
+ this.insertParagraph();
1274
+ }
1275
+ else if (type === "insertLineBreak") {
1276
+ event.preventDefault();
1277
+ this.insertLineBreak();
1278
+ }
1279
+ else if (["formatBold", "formatItalic", "formatUnderline", "formatStrikeThrough"].includes(type)) {
1280
+ event.preventDefault();
1281
+ const id = type === "formatBold" ? "bold" : type === "formatItalic" ? "italic" : type === "formatUnderline" ? "underline" : "strikethrough";
1282
+ const declaration = inlineToolDeclarations.find((tool) => tool.id === id);
1283
+ executeMarkTool(this.editor, declaration, "toggle");
1284
+ this.renderer.render(this.editor.document, this.editor.selection);
1285
+ }
1286
+ else if (["deleteContentBackward", "deleteContentForward", "deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward"].includes(type)) {
1287
+ event.preventDefault();
1288
+ this.deleteByInputType(type);
1289
+ }
1290
+ else if (type === "historyUndo" || type === "historyRedo") {
1291
+ event.preventDefault();
1292
+ if (type === "historyUndo")
1293
+ this.editor.undo();
1294
+ else
1295
+ this.editor.redo();
1296
+ this.renderer.render(this.editor.document, this.editor.selection);
1297
+ }
1298
+ else if (["insertFromPaste", "insertFromDrop", "deleteByCut"].includes(type)) {
1299
+ // Owned by paste/drop/cut events, which carry the actual transfer payload.
1300
+ event.preventDefault();
1301
+ }
1302
+ else {
1303
+ event.preventDefault();
1304
+ this.unhandled.push(type || "unknown");
1305
+ console.warn(`[Smart RTE canonical input] cancelled unsupported inputType: ${type || "unknown"}`);
1306
+ }
1307
+ }
1308
+ moveCaret(direction, word = false) {
1309
+ const selection = this.editor.selection;
1310
+ const active = selection.head;
1311
+ const activeOwner = nodeAtPath(this.editor.document, active.path);
1312
+ if (activeOwner && !isTextNode(activeOwner) && !isInlineOwnerNode(activeOwner, this.editor.schema)) {
1313
+ if (this.moveFromStructuralBoundary(active.path, active.offset, direction))
1314
+ return;
1315
+ return;
1316
+ }
1317
+ const owner = ownerAt(this.editor, active);
1318
+ const text = inlineText(owner);
1319
+ let offset = word
1320
+ ? direction < 0 ? previousWord(text, active.offset) : nextWord(text, active.offset)
1321
+ : direction < 0
1322
+ ? [...inlineGraphemeBoundaries(owner.children || [])].reverse().find((value) => value < active.offset) ?? active.offset
1323
+ : inlineGraphemeBoundaries(owner.children || []).find((value) => value > active.offset) ?? active.offset;
1324
+ let path = active.path;
1325
+ if (offset === active.offset) {
1326
+ const parentPath = active.path.slice(0, -1);
1327
+ const siblingIndex = active.path.length ? active.path[active.path.length - 1] + direction : -1;
1328
+ const parent = active.path.length ? nodeAtPath(this.editor.document, parentPath) : undefined;
1329
+ const neighbor = !parent || isTextNode(parent) ? undefined : parent.children?.[siblingIndex];
1330
+ if (neighbor && !isTextNode(neighbor)) {
1331
+ // Enter a structural sibling through its nearest editable owner. The
1332
+ // previous implementation treated the structural node itself as an
1333
+ // inline owner, which made quote/table edges unreachable and left the
1334
+ // browser selection on an address with no caret.
1335
+ const target = editableOwnerPosition(neighbor, [...parentPath, siblingIndex], direction, this.editor.schema);
1336
+ if (target) {
1337
+ path = target.path;
1338
+ offset = target.offset;
1339
+ }
1340
+ else if (this.moveFromStructuralBoundary(parentPath, direction > 0 ? siblingIndex : siblingIndex + 1, direction)) {
1341
+ return;
1342
+ }
1343
+ }
1344
+ else {
1345
+ // A quote or table may be separated from the current owner by a
1346
+ // structural ancestor. Walk logical owner order rather than only the
1347
+ // immediate sibling array, so arrows cross nested containers and the
1348
+ // editable boundary paragraphs installed by the normalizer.
1349
+ const owners = editableOwners(this.editor.document, [], this.editor.schema);
1350
+ const currentIndex = owners.findIndex((entry) => entry.path.length === active.path.length
1351
+ && entry.path.every((part, index) => part === active.path[index]));
1352
+ const adjacent = currentIndex >= 0 ? owners[currentIndex + direction] : undefined;
1353
+ if (adjacent) {
1354
+ path = adjacent.path;
1355
+ offset = direction < 0 ? inlineText(adjacent.node).length : 0;
1356
+ }
1357
+ }
1358
+ }
1359
+ const next = { path: [...path], offset };
1360
+ const model = { type: "text", anchor: next, head: next };
1361
+ this.editor.setSelection(model, { source: "keyboard" });
1362
+ this.renderer.render(this.editor.document, model);
1363
+ }
1364
+ handleKeyDown(event) {
1365
+ // Selectionchange is delivered asynchronously in Chromium. A keyboard
1366
+ // navigation key can therefore arrive immediately after Ctrl/Cmd+A while
1367
+ // the native range is document-wide but the canonical selection still
1368
+ // describes the previous owner. Import the current native range before
1369
+ // directional handling so ArrowUp/Down never operates on stale paths.
1370
+ // Home/End and Enter's own modifier branches below (Ctrl/Cmd+Enter,
1371
+ // Shift+Enter) read `this.editor.selection.head` synchronously too, and
1372
+ // are exposed to the exact same race - most visibly, a real click
1373
+ // immediately followed by End: Home/End's handler used to trust the
1374
+ // still-stale selection, compute "end of the stale owner", and
1375
+ // explicitly commit + render *that* wrong position - actively moving
1376
+ // the real caret away from wherever the click had just placed it (for
1377
+ // example out of a code block nested in a blockquote and onto an
1378
+ // unrelated preceding paragraph), so a subsequent Enter then genuinely
1379
+ // operated on the wrong position instead of merely reading it wrong.
1380
+ // Plain Enter is additionally covered by the beforeinput handler's own
1381
+ // resync, but the modifier branches here fire before that ever runs.
1382
+ if (!this.composition && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "Enter"].includes(event.key)) {
1383
+ this.syncSelectionFromDom();
1384
+ }
1385
+ const modifier = event.metaKey || event.ctrlKey;
1386
+ if (event.key === "Enter" && modifier && !event.shiftKey) {
1387
+ const result = exitCodeBlock(this.editor.document, this.editor.selection.head, createNodeId());
1388
+ if (result) {
1389
+ event.preventDefault();
1390
+ this.commitStructuralResult(result, "keyboard");
1391
+ return;
1392
+ }
1393
+ }
1394
+ // WebKit does not consistently surface Shift+Enter as
1395
+ // beforeinput(insertLineBreak). Own the intent at keydown so every browser
1396
+ // produces the same atomic hard_break representation.
1397
+ if (event.key === "Enter" && event.shiftKey && !modifier) {
1398
+ event.preventDefault();
1399
+ this.insertLineBreak();
1400
+ return;
1401
+ }
1402
+ const shortcut = modifier ? { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()] : undefined;
1403
+ if (shortcut) {
1404
+ event.preventDefault();
1405
+ executeMarkTool(this.editor, inlineToolDeclarations.find((tool) => tool.id === shortcut), "toggle");
1406
+ this.renderer.render(this.editor.document, this.editor.selection);
1407
+ return;
1408
+ }
1409
+ if (modifier && event.key.toLowerCase() === "k") {
1410
+ event.preventDefault();
1411
+ this.root.dispatchEvent(new CustomEvent("smart-link-request", { bubbles: true }));
1412
+ return;
1413
+ }
1414
+ if (modifier && event.key.toLowerCase() === "z") {
1415
+ event.preventDefault();
1416
+ if (event.shiftKey)
1417
+ this.editor.redo();
1418
+ else
1419
+ this.editor.undo();
1420
+ this.renderer.render(this.editor.document, this.editor.selection);
1421
+ return;
1422
+ }
1423
+ if (modifier && event.key.toLowerCase() === "y") {
1424
+ event.preventDefault();
1425
+ this.editor.redo();
1426
+ this.renderer.render(this.editor.document, this.editor.selection);
1427
+ return;
1428
+ }
1429
+ if (event.key === "Tab") {
1430
+ const active = this.editor.selection.head;
1431
+ const description = this.editor.resolveScope({ want: "describe" });
1432
+ const inTable = "inTable" in description && Boolean(description.inTable);
1433
+ // Data-driven replacement for the old hardcoded code/list/table
1434
+ // precedence if-chain: try each registered Tab contribution (block's
1435
+ // code-indent, list's indent/outdent - priority 20 before 10, see
1436
+ // block/plugin.ts and list/plugin.ts) in order, actually resolving its
1437
+ // declared scope and running its real command, and only counting it as
1438
+ // a match if that produces real operations. Nothing is declared for
1439
+ // table-grid, so a table caret always falls through unmatched, same as
1440
+ // before. `inTable` short-circuits list contributions specifically:
1441
+ // a list nested inside a table cell still yields Tab to the table,
1442
+ // exactly as the old chain did.
1443
+ const resolved = resolveShortcut(this.editor.keyboardShortcuts, { key: "Tab", shiftKey: event.shiftKey }, (shortcut) => {
1444
+ const command = this.editor.commands.get(shortcut.commandId);
1445
+ if (!command)
1446
+ return null;
1447
+ if (inTable && (shortcut.commandId === "list.indent" || shortcut.commandId === "list.outdent"))
1448
+ return null;
1449
+ const wanted = shortcut.scopeKinds[0];
1450
+ if (wanted === "mixed" || wanted === "empty")
1451
+ return null;
1452
+ const scope = this.editor.resolveScope({ want: wanted });
1453
+ if (!("kind" in scope) || scope.kind !== wanted)
1454
+ return null;
1455
+ const params = shortcut.commandId === "list.indent" ? { nestedListIds: [createNodeId()] }
1456
+ : shortcut.commandId === "list.outdent" ? { splitListIds: [createNodeId()] }
1457
+ : shortcut.params;
1458
+ return command.run(this.editor.document, scope, params, this.commandContext());
1459
+ });
1460
+ if (resolved) {
1461
+ event.preventDefault();
1462
+ const { shortcut, operations } = resolved;
1463
+ const selectionTarget = shortcut.commandId === "block.code.indentTab"
1464
+ ? { ownerId: ownerAt(this.editor, active).id, offset: active.offset + 1 }
1465
+ : { ownerId: ownerAt(this.editor, active).id, offset: active.offset };
1466
+ const intent = shortcut.commandId === "list.outdent" ? "outdent" : "indent";
1467
+ this.commitStructuralResult({ operations: [...operations], selectionTarget, intent }, "keyboard");
1468
+ return;
1469
+ }
1470
+ // Tab inside a list item is always ours to handle, even when there is
1471
+ // no legal indent/outdent (e.g. the first item, or already at the max
1472
+ // legal depth) — preventDefault unconditionally here, or the browser
1473
+ // falls through to its native Tab-to-next-focusable-element behavior
1474
+ // and keyboard focus silently leaves the editor entirely.
1475
+ if (!inTable && listItemAt(this.editor.document, active)) {
1476
+ event.preventDefault();
1477
+ return;
1478
+ }
1479
+ if (inTable && description.inTable) {
1480
+ event.preventDefault();
1481
+ this.handleTableTab(description.inTable.tableId, description.inTable.cellId, event.shiftKey);
1482
+ return;
1483
+ }
1484
+ return;
1485
+ }
1486
+ // Space is ordinary text while the caret is inside a checklist item. When
1487
+ // keyboard focus is actually on the projected checkbox button, preserve
1488
+ // native button semantics by routing Space to the same click path. The
1489
+ // focus check is essential: a global checklist-item shortcut makes a
1490
+ // sentence such as "Buy milk" toggle the item and drops the separator.
1491
+ if ((event.key === " " || event.key === "Spacebar")
1492
+ && this.ownerDocument.activeElement instanceof Element) {
1493
+ const checkControl = this.ownerDocument.activeElement.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`);
1494
+ if (checkControl) {
1495
+ event.preventDefault();
1496
+ checkControl.click();
1497
+ return;
1498
+ }
1499
+ }
1500
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1501
+ const direction = event.key === "ArrowLeft" ? -1 : 1;
1502
+ if (event.shiftKey && this.handleTableShiftArrow(0, direction)) {
1503
+ event.preventDefault();
1504
+ return;
1505
+ }
1506
+ event.preventDefault();
1507
+ // Plain arrows collapse a range to its normalized endpoint. Reading
1508
+ // `selection.head` here makes the result depend on drag direction,
1509
+ // violating the anchor/head contract and reversing Left/Right for a
1510
+ // bottom-to-top selection.
1511
+ if (!event.shiftKey && this.editor.selection.type === "text" && !collapsed(this.editor.selection)) {
1512
+ const range = normalizedRange(this.editor.selection);
1513
+ const target = direction < 0 ? range.from : range.to;
1514
+ const model = { type: "text", anchor: target, head: target };
1515
+ this.editor.setSelection(model, { source: "keyboard" });
1516
+ this.renderer.render(this.editor.document, model);
1517
+ return;
1518
+ }
1519
+ this.moveCaret(direction, modifier || event.altKey);
1520
+ }
1521
+ else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
1522
+ if (event.shiftKey && this.handleTableShiftArrow(event.key === "ArrowUp" ? -1 : 1, 0)) {
1523
+ event.preventDefault();
1524
+ return;
1525
+ }
1526
+ if (this.editor.selection.type === "node") {
1527
+ // Native vertical movement has no useful target while a block atom is
1528
+ // selected. Treat it as movement to the preceding/following editable
1529
+ // line, matching left/right atom-boundary navigation.
1530
+ event.preventDefault();
1531
+ this.moveCaret(event.key === "ArrowUp" ? -1 : 1);
1532
+ }
1533
+ else if (!event.shiftKey && this.editor.selection.type === "text" && !collapsed(this.editor.selection)
1534
+ && isWholeDocumentRange(this.editor, normalizedRange(this.editor.selection))) {
1535
+ // Native vertical-arrow collapse of a whole-document (Ctrl/Cmd+A)
1536
+ // selection is unreliable once the document contains structural
1537
+ // content (lists, tables): the browser's own notion of where
1538
+ // selection "focus" lands after select-all does not consistently
1539
+ // resolve to the true first/last editable position once nested
1540
+ // containers are involved, so ArrowDown can land mid-document
1541
+ // instead of at the real end (and symmetrically for ArrowUp/start).
1542
+ // Ordinary, non-whole-document vertical movement is deliberately
1543
+ // left native above (real line-based caret movement isn't something
1544
+ // the model layer can replicate) - only this specific, well-defined
1545
+ // state is collapsed deterministically here, reusing the same
1546
+ // document-order editable-owner walk moveCaret already relies on for
1547
+ // cross-container navigation.
1548
+ event.preventDefault();
1549
+ const owners = editableOwners(this.editor.document, [], this.editor.schema);
1550
+ const target = event.key === "ArrowUp" ? owners[0] : owners[owners.length - 1];
1551
+ if (target) {
1552
+ const pos = { path: [...target.path], offset: event.key === "ArrowUp" ? 0 : inlineText(target.node).length };
1553
+ const model = { type: "text", anchor: pos, head: pos };
1554
+ this.editor.setSelection(model, { source: "keyboard" });
1555
+ this.renderer.render(this.editor.document, model);
1556
+ }
1557
+ }
1558
+ }
1559
+ else if (event.key === "Home" || event.key === "End") {
1560
+ event.preventDefault();
1561
+ const active = this.editor.selection.head;
1562
+ const owner = ownerAt(this.editor, active);
1563
+ const next = { path: [...active.path], offset: event.key === "Home" ? 0 : inlineText(owner).length };
1564
+ // Shift+Home/End is the standard "select to line start/end" gesture -
1565
+ // this previously collapsed to `next` unconditionally, discarding
1566
+ // the existing anchor regardless of the Shift key, so Shift+Home/
1567
+ // End could never actually select anything; it only ever moved the
1568
+ // caret. Preserve the current anchor when extending.
1569
+ const anchor = event.shiftKey ? this.editor.selection.anchor : next;
1570
+ const model = { type: "text", anchor, head: next };
1571
+ this.editor.setSelection(model, { source: "keyboard" });
1572
+ this.renderer.render(this.editor.document, model);
1573
+ }
1574
+ }
1575
+ handleCompositionStart(_event) {
1576
+ const selection = this.editor.selection;
1577
+ const owner = ownerAt(this.editor, selection.head);
1578
+ const element = this.renderer.mapping.nodeToDom(owner.id);
1579
+ const observer = element && typeof MutationObserver !== "undefined" ? new MutationObserver(() => undefined) : null;
1580
+ observer?.observe(element, { subtree: true, childList: true, characterData: true });
1581
+ this.composition = {
1582
+ id: createNodeId(), ownerId: owner.id, ownerPath: [...selection.head.path], beforeText: inlineText(owner), selectionBefore: selection, observer,
1583
+ beforeTokens: tokenizeCompositionOwner(owner),
1584
+ activeMarks: [...(this.editor.storedMarks || marksAtInsertion(this.editor.document, selection.head, this.editor.schema))],
1585
+ };
1586
+ this.renderer.beginComposition(owner.id);
1587
+ }
1588
+ /** DOM remains authoritative; updates are intentionally observed, not rendered. */
1589
+ handleCompositionUpdate(_event) { }
1590
+ handleCompositionEnd(_event) {
1591
+ const state = this.composition;
1592
+ if (!state)
1593
+ return;
1594
+ state.observer?.disconnect();
1595
+ const element = this.renderer.mapping.nodeToDom(state.ownerId);
1596
+ const afterTokens = [];
1597
+ if (element) {
1598
+ [...element.childNodes].forEach((direct) => {
1599
+ const directElement = direct instanceof HTMLElement ? direct : direct.parentElement;
1600
+ if (directElement?.closest("[data-smart-ui]"))
1601
+ return;
1602
+ const atomic = directElement?.closest("[data-smart-atomic]");
1603
+ if (atomic) {
1604
+ const mapped = this.renderer.mapping.domToNode(atomic);
1605
+ if (mapped && !isTextNode(mapped.node))
1606
+ afterTokens.push({ kind: "atom", nodeId: mapped.node.id, atomType: mapped.node.type });
1607
+ return;
1608
+ }
1609
+ const text = direct.textContent || "";
1610
+ if (!text)
1611
+ return;
1612
+ const marks = [];
1613
+ const walker = element.ownerDocument.createTreeWalker(direct, NodeFilter.SHOW_TEXT);
1614
+ const firstText = direct.nodeType === direct.TEXT_NODE ? direct : walker.nextNode();
1615
+ let cursor = firstText?.parentElement || null;
1616
+ while (cursor && cursor !== element) {
1617
+ const type = cursor.getAttribute("data-smart-mark");
1618
+ if (type) {
1619
+ const raw = cursor.getAttribute("data-smart-mark-attrs");
1620
+ marks.push({ type, ...(raw ? { attrs: JSON.parse(raw) } : {}) });
1621
+ }
1622
+ cursor = cursor.parentElement;
1623
+ }
1624
+ afterTokens.push({ kind: "text", text, marks: canonicalMarkOrder(marks) });
1625
+ });
1626
+ }
1627
+ const beforeUnits = tokenUnits(state.beforeTokens);
1628
+ const afterUnits = tokenUnits(afterTokens);
1629
+ let prefix = 0;
1630
+ while (prefix < beforeUnits.length && prefix < afterUnits.length
1631
+ && beforeUnits[prefix].char === afterUnits[prefix].char && beforeUnits[prefix].key === afterUnits[prefix].key)
1632
+ prefix += 1;
1633
+ let suffix = 0;
1634
+ while (suffix < beforeUnits.length - prefix && suffix < afterUnits.length - prefix
1635
+ && beforeUnits[beforeUnits.length - 1 - suffix].char === afterUnits[afterUnits.length - 1 - suffix].char
1636
+ && beforeUnits[beforeUnits.length - 1 - suffix].key === afterUnits[afterUnits.length - 1 - suffix].key)
1637
+ suffix += 1;
1638
+ const removed = beforeUnits.slice(prefix, beforeUnits.length - suffix);
1639
+ const inserted = afterUnits.slice(prefix, afterUnits.length - suffix);
1640
+ const domSelection = this.ownerDocument.getSelection();
1641
+ const domCaret = domSelection?.focusNode ? this.renderer.mapping.domToPos(domSelection.focusNode, domSelection.focusOffset) : null;
1642
+ this.renderer.endComposition();
1643
+ this.composition = null;
1644
+ // Atoms are opaque composition boundaries. If a browser mutation crosses,
1645
+ // reject the DOM mutation and restore the unchanged canonical owner.
1646
+ if (removed.some((unit) => unit.kind === "atom") || inserted.some((unit) => unit.kind === "atom")) {
1647
+ return this.renderer.render(this.editor.document, this.editor.selection);
1648
+ }
1649
+ if (!removed.length && !inserted.length)
1650
+ return this.renderer.render(this.editor.document, this.editor.selection);
1651
+ this.commit((builder) => {
1652
+ if (removed.length)
1653
+ queueInlineDeletion(builder, state.ownerPath, ownerAt(this.editor, { path: state.ownerPath, offset: prefix }), prefix, prefix + removed.length);
1654
+ let insertedOffset = 0;
1655
+ while (insertedOffset < inserted.length) {
1656
+ const key = inserted[insertedOffset].key;
1657
+ let end = insertedOffset + 1;
1658
+ while (end < inserted.length && inserted[end].key === key)
1659
+ end += 1;
1660
+ const text = inserted.slice(insertedOffset, end).map((unit) => unit.char).join("");
1661
+ const marks = inserted[insertedOffset].marks.length ? inserted[insertedOffset].marks : state.activeMarks;
1662
+ builder.operations.push({ type: "insertText", pos: { path: state.ownerPath, offset: prefix + insertedOffset }, text, ...(marks.length ? { marks } : {}) });
1663
+ insertedOffset = end;
1664
+ }
1665
+ const caret = domCaret || { path: state.ownerPath, offset: prefix + inserted.length };
1666
+ return { type: "text", anchor: caret, head: caret };
1667
+ }, { compositionId: state.id });
1668
+ }
1669
+ setTrackChanges(enabled, authorId) {
1670
+ this.trackChanges = { enabled, authorId: authorId ?? this.trackChanges.authorId };
1671
+ }
1672
+ syncSelectionFromDom() {
1673
+ if (this.destroyed || this.composition)
1674
+ return;
1675
+ const native = this.ownerDocument.getSelection();
1676
+ if (!native?.anchorNode || !native.focusNode || !this.root.contains(native.anchorNode) || !this.root.contains(native.focusNode))
1677
+ return;
1678
+ const anchor = this.renderer.mapping.domToPos(native.anchorNode, native.anchorOffset);
1679
+ const head = this.renderer.mapping.domToPos(native.focusNode, native.focusOffset);
1680
+ if (!anchor || !head)
1681
+ return;
1682
+ // A selectable atom is projected as a native range spanning its parent
1683
+ // boundary. The browser emits `selectionchange` after that projection,
1684
+ // and importing the range as a text selection would immediately discard
1685
+ // the semantic node selection that the atom click established. Preserve
1686
+ // it only when the native endpoints are exactly the node range; a
1687
+ // genuinely different native range must still replace the node selection.
1688
+ const current = this.editor.selection;
1689
+ if (current.type === "node") {
1690
+ const currentRange = normalizedRange(current);
1691
+ if (samePos(anchor, currentRange.from) && samePos(head, currentRange.to))
1692
+ return;
1693
+ if (samePos(anchor, currentRange.to) && samePos(head, currentRange.from))
1694
+ return;
1695
+ }
1696
+ // A cell selection is projected as a native range spanning the selected
1697
+ // cell boundaries. The browser emits `selectionchange` for that
1698
+ // projection; importing the endpoints as a text selection would lose the
1699
+ // semantic cell selection (and, after a merge, make the result depend on
1700
+ // which render frame happened to win). Preserve it while the native range
1701
+ // still represents the same model endpoints. A genuinely different click
1702
+ // or drag maps to a new selection below.
1703
+ if (current.type === "cell") {
1704
+ if (samePos(anchor, current.anchor) && samePos(head, current.head))
1705
+ return;
1706
+ if (samePos(anchor, current.head) && samePos(head, current.anchor))
1707
+ return;
1708
+ }
1709
+ // A native selection may collapse on the editable root (or another
1710
+ // structural container) instead of an inline owner. Keep that DOM detail
1711
+ // out of canonical state: root offset `children.length`, in particular,
1712
+ // is the browser's representation of "below the final blockquote" and
1713
+ // would otherwise leave ownerAt() with an unusable document path.
1714
+ const collapsedNative = native.isCollapsed;
1715
+ const normalizedAnchor = editablePositionForStructuralBoundary(this.editor, anchor, collapsedNative ? (anchor.offset === 0 ? 1 : -1) : 1) || anchor;
1716
+ const normalizedHead = editablePositionForStructuralBoundary(this.editor, head, collapsedNative ? (head.offset === 0 ? 1 : -1) : -1) || head;
1717
+ let model = { type: "text", anchor: normalizedAnchor, head: normalizedHead };
1718
+ const anchorCell = this.cellFromNode(native.anchorNode);
1719
+ const headCell = this.cellFromNode(native.focusNode);
1720
+ if (anchorCell && headCell && anchorCell.tableId === headCell.tableId && anchorCell.id !== headCell.id) {
1721
+ const cellSelection = cellSelectionFromIds(anchorCell.id, headCell.id, this.editor.positions);
1722
+ if (cellSelection)
1723
+ model = cellSelection;
1724
+ }
1725
+ const description = this.editor.resolveScope({ want: "describe" }, model);
1726
+ if (model.type !== "cell" && "spansIsolatingBoundary" in description && description.spansIsolatingBoundary) {
1727
+ const isolateId = description.inTable?.tableId || description.isolatingAncestorId;
1728
+ const range = isolateId ? this.editor.positions.rangeOf(isolateId) : null;
1729
+ if (range)
1730
+ model = { type: "node", anchor: range.from, head: range.to };
1731
+ }
1732
+ if (current.type === model.type && samePos(current.anchor, model.anchor) && samePos(current.head, model.head))
1733
+ return;
1734
+ this.editor.setSelection(model, { source: "api" });
1735
+ // Re-project a normalized structural DOM point immediately. Without this
1736
+ // call the model would know the right owner while the browser caret stayed
1737
+ // on the root boundary, making the first subsequent input appear inert.
1738
+ this.renderer.render(this.editor.document, model);
1739
+ }
1740
+ destroy() {
1741
+ if (this.destroyed)
1742
+ return;
1743
+ this.destroyed = true;
1744
+ this.composition?.observer?.disconnect();
1745
+ this.root.removeEventListener("beforeinput", this.beforeInputListener);
1746
+ this.root.removeEventListener("keydown", this.keyDownListener);
1747
+ this.root.removeEventListener("compositionstart", this.compositionStartListener);
1748
+ this.root.removeEventListener("compositionupdate", this.compositionUpdateListener);
1749
+ this.root.removeEventListener("compositionend", this.compositionEndListener);
1750
+ this.root.removeEventListener("click", this.clickListener);
1751
+ this.root.removeEventListener("mousedown", this.tableMouseDownListener);
1752
+ this.root.removeEventListener("mouseup", this.tableMouseUpListener);
1753
+ this.ownerDocument.removeEventListener("selectionchange", this.selectionChangeListener);
1754
+ this.root.removeEventListener("paste", this.pasteListener);
1755
+ this.root.removeEventListener("copy", this.copyListener);
1756
+ this.root.removeEventListener("cut", this.cutListener);
1757
+ this.root.removeEventListener("dragstart", this.dragStartListener);
1758
+ this.root.removeEventListener("drop", this.dropListener);
1759
+ }
1760
+ }
1761
+ export const createInputPipeline = (editor, renderer, root, options = {}) => new FoundationInputPipeline(editor, renderer, root, options);