@liminis/editor 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,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 {};