@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,185 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { $getRoot, $isElementNode, $isTextNode } from 'lexical';
|
|
4
|
+
import { $isCodeNode } from '@lexical/code';
|
|
5
|
+
import { $isFrontmatterNode } from './nodes/index.js';
|
|
6
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
7
|
+
/** Return text content from prose nodes only (skips code blocks and frontmatter). */
|
|
8
|
+
function collectProseText(root) {
|
|
9
|
+
let text = '';
|
|
10
|
+
const walk = (node) => {
|
|
11
|
+
if ($isCodeNode(node) || $isFrontmatterNode(node))
|
|
12
|
+
return;
|
|
13
|
+
if ($isTextNode(node)) {
|
|
14
|
+
text += node.getTextContent();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if ($isElementNode(node)) {
|
|
18
|
+
for (const child of node.getChildren())
|
|
19
|
+
walk(child);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
walk(root);
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
/** True if the char is a word character (letter, digit, or underscore). */
|
|
26
|
+
function isWordChar(c) {
|
|
27
|
+
return /\w/.test(c);
|
|
28
|
+
}
|
|
29
|
+
/** True if `region` consists only of word characters (single word token). */
|
|
30
|
+
function isSingleWordToken(region) {
|
|
31
|
+
return region.length > 0 && /^\w+$/.test(region);
|
|
32
|
+
}
|
|
33
|
+
function escapeRegex(s) {
|
|
34
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Preserve the original capitalisation pattern of a matched word when replacing.
|
|
38
|
+
* - ALL-CAPS → ALL-CAPS replacement
|
|
39
|
+
* - Title-case → Title-case replacement
|
|
40
|
+
* - otherwise → lowercase replacement
|
|
41
|
+
*/
|
|
42
|
+
function applyCase(match, replacement) {
|
|
43
|
+
if (match === match.toUpperCase() && match !== match.toLowerCase()) {
|
|
44
|
+
return replacement.toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
const firstChar = match.charAt(0);
|
|
47
|
+
if (match.length > 0 &&
|
|
48
|
+
firstChar.toUpperCase() === firstChar &&
|
|
49
|
+
firstChar.toLowerCase() !== firstChar &&
|
|
50
|
+
match.slice(1) === match.slice(1).toLowerCase()) {
|
|
51
|
+
return replacement.length > 0
|
|
52
|
+
? replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase()
|
|
53
|
+
: replacement;
|
|
54
|
+
}
|
|
55
|
+
return replacement.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
// ── Substitution detection ────────────────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Analyse a before/after text pair to detect a single-word substitution.
|
|
60
|
+
* Returns `{ oldTerm, newTerm }` when a valid substitution is detected, or null.
|
|
61
|
+
*/
|
|
62
|
+
function analyzeForSubstitution(prev, current) {
|
|
63
|
+
if (prev === current)
|
|
64
|
+
return null;
|
|
65
|
+
// Find common prefix
|
|
66
|
+
let prefixLen = 0;
|
|
67
|
+
const minLen = Math.min(prev.length, current.length);
|
|
68
|
+
while (prefixLen < minLen && prev[prefixLen] === current[prefixLen])
|
|
69
|
+
prefixLen++;
|
|
70
|
+
// Find common suffix (must not overlap with prefix)
|
|
71
|
+
let suffixLen = 0;
|
|
72
|
+
const maxSuffix = minLen - prefixLen;
|
|
73
|
+
while (suffixLen < maxSuffix &&
|
|
74
|
+
prev[prev.length - 1 - suffixLen] === current[current.length - 1 - suffixLen]) {
|
|
75
|
+
suffixLen++;
|
|
76
|
+
}
|
|
77
|
+
const oldRegion = prev.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined);
|
|
78
|
+
const newRegion = current.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined);
|
|
79
|
+
if (!isSingleWordToken(oldRegion) || !isSingleWordToken(newRegion))
|
|
80
|
+
return null;
|
|
81
|
+
if (oldRegion.toLowerCase() === newRegion.toLowerCase())
|
|
82
|
+
return null;
|
|
83
|
+
// Must sit at word boundaries in the prose text
|
|
84
|
+
const charBefore = prefixLen > 0 ? prev[prefixLen - 1] : null;
|
|
85
|
+
const charAfter = prev.length - suffixLen < prev.length ? prev[prev.length - suffixLen] : null;
|
|
86
|
+
if (charBefore !== null && isWordChar(charBefore))
|
|
87
|
+
return null;
|
|
88
|
+
if (charAfter !== null && isWordChar(charAfter))
|
|
89
|
+
return null;
|
|
90
|
+
return { oldTerm: oldRegion, newTerm: newRegion };
|
|
91
|
+
}
|
|
92
|
+
// ── Plugin ────────────────────────────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* Ambient correction plugin.
|
|
95
|
+
*
|
|
96
|
+
* 1. Detects single-word substitutions (debounced 300 ms) and calls
|
|
97
|
+
* `onSubstitutionDetected` so the parent can show a nudge toast.
|
|
98
|
+
*
|
|
99
|
+
* 2. Populates `sweepRef.current` with a function that performs the confirmed
|
|
100
|
+
* sweep: replaces all remaining occurrences of `oldTerm` with `newTerm`
|
|
101
|
+
* (case-preserving, skipping code blocks and frontmatter) inside a single
|
|
102
|
+
* `editor.update()` call so undo produces one history entry.
|
|
103
|
+
*/
|
|
104
|
+
export function AmbientCorrectionPlugin({ onSubstitutionDetected, sweepRef, }) {
|
|
105
|
+
const [editor] = useLexicalComposerContext();
|
|
106
|
+
const debounceRef = useRef(null);
|
|
107
|
+
const callbackRef = useRef(onSubstitutionDetected);
|
|
108
|
+
callbackRef.current = onSubstitutionDetected;
|
|
109
|
+
// Populate the sweep ref so the parent can trigger a sweep from outside.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!sweepRef)
|
|
112
|
+
return;
|
|
113
|
+
sweepRef.current = (oldTerm, newTerm) => new Promise((resolve) => {
|
|
114
|
+
let count = 0;
|
|
115
|
+
const escaped = escapeRegex(oldTerm);
|
|
116
|
+
const regex = new RegExp(`\\b${escaped}\\b`, 'gi');
|
|
117
|
+
editor.update(() => {
|
|
118
|
+
// Tree-walker: skip entire code/frontmatter subtrees rather than
|
|
119
|
+
// checking ancestors per-node (O(N) vs O(N * depth)).
|
|
120
|
+
const walk = (node) => {
|
|
121
|
+
if ($isCodeNode(node) || $isFrontmatterNode(node))
|
|
122
|
+
return;
|
|
123
|
+
if ($isTextNode(node)) {
|
|
124
|
+
const text = node.getTextContent();
|
|
125
|
+
const replaced = text.replace(regex, (match) => {
|
|
126
|
+
count++;
|
|
127
|
+
return applyCase(match, newTerm);
|
|
128
|
+
});
|
|
129
|
+
if (replaced !== text)
|
|
130
|
+
node.setTextContent(replaced);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if ($isElementNode(node)) {
|
|
134
|
+
for (const child of node.getChildren())
|
|
135
|
+
walk(child);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
walk($getRoot());
|
|
139
|
+
}, { discrete: true, onUpdate: () => resolve(count) });
|
|
140
|
+
});
|
|
141
|
+
return () => {
|
|
142
|
+
sweepRef.current = null;
|
|
143
|
+
};
|
|
144
|
+
}, [editor, sweepRef]);
|
|
145
|
+
// Register change listener for substitution detection.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const unregister = editor.registerUpdateListener(({ editorState, prevEditorState, dirtyElements, dirtyLeaves }) => {
|
|
148
|
+
// Skip pure selection changes that don't touch content nodes.
|
|
149
|
+
if (dirtyLeaves.size === 0 && dirtyElements.size === 0)
|
|
150
|
+
return;
|
|
151
|
+
if (debounceRef.current !== null)
|
|
152
|
+
clearTimeout(debounceRef.current);
|
|
153
|
+
// Capture editor state refs — defer expensive getTextContent() reads
|
|
154
|
+
// until the debounce fires, so we don't read full document on every keystroke.
|
|
155
|
+
const capturedState = editorState;
|
|
156
|
+
const capturedPrevState = prevEditorState;
|
|
157
|
+
debounceRef.current = setTimeout(() => {
|
|
158
|
+
debounceRef.current = null;
|
|
159
|
+
// Compare prose-only text (skipping code blocks and frontmatter) so that:
|
|
160
|
+
// - substitutions inside excluded regions don't fire (false positives)
|
|
161
|
+
// - terms that appear in code blocks don't suppress prose detection (false negatives)
|
|
162
|
+
let currentProseText = '';
|
|
163
|
+
let prevProseText = '';
|
|
164
|
+
capturedState.read(() => {
|
|
165
|
+
currentProseText = collectProseText($getRoot());
|
|
166
|
+
});
|
|
167
|
+
capturedPrevState.read(() => {
|
|
168
|
+
prevProseText = collectProseText($getRoot());
|
|
169
|
+
});
|
|
170
|
+
const result = analyzeForSubstitution(prevProseText, currentProseText);
|
|
171
|
+
if (result) {
|
|
172
|
+
callbackRef.current(result.oldTerm, result.newTerm);
|
|
173
|
+
}
|
|
174
|
+
}, 300);
|
|
175
|
+
});
|
|
176
|
+
return () => {
|
|
177
|
+
unregister();
|
|
178
|
+
if (debounceRef.current !== null) {
|
|
179
|
+
clearTimeout(debounceRef.current);
|
|
180
|
+
debounceRef.current = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}, [editor]);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnchorScrollPlugin - Scrolls to headings when anchor links are clicked
|
|
3
|
+
*
|
|
4
|
+
* This plugin listens for host "scroll to anchor" requests (raised when an anchor
|
|
5
|
+
* link like `[[#Heading Name]]` is clicked) and scrolls to the matching heading in
|
|
6
|
+
* the document.
|
|
7
|
+
*
|
|
8
|
+
* The plugin:
|
|
9
|
+
* 1. Subscribes via the injected `onScrollToAnchor` host service
|
|
10
|
+
* 2. Finds headings by GitHub-style slug matching (case-insensitive, punctuation-stripped)
|
|
11
|
+
* 3. Retries across animation frames (bounded) while async content (equations,
|
|
12
|
+
* diagrams, code highlighting) is still rendering the target heading
|
|
13
|
+
* 4. Scrolls the discovered scrollable ancestor, positioning the heading near the top
|
|
14
|
+
* 5. Cleans up the subscription and any in-flight retry loop on unmount
|
|
15
|
+
*/
|
|
16
|
+
export declare function AnchorScrollPlugin(): null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { useEditorHost } from '../../host/context.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reduce text to a GitHub-style anchor slug so a link fragment matches a heading
|
|
6
|
+
* regardless of punctuation the anchor drops. GitHub lowercases, removes
|
|
7
|
+
* punctuation (dots, backticks, `?`, emoji, …), and turns spaces into hyphens —
|
|
8
|
+
* e.g. a heading "Rebase and migration from `.env.enc`" slugs to
|
|
9
|
+
* `rebase-and-migration-from-envenc`, matching that link fragment. Applying the
|
|
10
|
+
* same reduction to both the anchor and each heading makes them comparable.
|
|
11
|
+
* `\p{L}\p{N}` (rather than `a-z0-9`) keeps non-Latin letters — e.g. "Übersicht"
|
|
12
|
+
* or "介绍" — instead of stripping them to nothing, which would make such
|
|
13
|
+
* headings permanently unreachable and collide with unrelated ASCII headings.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeForMatch(text) {
|
|
16
|
+
return text
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^\p{L}\p{N}\s\-_]/gu, '') // drop punctuation/emoji GitHub strips
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/[\s-]+/g, '-') // spaces and dash runs → single hyphen
|
|
21
|
+
.replace(/^-+|-+$/g, ''); // trim stray hyphens
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Find the scrollable container for a heading: the known editor scroll ids first,
|
|
25
|
+
* then the nearest ancestor that actually scrolls (robust to host markup).
|
|
26
|
+
*/
|
|
27
|
+
function scrollContainerFor(heading) {
|
|
28
|
+
// Assumes the two known ids are never nested inside one another; neither
|
|
29
|
+
// known host does. If that ever changes, the closer one should win instead.
|
|
30
|
+
const byId = heading.closest('#editor-scroll-container') ||
|
|
31
|
+
heading.closest('#editor-panel-scroll-container');
|
|
32
|
+
if (byId)
|
|
33
|
+
return byId;
|
|
34
|
+
let el = heading.parentElement;
|
|
35
|
+
while (el) {
|
|
36
|
+
const overflowY = getComputedStyle(el).overflowY;
|
|
37
|
+
if (/(auto|scroll)/.test(overflowY) && el.scrollHeight > el.clientHeight)
|
|
38
|
+
return el;
|
|
39
|
+
el = el.parentElement;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Scroll the editor to the heading whose normalized text matches `anchor`.
|
|
45
|
+
* Returns true if a matching heading was found and scrolled — false if not (yet)
|
|
46
|
+
* present, so the caller can retry while async content finishes rendering.
|
|
47
|
+
*/
|
|
48
|
+
function scrollToHeading(editor, anchor) {
|
|
49
|
+
const normalizedAnchor = normalizeForMatch(anchor);
|
|
50
|
+
if (!normalizedAnchor)
|
|
51
|
+
return false;
|
|
52
|
+
const rootElement = editor.getRootElement();
|
|
53
|
+
if (!rootElement)
|
|
54
|
+
return false;
|
|
55
|
+
const selector = [
|
|
56
|
+
'.editor-heading-h1',
|
|
57
|
+
'.editor-heading-h2',
|
|
58
|
+
'.editor-heading-h3',
|
|
59
|
+
'.editor-heading-h4',
|
|
60
|
+
'.editor-heading-h5',
|
|
61
|
+
].join(',');
|
|
62
|
+
const headings = Array.from(rootElement.querySelectorAll(selector));
|
|
63
|
+
const heading = headings.find((h) => normalizeForMatch(h.textContent || '') === normalizedAnchor);
|
|
64
|
+
if (!heading)
|
|
65
|
+
return false;
|
|
66
|
+
const container = scrollContainerFor(heading);
|
|
67
|
+
if (container) {
|
|
68
|
+
// rect-based offset is robust regardless of the heading's offsetParent;
|
|
69
|
+
// leave a small margin so the heading sits near the top, not flush against it.
|
|
70
|
+
const MARGIN = 16;
|
|
71
|
+
const top = Math.max(0, heading.getBoundingClientRect().top -
|
|
72
|
+
container.getBoundingClientRect().top +
|
|
73
|
+
container.scrollTop -
|
|
74
|
+
MARGIN);
|
|
75
|
+
container.scrollTo({ top, behavior: 'smooth' });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* AnchorScrollPlugin - Scrolls to headings when anchor links are clicked
|
|
84
|
+
*
|
|
85
|
+
* This plugin listens for host "scroll to anchor" requests (raised when an anchor
|
|
86
|
+
* link like `[[#Heading Name]]` is clicked) and scrolls to the matching heading in
|
|
87
|
+
* the document.
|
|
88
|
+
*
|
|
89
|
+
* The plugin:
|
|
90
|
+
* 1. Subscribes via the injected `onScrollToAnchor` host service
|
|
91
|
+
* 2. Finds headings by GitHub-style slug matching (case-insensitive, punctuation-stripped)
|
|
92
|
+
* 3. Retries across animation frames (bounded) while async content (equations,
|
|
93
|
+
* diagrams, code highlighting) is still rendering the target heading
|
|
94
|
+
* 4. Scrolls the discovered scrollable ancestor, positioning the heading near the top
|
|
95
|
+
* 5. Cleans up the subscription and any in-flight retry loop on unmount
|
|
96
|
+
*/
|
|
97
|
+
export function AnchorScrollPlugin() {
|
|
98
|
+
const [editor] = useLexicalComposerContext();
|
|
99
|
+
const { onScrollToAnchor } = useEditorHost();
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
// Early exit if the host does not offer anchor scrolling
|
|
102
|
+
if (!onScrollToAnchor) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let raf = 0;
|
|
106
|
+
const unsubscribe = onScrollToAnchor((anchor) => {
|
|
107
|
+
// A new anchor emission supersedes any retry loop still in flight for a
|
|
108
|
+
// previous one.
|
|
109
|
+
cancelAnimationFrame(raf);
|
|
110
|
+
// Normalization is independent of DOM timing, so an anchor that
|
|
111
|
+
// normalizes to empty (e.g. punctuation-only) can never match no
|
|
112
|
+
// matter how long we retry — skip the animation-frame budget entirely.
|
|
113
|
+
if (!normalizeForMatch(anchor))
|
|
114
|
+
return;
|
|
115
|
+
// attempts counts this call before scrollToHeading runs, so the budget
|
|
116
|
+
// below caps the loop at exactly 30 total calls (1 immediate + up to 29
|
|
117
|
+
// retries), not 31.
|
|
118
|
+
let attempts = 0;
|
|
119
|
+
const tryScroll = () => {
|
|
120
|
+
attempts++;
|
|
121
|
+
if (scrollToHeading(editor, anchor))
|
|
122
|
+
return;
|
|
123
|
+
if (attempts < 30)
|
|
124
|
+
raf = requestAnimationFrame(tryScroll);
|
|
125
|
+
};
|
|
126
|
+
tryScroll();
|
|
127
|
+
});
|
|
128
|
+
return () => {
|
|
129
|
+
cancelAnimationFrame(raf);
|
|
130
|
+
unsubscribe();
|
|
131
|
+
};
|
|
132
|
+
}, [editor, onScrollToAnchor]);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnnotationKindConfigs, MarkerTarget } from '../../annotations/types.js';
|
|
2
|
+
interface AnnotationMarkerPluginProps {
|
|
3
|
+
targets: MarkerTarget[];
|
|
4
|
+
kinds: AnnotationKindConfigs;
|
|
5
|
+
activeAnnotationId: string | null;
|
|
6
|
+
onActivateAnnotation: (id: string) => void;
|
|
7
|
+
/** Host-driven signal to scroll to an annotation's marker (nonce forces re-scroll on repeats). */
|
|
8
|
+
scrollToAnnotation?: {
|
|
9
|
+
id: string;
|
|
10
|
+
nonce: number;
|
|
11
|
+
} | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Decorates the real `MarkNode` DOM elements placed by `annotation-marks.ts`
|
|
15
|
+
* with per-kind styling and click/keydown activation — never a separate
|
|
16
|
+
* overlay layer.
|
|
17
|
+
*
|
|
18
|
+
* Only targets that earned a live mark are decorated; `flagged`/`orphaned`
|
|
19
|
+
* anchors, and any kind whose `livemarkPolicy` declines, are simply absent
|
|
20
|
+
* from the DOM here and stay panel-only. Activation routes back to the host,
|
|
21
|
+
* which owns whatever panel or thread the annotation belongs to.
|
|
22
|
+
*/
|
|
23
|
+
export declare function AnnotationMarkerPlugin({ targets, kinds, activeAnnotationId, onActivateAnnotation, scrollToAnnotation, }: AnnotationMarkerPluginProps): null;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { markElementsByAnnotationId, markElementsForId } from './annotation-marks.js';
|
|
4
|
+
const ACTIVE_CLASS = 'annotation-mark-active';
|
|
5
|
+
const PULSE_CLASS = 'annotation-mark-pulse';
|
|
6
|
+
/**
|
|
7
|
+
* Decorates the real `MarkNode` DOM elements placed by `annotation-marks.ts`
|
|
8
|
+
* with per-kind styling and click/keydown activation — never a separate
|
|
9
|
+
* overlay layer.
|
|
10
|
+
*
|
|
11
|
+
* Only targets that earned a live mark are decorated; `flagged`/`orphaned`
|
|
12
|
+
* anchors, and any kind whose `livemarkPolicy` declines, are simply absent
|
|
13
|
+
* from the DOM here and stay panel-only. Activation routes back to the host,
|
|
14
|
+
* which owns whatever panel or thread the annotation belongs to.
|
|
15
|
+
*/
|
|
16
|
+
export function AnnotationMarkerPlugin({ targets, kinds, activeAnnotationId, onActivateAnnotation, scrollToAnnotation, }) {
|
|
17
|
+
const [editor] = useLexicalComposerContext();
|
|
18
|
+
// Cleanups from the previous decoration pass, run before the next one —
|
|
19
|
+
// avoids leaking listeners/attributes onto an element a since-removed or
|
|
20
|
+
// since-recreated target no longer covers.
|
|
21
|
+
const cleanupsRef = useRef([]);
|
|
22
|
+
const decorate = useCallback(() => {
|
|
23
|
+
const cleanups = cleanupsRef.current;
|
|
24
|
+
for (const cleanup of cleanups)
|
|
25
|
+
cleanup();
|
|
26
|
+
cleanups.length = 0;
|
|
27
|
+
// Group by DOM element before decorating, rather than iterating targets.
|
|
28
|
+
// Overlapping annotations share one MarkNode — and therefore one element —
|
|
29
|
+
// by design (`removeMarksForAnnotation` keeps a mark alive while it still
|
|
30
|
+
// carries other ids). Decorating per-target would attach a second listener
|
|
31
|
+
// pair to that shared element, so one click fired `onActivateAnnotation`
|
|
32
|
+
// once per overlapping annotation, and title/aria-label/data-kind/active
|
|
33
|
+
// would each reflect only whichever target happened to be processed last.
|
|
34
|
+
const decoratable = targets.filter((target) => {
|
|
35
|
+
const config = kinds[target.kind];
|
|
36
|
+
return !!config && config.markerStyle !== 'none';
|
|
37
|
+
});
|
|
38
|
+
// One tree walk for the whole set, not one per target — this runs after
|
|
39
|
+
// every content edit, so the single-id lookup would make it
|
|
40
|
+
// O(annotations x nodes) per keystroke.
|
|
41
|
+
const elementsById = markElementsByAnnotationId(editor, new Set(decoratable.map((target) => target.annotationId)));
|
|
42
|
+
const byElement = new Map();
|
|
43
|
+
for (const target of decoratable) {
|
|
44
|
+
for (const element of elementsById.get(target.annotationId) ?? []) {
|
|
45
|
+
const existing = byElement.get(element);
|
|
46
|
+
if (existing)
|
|
47
|
+
existing.push(target);
|
|
48
|
+
else
|
|
49
|
+
byElement.set(element, [target]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [element, elementTargets] of byElement) {
|
|
53
|
+
// Visual classes are additive — every annotation covering this element
|
|
54
|
+
// contributes its kind's marker style and any host-supplied className, so
|
|
55
|
+
// an overlap can be styled as one.
|
|
56
|
+
const classes = new Set();
|
|
57
|
+
for (const target of elementTargets) {
|
|
58
|
+
classes.add(`annotation-mark-${kinds[target.kind].markerStyle}`);
|
|
59
|
+
const extra = target.presentation?.className;
|
|
60
|
+
if (extra)
|
|
61
|
+
classes.add(extra);
|
|
62
|
+
}
|
|
63
|
+
// Identity and behaviour are single-valued, so one target has to speak
|
|
64
|
+
// for the element: the active annotation if one of them is active,
|
|
65
|
+
// otherwise the first in document order. Activation fires exactly once.
|
|
66
|
+
const representative = elementTargets.find((t) => t.annotationId === activeAnnotationId) ?? elementTargets[0];
|
|
67
|
+
const isActive = elementTargets.some((t) => t.annotationId === activeAnnotationId);
|
|
68
|
+
const baseLabel = representative.presentation?.label ?? representative.kind;
|
|
69
|
+
// A host-supplied `presentation.label` wins over the kind default; the
|
|
70
|
+
// flagged suffix still applies so the uncertainty isn't hidden by an
|
|
71
|
+
// override, and an overlap says so rather than silently naming one of them.
|
|
72
|
+
const flaggedLabel = representative.outcome === 'flagged'
|
|
73
|
+
? `${baseLabel} — location uncertain since this text changed`
|
|
74
|
+
: baseLabel;
|
|
75
|
+
const label = elementTargets.length > 1
|
|
76
|
+
? `${flaggedLabel} (+${elementTargets.length - 1} more here)`
|
|
77
|
+
: flaggedLabel;
|
|
78
|
+
for (const className of classes)
|
|
79
|
+
element.classList.add(className);
|
|
80
|
+
element.classList.toggle(ACTIVE_CLASS, isActive);
|
|
81
|
+
element.dataset.annotationKind = representative.kind;
|
|
82
|
+
element.title = label;
|
|
83
|
+
element.tabIndex = 0;
|
|
84
|
+
element.setAttribute('role', 'button');
|
|
85
|
+
element.setAttribute('aria-label', label);
|
|
86
|
+
const activate = () => onActivateAnnotation(representative.annotationId);
|
|
87
|
+
const handleKeydown = (e) => {
|
|
88
|
+
if (e.key !== 'Enter' && e.key !== ' ')
|
|
89
|
+
return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
activate();
|
|
92
|
+
};
|
|
93
|
+
element.addEventListener('click', activate);
|
|
94
|
+
element.addEventListener('keydown', handleKeydown);
|
|
95
|
+
cleanups.push(() => {
|
|
96
|
+
element.classList.remove(ACTIVE_CLASS);
|
|
97
|
+
for (const className of classes)
|
|
98
|
+
element.classList.remove(className);
|
|
99
|
+
delete element.dataset.annotationKind;
|
|
100
|
+
element.removeAttribute('title');
|
|
101
|
+
element.removeAttribute('aria-label');
|
|
102
|
+
element.removeAttribute('role');
|
|
103
|
+
element.removeAttribute('tabindex');
|
|
104
|
+
element.removeEventListener('click', activate);
|
|
105
|
+
element.removeEventListener('keydown', handleKeydown);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}, [editor, targets, kinds, activeAnnotationId, onActivateAnnotation]);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
decorate();
|
|
111
|
+
}, [decorate]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
|
|
114
|
+
// Selection-only updates cannot move or recreate a mark's element, so
|
|
115
|
+
// there is nothing to re-decorate. Lexical fires this listener on every
|
|
116
|
+
// cursor move as well as every edit, and decoration tears down and
|
|
117
|
+
// rebuilds each element's listeners and attributes — so skipping the
|
|
118
|
+
// no-op case takes arrow-key navigation off that path entirely
|
|
119
|
+
// (review finding, @handarbeit-pruefer).
|
|
120
|
+
if (dirtyElements.size === 0 && dirtyLeaves.size === 0)
|
|
121
|
+
return;
|
|
122
|
+
decorate();
|
|
123
|
+
});
|
|
124
|
+
}, [editor, decorate]);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
// Captured here rather than read in the cleanup: the ref's *identity* is
|
|
127
|
+
// stable for the component's lifetime (decorate mutates the array in
|
|
128
|
+
// place, never reassigns), so this holds the same array the last
|
|
129
|
+
// decoration pass filled.
|
|
130
|
+
const cleanups = cleanupsRef.current;
|
|
131
|
+
return () => {
|
|
132
|
+
for (const cleanup of cleanups)
|
|
133
|
+
cleanup();
|
|
134
|
+
cleanups.length = 0;
|
|
135
|
+
};
|
|
136
|
+
}, []);
|
|
137
|
+
// Smooth-scroll to an annotation's marker, keep it centered as the layout
|
|
138
|
+
// settles, and pulse it on arrival. Scrolling once lands short on a fresh
|
|
139
|
+
// open: async content above the anchor (images loading their height,
|
|
140
|
+
// diagrams/syntax highlighting rendering) shifts the anchor down *after* the
|
|
141
|
+
// scroll, and that timing is variable. Rather than guess when layout is
|
|
142
|
+
// "done", react to it — scroll once the marker exists, then re-center on
|
|
143
|
+
// every content resize for a short window.
|
|
144
|
+
//
|
|
145
|
+
// An unknown or orphaned id has no marker element, so `center` never
|
|
146
|
+
// succeeds and this quietly gives up after the appear window: a no-op, not
|
|
147
|
+
// an error.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const id = scrollToAnnotation?.id;
|
|
150
|
+
if (!id)
|
|
151
|
+
return;
|
|
152
|
+
let raf = 0;
|
|
153
|
+
let appearFrames = 0;
|
|
154
|
+
let stopTimer = 0;
|
|
155
|
+
let pulseTimer = 0;
|
|
156
|
+
let observer = null;
|
|
157
|
+
let visibility = null;
|
|
158
|
+
const center = (behavior) => {
|
|
159
|
+
const element = markElementsForId(editor, id)[0];
|
|
160
|
+
if (!element)
|
|
161
|
+
return false;
|
|
162
|
+
element.scrollIntoView({ behavior, block: 'center' });
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
const pulse = () => {
|
|
166
|
+
for (const el of markElementsForId(editor, id)) {
|
|
167
|
+
el.classList.remove(PULSE_CLASS);
|
|
168
|
+
void el.offsetWidth; // reflow so the animation restarts on a repeat navigation
|
|
169
|
+
el.classList.add(PULSE_CLASS);
|
|
170
|
+
}
|
|
171
|
+
pulseTimer = window.setTimeout(() => {
|
|
172
|
+
for (const el of markElementsForId(editor, id))
|
|
173
|
+
el.classList.remove(PULSE_CLASS);
|
|
174
|
+
}, 1400);
|
|
175
|
+
};
|
|
176
|
+
const start = () => {
|
|
177
|
+
if (!center('smooth')) {
|
|
178
|
+
if (appearFrames++ < 60)
|
|
179
|
+
raf = requestAnimationFrame(start); // wait for the mark to be placed
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Pulse when the anchor actually scrolls into view, not now — firing it
|
|
183
|
+
// here plays the flash while it's still off-screen so it fades before you
|
|
184
|
+
// see it.
|
|
185
|
+
const marker = markElementsForId(editor, id)[0];
|
|
186
|
+
if (marker && typeof IntersectionObserver !== 'undefined') {
|
|
187
|
+
visibility = new IntersectionObserver((entries, io) => {
|
|
188
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
189
|
+
pulse();
|
|
190
|
+
io.disconnect();
|
|
191
|
+
visibility = null;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
// threshold 0 (fire as soon as any part is visible), not 0.5 — a mark
|
|
195
|
+
// spanning more than half the viewport never reaches a 0.5 ratio, so
|
|
196
|
+
// the pulse would silently never play.
|
|
197
|
+
{ threshold: 0 });
|
|
198
|
+
visibility.observe(marker);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
pulse();
|
|
202
|
+
}
|
|
203
|
+
const root = editor.getRootElement();
|
|
204
|
+
if (root && typeof ResizeObserver !== 'undefined') {
|
|
205
|
+
observer = new ResizeObserver(() => center('smooth'));
|
|
206
|
+
observer.observe(root);
|
|
207
|
+
stopTimer = window.setTimeout(() => observer?.disconnect(), 2500);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
start();
|
|
211
|
+
return () => {
|
|
212
|
+
cancelAnimationFrame(raf);
|
|
213
|
+
observer?.disconnect();
|
|
214
|
+
visibility?.disconnect();
|
|
215
|
+
if (stopTimer)
|
|
216
|
+
clearTimeout(stopTimer);
|
|
217
|
+
if (pulseTimer)
|
|
218
|
+
clearTimeout(pulseTimer);
|
|
219
|
+
};
|
|
220
|
+
// Depends on `nonce` as well as `id` so a repeat scroll-to the same
|
|
221
|
+
// annotation re-triggers rather than being skipped as an unchanged dep.
|
|
222
|
+
}, [editor, scrollToAnnotation?.id, scrollToAnnotation?.nonce]);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AnchorFields } from '../../annotations/anchor-model.js';
|
|
2
|
+
import type { AnnotationKindConfigs } from '../../annotations/types.js';
|
|
3
|
+
import type { WikiLinkPromotionMode } from '../mapper/lexicalToMdast.js';
|
|
4
|
+
export interface AnnotationCreateEvent {
|
|
5
|
+
/** The kind that was created, as configured by the host. */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** The id the package minted for the transient mark. Hosts may adopt it or mint their own. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** The captured anchor, or null if the selection didn't resolve to real content. */
|
|
10
|
+
anchor: AnchorFields | null;
|
|
11
|
+
/** Where the selection was on screen, for hosts that position a composer. */
|
|
12
|
+
rect: DOMRect;
|
|
13
|
+
}
|
|
14
|
+
interface AnnotationPluginProps {
|
|
15
|
+
kinds: AnnotationKindConfigs;
|
|
16
|
+
/**
|
|
17
|
+
* Fires when the user invokes a kind's create affordance. The package
|
|
18
|
+
* captures the anchor and hands it over — it never persists anything
|
|
19
|
+
* (ADR-075/FR-005); storage, identity and lifecycle are the host's.
|
|
20
|
+
*/
|
|
21
|
+
onCreateAnnotation: (event: AnnotationCreateEvent) => void;
|
|
22
|
+
/** Injected so the package never imports a host logger directly (FR-010). */
|
|
23
|
+
logger?: {
|
|
24
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
25
|
+
};
|
|
26
|
+
/** Forwarded to the anchor-capture export pass — see `Editor`'s own prop of the same name. */
|
|
27
|
+
wikiLinkPromotion?: WikiLinkPromotionMode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Turns a user selection into a captured anchor for a configured kind.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately headless: it renders no composer of its own. Whether a comment
|
|
33
|
+
* gets a textarea or a correction gets a panel is host UX, so this plugin's
|
|
34
|
+
* whole job is to listen for the create command, run the shared capture
|
|
35
|
+
* primitive, and hand the anchor out. Both kinds take the same path here —
|
|
36
|
+
* only `retainMarkOnCreate` differs, which is what SC-001 rests on.
|
|
37
|
+
*
|
|
38
|
+
* Not gated on `editable`: annotating is decoupled from editing, and a
|
|
39
|
+
* read-only document is a legitimate place to comment.
|
|
40
|
+
*/
|
|
41
|
+
export declare function AnnotationPlugin({ kinds, onCreateAnnotation, logger, wikiLinkPromotion }: AnnotationPluginProps): null;
|
|
42
|
+
export {};
|