@octanejs/lexical 0.1.2

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/package.json +104 -0
  4. package/src/LexicalAutoEmbedPlugin.tsrx +144 -0
  5. package/src/LexicalAutoFocusPlugin.tsrx +25 -0
  6. package/src/LexicalAutoLinkPlugin.tsrx +32 -0
  7. package/src/LexicalBlockWithAlignableContents.tsrx +85 -0
  8. package/src/LexicalCharacterLimitPlugin.tsrx +66 -0
  9. package/src/LexicalCheckListPlugin.tsrx +16 -0
  10. package/src/LexicalClearEditorPlugin.tsrx +12 -0
  11. package/src/LexicalClickableLinkPlugin.tsrx +17 -0
  12. package/src/LexicalCollaborationContext.ts +67 -0
  13. package/src/LexicalComposer.tsrx +100 -0
  14. package/src/LexicalComposerContext.ts +49 -0
  15. package/src/LexicalContentEditable.tsrx +48 -0
  16. package/src/LexicalDecoratorBlockNode.ts +78 -0
  17. package/src/LexicalDraggableBlockPlugin.tsrx +499 -0
  18. package/src/LexicalEditorRefPlugin.tsrx +19 -0
  19. package/src/LexicalErrorBoundary.tsrx +23 -0
  20. package/src/LexicalHashtagPlugin.tsrx +17 -0
  21. package/src/LexicalHistoryPlugin.tsrx +23 -0
  22. package/src/LexicalHorizontalRuleNode.tsrx +99 -0
  23. package/src/LexicalHorizontalRulePlugin.tsrx +34 -0
  24. package/src/LexicalLinkPlugin.tsrx +27 -0
  25. package/src/LexicalListPlugin.tsrx +37 -0
  26. package/src/LexicalMarkdownShortcutPlugin.tsrx +39 -0
  27. package/src/LexicalNestedComposer.tsrx +124 -0
  28. package/src/LexicalNodeContextMenuPlugin.tsrx +252 -0
  29. package/src/LexicalNodeEventPlugin.tsrx +48 -0
  30. package/src/LexicalNodeMenuPlugin.tsrx +84 -0
  31. package/src/LexicalOnChangePlugin.tsrx +36 -0
  32. package/src/LexicalPlainTextPlugin.tsrx +33 -0
  33. package/src/LexicalRichTextPlugin.tsrx +35 -0
  34. package/src/LexicalSelectionAlwaysOnDisplay.tsrx +11 -0
  35. package/src/LexicalTabIndentationPlugin.tsrx +21 -0
  36. package/src/LexicalTableOfContentsPlugin.tsrx +213 -0
  37. package/src/LexicalTablePlugin.tsrx +67 -0
  38. package/src/LexicalTypeaheadMenuPlugin.tsrx +141 -0
  39. package/src/autoEmbedShared.ts +43 -0
  40. package/src/index.ts +99 -0
  41. package/src/shared/LexicalContentEditableElement.tsrx +101 -0
  42. package/src/shared/LexicalDecorators.tsrx +88 -0
  43. package/src/shared/LexicalMenu.tsrx +275 -0
  44. package/src/shared/internal.ts +32 -0
  45. package/src/shared/menuShared.ts +152 -0
  46. package/src/shared/point.ts +46 -0
  47. package/src/shared/rect.ts +136 -0
  48. package/src/shared/useCanShowPlaceholder.ts +38 -0
  49. package/src/shared/useCharacterLimit.ts +288 -0
  50. package/src/shared/useDynamicPositioning.ts +72 -0
  51. package/src/shared/useMenuAnchorRef.ts +131 -0
  52. package/src/shared/usePlainTextSetup.ts +15 -0
  53. package/src/shared/useRichTextSetup.ts +16 -0
  54. package/src/tsrx-modules.d.ts +4 -0
  55. package/src/typeaheadShared.ts +132 -0
  56. package/src/types.ts +29 -0
  57. package/src/useLexicalEditable.ts +22 -0
  58. package/src/useLexicalIsTextContentEmpty.ts +35 -0
  59. package/src/useLexicalNodeSelection.ts +91 -0
  60. package/src/useLexicalSlotRef.ts +26 -0
  61. package/src/useLexicalSubscription.ts +55 -0
  62. package/src/useLexicalTextEntity.ts +24 -0
