@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.
- package/LICENSE +34 -0
- package/README.md +376 -0
- package/dist/annotations/anchor-align.d.ts +50 -0
- package/dist/annotations/anchor-align.js +74 -0
- package/dist/annotations/anchor-model.d.ts +81 -0
- package/dist/annotations/anchor-model.js +80 -0
- package/dist/annotations/anchor-resolver.d.ts +92 -0
- package/dist/annotations/anchor-resolver.js +231 -0
- package/dist/annotations/block-structure.d.ts +65 -0
- package/dist/annotations/block-structure.js +113 -0
- package/dist/annotations/types.d.ts +159 -0
- package/dist/annotations/types.js +50 -0
- package/dist/annotations.d.ts +25 -0
- package/dist/annotations.js +27 -0
- package/dist/app/App.d.ts +64 -0
- package/dist/app/App.js +180 -0
- package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
- package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
- package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
- package/dist/app/editor/AnchorScrollPlugin.js +134 -0
- package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
- package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
- package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
- package/dist/app/editor/AnnotationPlugin.js +135 -0
- package/dist/app/editor/AnnotationSurface.d.ts +34 -0
- package/dist/app/editor/AnnotationSurface.js +115 -0
- package/dist/app/editor/AssetContext.d.ts +20 -0
- package/dist/app/editor/AssetContext.js +81 -0
- package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
- package/dist/app/editor/BlockClickPlugin.js +126 -0
- package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
- package/dist/app/editor/CodeBlockPlugin.js +219 -0
- package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
- package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
- package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
- package/dist/app/editor/DragHandlePlugin.js +300 -0
- package/dist/app/editor/Editor.d.ts +84 -0
- package/dist/app/editor/Editor.js +539 -0
- package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
- package/dist/app/editor/FrontmatterPlugin.js +301 -0
- package/dist/app/editor/ImageModal.d.ts +13 -0
- package/dist/app/editor/ImageModal.js +267 -0
- package/dist/app/editor/ImagePlugin.d.ts +3 -0
- package/dist/app/editor/ImagePlugin.js +187 -0
- package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
- package/dist/app/editor/LinkClickPlugin.js +90 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
- package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
- package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
- package/dist/app/editor/SearchPlugin.d.ts +1 -0
- package/dist/app/editor/SearchPlugin.js +379 -0
- package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
- package/dist/app/editor/SlashMenu.d.ts +11 -0
- package/dist/app/editor/SlashMenu.js +494 -0
- package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
- package/dist/app/editor/SlashMenuPlugin.js +168 -0
- package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
- package/dist/app/editor/TableActionsPlugin.js +398 -0
- package/dist/app/editor/TogglePlugin.d.ts +1 -0
- package/dist/app/editor/TogglePlugin.js +159 -0
- package/dist/app/editor/Toolbar.d.ts +10 -0
- package/dist/app/editor/Toolbar.js +263 -0
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
- package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
- package/dist/app/editor/annotation-marks.d.ts +177 -0
- package/dist/app/editor/annotation-marks.js +750 -0
- package/dist/app/editor/annotationCommands.d.ts +13 -0
- package/dist/app/editor/annotationCommands.js +11 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
- package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
- package/dist/app/editor/c4/edge-clipping.js +139 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
- package/dist/app/editor/c4/layout.d.ts +25 -0
- package/dist/app/editor/c4/layout.js +839 -0
- package/dist/app/editor/c4/parser.d.ts +19 -0
- package/dist/app/editor/c4/parser.js +410 -0
- package/dist/app/editor/c4/render-to-string.d.ts +24 -0
- package/dist/app/editor/c4/render-to-string.js +34 -0
- package/dist/app/editor/c4/renderer.d.ts +64 -0
- package/dist/app/editor/c4/renderer.js +569 -0
- package/dist/app/editor/c4/types.d.ts +203 -0
- package/dist/app/editor/c4/types.js +43 -0
- package/dist/app/editor/correction-yaml.d.ts +10 -0
- package/dist/app/editor/correction-yaml.js +40 -0
- package/dist/app/editor/editorNodes.d.ts +9 -0
- package/dist/app/editor/editorNodes.js +36 -0
- package/dist/app/editor/index.d.ts +1 -0
- package/dist/app/editor/index.js +1 -0
- package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
- package/dist/app/editor/mark-overlap-resolver.js +45 -0
- package/dist/app/editor/nodes/C4Component.d.ts +14 -0
- package/dist/app/editor/nodes/C4Component.js +284 -0
- package/dist/app/editor/nodes/C4Node.d.ts +32 -0
- package/dist/app/editor/nodes/C4Node.js +112 -0
- package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
- package/dist/app/editor/nodes/CalloutNode.js +94 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
- package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
- package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
- package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
- package/dist/app/editor/nodes/CustomListNode.js +54 -0
- package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
- package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
- package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
- package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
- package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
- package/dist/app/editor/nodes/EquationComponent.js +130 -0
- package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
- package/dist/app/editor/nodes/EquationNode.js +179 -0
- package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
- package/dist/app/editor/nodes/FootnoteNode.js +151 -0
- package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
- package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
- package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
- package/dist/app/editor/nodes/HtmlNode.js +121 -0
- package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
- package/dist/app/editor/nodes/ImageComponent.js +228 -0
- package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
- package/dist/app/editor/nodes/ImageNode.js +183 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
- package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
- package/dist/app/editor/nodes/MermaidComponent.js +216 -0
- package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
- package/dist/app/editor/nodes/MermaidNode.js +96 -0
- package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
- package/dist/app/editor/nodes/ToggleNode.js +237 -0
- package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
- package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
- package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
- package/dist/app/editor/nodes/diagram-utils.js +29 -0
- package/dist/app/editor/nodes/index.d.ts +30 -0
- package/dist/app/editor/nodes/index.js +17 -0
- package/dist/app/editor/utils.d.ts +2 -0
- package/dist/app/editor/utils.js +17 -0
- package/dist/app/mapper/index.d.ts +3 -0
- package/dist/app/mapper/index.js +2 -0
- package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
- package/dist/app/mapper/lexicalToMdast.js +1937 -0
- package/dist/app/mapper/mdastToLexical.d.ts +31 -0
- package/dist/app/mapper/mdastToLexical.js +1075 -0
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +8 -0
- package/dist/headless.d.ts +40 -0
- package/dist/headless.js +45 -0
- package/dist/host/context.d.ts +20 -0
- package/dist/host/context.js +24 -0
- package/dist/host/defaults.d.ts +21 -0
- package/dist/host/defaults.js +47 -0
- package/dist/host/messages.d.ts +26 -0
- package/dist/host/messages.js +47 -0
- package/dist/host/types.d.ts +75 -0
- package/dist/host/types.js +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51 -0
- package/dist/markdown/annotate-sentinels.d.ts +42 -0
- package/dist/markdown/annotate-sentinels.js +86 -0
- package/dist/markdown/parse.d.ts +56 -0
- package/dist/markdown/parse.js +516 -0
- package/dist/markdown/stringify.d.ts +7 -0
- package/dist/markdown/stringify.js +439 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.js +44 -0
- package/dist/mathjax-config.d.ts +89 -0
- package/dist/mathjax-config.js +133 -0
- package/dist/nodes.d.ts +34 -0
- package/dist/nodes.js +34 -0
- package/dist/stores/correctionStore.d.ts +15 -0
- package/dist/stores/correctionStore.js +12 -0
- package/dist/stores/editorStore.d.ts +16 -0
- package/dist/stores/editorStore.js +27 -0
- package/dist/styles.css +2477 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.js +91 -0
- package/dist/utils/file-types.d.ts +10 -0
- package/dist/utils/file-types.js +46 -0
- package/docs/annotations.md +134 -0
- package/docs/decisions/README.md +72 -0
- package/docs/decisions/adr-002.md +27 -0
- package/docs/decisions/adr-007.md +27 -0
- package/docs/decisions/adr-010.md +26 -0
- package/docs/decisions/adr-024.md +124 -0
- package/docs/decisions/adr-024b.md +0 -0
- package/docs/decisions/adr-025.md +103 -0
- package/docs/decisions/adr-027.md +66 -0
- package/docs/decisions/adr-057.md +67 -0
- package/docs/decisions/adr-070.md +131 -0
- package/docs/decisions/adr-075.md +410 -0
- package/docs/decisions/adr-076.md +183 -0
- package/docs/decisions/adr-077.md +410 -0
- package/docs/decisions/adr-078.md +243 -0
- package/docs/decisions/adr-079.md +167 -0
- package/docs/decisions/adr-080.md +325 -0
- package/docs/decisions/adr-081.md +108 -0
- package/docs/decisions/adr-082.md +123 -0
- package/docs/decisions/adr-083.md +204 -0
- package/docs/decisions/adr-084.md +179 -0
- package/docs/editor-api.md +106 -0
- package/docs/markdown-pipeline.md +282 -0
- package/docs/provenance.md +106 -0
- package/docs/zusammen-editor-capability-map.md +232 -0
- 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
|
+
}
|