@liminis/editor 0.1.0-rc.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 (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,750 @@
1
+ /**
2
+ * PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
3
+ *
4
+ * The doc comments below are the original author's and are kept verbatim so
5
+ * this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
6
+ * identifiers, `#NN` issue references and `adrs/` paths therefore name
7
+ * **Zusammen's** spec, issues and ADRs — not this repository's, where the same
8
+ * identifiers mean something else entirely. For the Liminis-side design see
9
+ * `docs/decisions/adr-077.md` and
10
+ * `docs/zusammen-editor-capability-map.md`.
11
+ *
12
+ * "Comment"/"thread" in these comments should be read as "annotation": this
13
+ * module now serves both annotation kinds, not comments alone.
14
+ */
15
+ /**
16
+ * Comment Anchor Marks (#43)
17
+ *
18
+ * Replaces `comment-anchor-mapping.ts`'s ordinal-block-index glue with a live
19
+ * `@lexical/mark` `MarkNode` per annotation id — the anchor moves with
20
+ * its text across in-app edits for free (Lexical splits/merges nodes around
21
+ * it), and serializes to nothing (see `lexicalToMdast.ts`'s
22
+ * `effectiveChildren`), so it never touches document content — an annotation is
23
+ * editor state, never bytes on disk.
24
+ *
25
+ * Two directions, both content-based rather than block-ordinal-based:
26
+ *
27
+ * - **Write** (capture, FR-006 re-anchor): a mark's own live plain text is
28
+ * *not* generally a verbatim substring of the document's current raw
29
+ * markdown — once the target contains or abuts bold/italic/inline-code/
30
+ * link syntax, the rendered text and the markdown diverge (#47 fixes the
31
+ * defect this caused: comments on formatted text spuriously flagging as
32
+ * changed on a completely unchanged document; see the corrected premise
33
+ * noted in `adrs/012-comment-anchor-as-live-mark.md`). Instead, a mark's
34
+ * raw-markdown range is recovered via {@link locateLiveMarkdownRange}'s
35
+ * annotated-serialize mechanism. A multi-block annotation shares one id
36
+ * across several sibling `MarkNode`s (the Lexical playground's own
37
+ * pattern); each is independently located, and the comment's overall
38
+ * range is the span from the first mark's start to the last mark's end —
39
+ * recovering exactly what
40
+ * {@link import('../../../shared/anchor-model').captureAnchor} would slice
41
+ * for a single-block annotation, and the same *kind* of raw-markdown slice
42
+ * (including whatever lies between the marks) for a multi-block one.
43
+ * - **Read** (load-time placement): an anchor's `targetText` is already the
44
+ * resolver's exact/resolved current text (main-process
45
+ * `resolveAnchorsForDocument` runs before marker targets ever reach the
46
+ * editor) — locate it once in the raw markdown via `locateInSpan`, map
47
+ * that offset range onto Lexical nodes via the parse-time `OffsetSpan[]`
48
+ * (boundary-snapping past any formatting-delimiter gap the target's edge
49
+ * falls on, #47), and wrap. No client-side fuzzy matching.
50
+ */
51
+ import { $createRangeSelection, $getNearestNodeFromDOMNode, $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, $isTextNode, $setSelection, } from 'lexical';
52
+ import { $isMarkNode, $unwrapMarkNode, $wrapSelectionInMarkNode } from '@lexical/mark';
53
+ import { captureAnchor } from '../../annotations/anchor-model.js';
54
+ import { locateInSpan } from '../../annotations/anchor-align.js';
55
+ import { exportLexicalToMdastInEditorState, markCloseToken, markOpenToken, setAnnotateTarget } from '../mapper/lexicalToMdast.js';
56
+ import { stringifyMarkdown } from '../../markdown/stringify.js';
57
+ /** Depth-first, document-order walk collecting every `MarkNode` in the tree. */
58
+ function collectMarkNodesInOrder(root) {
59
+ const result = [];
60
+ const visit = (node) => {
61
+ if ($isMarkNode(node))
62
+ result.push(node);
63
+ if ($isElementNode(node)) {
64
+ for (const child of node.getChildren())
65
+ visit(child);
66
+ }
67
+ };
68
+ visit(root);
69
+ return result;
70
+ }
71
+ /** Document-order live plain text of every `MarkNode` currently wrapping `id`. */
72
+ function liveMarkTexts(id) {
73
+ return collectMarkNodesInOrder($getRoot())
74
+ .filter((node) => node.hasID(id))
75
+ .map((node) => node.getTextContent());
76
+ }
77
+ /**
78
+ * Runs the annotated-serialize export for `id` (see `lexicalToMdast.ts`'s
79
+ * `setAnnotateTarget`) and discards everything except the resulting string —
80
+ * a throwaway mechanism purely for locating `id`'s live mark(s) in the
81
+ * markdown domain (#47). Must run inside an active `editor.update()`/
82
+ * `editor.getEditorState().read()`.
83
+ */
84
+ function exportAnnotatedMarkdown(id, exportOptions) {
85
+ setAnnotateTarget(id);
86
+ try {
87
+ return stringifyMarkdown(exportLexicalToMdastInEditorState(exportOptions));
88
+ }
89
+ finally {
90
+ setAnnotateTarget(null);
91
+ }
92
+ }
93
+ /**
94
+ * Locates an annotation's overall raw-markdown range from its live mark(s) —
95
+ * the write pathway, and the fix for #47: rather than searching for a mark's
96
+ * *rendered* plain text inside `markdownText` (unreliable once the target
97
+ * contains or abuts formatting — "quick" is not a substring of
98
+ * "**quick**...brown" once the closing delimiter falls inside the target),
99
+ * this brackets the mark's own content with sentinel tokens via an annotated
100
+ * serialize, then finds those tokens in the result. Annotate mode changes
101
+ * nothing else, so everything outside the sentinel-bracketed span is
102
+ * byte-identical to `markdownText` — meaning the first open token's own
103
+ * position in the annotated string *is* the mark's real start offset in
104
+ * `markdownText`, and the (sentinel-stripped) text between the outermost
105
+ * tokens *is* the exact raw-markdown slice the mark covers.
106
+ *
107
+ * A multi-block/multi-mark annotation (several sibling MarkNodes sharing `id`,
108
+ * e.g. spanning a heading and the paragraph after it) produces one
109
+ * open/close pair per mark instance; the range spans the earliest open to
110
+ * the latest close, with any of the *other* instances' own tokens landing
111
+ * inside that span stripped back out of the slice — recovering exactly what
112
+ * {@link captureAnchor} would slice for the equivalent single range,
113
+ * including whatever real markdown lies between the marks.
114
+ *
115
+ * Returns null if the annotation has no live mark, (defensively — should not
116
+ * happen for a live mark) its sentinel tokens can't be found, or (review
117
+ * finding, #47) `markdownText` turns out not to actually match the derived
118
+ * range — see the check below.
119
+ */
120
+ function locateLiveMarkdownRange(markdownText, id, exportOptions) {
121
+ if (collectMarkNodesInOrder($getRoot()).filter((node) => node.hasID(id)).length === 0)
122
+ return null;
123
+ const annotated = exportAnnotatedMarkdown(id, exportOptions);
124
+ const openToken = markOpenToken(id);
125
+ const closeToken = markCloseToken(id);
126
+ const firstOpen = annotated.indexOf(openToken);
127
+ const lastClose = annotated.lastIndexOf(closeToken);
128
+ if (firstOpen === -1 || lastClose === -1 || lastClose < firstOpen)
129
+ return null;
130
+ const start = firstOpen;
131
+ const rawSlice = annotated
132
+ .slice(firstOpen + openToken.length, lastClose)
133
+ .split(openToken)
134
+ .join('')
135
+ .split(closeToken)
136
+ .join('');
137
+ const end = start + rawSlice.length;
138
+ // The offset math above is only valid because annotate mode changes
139
+ // nothing else, so everything outside the sentinel-bracketed span is
140
+ // byte-identical to a *plain* export of this same live state — true by
141
+ // construction for every current caller (each derives `markdownText`
142
+ // fresh from the same read, or from a ref kept in sync with it), but not
143
+ // enforced by this function's own signature. If a caller ever passed a
144
+ // `markdownText` that had drifted from the live tree, `start`/`end` would
145
+ // silently point at the wrong substring rather than failing — verify
146
+ // before trusting them.
147
+ if (markdownText.slice(start, end) !== rawSlice)
148
+ return null;
149
+ return { start, end };
150
+ }
151
+ /** First text node in `node`'s subtree in document order (or `node` itself if it is one). */
152
+ function firstTextDescendant(node) {
153
+ if (node.nodeType === Node.TEXT_NODE)
154
+ return node;
155
+ for (let child = node.firstChild; child; child = child.nextSibling) {
156
+ const text = firstTextDescendant(child);
157
+ if (text)
158
+ return text;
159
+ }
160
+ return null;
161
+ }
162
+ /** Last text node in `node`'s subtree in document order (or `node` itself if it is one). */
163
+ function lastTextDescendant(node) {
164
+ if (node.nodeType === Node.TEXT_NODE)
165
+ return node;
166
+ for (let child = node.lastChild; child; child = child.previousSibling) {
167
+ const text = lastTextDescendant(child);
168
+ if (text)
169
+ return text;
170
+ }
171
+ return null;
172
+ }
173
+ /**
174
+ * Resolve one endpoint of a native DOM `Range` to a Lexical text point.
175
+ *
176
+ * A native Range boundary is `(container, offset)` where `offset` means a
177
+ * *character* index only when `container` is a text node. For selections that
178
+ * land on element boundaries — word double-clicks, drag-to-block-edge, a
179
+ * caret just before/after an inline span — Chromium reports `container` as the
180
+ * *element* and `offset` as a *child index*. The old code fed that child index
181
+ * straight in as a character offset, which is what silently truncated targets
182
+ * to fragments ("agentic platform product" → "platform product") and let a
183
+ * selection graze one character into the next block. Here we first normalize an
184
+ * element boundary down to a concrete text node — the start of the child it
185
+ * points before (`atEnd=false`) or the end of the child it points after
186
+ * (`atEnd=true`) — so `offset` is always a real character offset into a single
187
+ * rendered `TextNode`, then map that node to Lexical.
188
+ */
189
+ function resolveRangeEndpoint(container, offset, atEnd) {
190
+ let domNode = container;
191
+ let domOffset = offset;
192
+ if (domNode.nodeType === Node.ELEMENT_NODE) {
193
+ const children = domNode.childNodes;
194
+ if (children.length === 0)
195
+ return null;
196
+ if (domOffset >= children.length) {
197
+ const text = lastTextDescendant(children[children.length - 1]);
198
+ if (!text)
199
+ return null;
200
+ domNode = text;
201
+ domOffset = text.textContent?.length ?? 0;
202
+ }
203
+ else if (atEnd && domOffset > 0) {
204
+ // A boundary *after* child N-1 (end point): land at the end of that child.
205
+ const text = lastTextDescendant(children[domOffset - 1]);
206
+ if (!text)
207
+ return null;
208
+ domNode = text;
209
+ domOffset = text.textContent?.length ?? 0;
210
+ }
211
+ else {
212
+ // A boundary *before* child N (start point): land at the start of it.
213
+ const text = firstTextDescendant(children[domOffset]);
214
+ if (!text)
215
+ return null;
216
+ domNode = text;
217
+ domOffset = 0;
218
+ }
219
+ }
220
+ if (domNode.nodeType !== Node.TEXT_NODE)
221
+ return null;
222
+ const lexNode = $getNearestNodeFromDOMNode(domNode);
223
+ if (!$isTextNode(lexNode))
224
+ return null;
225
+ return { node: lexNode, offset: Math.min(domOffset, lexNode.getTextContentSize()) };
226
+ }
227
+ /**
228
+ * Push a selection's endpoints inward past any leading/trailing whitespace, so
229
+ * a drag that grazes the space before the next block (or after the previous
230
+ * one) never records a target whose enclosing block is ambiguous — the exact
231
+ * cause of a "…Protocol " capture resolving as "crossed a structural boundary"
232
+ * and flagging on reload. Only trims within the boundary text nodes themselves
233
+ * (whitespace at a selection's very edge always lives there); a no-op for a
234
+ * target that is already tight or is pure whitespace.
235
+ */
236
+ function trimSelectionWhitespace(selection) {
237
+ const text = selection.getTextContent();
238
+ const leading = text.length - text.trimStart().length;
239
+ const trailing = text.length - text.trimEnd().length;
240
+ if (leading + trailing >= text.length)
241
+ return; // all whitespace — leave as-is
242
+ // Resolve the document-order start/end from the selection's direction rather
243
+ // than assuming anchor==start: our caller always builds a forward selection,
244
+ // but keying the trim off `isBackward()` keeps this correct if it is ever
245
+ // reused for a backward one (pushing the wrong endpoint would corrupt it).
246
+ const isBackward = selection.isBackward();
247
+ const startPoint = isBackward ? selection.focus : selection.anchor;
248
+ const endPoint = isBackward ? selection.anchor : selection.focus;
249
+ const startNode = startPoint.getNode();
250
+ const endNode = endPoint.getNode();
251
+ if (leading > 0 && $isTextNode(startNode) && startPoint.offset + leading <= startNode.getTextContentSize()) {
252
+ startPoint.set(startPoint.key, startPoint.offset + leading, 'text');
253
+ }
254
+ if (trailing > 0 && $isTextNode(endNode) && endPoint.offset - trailing >= 0) {
255
+ endPoint.set(endPoint.key, endPoint.offset - trailing, 'text');
256
+ }
257
+ }
258
+ /**
259
+ * Wrap a native DOM Range in a `MarkNode` keyed by `id`. Fire-and-forget: the
260
+ * Lexical update runs the wrap, but its commit isn't observable synchronously
261
+ * (in controlled mode the callback runs on a later tick), so callers must NOT
262
+ * gate anything on a return value here — the anchor is read back from the live
263
+ * mark later via {@link readAnchorFields}.
264
+ */
265
+ export function wrapNativeRangeInMark(editor, range, id) {
266
+ editor.update(() => {
267
+ // A native Range (unlike a Selection) is always given in document
268
+ // order — start never comes after end — so start→anchor / end→focus
269
+ // map directly with no backward case to handle.
270
+ const start = resolveRangeEndpoint(range.startContainer, range.startOffset, false);
271
+ const end = resolveRangeEndpoint(range.endContainer, range.endOffset, true);
272
+ if (!start || !end)
273
+ return;
274
+ const selection = $createRangeSelection();
275
+ selection.anchor.set(start.node.getKey(), start.offset, 'text');
276
+ selection.focus.set(end.node.getKey(), end.offset, 'text');
277
+ trimSelectionWhitespace(selection);
278
+ if (selection.getTextContent().length === 0)
279
+ return; // nothing real selected
280
+ $setSelection(selection);
281
+ $wrapSelectionInMarkNode(selection, false, id);
282
+ }, { discrete: true });
283
+ }
284
+ /**
285
+ * Read an annotation's anchor fields directly from its live `MarkNode`(s) in the
286
+ * Lexical tree — targetText, surrounding context, block type, and an
287
+ * occurrence hint. The mark *is* the anchor; its position is known precisely
288
+ * from the node structure, so capture never depends on a (possibly stale)
289
+ * serialized-markdown string held elsewhere (`currentContentRef`) — this
290
+ * function derives its own current markdown export fresh, every call. The
291
+ * store stamps `docVersion`; the resolver (#25) still re-locates by content
292
+ * on future document versions.
293
+ *
294
+ * #47: targetText (and the context/blockType/occurrenceIndex derived from
295
+ * it) is a raw-*markdown* slice, not the mark's rendered plain text — see
296
+ * {@link locateLiveMarkdownRange}. Once the mark's exact raw-markdown range
297
+ * is known, {@link captureAnchor} (already markdown-domain-correct, and
298
+ * already used by the FR-006 re-anchor path) does the rest.
299
+ */
300
+ export function readAnchorFields(editor, id, exportOptions = {}) {
301
+ return editor.getEditorState().read(() => {
302
+ if (collectMarkNodesInOrder($getRoot()).filter((n) => n.hasID(id)).length === 0)
303
+ return null;
304
+ const markdownText = stringifyMarkdown(exportLexicalToMdastInEditorState(exportOptions));
305
+ const range = locateLiveMarkdownRange(markdownText, id, exportOptions);
306
+ if (!range)
307
+ return null;
308
+ const { docVersion: _docVersion, ...fields } = captureAnchor(markdownText, range, 'live');
309
+ return fields;
310
+ });
311
+ }
312
+ /**
313
+ * Read pathway: places a `MarkNode` for `anchor` by locating its (already
314
+ * resolved/current) `targetText` in `markdownText` and mapping the located
315
+ * offset range onto Lexical nodes via `offsetSpans` (the parse-time
316
+ * offset->node table from `importMarkdownToLexicalWithOffsets`). Idempotent:
317
+ * a no-op if `id` already has a live mark. Returns whether a mark now
318
+ * exists for `id` (already present, or just placed) — false only when the
319
+ * anchor's target can't be located or mapped, in which case the comment
320
+ * stays panel-only (FR-008), never placed on unrelated text.
321
+ */
322
+ export function placeMarkForAnchor(editor, offsetSpans, markdownText, anchor, id) {
323
+ // The single-entry case of the batch form, so there is exactly one placement
324
+ // code path (and one set of decline guards) to reason about.
325
+ return placeMarksForAnchors(editor, offsetSpans, markdownText, [{ anchor, id }]).length > 0;
326
+ }
327
+ /**
328
+ * Runs `fn` and puts the user's caret/selection back where it was.
329
+ *
330
+ * Placement moves the selection twice over: `$placeMarkForAnchor` sets the
331
+ * range it is about to wrap, and `$wrapSelectionInMarkNode` then collapses the
332
+ * selection into the mark it created (`selectStart`/`selectEnd`, at the end of
333
+ * its own implementation). Placement is a *background* operation — it runs
334
+ * whenever a re-parse invalidates the offset table, not in response to
335
+ * anything the user did — so without this the caret jumps into the last-placed
336
+ * mark while they are typing (review finding, CodeRabbit). Note that dropping
337
+ * our own `$setSelection` would not be enough on its own: the collapse inside
338
+ * `$wrapSelectionInMarkNode` is not ours to remove.
339
+ *
340
+ * The saved points are recorded as *absolute* character offsets across the
341
+ * document's text nodes, not as `(key, offset)` pairs. Wrapping splits text
342
+ * nodes — Lexical's `splitText` keeps the original key on the first segment —
343
+ * so a caret sitting after a placement would find its own key now holding far
344
+ * fewer characters, and a naive key-based restore would have to decline in
345
+ * exactly the ordinary case. Marking changes no text, so absolute offsets
346
+ * survive it unchanged and re-resolve cleanly against the post-placement tree.
347
+ *
348
+ * Non-text (element) points and a selection that can't be re-resolved are left
349
+ * alone: a stale caret would be worse than the jump this avoids.
350
+ */
351
+ function $withPreservedSelection(fn) {
352
+ const previous = $getSelection();
353
+ const saved = $isRangeSelection(previous)
354
+ ? {
355
+ anchor: $absoluteTextOffset(previous.anchor.key, previous.anchor.offset, previous.anchor.type),
356
+ focus: $absoluteTextOffset(previous.focus.key, previous.focus.offset, previous.focus.type),
357
+ format: previous.format,
358
+ style: previous.style,
359
+ }
360
+ : null;
361
+ const result = fn();
362
+ if (saved?.anchor != null && saved.focus != null) {
363
+ const anchorPoint = $pointAtAbsoluteTextOffset(saved.anchor);
364
+ const focusPoint = $pointAtAbsoluteTextOffset(saved.focus);
365
+ if (anchorPoint && focusPoint) {
366
+ const restored = $createRangeSelection();
367
+ restored.anchor.set(anchorPoint.key, anchorPoint.offset, 'text');
368
+ restored.focus.set(focusPoint.key, focusPoint.offset, 'text');
369
+ restored.format = saved.format;
370
+ restored.style = saved.style;
371
+ $setSelection(restored);
372
+ }
373
+ }
374
+ return result;
375
+ }
376
+ /**
377
+ * Position of a selection point as a character offset across every text node in
378
+ * the document, or null for a non-text point or a key no longer in the tree.
379
+ */
380
+ function $absoluteTextOffset(key, offset, type) {
381
+ if (type !== 'text')
382
+ return null;
383
+ let seen = 0;
384
+ for (const node of $getRoot().getAllTextNodes()) {
385
+ if (node.getKey() === key)
386
+ return seen + Math.min(offset, node.getTextContentSize());
387
+ seen += node.getTextContentSize();
388
+ }
389
+ return null;
390
+ }
391
+ /**
392
+ * Inverse of {@link $absoluteTextOffset} against the current tree.
393
+ *
394
+ * An offset that falls exactly on the seam between two adjacent text nodes is
395
+ * ambiguous — it is both the end of the earlier node and the start of the
396
+ * later one. `direction` resolves it the same way {@link pointAtMarkdownOffset}
397
+ * resolves the equivalent seam between two `OffsetSpan`s: an `end` point stops
398
+ * at the end of the earlier node, a `start` point lands at offset 0 of the
399
+ * later one. That matters for a target whose first rendered character opens an
400
+ * inline construct (`[project docs](…)` — the start snaps forward onto the
401
+ * link's own text node), where anchoring at the end of the *preceding* node
402
+ * would hand `$wrapSelectionInMarkNode` a zero-width leading segment.
403
+ */
404
+ function $pointAtAbsoluteTextOffset(absolute, direction = 'end') {
405
+ let seen = 0;
406
+ let last = null;
407
+ for (const node of $getRoot().getAllTextNodes()) {
408
+ const size = node.getTextContentSize();
409
+ const contains = direction === 'start' ? absolute < seen + size : absolute <= seen + size;
410
+ if (contains)
411
+ return { key: node.getKey(), offset: absolute - seen };
412
+ seen += size;
413
+ last = { key: node.getKey(), offset: size };
414
+ }
415
+ // Past the end of the document's text (it shrank): clamp to the last node
416
+ // rather than dropping the caret entirely.
417
+ return last;
418
+ }
419
+ /**
420
+ * Places many anchors inside a *single* `editor.update()`.
421
+ *
422
+ * Same per-anchor logic as {@link placeMarkForAnchor}, but one Lexical
423
+ * reconciliation for the whole batch instead of one per annotation — a
424
+ * document with dozens of live annotations otherwise pays a synchronous
425
+ * reconcile each, every time a re-parse invalidates the offset table (review
426
+ * finding, @handarbeit-pruefer). Returns the ids that now have a live mark, in
427
+ * the caller's own order.
428
+ *
429
+ * **Two phases, because `offsetSpans` describes only the pristine parse.**
430
+ * Placing a mark splits the `TextNode` it lands in, and Lexical's `splitText`
431
+ * keeps the original key on the *first* segment — so after one placement the
432
+ * table's `(nodeKey, offset)` pairs can point past the end of what that key now
433
+ * holds, and the guards below decline. An earlier fix ordered the batch back to
434
+ * front, which keeps every *disjoint* entry's span valid but does nothing for
435
+ * ranges that overlap, nest or coincide: there the later placement splits a node
436
+ * an *earlier* entry still needs, and that entry silently declines (Liminis
437
+ * #970 — an ordinary pair of overlapping annotations placed only one mark).
438
+ *
439
+ * The fix is to stop depending on the table surviving at all. Marking changes
440
+ * no text, so a point's *absolute* character offset across every text node in
441
+ * the document is invariant under placement — the same invariant
442
+ * {@link $withPreservedSelection} already relies on to put the caret back.
443
+ * So:
444
+ *
445
+ * 1. **Resolve**, against the pristine tree, before anything is mutated: run
446
+ * every entry through the offset table and today's decline guards, and
447
+ * convert each surviving endpoint to an absolute text offset.
448
+ * 2. **Place**, in the caller's order: re-resolve each absolute offset against
449
+ * the *current* tree and wrap.
450
+ *
451
+ * Every entry is therefore independent of every other, so the set of ids placed
452
+ * no longer depends on the order they were supplied in and the back-to-front
453
+ * sort is gone. Both phases run inside the one `editor.update()`.
454
+ */
455
+ export function placeMarksForAnchors(editor, offsetSpans, markdownText, entries) {
456
+ const placedIds = new Set();
457
+ if (entries.length === 0)
458
+ return [];
459
+ editor.update(() => {
460
+ $withPreservedSelection(() => {
461
+ // Phase 1 — resolve against the pristine tree.
462
+ const resolved = [];
463
+ for (const entry of entries) {
464
+ // Idempotent: an id that already has a live mark counts as placed and
465
+ // is not resolved again.
466
+ if (liveMarkTexts(entry.id).length > 0) {
467
+ placedIds.add(entry.id);
468
+ continue;
469
+ }
470
+ const range = $resolveAnchorPlacement(offsetSpans, markdownText, entry.anchor);
471
+ if (range)
472
+ resolved.push({ id: entry.id, ...range });
473
+ }
474
+ // Phase 2 — place, in caller order.
475
+ for (const placement of resolved) {
476
+ // Re-checked here as well as in phase 1: the same id can legitimately
477
+ // appear twice in one batch, and the first of the pair only becomes
478
+ // visible to `liveMarkTexts` once it has actually been placed.
479
+ if (liveMarkTexts(placement.id).length > 0) {
480
+ placedIds.add(placement.id);
481
+ continue;
482
+ }
483
+ if ($applyPlacement(placement.id, placement.start, placement.end)) {
484
+ placedIds.add(placement.id);
485
+ }
486
+ }
487
+ });
488
+ }, { discrete: true });
489
+ return entries.filter((entry) => placedIds.has(entry.id)).map((entry) => entry.id);
490
+ }
491
+ /**
492
+ * Phase 1 of {@link placeMarksForAnchors}: locate `anchor`'s target in
493
+ * `markdownText`, map it onto the tree through `offsetSpans`, and return the
494
+ * endpoints as *absolute* character offsets across the document's text nodes.
495
+ * Null for any anchor that can't be located or safely mapped — never a throw,
496
+ * so one declining entry can't poison the rest of its batch.
497
+ *
498
+ * Must run inside an active `editor.update()`/`read()` (the `$` prefix is
499
+ * Lexical's convention), and specifically before any of the batch's placements
500
+ * have mutated the tree: every guard below is a statement about the parse that
501
+ * produced `offsetSpans`.
502
+ */
503
+ function $resolveAnchorPlacement(offsetSpans, markdownText, anchor) {
504
+ const located = locateInSpan(markdownText, anchor.targetText, {
505
+ occurrenceIndex: anchor.occurrenceIndex,
506
+ prefixContext: anchor.prefixContext,
507
+ suffixContext: anchor.suffixContext,
508
+ });
509
+ if (!located)
510
+ return null;
511
+ const startPoint = pointAtMarkdownOffset(offsetSpans, located.start, 'start');
512
+ const endPoint = pointAtMarkdownOffset(offsetSpans, located.end, 'end');
513
+ if (!startPoint || !endPoint)
514
+ return null;
515
+ // An `OffsetSpan[]` is only valid for the parse that produced it: a
516
+ // re-import (`$getRoot().clear()` + rebuild) destroys every node key in
517
+ // it. Callers are expected to refresh the table with each parse — but a
518
+ // point built from a dead key would make `$wrapSelectionInMarkNode`
519
+ // throw on `Point.getNode()`, turning a caller's bookkeeping slip into a
520
+ // crash inside the editor. Decline instead, same as any other
521
+ // unmappable anchor.
522
+ const startNode = $getNodeByKey(startPoint.key);
523
+ const endNode = $getNodeByKey(endPoint.key);
524
+ if (!$isTextNode(startNode) || !$isTextNode(endNode))
525
+ return null;
526
+ // The offsets must also still fit the nodes they name. Nothing in this batch
527
+ // has split anything yet (that is what "phase 1" buys), so this now only
528
+ // catches a genuinely inconsistent table — but a mismatch here would make
529
+ // Lexical throw `$getTextNodeOffset: invalid offset` from inside the update
530
+ // rather than returning, so it stays a guard rather than an assertion.
531
+ if (startPoint.offset > startNode.getTextContentSize())
532
+ return null;
533
+ if (endPoint.offset > endNode.getTextContentSize())
534
+ return null;
535
+ // A start/end offset that both fall inside the same untracked gap (e.g.
536
+ // a fenced code block, which today has no OffsetSpan coverage at all —
537
+ // unrelated to and much wider than a formatting delimiter) would
538
+ // otherwise snap to two DIFFERENT spans on opposite sides of that gap:
539
+ // start forward past it, end backward before it — an inverted
540
+ // selection that $wrapSelectionInMarkNode would silently wrap as
541
+ // everything *between* them, i.e. the entire gap, not the intended
542
+ // target. Reject rather than mis-place; matches the safe no-mark
543
+ // fallback this replaced (FR-008).
544
+ if (startPoint.span.start > endPoint.span.start)
545
+ return null;
546
+ const start = $absoluteTextOffset(startPoint.key, startPoint.offset, 'text');
547
+ const end = $absoluteTextOffset(endPoint.key, endPoint.offset, 'text');
548
+ if (start === null || end === null || end < start)
549
+ return null;
550
+ return { start, end };
551
+ }
552
+ /**
553
+ * Phase 2 of {@link placeMarksForAnchors}: re-resolve a pair of absolute text
554
+ * offsets against the *current* tree — which earlier placements in the same
555
+ * batch may already have split — and wrap the resulting range in a `MarkNode`
556
+ * for `id`. Reports whether a mark actually landed.
557
+ */
558
+ function $applyPlacement(id, start, end) {
559
+ const startPoint = $pointAtAbsoluteTextOffset(start, 'start');
560
+ const endPoint = $pointAtAbsoluteTextOffset(end, 'end');
561
+ if (!startPoint || !endPoint)
562
+ return false;
563
+ const selection = $createRangeSelection();
564
+ selection.anchor.set(startPoint.key, startPoint.offset, 'text');
565
+ selection.focus.set(endPoint.key, endPoint.offset, 'text');
566
+ $setSelection(selection);
567
+ $wrapSelectionInMarkNode(selection, false, id);
568
+ // Not a formality: `$wrapSelectionInMarkNode` wraps whatever the selection
569
+ // *extracts*, and a collapsed selection extracts nothing, so it can return
570
+ // having created no mark at all. That happens for a target whose every
571
+ // character is markdown syntax rather than rendered text — `**` in
572
+ // `**bold**`, or a stale anchor left holding `](url)` — where both endpoints
573
+ // snap out of the same gap onto the same edge of the same span. Reporting
574
+ // that id as placed would be a lie the caller cannot detect: the surface
575
+ // records it in `placedRef` and never attempts it again, so the annotation
576
+ // stays markerless for the life of the document. Decline instead, the same
577
+ // as any other anchor this module cannot map (FR-004/FR-010).
578
+ return liveMarkTexts(id).length > 0;
579
+ }
580
+ /**
581
+ * Finds the `OffsetSpan` containing `offset` and returns the corresponding
582
+ * Lexical text point. Two passes: an offset shared by two adjacent spans
583
+ * (span A's exclusive `end` equals span B's `start`) prefers the span it's
584
+ * strictly *inside* of first, only falling back to the exclusive-end match
585
+ * for the offset at the very end of the last span — otherwise a start point
586
+ * at that boundary would land at the end of the preceding node instead of
587
+ * the start of the intended one.
588
+ *
589
+ * #47: `offsetSpans` has real, by-design gaps at every formatting delimiter
590
+ * (a bold/italic/code/link marker isn't rendered text, so it was never
591
+ * given a span) — and a markdown-slice target's boundary can legitimately
592
+ * land inside one of those gaps (e.g. "**quick**"'s start offset sits on the
593
+ * opening `**`). When neither pass above finds a containing/bordering span,
594
+ * `direction` resolves the gap: a `start` offset snaps forward to the
595
+ * nearest following span's own start (skip past leading syntax to the
596
+ * rendered text it wraps); an `end` offset snaps back to the nearest
597
+ * preceding span's own end (stop at the rendered text just before trailing
598
+ * syntax). No span exists in that direction (the gap is at a document/block
599
+ * edge): returns null, same as today's no-mark-placed fallback (FR-008).
600
+ *
601
+ * The matched `span` itself is returned alongside the point so
602
+ * {@link $resolveAnchorPlacement} can sanity-check that a start/end pair snapped
603
+ * to two spans in the wrong order (see its own doc) — a gap wide enough to
604
+ * contain other real spans (a fenced code block, today untracked entirely)
605
+ * can otherwise snap the start forward past it while the end snaps backward
606
+ * before it.
607
+ */
608
+ function pointAtMarkdownOffset(spans, offset, direction) {
609
+ for (const span of spans) {
610
+ if (offset >= span.start && offset < span.end) {
611
+ return { key: span.nodeKey, offset: offset - span.start, span };
612
+ }
613
+ }
614
+ for (const span of spans) {
615
+ if (offset === span.end) {
616
+ return { key: span.nodeKey, offset: offset - span.start, span };
617
+ }
618
+ }
619
+ if (direction === 'start') {
620
+ let best = null;
621
+ for (const span of spans) {
622
+ if (span.start >= offset && (!best || span.start < best.start))
623
+ best = span;
624
+ }
625
+ return best ? { key: best.nodeKey, offset: 0, span: best } : null;
626
+ }
627
+ let best = null;
628
+ for (const span of spans) {
629
+ if (span.end <= offset && (!best || span.end > best.end))
630
+ best = span;
631
+ }
632
+ return best ? { key: best.nodeKey, offset: best.end - best.start, span: best } : null;
633
+ }
634
+ /**
635
+ * FR-006: the current live range of every comment with a mark in the
636
+ * document, keyed by comment id — used at checkpoint time to decide which
637
+ * comments' anchors actually need re-recording (only those whose current
638
+ * range's text differs from their last durable `anchor.targetText`).
639
+ * Comments with no live mark (orphaned, flagged, or not yet resolved into
640
+ * the open document) are simply absent from the returned map.
641
+ */
642
+ export function collectLiveAnchorSnapshots(editor, markdownText, exportOptions = {}) {
643
+ return editor.getEditorState().read(() => {
644
+ const ids = new Set();
645
+ for (const markNode of collectMarkNodesInOrder($getRoot())) {
646
+ for (const id of markNode.getIDs())
647
+ ids.add(id);
648
+ }
649
+ const snapshots = new Map();
650
+ for (const id of ids) {
651
+ const range = locateLiveMarkdownRange(markdownText, id, exportOptions);
652
+ if (range)
653
+ snapshots.set(id, range);
654
+ }
655
+ return snapshots;
656
+ });
657
+ }
658
+ /** Whether `id` currently has a live mark anywhere in the document. */
659
+ export function hasLiveMark(editor, id) {
660
+ return editor.getEditorState().read(() => liveMarkTexts(id).length > 0);
661
+ }
662
+ /**
663
+ * The real, rendered DOM elements for every live `MarkNode` wrapping `id`,
664
+ * in document order — for `CommentMarkerPlugin` to decorate directly
665
+ * (outcome/active class, click/keydown navigation) instead of computing a
666
+ * synthetic overlay. Empty when `id` has no live mark (e.g. a `flagged`
667
+ * target, which never gets one placed — see `placeMarkForAnchor` callers).
668
+ */
669
+ export function markElementsForId(editor, id) {
670
+ return editor.getEditorState().read(() => {
671
+ const elements = [];
672
+ for (const markNode of collectMarkNodesInOrder($getRoot())) {
673
+ if (!markNode.hasID(id))
674
+ continue;
675
+ const element = editor.getElementByKey(markNode.getKey());
676
+ if (element)
677
+ elements.push(element);
678
+ }
679
+ return elements;
680
+ });
681
+ }
682
+ /**
683
+ * {@link markElementsForId} for many ids at once, in one tree walk.
684
+ *
685
+ * The marker plugin re-decorates on every editor update, and calling the
686
+ * single-id form per annotation makes that O(annotations x nodes) on a path
687
+ * that runs after each edit (review finding, @handarbeit-pruefer). One walk
688
+ * makes it O(nodes) regardless of how many annotations are live. Element lists
689
+ * are in document order, and ids with no live mark are simply absent.
690
+ */
691
+ export function markElementsByAnnotationId(editor, ids) {
692
+ const byId = new Map();
693
+ if (ids.size === 0)
694
+ return byId;
695
+ editor.getEditorState().read(() => {
696
+ for (const markNode of collectMarkNodesInOrder($getRoot())) {
697
+ const element = editor.getElementByKey(markNode.getKey());
698
+ if (!element)
699
+ continue;
700
+ for (const id of markNode.getIDs()) {
701
+ if (!ids.has(id))
702
+ continue;
703
+ const existing = byId.get(id);
704
+ if (existing)
705
+ existing.push(element);
706
+ else
707
+ byId.set(id, [element]);
708
+ }
709
+ }
710
+ });
711
+ return byId;
712
+ }
713
+ /**
714
+ * Removes every `MarkNode` wrapping `id` (annotation deleted, or composer
715
+ * cancelled before submit). A mark shared with other annotation ids
716
+ * (overlapping annotations) keeps its other ids and stays in the tree; a mark
717
+ * left with none is unwrapped back to plain content.
718
+ */
719
+ export function removeMarksForAnnotation(editor, id) {
720
+ removeMarksForAnnotations(editor, [id]);
721
+ }
722
+ /**
723
+ * Removes many annotations' marks inside a *single* `editor.update()`.
724
+ *
725
+ * The batched counterpart to {@link removeMarksForAnnotation}, for the same
726
+ * reason {@link placeMarksForAnchors} exists: a host that drops a set of live
727
+ * annotations at once — "resolve all comments", or an `offsetsVersion` bump
728
+ * that takes a batch of ids out of the marker targets — would otherwise force
729
+ * one synchronous Lexical reconciliation per id (review finding,
730
+ * @handarbeit-pruefer). One tree walk covers the whole set.
731
+ */
732
+ export function removeMarksForAnnotations(editor, ids) {
733
+ if (ids.length === 0)
734
+ return;
735
+ const targetIds = new Set(ids);
736
+ editor.update(() => {
737
+ for (const markNode of collectMarkNodesInOrder($getRoot())) {
738
+ let current = markNode;
739
+ let changed = false;
740
+ for (const id of markNode.getIDs()) {
741
+ if (!targetIds.has(id))
742
+ continue;
743
+ current = current.deleteID(id);
744
+ changed = true;
745
+ }
746
+ if (changed && current.getIDs().length === 0)
747
+ $unwrapMarkNode(current);
748
+ }
749
+ }, { discrete: true });
750
+ }