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