@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,379 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
4
+ import { $createRangeSelection, $getRoot, $isTextNode, $setSelection } from 'lexical';
5
+ // Debounce delay in ms
6
+ const SEARCH_DEBOUNCE_MS = 150;
7
+ export function SearchPlugin() {
8
+ const [editor] = useLexicalComposerContext();
9
+ const [isOpen, setIsOpen] = useState(false);
10
+ const [query, setQuery] = useState('');
11
+ const [debouncedQuery, setDebouncedQuery] = useState('');
12
+ const [matches, setMatches] = useState([]);
13
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
14
+ const inputRef = useRef(null);
15
+ const highlightLayerRef = useRef(null);
16
+ const debounceTimerRef = useRef(null);
17
+ const pendingMatchOffsetRef = useRef(null);
18
+ const lastContentRef = useRef('');
19
+ useEffect(() => {
20
+ const handleExternalSearch = (event) => {
21
+ const customEvent = event;
22
+ const queryText = customEvent?.detail?.query ?? '';
23
+ const matchIndex = customEvent?.detail?.matchIndex;
24
+ pendingMatchOffsetRef.current = typeof matchIndex === 'number' ? matchIndex : null;
25
+ setIsOpen(true);
26
+ setQuery(queryText);
27
+ setDebouncedQuery(queryText);
28
+ setTimeout(() => {
29
+ inputRef.current?.focus();
30
+ inputRef.current?.select();
31
+ }, 0);
32
+ };
33
+ window.addEventListener('slashmd:search', handleExternalSearch);
34
+ return () => window.removeEventListener('slashmd:search', handleExternalSearch);
35
+ }, []);
36
+ // Debounce the query
37
+ useEffect(() => {
38
+ if (debounceTimerRef.current !== null) {
39
+ clearTimeout(debounceTimerRef.current);
40
+ }
41
+ debounceTimerRef.current = window.setTimeout(() => {
42
+ setDebouncedQuery(query);
43
+ debounceTimerRef.current = null;
44
+ }, SEARCH_DEBOUNCE_MS);
45
+ return () => {
46
+ if (debounceTimerRef.current !== null) {
47
+ clearTimeout(debounceTimerRef.current);
48
+ }
49
+ };
50
+ }, [query]);
51
+ // Get all text nodes recursively - defined as a regular function to allow recursion
52
+ const getAllTextNodes = useCallback((startNode) => {
53
+ const textNodes = [];
54
+ // Inner recursive function
55
+ const collect = (node) => {
56
+ if ($isTextNode(node)) {
57
+ textNodes.push(node);
58
+ }
59
+ if ('getChildren' in node && typeof node.getChildren === 'function') {
60
+ const children = node.getChildren();
61
+ children.forEach((child) => {
62
+ collect(child);
63
+ });
64
+ }
65
+ };
66
+ collect(startNode);
67
+ return textNodes;
68
+ }, []);
69
+ // Find all text matches in the editor
70
+ const findMatches = useCallback((searchQuery) => {
71
+ if (!searchQuery.trim())
72
+ return [];
73
+ const foundMatches = [];
74
+ const lowerQuery = searchQuery.toLowerCase();
75
+ editor.getEditorState().read(() => {
76
+ const root = $getRoot();
77
+ const textNodes = getAllTextNodes(root);
78
+ let runningOffset = 0;
79
+ textNodes.forEach((node) => {
80
+ const text = node.getTextContent().toLowerCase();
81
+ let startIndex = 0;
82
+ while (true) {
83
+ const matchIndex = text.indexOf(lowerQuery, startIndex);
84
+ if (matchIndex === -1)
85
+ break;
86
+ foundMatches.push({
87
+ node,
88
+ startOffset: matchIndex,
89
+ endOffset: matchIndex + searchQuery.length,
90
+ index: foundMatches.length,
91
+ globalOffset: runningOffset + matchIndex,
92
+ });
93
+ startIndex = matchIndex + 1;
94
+ }
95
+ runningOffset += node.getTextContent().length;
96
+ });
97
+ });
98
+ return foundMatches;
99
+ }, [editor, getAllTextNodes]);
100
+ // Run search when debounced query changes
101
+ useEffect(() => {
102
+ const found = findMatches(debouncedQuery);
103
+ setMatches(found);
104
+ if (found.length === 0) {
105
+ setCurrentMatchIndex(-1);
106
+ return;
107
+ }
108
+ if (pendingMatchOffsetRef.current !== null) {
109
+ const targetOffset = pendingMatchOffsetRef.current;
110
+ const closest = found.reduce((best, match) => {
111
+ const distance = Math.abs(match.globalOffset - targetOffset);
112
+ if (!best || distance < best.distance) {
113
+ return { index: match.index, distance };
114
+ }
115
+ return best;
116
+ }, null);
117
+ setCurrentMatchIndex(closest ? closest.index : 0);
118
+ pendingMatchOffsetRef.current = null;
119
+ return;
120
+ }
121
+ setCurrentMatchIndex(0);
122
+ }, [debouncedQuery, findMatches]);
123
+ // Find the actual text node in the DOM
124
+ const findTextNodeWithContent = useCallback((element, content) => {
125
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
126
+ let node;
127
+ while ((node = walker.nextNode())) {
128
+ if (node.textContent?.includes(content.substring(0, Math.min(content.length, 10)))) {
129
+ return node;
130
+ }
131
+ }
132
+ // Fallback: just return the first text node
133
+ const textWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
134
+ return textWalker.nextNode();
135
+ }, []);
136
+ // Create highlight overlay for matches
137
+ const updateHighlights = useCallback(() => {
138
+ // Remove existing highlights
139
+ if (highlightLayerRef.current) {
140
+ highlightLayerRef.current.remove();
141
+ highlightLayerRef.current = null;
142
+ }
143
+ if (matches.length === 0 || !debouncedQuery.trim())
144
+ return;
145
+ // Create highlight layer
146
+ const editorElement = editor.getRootElement();
147
+ if (!editorElement)
148
+ return;
149
+ const editorContainer = editorElement.closest('.editor-container');
150
+ if (!editorContainer)
151
+ return;
152
+ // Create highlight container
153
+ const highlightLayer = document.createElement('div');
154
+ highlightLayer.className = 'search-highlight-layer';
155
+ editorContainer.appendChild(highlightLayer);
156
+ highlightLayerRef.current = highlightLayer;
157
+ // Get container bounds for positioning (use container, not editor element)
158
+ const containerRect = editorContainer.getBoundingClientRect();
159
+ matches.forEach((match, idx) => {
160
+ editor.getEditorState().read(() => {
161
+ const key = match.node.getKey();
162
+ const domElement = editor.getElementByKey(key);
163
+ if (!domElement)
164
+ return;
165
+ // Find the text node within the DOM element
166
+ const textNode = findTextNodeWithContent(domElement, match.node.getTextContent());
167
+ if (!textNode)
168
+ return;
169
+ // Create a range for the match
170
+ const range = document.createRange();
171
+ try {
172
+ range.setStart(textNode, match.startOffset);
173
+ range.setEnd(textNode, match.endOffset);
174
+ }
175
+ catch {
176
+ return;
177
+ }
178
+ // Get the bounding rectangles for the range
179
+ const rects = Array.from(range.getClientRects());
180
+ for (const rect of rects) {
181
+ const highlight = document.createElement('div');
182
+ highlight.className = `search-highlight ${idx === currentMatchIndex ? 'current' : ''}`;
183
+ // Position relative to the container, accounting for scroll
184
+ highlight.style.left = `${rect.left - containerRect.left + editorContainer.scrollLeft}px`;
185
+ highlight.style.top = `${rect.top - containerRect.top + editorContainer.scrollTop}px`;
186
+ highlight.style.width = `${rect.width}px`;
187
+ highlight.style.height = `${rect.height}px`;
188
+ highlightLayer.appendChild(highlight);
189
+ }
190
+ });
191
+ });
192
+ }, [editor, matches, currentMatchIndex, debouncedQuery, findTextNodeWithContent]);
193
+ // Scroll to current match
194
+ // setSelection parameter controls whether to set Lexical selection (which steals focus)
195
+ // - true (default): Set selection AND scroll (for navigation via Enter/buttons)
196
+ // - false: Only scroll, no selection (for soft scroll during typing)
197
+ const scrollToMatch = useCallback((matchIndex, setSelection = true) => {
198
+ if (matches.length === 0 || matchIndex < 0 || matchIndex >= matches.length)
199
+ return;
200
+ const match = matches[matchIndex];
201
+ // Only set Lexical selection when explicitly navigating
202
+ if (setSelection) {
203
+ editor.update(() => {
204
+ const selection = $createRangeSelection();
205
+ selection.anchor.set(match.node.getKey(), match.startOffset, 'text');
206
+ selection.focus.set(match.node.getKey(), match.endOffset, 'text');
207
+ $setSelection(selection);
208
+ });
209
+ }
210
+ // Always scroll (no focus change)
211
+ editor.getEditorState().read(() => {
212
+ const key = match.node.getKey();
213
+ const domElement = editor.getElementByKey(key);
214
+ if (domElement) {
215
+ domElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
216
+ }
217
+ });
218
+ }, [editor, matches]);
219
+ // Navigate to next match
220
+ const goToNextMatch = useCallback(() => {
221
+ if (matches.length === 0)
222
+ return;
223
+ const nextIndex = (currentMatchIndex + 1) % matches.length;
224
+ setCurrentMatchIndex(nextIndex);
225
+ scrollToMatch(nextIndex, true); // Explicit selection for navigation
226
+ }, [matches, currentMatchIndex, scrollToMatch]);
227
+ // Navigate to previous match
228
+ const goToPrevMatch = useCallback(() => {
229
+ if (matches.length === 0)
230
+ return;
231
+ const prevIndex = currentMatchIndex <= 0 ? matches.length - 1 : currentMatchIndex - 1;
232
+ setCurrentMatchIndex(prevIndex);
233
+ scrollToMatch(prevIndex, true); // Explicit selection for navigation
234
+ }, [matches, currentMatchIndex, scrollToMatch]);
235
+ // Close search
236
+ const closeSearch = useCallback(() => {
237
+ // Set selection at current match before closing (if we have a valid match)
238
+ // This allows user to immediately edit at the found location after pressing Escape
239
+ scrollToMatch(currentMatchIndex, true);
240
+ // Save scroll position before any DOM changes
241
+ const scrollY = window.scrollY;
242
+ const scrollX = window.scrollX;
243
+ setIsOpen(false);
244
+ setQuery('');
245
+ setDebouncedQuery('');
246
+ setMatches([]);
247
+ setCurrentMatchIndex(-1);
248
+ // Remove highlights
249
+ if (highlightLayerRef.current) {
250
+ highlightLayerRef.current.remove();
251
+ highlightLayerRef.current = null;
252
+ }
253
+ // Return focus to editor without scrolling
254
+ const editorElement = editor.getRootElement();
255
+ if (editorElement) {
256
+ // Use preventScroll to avoid jumping
257
+ editorElement.focus({ preventScroll: true });
258
+ }
259
+ // Restore scroll position in case it shifted
260
+ requestAnimationFrame(() => {
261
+ window.scrollTo(scrollX, scrollY);
262
+ });
263
+ }, [editor, scrollToMatch, currentMatchIndex]);
264
+ // Update highlights when matches or current index changes
265
+ useEffect(() => {
266
+ if (isOpen) {
267
+ updateHighlights();
268
+ }
269
+ }, [isOpen, matches, currentMatchIndex, updateHighlights]);
270
+ // Update highlights on scroll
271
+ useEffect(() => {
272
+ if (!isOpen || matches.length === 0)
273
+ return;
274
+ const editorElement = editor.getRootElement();
275
+ if (!editorElement)
276
+ return;
277
+ const handleScroll = () => {
278
+ requestAnimationFrame(updateHighlights);
279
+ };
280
+ // Listen on window and editor container for scroll events
281
+ window.addEventListener('scroll', handleScroll, true);
282
+ return () => {
283
+ window.removeEventListener('scroll', handleScroll, true);
284
+ };
285
+ }, [isOpen, matches.length, editor, updateHighlights]);
286
+ // Cleanup highlights on unmount or when closing
287
+ useEffect(() => {
288
+ return () => {
289
+ if (highlightLayerRef.current) {
290
+ highlightLayerRef.current.remove();
291
+ highlightLayerRef.current = null;
292
+ }
293
+ };
294
+ }, []);
295
+ // Re-update highlights when editor content changes
296
+ useEffect(() => {
297
+ if (!isOpen)
298
+ return;
299
+ const unregister = editor.registerUpdateListener(({ editorState }) => {
300
+ if (!debouncedQuery)
301
+ return;
302
+ const textContent = editorState.read(() => $getRoot().getTextContent());
303
+ if (textContent === lastContentRef.current)
304
+ return;
305
+ lastContentRef.current = textContent;
306
+ const found = findMatches(debouncedQuery);
307
+ setMatches(found);
308
+ // Keep current index in bounds
309
+ setCurrentMatchIndex(prev => found.length === 0 ? -1 : Math.min(prev, found.length - 1));
310
+ });
311
+ return unregister;
312
+ }, [editor, isOpen, debouncedQuery, findMatches]);
313
+ // Handle keyboard shortcuts
314
+ useEffect(() => {
315
+ const handleKeyDown = (e) => {
316
+ // Cmd+F or Ctrl+F to open search
317
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
318
+ e.preventDefault();
319
+ e.stopPropagation();
320
+ setIsOpen(true);
321
+ // Focus input after render
322
+ setTimeout(() => {
323
+ inputRef.current?.focus();
324
+ inputRef.current?.select();
325
+ }, 0);
326
+ return;
327
+ }
328
+ // Only handle these if search is open
329
+ if (!isOpen)
330
+ return;
331
+ // Escape to close
332
+ if (e.key === 'Escape') {
333
+ e.preventDefault();
334
+ closeSearch();
335
+ return;
336
+ }
337
+ // Enter to go to next, Shift+Enter to go to previous
338
+ if (e.key === 'Enter' && document.activeElement === inputRef.current) {
339
+ e.preventDefault();
340
+ if (e.shiftKey) {
341
+ goToPrevMatch();
342
+ }
343
+ else {
344
+ goToNextMatch();
345
+ }
346
+ return;
347
+ }
348
+ // Cmd+G or Ctrl+G for next match (even when not focused on input)
349
+ if ((e.metaKey || e.ctrlKey) && e.key === 'g') {
350
+ e.preventDefault();
351
+ if (e.shiftKey) {
352
+ goToPrevMatch();
353
+ }
354
+ else {
355
+ goToNextMatch();
356
+ }
357
+ return;
358
+ }
359
+ };
360
+ // Use capture phase to intercept before browser default
361
+ document.addEventListener('keydown', handleKeyDown, true);
362
+ return () => {
363
+ document.removeEventListener('keydown', handleKeyDown, true);
364
+ };
365
+ }, [isOpen, closeSearch, goToNextMatch, goToPrevMatch]);
366
+ // Scroll to first match when search results appear (soft scroll, no selection)
367
+ useEffect(() => {
368
+ if (!isOpen)
369
+ return;
370
+ if (matches.length > 0 && currentMatchIndex >= 0) {
371
+ scrollToMatch(currentMatchIndex, false); // Soft scroll only - don't steal focus from input
372
+ }
373
+ }, [isOpen, matches, currentMatchIndex, scrollToMatch]);
374
+ if (!isOpen)
375
+ return null;
376
+ return (_jsxs("div", { className: "search-bar", children: [_jsxs("div", { className: "search-input-wrapper", children: [_jsx("svg", { className: "search-icon", viewBox: "0 0 24 24", width: "16", height: "16", children: _jsx("path", { fill: "currentColor", d: "M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" }) }), _jsx("input", { ref: inputRef, type: "text", className: "search-input", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search...", autoFocus: true }), query && (_jsx("span", { className: "search-count", children: matches.length > 0
377
+ ? `${currentMatchIndex + 1} of ${matches.length}`
378
+ : debouncedQuery !== query ? '...' : 'No results' }))] }), _jsxs("div", { className: "search-buttons", children: [_jsx("button", { type: "button", className: "search-nav-button", onClick: goToPrevMatch, disabled: matches.length === 0, title: "Previous match (Shift+Enter)", "aria-label": "Previous match", children: _jsx("svg", { viewBox: "0 0 24 24", width: "16", height: "16", children: _jsx("path", { fill: "currentColor", d: "M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" }) }) }), _jsx("button", { type: "button", className: "search-nav-button", onClick: goToNextMatch, disabled: matches.length === 0, title: "Next match (Enter)", "aria-label": "Next match", children: _jsx("svg", { viewBox: "0 0 24 24", width: "16", height: "16", children: _jsx("path", { fill: "currentColor", d: "M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" }) }) }), _jsx("button", { type: "button", className: "search-close-button", onClick: closeSearch, title: "Close (Escape)", "aria-label": "Close search", children: _jsx("svg", { viewBox: "0 0 24 24", width: "16", height: "16", children: _jsx("path", { fill: "currentColor", d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })] }));
379
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Lexical plugin that shows a context menu when right-clicking a text selection.
3
+ * Provides a "Chat about this..." action that passes the selected text and click
4
+ * position to the host via callback, plus one entry per configured
5
+ * `contextMenu`-surfaced annotation kind (ADR-077) — `correction` additionally
6
+ * opens the legacy inline correction panel.
7
+ *
8
+ * Follows the TableActionsPlugin pattern for attaching to the editor root element,
9
+ * and the DiagramContextMenu pattern for the overlay menu UI.
10
+ */
11
+ export interface SelectionContextMenuEvent {
12
+ position: {
13
+ x: number;
14
+ y: number;
15
+ };
16
+ selectedText: string;
17
+ }
18
+ /** One context-menu-surfaced annotation kind's affordance, plain data only (FR-004/FR-010). */
19
+ export interface ContextMenuAnnotationAffordance {
20
+ kind: string;
21
+ label: string;
22
+ }
23
+ interface SelectionContextMenuPluginProps {
24
+ onSelectionContextMenu?: (event: SelectionContextMenuEvent) => void;
25
+ /** One entry per configured kind whose `createAffordance.surface` is `contextMenu` (ADR-077). */
26
+ annotationAffordances?: ContextMenuAnnotationAffordance[];
27
+ }
28
+ export declare function SelectionContextMenuPlugin({ onSelectionContextMenu, annotationAffordances, }: SelectionContextMenuPluginProps): import("react").JSX.Element;
29
+ export {};
@@ -0,0 +1,160 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Lexical plugin that shows a context menu when right-clicking a text selection.
4
+ * Provides a "Chat about this..." action that passes the selected text and click
5
+ * position to the host via callback, plus one entry per configured
6
+ * `contextMenu`-surfaced annotation kind (ADR-077) — `correction` additionally
7
+ * opens the legacy inline correction panel.
8
+ *
9
+ * Follows the TableActionsPlugin pattern for attaching to the editor root element,
10
+ * and the DiagramContextMenu pattern for the overlay menu UI.
11
+ */
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
14
+ import { OPEN_ANNOTATION_COMPOSER_COMMAND } from './annotationCommands.js';
15
+ import { $getSelection, $isRangeSelection } from 'lexical';
16
+ import { useCorrectionStore } from '../../stores/correctionStore.js';
17
+ // --- Menu overlay component ---
18
+ const menuStyle = {
19
+ position: 'fixed',
20
+ zIndex: 10000,
21
+ borderRadius: '6px',
22
+ padding: '4px 0',
23
+ minWidth: '180px',
24
+ };
25
+ const itemStyle = {
26
+ display: 'block',
27
+ width: '100%',
28
+ padding: '6px 12px',
29
+ background: 'transparent',
30
+ border: 'none',
31
+ fontSize: '13px',
32
+ textAlign: 'left',
33
+ cursor: 'pointer',
34
+ fontFamily: 'system-ui, -apple-system, sans-serif',
35
+ };
36
+ function SelectionContextMenu({ visible, x, y, selectedText, onChatAboutThis, annotationAffordances, onCreateAnnotation, onClose, }) {
37
+ const menuRef = useRef(null);
38
+ const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
39
+ const bgColor = `var(--vscode-menu-background, ${isDark ? '#252526' : '#ffffff'})`;
40
+ const borderColor = `var(--vscode-menu-border, ${isDark ? '#454545' : '#d4d4d4'})`;
41
+ const textColor = `var(--vscode-menu-foreground, ${isDark ? '#cccccc' : '#333333'})`;
42
+ const hoverBg = `var(--vscode-menu-selectionBackground, ${isDark ? '#094771' : '#e8e8e8'})`;
43
+ useEffect(() => {
44
+ if (!visible)
45
+ return;
46
+ const handleClick = (e) => {
47
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
48
+ onClose();
49
+ }
50
+ };
51
+ const handleKey = (e) => {
52
+ if (e.key === 'Escape')
53
+ onClose();
54
+ };
55
+ document.addEventListener('mousedown', handleClick);
56
+ document.addEventListener('keydown', handleKey);
57
+ return () => {
58
+ document.removeEventListener('mousedown', handleClick);
59
+ document.removeEventListener('keydown', handleKey);
60
+ };
61
+ }, [visible, onClose]);
62
+ if (!visible)
63
+ return null;
64
+ const handleHover = (e, entering) => {
65
+ e.currentTarget.style.background = entering ? hoverBg : 'transparent';
66
+ };
67
+ const handleAnnotationClick = (kind) => {
68
+ // Every kind on this surface enters the same shared capture path
69
+ // (ADR-077). `correction` additionally opens the legacy, name-gated
70
+ // correction panel (Out-of-Scope: this generalizes only the dispatch, not
71
+ // the panel). The `correction` kind discards its transient mark, so
72
+ // nothing paints and the panel's visible behaviour is unchanged. A host
73
+ // that supplies no `onCreateAnnotation` (as liminis-app does not) has no
74
+ // handler registered for the command, so dispatch is simply a no-op there.
75
+ //
76
+ // CAUTION for future hosts: if a host ever configures a kind literally
77
+ // named `correction` *and* supplies `onCreateAnnotation`, both fire off
78
+ // this one click — the host's generic create flow and this hardcoded
79
+ // legacy panel, with no coordination between them. See the caveat on
80
+ // `AnnotationKindConfig` in `annotations/types.ts`.
81
+ onCreateAnnotation(kind);
82
+ if (kind === 'correction') {
83
+ useCorrectionStore.getState().open({ x, y }, selectedText);
84
+ }
85
+ onClose();
86
+ };
87
+ return (_jsxs("div", { ref: menuRef,
88
+ // Keep the document selection alive across a menu click. This overlay is
89
+ // a fixed-position sibling of the contenteditable, so pressing a button
90
+ // in it moves focus out of the editor on `mousedown` and collapses
91
+ // `window.getSelection()` — before any `onClick` handler runs. The
92
+ // annotation create path reads that native selection
93
+ // (`AnnotationPlugin`) and bails silently on a collapsed one, so a
94
+ // contextMenu-surfaced kind would capture no anchor while the correction
95
+ // panel still opened as if nothing had gone wrong (review finding,
96
+ // @handarbeit-pruefer). Preventing the default mousedown action stops
97
+ // the focus shift without affecting the subsequent click.
98
+ //
99
+ // Liminis doesn't feel this today only because it supplies no
100
+ // `onCreateAnnotation`, leaving the command unlistened — i.e. it would
101
+ // have bitten the first host to wire the callback up.
102
+ onMouseDown: (e) => e.preventDefault(), style: {
103
+ ...menuStyle,
104
+ left: x,
105
+ top: y,
106
+ background: bgColor,
107
+ border: `1px solid ${borderColor}`,
108
+ boxShadow: isDark ? '0 4px 12px rgba(0,0,0,0.3)' : '0 4px 12px rgba(0,0,0,0.12)',
109
+ }, children: [onChatAboutThis && (_jsx("button", { onClick: () => { onChatAboutThis(); onClose(); }, style: { ...itemStyle, color: textColor }, onMouseEnter: (e) => handleHover(e, true), onMouseLeave: (e) => handleHover(e, false), children: "Chat about this\u2026" })), onChatAboutThis && annotationAffordances.length > 0 && (_jsx("hr", { style: { margin: '4px 0', border: 'none', borderTop: `1px solid ${borderColor}` } })), annotationAffordances.map(({ kind, label }) => (_jsx("button", { onClick: () => handleAnnotationClick(kind), style: { ...itemStyle, color: textColor }, onMouseEnter: (e) => handleHover(e, true), onMouseLeave: (e) => handleHover(e, false), children: label }, kind)))] }));
110
+ }
111
+ // --- Plugin ---
112
+ export function SelectionContextMenuPlugin({ onSelectionContextMenu, annotationAffordances = [], }) {
113
+ const [editor] = useLexicalComposerContext();
114
+ const [menuState, setMenuState] = useState({ visible: false, x: 0, y: 0, selectedText: '' });
115
+ const close = useCallback(() => {
116
+ setMenuState((prev) => ({ ...prev, visible: false }));
117
+ }, []);
118
+ useEffect(() => {
119
+ const handleContextMenu = (e) => {
120
+ editor.getEditorState().read(() => {
121
+ const selection = $getSelection();
122
+ if (!$isRangeSelection(selection) || selection.isCollapsed())
123
+ return;
124
+ const selectedText = selection.getTextContent();
125
+ if (!selectedText.trim())
126
+ return;
127
+ e.preventDefault();
128
+ setMenuState({
129
+ visible: true,
130
+ x: e.clientX,
131
+ y: e.clientY,
132
+ selectedText,
133
+ });
134
+ });
135
+ };
136
+ const editorElement = editor.getRootElement();
137
+ if (editorElement) {
138
+ editorElement.addEventListener('contextmenu', handleContextMenu);
139
+ return () => editorElement.removeEventListener('contextmenu', handleContextMenu);
140
+ }
141
+ }, [editor]);
142
+ // Dispatching the command rather than calling the capture primitive
143
+ // directly is deliberate: `annotationCommands` declares the command and
144
+ // nothing else, so this statically-imported plugin can enter the annotation
145
+ // mechanism without pulling any annotation machinery into the default
146
+ // (annotations-disabled) import graph. The listener lives in the lazily
147
+ // loaded surface, and is simply absent when annotations are off.
148
+ const dispatchCreateAnnotation = useCallback((kind) => {
149
+ editor.dispatchCommand(OPEN_ANNOTATION_COMPOSER_COMMAND, { kind });
150
+ }, [editor]);
151
+ const handleChatAboutThis = useCallback(() => {
152
+ if (onSelectionContextMenu && menuState.selectedText) {
153
+ onSelectionContextMenu({
154
+ position: { x: menuState.x, y: menuState.y },
155
+ selectedText: menuState.selectedText,
156
+ });
157
+ }
158
+ }, [onSelectionContextMenu, menuState]);
159
+ return (_jsx(SelectionContextMenu, { visible: menuState.visible, x: menuState.x, y: menuState.y, selectedText: menuState.selectedText, onChatAboutThis: onSelectionContextMenu ? handleChatAboutThis : null, annotationAffordances: annotationAffordances, onCreateAnnotation: dispatchCreateAnnotation, onClose: close }));
160
+ }
@@ -0,0 +1,11 @@
1
+ interface SlashMenuProps {
2
+ isOpen: boolean;
3
+ position: {
4
+ top: number;
5
+ left: number;
6
+ } | null;
7
+ query: string;
8
+ onClose: () => void;
9
+ }
10
+ export declare function SlashMenu({ isOpen, position, query, onClose }: SlashMenuProps): import("react").JSX.Element | null;
11
+ export {};