@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,263 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useState, useRef } from 'react';
3
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
4
+ import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_LOW, } from 'lexical';
5
+ import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
6
+ import { getSelectedNode } from './utils.js';
7
+ import { OPEN_ANNOTATION_COMPOSER_COMMAND } from './annotationCommands.js';
8
+ const initialToolbarState = {
9
+ isVisible: false,
10
+ isBold: false,
11
+ isItalic: false,
12
+ isStrikethrough: false,
13
+ isCode: false,
14
+ isLink: false,
15
+ position: { top: 0, left: 0 },
16
+ showLinkInput: false,
17
+ linkUrl: '',
18
+ };
19
+ export function Toolbar({ annotationAffordances = [] }) {
20
+ const [editor] = useLexicalComposerContext();
21
+ const [state, setState] = useState(initialToolbarState);
22
+ const [editable, setEditable] = useState(editor.isEditable());
23
+ const linkInputRef = useRef(null);
24
+ const toolbarRef = useRef(null);
25
+ const dismissedByClickRef = useRef(false);
26
+ // Visibility + position, sourced from the native browser selection rather
27
+ // than Lexical's. Lexical never reports a range selection on a
28
+ // non-editable root, and doesn't reliably dispatch SELECTION_CHANGE_COMMAND
29
+ // for a double-click word-selection even when editable — window.getSelection()
30
+ // is accurate in both cases, matching AnnotationPlugin's own capture path.
31
+ const updateVisibility = useCallback(() => {
32
+ // Don't show toolbar if it was just dismissed by clicking outside
33
+ if (dismissedByClickRef.current) {
34
+ return;
35
+ }
36
+ // Don't hide the toolbar while focus is inside it. Opening the link
37
+ // input moves focus (and therefore the native selection) away from the
38
+ // editor root the instant it appears, which would otherwise look
39
+ // identical to "selection left the editor" and close the input the user
40
+ // just opened.
41
+ if (toolbarRef.current && document.activeElement && toolbarRef.current.contains(document.activeElement)) {
42
+ return;
43
+ }
44
+ const nativeSelection = window.getSelection();
45
+ const rootElement = editor.getRootElement();
46
+ // Both endpoints, not just one, must be inside the root — a selection
47
+ // that only partially overlaps the editor (e.g. a drag starting in
48
+ // surrounding page content and ending inside this editor) is deliberately
49
+ // treated as "outside," not shown. AnnotationPlugin's capture wraps the
50
+ // live native range directly in a Lexical mark; letting the toolbar
51
+ // appear for a range that reaches outside the editor's own DOM would
52
+ // make that affordance reachable for a selection capture was never
53
+ // designed to wrap.
54
+ if (!nativeSelection ||
55
+ nativeSelection.rangeCount === 0 ||
56
+ nativeSelection.isCollapsed ||
57
+ !rootElement ||
58
+ !nativeSelection.anchorNode ||
59
+ !nativeSelection.focusNode ||
60
+ !rootElement.contains(nativeSelection.anchorNode) ||
61
+ !rootElement.contains(nativeSelection.focusNode)) {
62
+ setState(prev => prev.isVisible ? { ...prev, isVisible: false } : prev);
63
+ return;
64
+ }
65
+ const range = nativeSelection.getRangeAt(0);
66
+ const rect = range.getBoundingClientRect();
67
+ setState(prev => ({
68
+ ...prev,
69
+ isVisible: true,
70
+ position: {
71
+ top: rect.top - 45,
72
+ left: rect.left + rect.width / 2,
73
+ },
74
+ }));
75
+ }, [editor]);
76
+ // Format-flag booleans (bold/italic/strikethrough/code/link) stay sourced
77
+ // from Lexical's own selection, exactly as before — only computed while
78
+ // editable, since the controls that display them don't render otherwise.
79
+ // Must be called from inside an editor state read.
80
+ //
81
+ // Known limitation: this reads whatever Lexical's own $getSelection() last
82
+ // reconciled, which Lexical updates from the native selection via its own
83
+ // pointerdown-gated sync (see the native selectionchange listener below).
84
+ // A genuine double-click always fires a real pointerdown first, so it
85
+ // reconciles correctly; a selection change with no preceding pointerdown
86
+ // on this root leaves these flags at their last-known value rather than
87
+ // the newly-selected text's actual formatting (see the regression test
88
+ // "format flags after a native-only selection with no preceding
89
+ // pointerdown"). Deriving these flags any other way would mean
90
+ // re-implementing bold/italic/link detection from raw DOM instead of
91
+ // Lexical's model, which is out of scope per the spec's Assumptions.
92
+ const updateFormatFlags = useCallback(() => {
93
+ const selection = $getSelection();
94
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
95
+ return;
96
+ }
97
+ const node = getSelectedNode(selection);
98
+ const parent = node.getParent();
99
+ const isLink = $isLinkNode(parent) || $isLinkNode(node);
100
+ setState(prev => ({
101
+ ...prev,
102
+ isBold: selection.hasFormat('bold'),
103
+ isItalic: selection.hasFormat('italic'),
104
+ isStrikethrough: selection.hasFormat('strikethrough'),
105
+ isCode: selection.hasFormat('code'),
106
+ isLink,
107
+ }));
108
+ }, []);
109
+ // A selection can already be live when `editable` flips true (e.g. a
110
+ // reviewer had bold text selected read-only, then the host makes the
111
+ // document editable) — format flags must be recomputed on that transition,
112
+ // not just left at their stale/default value until the next selection change.
113
+ useEffect(() => {
114
+ return editor.registerEditableListener((isEditable) => {
115
+ setEditable(isEditable);
116
+ if (isEditable) {
117
+ editor.getEditorState().read(() => {
118
+ updateFormatFlags();
119
+ });
120
+ }
121
+ });
122
+ }, [editor, updateFormatFlags]);
123
+ useEffect(() => {
124
+ const unregisterSelection = editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
125
+ updateVisibility();
126
+ if (editor.isEditable()) {
127
+ editor.getEditorState().read(() => {
128
+ updateFormatFlags();
129
+ });
130
+ }
131
+ return false;
132
+ }, COMMAND_PRIORITY_CRITICAL);
133
+ // Also listen for format changes to update button states
134
+ const unregisterFormat = editor.registerCommand(FORMAT_TEXT_COMMAND, () => {
135
+ // Defer the update to run after the format is applied
136
+ setTimeout(() => {
137
+ if (editor.isEditable()) {
138
+ editor.getEditorState().read(() => {
139
+ updateFormatFlags();
140
+ });
141
+ }
142
+ }, 0);
143
+ return false;
144
+ }, COMMAND_PRIORITY_LOW);
145
+ return () => {
146
+ unregisterSelection();
147
+ unregisterFormat();
148
+ };
149
+ }, [editor, updateVisibility, updateFormatFlags]);
150
+ // Native selectionchange fires for every selection the browser makes,
151
+ // including a non-editable root and a double-click word-selection — neither
152
+ // of which reliably drives SELECTION_CHANGE_COMMAND (FR-001/FR-002).
153
+ //
154
+ // For a normal drag-selection while editable, this listener and the
155
+ // SELECTION_CHANGE_COMMAND handler above both fire for the same underlying
156
+ // browser event (Lexical's own command is itself dispatched from its
157
+ // internal selectionchange handling), so a single real selection change
158
+ // runs updateVisibility/updateFormatFlags twice. This is intentional, not
159
+ // an oversight: both computations are idempotent (same selection in ⇒ same
160
+ // state out), the extra pass is cheap, and de-duplicating the two triggers
161
+ // would mean re-introducing a way to tell "did SELECTION_CHANGE_COMMAND
162
+ // already run for this event" — more state to get wrong than the
163
+ // duplicate, harmless recompute it would save.
164
+ useEffect(() => {
165
+ const handleNativeSelectionChange = () => {
166
+ updateVisibility();
167
+ if (editor.isEditable()) {
168
+ editor.getEditorState().read(() => {
169
+ updateFormatFlags();
170
+ });
171
+ }
172
+ };
173
+ document.addEventListener('selectionchange', handleNativeSelectionChange);
174
+ return () => document.removeEventListener('selectionchange', handleNativeSelectionChange);
175
+ }, [editor, updateVisibility, updateFormatFlags]);
176
+ // Dismiss toolbar immediately on mousedown outside
177
+ useEffect(() => {
178
+ const handleMouseDown = (e) => {
179
+ // If toolbar is not visible, nothing to do
180
+ if (!state.isVisible)
181
+ return;
182
+ // If click is inside the toolbar, don't dismiss
183
+ if (toolbarRef.current?.contains(e.target)) {
184
+ return;
185
+ }
186
+ // Set flag to prevent selection change from re-showing toolbar
187
+ dismissedByClickRef.current = true;
188
+ // Dismiss immediately
189
+ setState(prev => ({ ...prev, isVisible: false, showLinkInput: false }));
190
+ // Reset flag after selection events have settled
191
+ setTimeout(() => {
192
+ dismissedByClickRef.current = false;
193
+ }, 100);
194
+ };
195
+ document.addEventListener('mousedown', handleMouseDown);
196
+ return () => document.removeEventListener('mousedown', handleMouseDown);
197
+ }, [state.isVisible]);
198
+ const formatText = useCallback((format) => {
199
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
200
+ }, [editor]);
201
+ const openLinkInput = useCallback(() => {
202
+ if (state.isLink) {
203
+ // Remove link
204
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
205
+ }
206
+ else {
207
+ // Show link input
208
+ setState(prev => ({ ...prev, showLinkInput: true, linkUrl: '' }));
209
+ // Focus the input after it renders
210
+ setTimeout(() => linkInputRef.current?.focus(), 0);
211
+ }
212
+ }, [editor, state.isLink]);
213
+ const submitLink = useCallback(() => {
214
+ if (state.linkUrl) {
215
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, state.linkUrl);
216
+ }
217
+ setState(prev => ({ ...prev, showLinkInput: false, linkUrl: '' }));
218
+ }, [editor, state.linkUrl]);
219
+ const cancelLink = useCallback(() => {
220
+ setState(prev => ({ ...prev, showLinkInput: false, linkUrl: '' }));
221
+ }, []);
222
+ const createAnnotation = useCallback((kind) => {
223
+ editor.dispatchCommand(OPEN_ANNOTATION_COMPOSER_COMMAND, { kind });
224
+ }, [editor]);
225
+ // Formatting controls are inert on a read-only root, so they never render
226
+ // there (FR-004); with nothing else to offer, the whole toolbar stays
227
+ // hidden too (FR-006) rather than rendering an empty bar.
228
+ if (!state.isVisible || (!editable && annotationAffordances.length === 0))
229
+ return null;
230
+ return (_jsxs("div", { ref: toolbarRef, className: "toolbar", style: {
231
+ position: 'fixed',
232
+ top: state.position.top,
233
+ left: state.position.left,
234
+ transform: 'translateX(-50%)',
235
+ }, children: [editable && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onMouseDown: (e) => {
236
+ e.preventDefault();
237
+ formatText('bold');
238
+ }, className: `toolbar-button ${state.isBold ? 'active' : ''}`, "aria-label": "Bold", title: "Bold (Cmd+B)", children: _jsx("strong", { children: "B" }) }), _jsx("button", { type: "button", onMouseDown: (e) => {
239
+ e.preventDefault();
240
+ formatText('italic');
241
+ }, className: `toolbar-button ${state.isItalic ? 'active' : ''}`, "aria-label": "Italic", title: "Italic (Cmd+I)", children: _jsx("em", { children: "I" }) }), _jsx("button", { type: "button", onMouseDown: (e) => {
242
+ e.preventDefault();
243
+ formatText('strikethrough');
244
+ }, className: `toolbar-button ${state.isStrikethrough ? 'active' : ''}`, "aria-label": "Strikethrough", title: "Strikethrough", children: _jsx("s", { children: "S" }) }), _jsx("button", { type: "button", onMouseDown: (e) => {
245
+ e.preventDefault();
246
+ formatText('code');
247
+ }, className: `toolbar-button ${state.isCode ? 'active' : ''}`, "aria-label": "Code", title: "Inline Code (Cmd+E)", children: '</>' }), _jsx("div", { className: "toolbar-divider" }), _jsx("button", { type: "button", onMouseDown: (e) => {
248
+ e.preventDefault();
249
+ openLinkInput();
250
+ }, className: `toolbar-button ${state.isLink ? 'active' : ''}`, "aria-label": "Link", title: "Link (Cmd+K)", children: "\uD83D\uDD17" }), state.showLinkInput && (_jsx("div", { className: "toolbar-link-input", children: _jsx("input", { ref: linkInputRef, type: "text", placeholder: "Enter URL...", value: state.linkUrl, onChange: (e) => setState(prev => ({ ...prev, linkUrl: e.target.value })), onKeyDown: (e) => {
251
+ if (e.key === 'Enter') {
252
+ e.preventDefault();
253
+ submitLink();
254
+ }
255
+ else if (e.key === 'Escape') {
256
+ e.preventDefault();
257
+ cancelLink();
258
+ }
259
+ }, onBlur: cancelLink }) }))] })), annotationAffordances.length > 0 && (_jsxs(_Fragment, { children: [editable && _jsx("div", { className: "toolbar-divider" }), annotationAffordances.map(({ kind, label }) => (_jsx("button", { type: "button", onMouseDown: (e) => {
260
+ e.preventDefault();
261
+ createAnnotation(kind);
262
+ }, className: "toolbar-button", "aria-label": label, title: label, children: label }, kind)))] }))] }));
263
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
3
+ *
4
+ * This plugin scans for wiki-links in the editor DOM and checks if their
5
+ * target files exist anywhere in the workspace. Links to non-existent files
6
+ * get a CSS class applied to render them in red.
7
+ *
8
+ * Uses the host-supplied `resolveWikiLinks` service, which handles:
9
+ * - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
10
+ * - File links with extension (e.g., "notes.md") → checks directly
11
+ * - File links without extension (e.g., "notes") → tries .md, .mdc
12
+ *
13
+ * The check is performed:
14
+ * - When the document is loaded
15
+ * - When the document content changes (debounced)
16
+ */
17
+ export declare function WikiLinkExistencePlugin(): null;
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { useEditorHost } from '../../host/context.js';
4
+ /**
5
+ * WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
6
+ *
7
+ * This plugin scans for wiki-links in the editor DOM and checks if their
8
+ * target files exist anywhere in the workspace. Links to non-existent files
9
+ * get a CSS class applied to render them in red.
10
+ *
11
+ * Uses the host-supplied `resolveWikiLinks` service, which handles:
12
+ * - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
13
+ * - File links with extension (e.g., "notes.md") → checks directly
14
+ * - File links without extension (e.g., "notes") → tries .md, .mdc
15
+ *
16
+ * The check is performed:
17
+ * - When the document is loaded
18
+ * - When the document content changes (debounced)
19
+ */
20
+ export function WikiLinkExistencePlugin() {
21
+ const [editor] = useLexicalComposerContext();
22
+ const { resolveWikiLinks } = useEditorHost();
23
+ const checkTimeoutRef = useRef(null);
24
+ const lastCheckedRef = useRef(new Set());
25
+ useEffect(() => {
26
+ const rootElement = editor.getRootElement();
27
+ if (!rootElement)
28
+ return;
29
+ const checkWikiLinks = async () => {
30
+ // Find all wiki-link elements
31
+ const wikiLinks = rootElement.querySelectorAll('a[data-wiki-link="true"]');
32
+ if (wikiLinks.length === 0)
33
+ return;
34
+ // Collect unique target paths
35
+ const targets = new Set();
36
+ wikiLinks.forEach((link) => {
37
+ const target = link.getAttribute('data-wiki-target');
38
+ if (target) {
39
+ targets.add(target);
40
+ }
41
+ });
42
+ if (targets.size === 0)
43
+ return;
44
+ // Skip if we've already checked these exact targets
45
+ const targetsArray = Array.from(targets);
46
+ const targetKey = targetsArray.sort().join('|');
47
+ if (lastCheckedRef.current.has(targetKey)) {
48
+ return;
49
+ }
50
+ // Use the host-supplied resolver which handles directory links, etc.
51
+ try {
52
+ if (!resolveWikiLinks) {
53
+ console.warn('[WikiLinkExistencePlugin] resolveWikiLinks host service not available');
54
+ return;
55
+ }
56
+ // Resolve all wiki-link paths
57
+ const resolved = await resolveWikiLinks(targetsArray);
58
+ lastCheckedRef.current.add(targetKey);
59
+ // Update CSS classes on wiki-links
60
+ wikiLinks.forEach((link) => {
61
+ const target = link.getAttribute('data-wiki-target');
62
+ if (target) {
63
+ // Link exists if resolver returned a non-null path
64
+ const exists = resolved[target] !== null;
65
+ if (!exists) {
66
+ link.classList.add('editor-link-broken');
67
+ }
68
+ else {
69
+ link.classList.remove('editor-link-broken');
70
+ }
71
+ }
72
+ });
73
+ }
74
+ catch (err) {
75
+ console.error('[WikiLinkExistencePlugin] Error checking wiki-link existence', err);
76
+ }
77
+ };
78
+ // Debounced check function
79
+ const scheduleCheck = () => {
80
+ if (checkTimeoutRef.current) {
81
+ clearTimeout(checkTimeoutRef.current);
82
+ }
83
+ checkTimeoutRef.current = setTimeout(() => { void checkWikiLinks(); }, 300);
84
+ };
85
+ // Initial check
86
+ scheduleCheck();
87
+ // Listen for editor updates
88
+ const unregisterListener = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
89
+ // Only re-check if there were actual changes
90
+ if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
91
+ // Clear the cache when content changes so we re-check
92
+ lastCheckedRef.current.clear();
93
+ scheduleCheck();
94
+ }
95
+ });
96
+ return () => {
97
+ unregisterListener();
98
+ if (checkTimeoutRef.current) {
99
+ clearTimeout(checkTimeoutRef.current);
100
+ }
101
+ };
102
+ }, [editor, resolveWikiLinks]);
103
+ return null;
104
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * WikiLinkFormatPlugin - Applies formatting to wiki links when surrounded by format markers
3
+ *
4
+ * This plugin detects patterns like `**[[page]]**` typed during live editing and:
5
+ * 1. Removes the format markers from the surrounding TextNodes
6
+ * 2. Applies the format (bold, italic, strikethrough) to the wiki link's text children
7
+ *
8
+ * Supports:
9
+ * - Bold: **[[page]]** or __[[page]]__
10
+ * - Italic: *[[page]]* or _[[page]]_
11
+ * - Strikethrough: ~~[[page]]~~
12
+ * - Nested: ***[[page]]*** (bold + italic)
13
+ *
14
+ * The plugin runs as a post-processing step after the markdown shortcuts transforms,
15
+ * using an update listener to detect and modify the tree.
16
+ */
17
+ export declare function WikiLinkFormatPlugin(): null;
@@ -0,0 +1,203 @@
1
+ import { useEffect } from 'react';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { $getRoot, $isTextNode, $isElementNode, } from 'lexical';
4
+ import { $isLinkNode } from '@lexical/link';
5
+ /**
6
+ * Ordered list of format markers to check.
7
+ * Order matters: longer markers (e.g., **) must come before shorter ones (e.g., *)
8
+ * to avoid partial matches.
9
+ */
10
+ const FORMAT_MARKERS = [
11
+ { marker: '**', format: 'bold' },
12
+ { marker: '__', format: 'bold' },
13
+ { marker: '~~', format: 'strikethrough' },
14
+ // Single * and _ must come after ** and __
15
+ { marker: '*', format: 'italic' },
16
+ { marker: '_', format: 'italic' },
17
+ ];
18
+ /**
19
+ * Detect format markers at the end of a text node and start of another.
20
+ * Returns the formats that should be applied based on matching markers.
21
+ *
22
+ * @param beforeText - Text content of the node before the link
23
+ * @param afterText - Text content of the node after the link
24
+ * @returns Array of detected formats with their marker lengths
25
+ */
26
+ function detectSurroundingFormats(beforeText, afterText) {
27
+ const detected = [];
28
+ let remainingBefore = beforeText;
29
+ let remainingAfter = afterText;
30
+ // Keep checking for markers until no more matches
31
+ let foundMatch = true;
32
+ while (foundMatch) {
33
+ foundMatch = false;
34
+ for (const { marker, format } of FORMAT_MARKERS) {
35
+ // Check if before ends with marker and after starts with marker
36
+ if (remainingBefore.endsWith(marker) && remainingAfter.startsWith(marker)) {
37
+ // Ensure we're not matching partial markers
38
+ // e.g., don't match single * when we're actually looking at **
39
+ // For single char markers (* and _), ensure we're not part of a double marker
40
+ if (marker.length === 1) {
41
+ const beforeIndex = remainingBefore.length - marker.length - 1;
42
+ const afterIndex = marker.length;
43
+ // If the character before our marker is the same, skip (it's part of ** or __)
44
+ const isDoubleBefore = beforeIndex >= 0 && remainingBefore[beforeIndex] === marker;
45
+ // If the character after the closing marker is the same, skip
46
+ const isDoubleAfter = afterIndex < remainingAfter.length && remainingAfter[afterIndex] === marker;
47
+ if (isDoubleBefore || isDoubleAfter)
48
+ continue;
49
+ }
50
+ detected.push({ format, markerLength: marker.length });
51
+ remainingBefore = remainingBefore.slice(0, -marker.length);
52
+ remainingAfter = remainingAfter.slice(marker.length);
53
+ foundMatch = true;
54
+ break; // Restart from beginning to handle nested formats correctly
55
+ }
56
+ }
57
+ }
58
+ return detected;
59
+ }
60
+ /**
61
+ * Apply formats to all text children of a LinkNode.
62
+ */
63
+ function applyFormatsToLinkChildren(link, formats) {
64
+ for (const child of link.getChildren()) {
65
+ if ($isTextNode(child)) {
66
+ for (const format of formats) {
67
+ if (!child.hasFormat(format)) {
68
+ child.toggleFormat(format);
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Process a single LinkNode and its surrounding TextNodes.
76
+ * Returns true if any changes were made.
77
+ */
78
+ function processLinkNode(linkNode) {
79
+ const prevSibling = linkNode.getPreviousSibling();
80
+ const nextSibling = linkNode.getNextSibling();
81
+ // We need TextNodes on both sides
82
+ if (!$isTextNode(prevSibling) || !$isTextNode(nextSibling)) {
83
+ return false;
84
+ }
85
+ const beforeText = prevSibling.getTextContent();
86
+ const afterText = nextSibling.getTextContent();
87
+ // Detect format markers
88
+ const detected = detectSurroundingFormats(beforeText, afterText);
89
+ if (detected.length === 0) {
90
+ return false;
91
+ }
92
+ // Calculate total marker lengths to remove
93
+ let totalBeforeLength = 0;
94
+ let totalAfterLength = 0;
95
+ const formats = [];
96
+ for (const { format, markerLength } of detected) {
97
+ formats.push(format);
98
+ totalBeforeLength += markerLength;
99
+ totalAfterLength += markerLength;
100
+ }
101
+ // Apply formats to link children
102
+ applyFormatsToLinkChildren(linkNode, formats);
103
+ // Remove markers from surrounding TextNodes
104
+ const newBeforeText = beforeText.slice(0, -totalBeforeLength);
105
+ const newAfterText = afterText.slice(totalAfterLength);
106
+ // Update or remove the before TextNode
107
+ if (newBeforeText.length === 0) {
108
+ prevSibling.remove();
109
+ }
110
+ else {
111
+ prevSibling.setTextContent(newBeforeText);
112
+ }
113
+ // Update or remove the after TextNode
114
+ if (newAfterText.length === 0) {
115
+ nextSibling.remove();
116
+ }
117
+ else {
118
+ nextSibling.setTextContent(newAfterText);
119
+ }
120
+ return true;
121
+ }
122
+ /**
123
+ * Find all LinkNodes in the editor tree.
124
+ */
125
+ function findAllLinkNodes(root) {
126
+ const linkNodes = [];
127
+ const traverse = (node) => {
128
+ if ($isLinkNode(node)) {
129
+ linkNodes.push(node);
130
+ }
131
+ if ($isElementNode(node)) {
132
+ for (const child of node.getChildren()) {
133
+ traverse(child);
134
+ }
135
+ }
136
+ };
137
+ traverse(root);
138
+ return linkNodes;
139
+ }
140
+ /**
141
+ * WikiLinkFormatPlugin - Applies formatting to wiki links when surrounded by format markers
142
+ *
143
+ * This plugin detects patterns like `**[[page]]**` typed during live editing and:
144
+ * 1. Removes the format markers from the surrounding TextNodes
145
+ * 2. Applies the format (bold, italic, strikethrough) to the wiki link's text children
146
+ *
147
+ * Supports:
148
+ * - Bold: **[[page]]** or __[[page]]__
149
+ * - Italic: *[[page]]* or _[[page]]_
150
+ * - Strikethrough: ~~[[page]]~~
151
+ * - Nested: ***[[page]]*** (bold + italic)
152
+ *
153
+ * The plugin runs as a post-processing step after the markdown shortcuts transforms,
154
+ * using an update listener to detect and modify the tree.
155
+ */
156
+ export function WikiLinkFormatPlugin() {
157
+ const [editor] = useLexicalComposerContext();
158
+ useEffect(() => {
159
+ return editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves, tags }) => {
160
+ // Skip if this update was triggered by us to prevent infinite loops
161
+ if (tags.has('wiki-link-format')) {
162
+ return;
163
+ }
164
+ // Early exit if nothing changed in this update
165
+ if (dirtyElements.size === 0 && dirtyLeaves.size === 0) {
166
+ return;
167
+ }
168
+ // Check if there are any LinkNodes with adjacent format markers,
169
+ // and apply formatting in a single pass if found
170
+ let needsUpdate = false;
171
+ editorState.read(() => {
172
+ const root = $getRoot();
173
+ const linkNodes = findAllLinkNodes(root);
174
+ for (const linkNode of linkNodes) {
175
+ const prevSibling = linkNode.getPreviousSibling();
176
+ const nextSibling = linkNode.getNextSibling();
177
+ if (!$isTextNode(prevSibling) || !$isTextNode(nextSibling)) {
178
+ continue;
179
+ }
180
+ const beforeText = prevSibling.getTextContent();
181
+ const afterText = nextSibling.getTextContent();
182
+ const detected = detectSurroundingFormats(beforeText, afterText);
183
+ if (detected.length > 0) {
184
+ needsUpdate = true;
185
+ break;
186
+ }
187
+ }
188
+ });
189
+ if (!needsUpdate) {
190
+ return;
191
+ }
192
+ // Perform the update
193
+ editor.update(() => {
194
+ const root = $getRoot();
195
+ const linkNodes = findAllLinkNodes(root);
196
+ for (const linkNode of linkNodes) {
197
+ processLinkNode(linkNode);
198
+ }
199
+ }, { tag: 'wiki-link-format' });
200
+ });
201
+ }, [editor]);
202
+ return null;
203
+ }