@@ -0,0 +1,34 @@
1
+ // Ported from @lexical/react/src/LexicalHorizontalRulePlugin.ts.
2
+ import { useLexicalComposerContext } from './LexicalComposerContext';
3
+ import {
4
+ $createHorizontalRuleNode,
5
+ INSERT_HORIZONTAL_RULE_COMMAND,
6
+ } from './LexicalHorizontalRuleNode.tsrx';
7
+ import { $insertNodeToNearestRoot } from '@lexical/utils';
8
+ import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical';
9
+ import { useEffect } from 'octane';
10
+
11
+ export function HorizontalRulePlugin() {
12
+ const [editor] = useLexicalComposerContext();
13
+
14
+ useEffect(() => {
15
+ return editor.registerCommand(
16
+ INSERT_HORIZONTAL_RULE_COMMAND,
17
+ (type) => {
18
+ const selection = $getSelection();
19
+ if (!$isRangeSelection(selection)) {
20
+ return false;
21
+ }
22
+ const focusNode = selection.focus.getNode();
23
+ if (focusNode !== null) {
24
+ const horizontalRuleNode = $createHorizontalRuleNode();
25
+ $insertNodeToNearestRoot(horizontalRuleNode);
26
+ }
27
+ return true;
28
+ },
29
+ COMMAND_PRIORITY_EDITOR,
30
+ );
31
+ }, [editor]);
32
+
33
+ return null;
34
+ }
@@ -0,0 +1,27 @@
1
+ // Ported from @lexical/react/src/LexicalLinkPlugin.ts. namedSignals is from
2
+ // @lexical/extension's framework-agnostic core.
3
+ import { namedSignals } from '@lexical/extension';
4
+ import { LinkNode, registerLink } from '@lexical/link';
5
+ import { useLexicalComposerContext } from './LexicalComposerContext';
6
+ import { useEffect } from 'octane';
7
+
8
+ export function LinkPlugin(props) {
9
+ const [editor] = useLexicalComposerContext();
10
+ const validateUrl = props.validateUrl;
11
+ const attributes = props.attributes;
12
+
13
+ // No deps — runs every render (matches @lexical/react).
14
+ useEffect(() => {
15
+ if (!editor.hasNodes([LinkNode])) {
16
+ throw new Error('LinkPlugin: LinkNode not registered on editor');
17
+ }
18
+ });
19
+
20
+ useEffect(() => registerLink(editor, namedSignals({ attributes, validateUrl })), [
21
+ editor,
22
+ validateUrl,
23
+ attributes,
24
+ ]);
25
+
26
+ return null;
27
+ }
@@ -0,0 +1,37 @@
1
+ // Ported from @lexical/react/src/LexicalListPlugin.ts (+ shared/useList.ts inlined
2
+ // as the third useEffect, matching @lexical/react which registers list twice).
3
+ import {
4
+ ListItemNode,
5
+ ListNode,
6
+ registerList,
7
+ registerListStrictIndentTransform,
8
+ } from '@lexical/list';
9
+ import { useLexicalComposerContext } from './LexicalComposerContext';
10
+ import { mergeRegister } from 'lexical';
11
+ import { useEffect } from 'octane';
12
+
13
+ export function ListPlugin(props) {
14
+ const [editor] = useLexicalComposerContext();
15
+ const hasStrictIndent =
16
+ props.hasStrictIndent === undefined ? false : props.hasStrictIndent;
17
+ const shouldPreserveNumbering =
18
+ props.shouldPreserveNumbering === undefined ? false : props.shouldPreserveNumbering;
19
+
20
+ useEffect(() => {
21
+ if (!editor.hasNodes([ListNode, ListItemNode])) {
22
+ throw new Error('ListPlugin: ListNode and/or ListItemNode not registered on editor');
23
+ }
24
+ }, [editor]);
25
+
26
+ useEffect(() => {
27
+ return mergeRegister(
28
+ registerList(editor, { restoreNumbering: shouldPreserveNumbering }),
29
+ hasStrictIndent ? registerListStrictIndentTransform(editor) : () => {},
30
+ );
31
+ }, [editor, hasStrictIndent, shouldPreserveNumbering]);
32
+
33
+ // useList(editor) — inlined.
34
+ useEffect(() => registerList(editor), [editor]);
35
+
36
+ return null;
37
+ }
@@ -0,0 +1,39 @@
1
+ // Ported from @lexical/react/src/LexicalMarkdownShortcutPlugin.tsx. The HR
2
+ // transformer + node come from @lexical/extension's agnostic core.
3
+ import {
4
+ $createHorizontalRuleNode,
5
+ $isHorizontalRuleNode,
6
+ HorizontalRuleNode,
7
+ } from '@lexical/extension';
8
+ import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
9
+ import { useLexicalComposerContext } from './LexicalComposerContext';
10
+ import { useEffect } from 'octane';
11
+
12
+ const HR = {
13
+ dependencies: [HorizontalRuleNode],
14
+ export: (node) => ($isHorizontalRuleNode(node) ? '***' : null),
15
+ regExp: /^(---|\*\*\*|___)\s?$/,
16
+ replace: (parentNode, _1, _2, isImport) => {
17
+ const line = $createHorizontalRuleNode();
18
+ if (isImport || parentNode.getNextSibling() != null) {
19
+ parentNode.replace(line);
20
+ } else {
21
+ parentNode.insertBefore(line);
22
+ }
23
+ line.selectNext();
24
+ },
25
+ triggerOnEnter: true,
26
+ type: 'element',
27
+ };
28
+
29
+ export const DEFAULT_TRANSFORMERS = [HR, ...TRANSFORMERS];
30
+
31
+ export function MarkdownShortcutPlugin(props) {
32
+ const [editor] = useLexicalComposerContext();
33
+ const transformers =
34
+ props.transformers === undefined ? DEFAULT_TRANSFORMERS : props.transformers;
35
+
36
+ useEffect(() => registerMarkdownShortcuts(editor, transformers), [editor, transformers]);
37
+
38
+ return null;
39
+ }
@@ -0,0 +1,124 @@
1
+ // Ported from @lexical/react/src/LexicalNestedComposer.tsx. Provides a nested
2
+ // LexicalEditor (e.g. the editor backing an image caption or table cell) to its
3
+ // children via context, inheriting the parent's theme/nodes/namespace/editable
4
+ // state by default. The nested editor is created ahead of time and passed in.
5
+ import { createLexicalComposerContext, LexicalComposerContext } from './LexicalComposerContext';
6
+ import { CollaborationContext } from './LexicalCollaborationContext';
7
+ import invariant from '@lexical/internal/invariant';
8
+ import warnOnlyOnce from '@lexical/internal/warnOnlyOnce';
9
+ import { createSharedNodeState, getRegisteredNode, getTransformSetFromKlass } from 'lexical';
10
+ import { useContext, useEffect, useMemo, useRef } from 'octane';
11
+
12
+ const initialNodesWarning = warnOnlyOnce(
13
+ `LexicalNestedComposer initialNodes is deprecated and will be removed in v0.32.0, it has never worked correctly.\nYou can configure your editor's nodes with createEditor({nodes: [], parentEditor: $getEditor()})`,
14
+ );
15
+ const explicitNamespaceWarning = warnOnlyOnce(
16
+ `LexicalNestedComposer initialEditor should explicitly initialize its namespace when the node configuration differs from the parentEditor. For backwards compatibility, the namespace will be initialized from parentEditor until v0.32.0, but this has always had incorrect copy/paste behavior when the configuration differed.\nYou can configure your editor's namespace with createEditor({namespace: 'nested-editor-namespace', nodes: [], parentEditor: $getEditor()}).`,
17
+ );
18
+
19
+ export function LexicalNestedComposer(props) {
20
+ const initialEditor = props.initialEditor;
21
+ const initialNodes = props.initialNodes;
22
+ const initialTheme = props.initialTheme;
23
+ const skipCollabChecks = props.skipCollabChecks;
24
+ const skipEditableListener = props.skipEditableListener;
25
+
26
+ const wasCollabPreviouslyReadyRef = useRef(false);
27
+ const parentContext = useContext(LexicalComposerContext);
28
+
29
+ if (parentContext == null) {
30
+ invariant(false, 'Unexpected parent context null on a nested composer');
31
+ }
32
+
33
+ const [parentEditor, { getTheme: getParentTheme }] = parentContext;
34
+
35
+ const composerContext = useMemo(() => {
36
+ const composerTheme = initialTheme || getParentTheme() || undefined;
37
+ const context = createLexicalComposerContext(parentContext, composerTheme);
38
+
39
+ if (composerTheme !== undefined) {
40
+ initialEditor._config.theme = composerTheme;
41
+ }
42
+
43
+ initialEditor._parentEditor = initialEditor._parentEditor || parentEditor;
44
+ const createEditorArgs = initialEditor._createEditorArgs;
45
+ const explicitNamespace = createEditorArgs && createEditorArgs.namespace;
46
+
47
+ if (!initialNodes) {
48
+ if (!(createEditorArgs && createEditorArgs.nodes)) {
49
+ const parentNodes = initialEditor._nodes = new Map(parentEditor._nodes);
50
+ if (!explicitNamespace) {
51
+ initialEditor._config.namespace = parentEditor._config.namespace;
52
+ }
53
+ for (const [type, entry] of parentNodes) {
54
+ initialEditor._nodes.set(type, {
55
+ exportDOM: entry.exportDOM,
56
+ klass: entry.klass,
57
+ replace: entry.replace,
58
+ replaceWithKlass: entry.replaceWithKlass,
59
+ sharedNodeState: createSharedNodeState(entry.klass),
60
+ transforms: getTransformSetFromKlass(entry.klass),
61
+ });
62
+ }
63
+ } else if (!explicitNamespace) {
64
+ explicitNamespaceWarning();
65
+ initialEditor._config.namespace = parentEditor._config.namespace;
66
+ }
67
+ } else {
68
+ initialNodesWarning();
69
+ if (!explicitNamespace) {
70
+ explicitNamespaceWarning();
71
+ initialEditor._config.namespace = parentEditor._config.namespace;
72
+ }
73
+ for (let klass of initialNodes) {
74
+ let replace = null;
75
+ let replaceWithKlass = null;
76
+
77
+ if (typeof klass !== 'function') {
78
+ const options = klass;
79
+ klass = options.replace;
80
+ replace = options.with;
81
+ replaceWithKlass = options.withKlass || null;
82
+ }
83
+ const registeredKlass = getRegisteredNode(initialEditor, klass.getType());
84
+
85
+ initialEditor._nodes.set(klass.getType(), {
86
+ exportDOM: registeredKlass ? registeredKlass.exportDOM : undefined,
87
+ klass,
88
+ replace,
89
+ replaceWithKlass,
90
+ sharedNodeState: createSharedNodeState(klass),
91
+ transforms: getTransformSetFromKlass(klass),
92
+ });
93
+ }
94
+ }
95
+
96
+ return [initialEditor, context];
97
+ }, []);
98
+
99
+ const collabContext = useContext(CollaborationContext);
100
+ const isCollabActive = collabContext ? collabContext.isCollabActive : undefined;
101
+ const yjsDocMap = collabContext ? collabContext.yjsDocMap : undefined;
102
+
103
+ const isCollabReady =
104
+ skipCollabChecks || wasCollabPreviouslyReadyRef.current ||
105
+ yjsDocMap && yjsDocMap.has(initialEditor.getKey());
106
+
107
+ useEffect(() => {
108
+ if (isCollabReady) {
109
+ wasCollabPreviouslyReadyRef.current = true;
110
+ }
111
+ }, [isCollabReady]);
112
+
113
+ useEffect(() => {
114
+ if (!skipEditableListener) {
115
+ const editableListener = (editable) => initialEditor.setEditable(editable);
116
+ editableListener(parentEditor.isEditable());
117
+ return parentEditor.registerEditableListener(editableListener);
118
+ }
119
+ }, [initialEditor, parentEditor, skipEditableListener]);
120
+
121
+ return <LexicalComposerContext.Provider value={composerContext}>
122
+ {!isCollabActive || isCollabReady ? props.children : null}
123
+ </LexicalComposerContext.Provider>;
124
+ }
@@ -0,0 +1,252 @@
1
+ // Ported 1:1 from @lexical/react/src/LexicalNodeContextMenuPlugin.tsx (0.46.0). The
2
+ // React version is built entirely on @floating-ui/REACT — useFloating, useRole,
3
+ // useDismiss, useListNavigation, useTypeahead, useInteractions, and the
4
+ // FloatingPortal/FloatingOverlay/FloatingFocusManager components. octane now has the
5
+ // faithful binding (@octanejs/floating-ui), so this mirrors the original directly
6
+ // instead of re-implementing positioning/keyboard/dismiss by hand. octane adaptations:
7
+ // no forwardRef (refs are props), and the per-item `getItemProps()` is spread onto the
8
+ // host <button>/<hr> (octane applies ref / on* / tabindex / role from a prop spread).
9
+ import {
10
+ useFloating,
11
+ useRole,
12
+ useDismiss,
13
+ useListNavigation,
14
+ useTypeahead,
15
+ useInteractions,
16
+ offset,
17
+ flip,
18
+ shift,
19
+ autoUpdate,
20
+ FloatingPortal,
21
+ FloatingOverlay,
22
+ FloatingFocusManager,
23
+ } from '@octanejs/floating-ui';
24
+ import { useLexicalComposerContext } from './LexicalComposerContext';
25
+ import { $getNearestNodeFromDOMNode, $getRoot } from 'lexical';
26
+ import { useEffect, useRef, useState } from 'octane';
27
+
28
+ class MenuOption {
29
+ key: string;
30
+ ref?: { current: HTMLElement | null };
31
+
32
+ constructor(key: string) {
33
+ this.key = key;
34
+ this.ref = { current: null };
35
+ this.setRefElement = this.setRefElement.bind(this);
36
+ }
37
+
38
+ setRefElement(element: HTMLElement | null) {
39
+ this.ref = { current: element };
40
+ }
41
+ }
42
+
43
+ export class NodeContextMenuOption extends MenuOption {
44
+ type: string;
45
+ title: string;
46
+ icon: unknown;
47
+ disabled: boolean;
48
+ $onSelect: () => void;
49
+ $showOn?: (node: unknown) => boolean;
50
+
51
+ constructor(title, options) {
52
+ super(title);
53
+ this.type = 'item';
54
+ this.title = title;
55
+ this.disabled = options.disabled ?? false;
56
+ this.icon = options.icon ?? null;
57
+ this.$onSelect = options.$onSelect;
58
+ if (options.$showOn) {
59
+ this.$showOn = options.$showOn;
60
+ }
61
+ }
62
+ }
63
+
64
+ export class NodeContextMenuSeparator extends MenuOption {
65
+ type: string;
66
+ $showOn?: (node: unknown) => boolean;
67
+
68
+ constructor(options) {
69
+ super('_separator');
70
+ this.type = 'separator';
71
+ if (options && options.$showOn) {
72
+ this.$showOn = options.$showOn;
73
+ }
74
+ }
75
+ }
76
+
77
+ // The floating menu container + its items. Mirrors the React render's inner <div>:
78
+ // the resolved floating props are spread onto it, and each item spreads its own
79
+ // getItemProps() onto a <button> (or <hr> for a separator).
80
+ function ContextMenuList(props) @{
81
+ <div
82
+ class={props.className}
83
+ ref={props.setFloating}
84
+ style={props.floatingStyles}
85
+ role="menu"
86
+ {...props.floatingProps}
87
+ >
88
+ @for (const item of props.items; key item.key) {
89
+ @if (item.type === 'item') {
90
+ <button {...item.itemProps} class={item.className} role="menuitem" disabled={item.disabled}>
91
+ {item.icon}
92
+ <span>
93
+ {item.label as string}
94
+ </span>
95
+ </button>
96
+ } @else {
97
+ <hr {...item.itemProps} class={item.className} />
98
+ }
99
+ }
100
+ </div>
101
+ }
102
+
103
+ export function NodeContextMenuPlugin(props) @{
104
+ const items = props.items;
105
+ const className = props.className;
106
+ const itemClassName = props.itemClassName;
107
+ const separatorClassName = props.separatorClassName;
108
+
109
+ const [editor] = useLexicalComposerContext();
110
+ const [activeIndex, setActiveIndex] = useState(null);
111
+ const [isOpen, setIsOpen] = useState(false);
112
+ const [renderItems, setRenderItems] = useState([]);
113
+
114
+ const listItemsRef = useRef([]);
115
+ const listContentRef = useRef([]);
116
+
117
+ const f = useFloating({
118
+ middleware: [
119
+ offset({ alignmentAxis: 4, mainAxis: 5 }),
120
+ flip({ fallbackPlacements: ['left-start'] }),
121
+ shift({ padding: 10 }),
122
+ ],
123
+ onOpenChange: setIsOpen,
124
+ open: isOpen,
125
+ placement: 'right-start',
126
+ strategy: 'fixed',
127
+ whileElementsMounted: autoUpdate,
128
+ });
129
+
130
+ const role = useRole(f.context, { role: 'menu' });
131
+ const dismiss = useDismiss(f.context);
132
+ const listNavigation = useListNavigation(f.context, {
133
+ activeIndex,
134
+ listRef: listItemsRef,
135
+ onNavigate: setActiveIndex,
136
+ });
137
+ const typeahead = useTypeahead(f.context, {
138
+ activeIndex,
139
+ enabled: isOpen,
140
+ listRef: listContentRef,
141
+ onMatch: setActiveIndex,
142
+ });
143
+ const { getFloatingProps, getItemProps } = useInteractions([
144
+ role,
145
+ dismiss,
146
+ listNavigation,
147
+ typeahead,
148
+ ]);
149
+
150
+ // Build the menu model on right-click and open the menu at the cursor.
151
+ useEffect(() => {
152
+ function onContextMenu(e) {
153
+ e.preventDefault();
154
+
155
+ f.refs.setPositionReference({
156
+ getBoundingClientRect() {
157
+ return {
158
+ bottom: e.clientY,
159
+ height: 0,
160
+ left: e.clientX,
161
+ right: e.clientX,
162
+ top: e.clientY,
163
+ width: 0,
164
+ x: e.clientX,
165
+ y: e.clientY,
166
+ };
167
+ },
168
+ });
169
+
170
+ let visibleItems = [];
171
+ if (items) {
172
+ editor.read(() => {
173
+ const node = $getNearestNodeFromDOMNode(e.target) ?? $getRoot();
174
+ if (node) {
175
+ visibleItems = items.filter((option) => (option.$showOn ? option.$showOn(node) : true));
176
+ }
177
+ });
178
+ }
179
+
180
+ const renderableItems = visibleItems.map((option, index) => {
181
+ if (option.type === 'separator') {
182
+ return {
183
+ className: separatorClassName,
184
+ key: option.key + '-' + index,
185
+ type: option.type,
186
+ };
187
+ }
188
+ return {
189
+ className: itemClassName,
190
+ disabled: option.disabled,
191
+ icon: option.icon,
192
+ key: option.key,
193
+ label: option.title,
194
+ onSelect: () => editor.update(() => option.$onSelect()),
195
+ title: option.title,
196
+ type: option.type,
197
+ };
198
+ });
199
+
200
+ listContentRef.current = renderableItems.map((item) => item.key);
201
+ setRenderItems(renderableItems);
202
+ setIsOpen(true);
203
+ }
204
+
205
+ return editor.registerRootListener((rootElement) => {
206
+ if (rootElement !== null) {
207
+ rootElement.addEventListener('contextmenu', onContextMenu);
208
+ return () => rootElement.removeEventListener('contextmenu', onContextMenu);
209
+ }
210
+ });
211
+ }, [items, itemClassName, separatorClassName, editor]);
212
+
213
+ // Attach per-item refs + handlers via getItemProps, mirroring the React render.
214
+ // (Computed every render; `renderItems` is empty while closed, so this is a no-op
215
+ // until the menu opens.)
216
+ listItemsRef.current = [];
217
+ const itemsForRender = renderItems.map((item, index) => {
218
+ const onSelect = () => {
219
+ if (item.onSelect) {
220
+ item.onSelect();
221
+ }
222
+ setIsOpen(false);
223
+ };
224
+ return {
225
+ ...item,
226
+ itemProps: getItemProps({
227
+ onClick: onSelect,
228
+ onMouseUp: onSelect,
229
+ ref: (node) => {
230
+ listItemsRef.current[index] = node;
231
+ },
232
+ tabIndex: activeIndex === index ? 0 : -1,
233
+ }),
234
+ };
235
+ });
236
+
237
+ @if (isOpen) {
238
+ <FloatingPortal>
239
+ <FloatingOverlay lockScroll={true}>
240
+ <FloatingFocusManager context={f.context} initialFocus={f.refs.floating}>
241
+ <ContextMenuList
242
+ className={className}
243
+ setFloating={f.refs.setFloating}
244
+ floatingStyles={f.floatingStyles}
245
+ floatingProps={getFloatingProps()}
246
+ items={itemsForRender}
247
+ />
248
+ </FloatingFocusManager>
249
+ </FloatingOverlay>
250
+ </FloatingPortal>
251
+ }
252
+ }
@@ -0,0 +1,48 @@
1
+ // Ported from @lexical/react/src/LexicalNodeEventPlugin.ts.
2
+ import { useLexicalComposerContext } from './LexicalComposerContext';
3
+ import { $findMatchingParent, $getNearestNodeFromDOMNode } from 'lexical';
4
+ import { useEffect, useRef } from 'octane';
5
+
6
+ const capturedEvents = new Set(['mouseenter', 'mouseleave']);
7
+
8
+ export function NodeEventPlugin(props) {
9
+ const [editor] = useLexicalComposerContext();
10
+ const nodeType = props.nodeType;
11
+ const eventType = props.eventType;
12
+ const eventListener = props.eventListener;
13
+
14
+ const listenerRef = useRef(eventListener);
15
+ listenerRef.current = eventListener;
16
+
17
+ useEffect(() => {
18
+ const isCaptured = capturedEvents.has(eventType);
19
+
20
+ const onEvent = (event) => {
21
+ editor.update(() => {
22
+ const nearestNode = $getNearestNodeFromDOMNode(event.target);
23
+ if (nearestNode !== null) {
24
+ const targetNode =
25
+ isCaptured
26
+ ? nearestNode instanceof nodeType
27
+ ? nearestNode
28
+ : null
29
+ : $findMatchingParent(nearestNode, (node) => node instanceof nodeType);
30
+ if (targetNode !== null) {
31
+ listenerRef.current(event, editor, targetNode.getKey());
32
+ return;
33
+ }
34
+ }
35
+ });
36
+ };
37
+
38
+ return editor.registerRootListener((rootElement) => {
39
+ if (rootElement) {
40
+ rootElement.addEventListener(eventType, onEvent, isCaptured);
41
+ return () => rootElement.removeEventListener(eventType, onEvent, isCaptured);
42
+ }
43
+ });
44
+ // We intentionally don't respect changes to eventType.
45
+ }, [editor, nodeType]);
46
+
47
+ return null;
48
+ }
@@ -0,0 +1,84 @@
1
+ // Ported from @lexical/react/src/LexicalNodeMenuPlugin.tsx — a floating menu
2
+ // anchored to a specific node (the node-anchored counterpart of the typeahead).
3
+ import { useLexicalComposerContext } from './LexicalComposerContext';
4
+ import { LexicalMenu } from './shared/LexicalMenu.tsrx';
5
+ import { useMenuAnchorRef } from './shared/useMenuAnchorRef';
6
+ import { $getNodeByKey, COMMAND_PRIORITY_LOW } from 'lexical';
7
+ import { startTransition, useCallback, useEffect, useState } from 'octane';
8
+
9
+ export function LexicalNodeMenuPlugin(props) {
10
+ const options = props.options;
11
+ const nodeKey = props.nodeKey;
12
+ const onClose = props.onClose;
13
+ const onOpen = props.onOpen;
14
+ const onSelectOption = props.onSelectOption;
15
+ const menuRenderFn = props.menuRenderFn;
16
+ const anchorClassName = props.anchorClassName;
17
+ const commandPriority =
18
+ props.commandPriority != null ? props.commandPriority : COMMAND_PRIORITY_LOW;
19
+ const parent = props.parent;
20
+
21
+ const [editor] = useLexicalComposerContext();
22
+ const [resolution, setResolution] = useState(null);
23
+ const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName, parent);
24
+
25
+ const closeNodeMenu = useCallback(() => {
26
+ setResolution(null);
27
+ if (onClose != null && resolution !== null) {
28
+ onClose();
29
+ }
30
+ }, [onClose, resolution]);
31
+
32
+ const openNodeMenu = useCallback((res) => {
33
+ setResolution(res);
34
+ if (onOpen != null && resolution === null) {
35
+ onOpen(res);
36
+ }
37
+ }, [onOpen, resolution]);
38
+
39
+ const positionOrCloseMenu = useCallback(() => {
40
+ if (nodeKey) {
41
+ editor.update(() => {
42
+ const node = $getNodeByKey(nodeKey);
43
+ const domElement = editor.getElementByKey(nodeKey);
44
+ if (node != null && domElement != null) {
45
+ if (resolution == null) {
46
+ startTransition(
47
+ () => openNodeMenu({ getRect: () => domElement.getBoundingClientRect() }),
48
+ );
49
+ }
50
+ }
51
+ });
52
+ } else if (nodeKey == null && resolution != null) {
53
+ closeNodeMenu();
54
+ }
55
+ }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
56
+
57
+ useEffect(() => {
58
+ positionOrCloseMenu();
59
+ }, [positionOrCloseMenu, nodeKey]);
60
+
61
+ useEffect(() => {
62
+ if (nodeKey != null) {
63
+ return editor.registerUpdateListener(({ dirtyElements }) => {
64
+ if (dirtyElements.get(nodeKey)) {
65
+ positionOrCloseMenu();
66
+ }
67
+ });
68
+ }
69
+ }, [editor, positionOrCloseMenu, nodeKey]);
70
+
71
+ if (anchorElementRef.current === null || resolution === null || editor === null) {
72
+ return null;
73
+ }
74
+ return <LexicalMenu
75
+ close={closeNodeMenu}
76
+ resolution={resolution}
77
+ editor={editor}
78
+ anchorElementRef={anchorElementRef}
79
+ options={options}
80
+ menuRenderFn={menuRenderFn}
81
+ onSelectOption={onSelectOption}
82
+ commandPriority={commandPriority}
83
+ />;
84
+ }
@@ -0,0 +1,36 @@
1
+ // Ported from @lexical/react/src/LexicalOnChangePlugin.ts.
2
+ import { useLexicalComposerContext } from './LexicalComposerContext';
3
+ import { HISTORY_MERGE_TAG } from 'lexical';
4
+ import { useLayoutEffect } from 'octane';
5
+
6
+ export function OnChangePlugin(props) {
7
+ const [editor] = useLexicalComposerContext();
8
+ const ignoreHistoryMergeTagChange =
9
+ props.ignoreHistoryMergeTagChange === undefined ? true : props.ignoreHistoryMergeTagChange;
10
+ const ignoreSelectionChange =
11
+ props.ignoreSelectionChange === undefined ? false : props.ignoreSelectionChange;
12
+ const onChange = props.onChange;
13
+
14
+ useLayoutEffect(() => {
15
+ if (onChange) {
16
+ return editor.registerUpdateListener(({
17
+ editorState,
18
+ dirtyElements,
19
+ dirtyLeaves,
20
+ prevEditorState,
21
+ tags,
22
+ }) => {
23
+ if (
24
+ ignoreSelectionChange && dirtyElements.size === 0 && dirtyLeaves.size === 0 ||
25
+ ignoreHistoryMergeTagChange && tags.has(HISTORY_MERGE_TAG) ||
26
+ prevEditorState.isEmpty()
27
+ ) {
28
+ return;
29
+ }
30
+ onChange(editorState, editor, tags);
31
+ });
32
+ }
33
+ }, [editor, ignoreHistoryMergeTagChange, ignoreSelectionChange, onChange]);
34
+
35
+ return null;
36
+ }