@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +104 -0
- package/src/LexicalAutoEmbedPlugin.tsrx +144 -0
- package/src/LexicalAutoFocusPlugin.tsrx +25 -0
- package/src/LexicalAutoLinkPlugin.tsrx +32 -0
- package/src/LexicalBlockWithAlignableContents.tsrx +85 -0
- package/src/LexicalCharacterLimitPlugin.tsrx +66 -0
- package/src/LexicalCheckListPlugin.tsrx +16 -0
- package/src/LexicalClearEditorPlugin.tsrx +12 -0
- package/src/LexicalClickableLinkPlugin.tsrx +17 -0
- package/src/LexicalCollaborationContext.ts +67 -0
- package/src/LexicalComposer.tsrx +100 -0
- package/src/LexicalComposerContext.ts +49 -0
- package/src/LexicalContentEditable.tsrx +48 -0
- package/src/LexicalDecoratorBlockNode.ts +78 -0
- package/src/LexicalDraggableBlockPlugin.tsrx +499 -0
- package/src/LexicalEditorRefPlugin.tsrx +19 -0
- package/src/LexicalErrorBoundary.tsrx +23 -0
- package/src/LexicalHashtagPlugin.tsrx +17 -0
- package/src/LexicalHistoryPlugin.tsrx +23 -0
- package/src/LexicalHorizontalRuleNode.tsrx +99 -0
- package/src/LexicalHorizontalRulePlugin.tsrx +34 -0
- package/src/LexicalLinkPlugin.tsrx +27 -0
- package/src/LexicalListPlugin.tsrx +37 -0
- package/src/LexicalMarkdownShortcutPlugin.tsrx +39 -0
- package/src/LexicalNestedComposer.tsrx +124 -0
- package/src/LexicalNodeContextMenuPlugin.tsrx +252 -0
- package/src/LexicalNodeEventPlugin.tsrx +48 -0
- package/src/LexicalNodeMenuPlugin.tsrx +84 -0
- package/src/LexicalOnChangePlugin.tsrx +36 -0
- package/src/LexicalPlainTextPlugin.tsrx +33 -0
- package/src/LexicalRichTextPlugin.tsrx +35 -0
- package/src/LexicalSelectionAlwaysOnDisplay.tsrx +11 -0
- package/src/LexicalTabIndentationPlugin.tsrx +21 -0
- package/src/LexicalTableOfContentsPlugin.tsrx +213 -0
- package/src/LexicalTablePlugin.tsrx +67 -0
- package/src/LexicalTypeaheadMenuPlugin.tsrx +141 -0
- package/src/autoEmbedShared.ts +43 -0
- package/src/index.ts +99 -0
- package/src/shared/LexicalContentEditableElement.tsrx +101 -0
- package/src/shared/LexicalDecorators.tsrx +88 -0
- package/src/shared/LexicalMenu.tsrx +275 -0
- package/src/shared/internal.ts +32 -0
- package/src/shared/menuShared.ts +152 -0
- package/src/shared/point.ts +46 -0
- package/src/shared/rect.ts +136 -0
- package/src/shared/useCanShowPlaceholder.ts +38 -0
- package/src/shared/useCharacterLimit.ts +288 -0
- package/src/shared/useDynamicPositioning.ts +72 -0
- package/src/shared/useMenuAnchorRef.ts +131 -0
- package/src/shared/usePlainTextSetup.ts +15 -0
- package/src/shared/useRichTextSetup.ts +16 -0
- package/src/tsrx-modules.d.ts +4 -0
- package/src/typeaheadShared.ts +132 -0
- package/src/types.ts +29 -0
- package/src/useLexicalEditable.ts +22 -0
- package/src/useLexicalIsTextContentEmpty.ts +35 -0
- package/src/useLexicalNodeSelection.ts +91 -0
- package/src/useLexicalSlotRef.ts +26 -0
- package/src/useLexicalSubscription.ts +55 -0
- package/src/useLexicalTextEntity.ts +24 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/shared/LexicalContentEditableElement.tsx.
|
|
2
|
+
// React's `forwardRef` + `mergeRefs` becomes octane's ref-as-prop + a multi-ref
|
|
3
|
+
// array (`ref={[userRef, handleRef]}`). `handleRef` binds the editor to the DOM
|
|
4
|
+
// node via setRootElement — octane fires callback refs at commit with a connected
|
|
5
|
+
// node, the same timing React's ref gets, and calls them with null on unmount.
|
|
6
|
+
//
|
|
7
|
+
// The full ARIA set + `{...rest}` passthrough mirror @lexical/react exactly:
|
|
8
|
+
// boolean ARIA values are stringified (octane drops `false` as an attribute), and
|
|
9
|
+
// unknown props flow through via `rest` (everything not consumed by name).
|
|
10
|
+
import { useCallback, useLayoutEffect, useState } from 'octane';
|
|
11
|
+
|
|
12
|
+
// Props consumed by name — everything else flows through to the <div> via `rest`.
|
|
13
|
+
const NAMED_PROPS = new Set([
|
|
14
|
+
'editor',
|
|
15
|
+
'ariaActiveDescendant',
|
|
16
|
+
'ariaAutoComplete',
|
|
17
|
+
'ariaControls',
|
|
18
|
+
'ariaDescribedBy',
|
|
19
|
+
'ariaErrorMessage',
|
|
20
|
+
'ariaExpanded',
|
|
21
|
+
'ariaInvalid',
|
|
22
|
+
'ariaLabel',
|
|
23
|
+
'ariaLabelledBy',
|
|
24
|
+
'ariaMultiline',
|
|
25
|
+
'ariaOwns',
|
|
26
|
+
'ariaRequired',
|
|
27
|
+
'autoCapitalize',
|
|
28
|
+
'className',
|
|
29
|
+
'contentEditable',
|
|
30
|
+
'id',
|
|
31
|
+
'role',
|
|
32
|
+
'spellCheck',
|
|
33
|
+
'style',
|
|
34
|
+
'tabIndex',
|
|
35
|
+
'ref',
|
|
36
|
+
'placeholder',
|
|
37
|
+
'data-testid',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function ContentEditableElement(props) @{
|
|
41
|
+
const editor = props.editor;
|
|
42
|
+
const [isEditable, setEditable] = useState(editor.isEditable());
|
|
43
|
+
|
|
44
|
+
const handleRef = useCallback((rootElement) => {
|
|
45
|
+
// defaultView is required for a root element. In multi-window setups it may
|
|
46
|
+
// not exist at certain points.
|
|
47
|
+
if (rootElement && rootElement.ownerDocument && rootElement.ownerDocument.defaultView) {
|
|
48
|
+
editor.setRootElement(rootElement);
|
|
49
|
+
} else {
|
|
50
|
+
editor.setRootElement(null);
|
|
51
|
+
}
|
|
52
|
+
}, [editor]);
|
|
53
|
+
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
setEditable(editor.isEditable());
|
|
56
|
+
return editor.registerEditableListener((currentIsEditable) => {
|
|
57
|
+
setEditable(currentIsEditable);
|
|
58
|
+
});
|
|
59
|
+
}, [editor]);
|
|
60
|
+
|
|
61
|
+
const role =
|
|
62
|
+
props.role != null ? props.role : 'textbox';
|
|
63
|
+
const rest: Record<string, any> = {};
|
|
64
|
+
for (const k in props) {
|
|
65
|
+
if (!NAMED_PROPS.has(k)) {
|
|
66
|
+
rest[k] = props[k];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Route `class` through the spread (setSpread omits an absent/undefined key) so
|
|
70
|
+
// an unset className produces NO attribute — matching React. An explicit
|
|
71
|
+
// `class={undefined}` binding would emit `class=""` on the spread/de-opt path.
|
|
72
|
+
if (props.className != null) {
|
|
73
|
+
rest['class'] = props.className;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
ref={props.ref != null ? [props.ref, handleRef] : handleRef}
|
|
78
|
+
aria-activedescendant={isEditable ? props.ariaActiveDescendant : undefined}
|
|
79
|
+
aria-autocomplete={isEditable ? props.ariaAutoComplete : 'none'}
|
|
80
|
+
aria-controls={isEditable ? props.ariaControls : undefined}
|
|
81
|
+
aria-describedby={props.ariaDescribedBy}
|
|
82
|
+
aria-errormessage={props.ariaErrorMessage != null ? props.ariaErrorMessage : undefined}
|
|
83
|
+
aria-expanded={isEditable && role === 'combobox' ? String(!!props.ariaExpanded) : undefined}
|
|
84
|
+
aria-invalid={props.ariaInvalid != null ? props.ariaInvalid : undefined}
|
|
85
|
+
aria-label={props.ariaLabel}
|
|
86
|
+
aria-labelledby={props.ariaLabelledBy}
|
|
87
|
+
aria-multiline={props.ariaMultiline}
|
|
88
|
+
aria-owns={isEditable ? props.ariaOwns : undefined}
|
|
89
|
+
aria-readonly={isEditable ? undefined : 'true'}
|
|
90
|
+
aria-required={props.ariaRequired != null ? String(props.ariaRequired) : undefined}
|
|
91
|
+
autocapitalize={props.autoCapitalize}
|
|
92
|
+
contenteditable={isEditable ? 'true' : 'false'}
|
|
93
|
+
data-testid={props['data-testid']}
|
|
94
|
+
id={props.id}
|
|
95
|
+
role={role}
|
|
96
|
+
spellcheck={props.spellCheck === false ? 'false' : 'true'}
|
|
97
|
+
style={props.style}
|
|
98
|
+
tabindex={props.tabIndex}
|
|
99
|
+
{...rest}
|
|
100
|
+
/>
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/shared/useDecorators.tsx (the LegacyDecorators
|
|
2
|
+
// path used by Rich/PlainTextPlugin). React's `useDecorators` hook is realized as
|
|
3
|
+
// a `Decorators` COMPONENT here: each <Decorators/> usage is its own octane scope,
|
|
4
|
+
// so its useState/useEffect/useMemo get per-instance identity automatically.
|
|
5
|
+
//
|
|
6
|
+
// Each decorator node renders into the DOM element Lexical created for it, via a
|
|
7
|
+
// portal. octane's createPortal lowers to portal() only as a DIRECT child of a host
|
|
8
|
+
// element (a createPortal VALUE is inert), so each portal lives inside its own
|
|
9
|
+
// `DecoratorPortal` (a display:contents wrapper — the content escapes into the
|
|
10
|
+
// node's element).
|
|
11
|
+
import {
|
|
12
|
+
useState,
|
|
13
|
+
useEffect,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
flushSync,
|
|
17
|
+
createElement,
|
|
18
|
+
createPortal,
|
|
19
|
+
Suspense,
|
|
20
|
+
} from 'octane';
|
|
21
|
+
|
|
22
|
+
function DecoratorPortalBody(props) {
|
|
23
|
+
return createElement(props.ErrorBoundary, {
|
|
24
|
+
onError: props.onError,
|
|
25
|
+
children: createElement(Suspense, { fallback: null, children: props.decorator }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function DecoratorPortal(props) {
|
|
30
|
+
return createPortal(DecoratorPortalBody, props.element, props.bodyProps);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Decorators(props) {
|
|
34
|
+
const editor = props.editor;
|
|
35
|
+
const ErrorBoundary = props.ErrorBoundary;
|
|
36
|
+
|
|
37
|
+
const [decorators, setDecorators] = useState(() => editor.getDecorators());
|
|
38
|
+
|
|
39
|
+
// Subscribe to decorator changes.
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
return editor.registerDecoratorListener((nextDecorators) => {
|
|
42
|
+
flushSync(() => {
|
|
43
|
+
setDecorators(nextDecorators);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}, [editor]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
// If the content editable mounts before the subscription is added, nothing
|
|
50
|
+
// renders on the initial pass — re-read on mount to cover that.
|
|
51
|
+
setDecorators(editor.getDecorators());
|
|
52
|
+
}, [editor]);
|
|
53
|
+
|
|
54
|
+
// Resolve each decorator node's DOM element + its decorator content.
|
|
55
|
+
const entries = useMemo(() => {
|
|
56
|
+
const out = [];
|
|
57
|
+
const decoratorKeys = Object.keys(decorators);
|
|
58
|
+
for (let i = 0; i < decoratorKeys.length; i++) {
|
|
59
|
+
const nodeKey = decoratorKeys[i];
|
|
60
|
+
const element = editor.getElementByKey(nodeKey);
|
|
61
|
+
if (element !== null) {
|
|
62
|
+
out.push({ nodeKey, element, decorator: decorators[nodeKey] });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}, [decorators, editor]);
|
|
67
|
+
|
|
68
|
+
// Render nothing when there are no decorators (matches @lexical/react). Each
|
|
69
|
+
// decorator is a portal into its node's element — a keyed list of DecoratorPortal
|
|
70
|
+
// components, each returning a createPortal VALUE (octane renders portals at any
|
|
71
|
+
// position, so no wrapper element is needed).
|
|
72
|
+
if (entries.length === 0) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return <>
|
|
76
|
+
{entries.map(
|
|
77
|
+
(entry) => <DecoratorPortal
|
|
78
|
+
key={entry.nodeKey}
|
|
79
|
+
element={entry.element}
|
|
80
|
+
bodyProps={{
|
|
81
|
+
ErrorBoundary,
|
|
82
|
+
onError: (e) => editor._onError(e),
|
|
83
|
+
decorator: entry.decorator,
|
|
84
|
+
}}
|
|
85
|
+
/>,
|
|
86
|
+
)}
|
|
87
|
+
</>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// The LexicalMenu component + default renderer, ported from
|
|
2
|
+
// @lexical/react/src/shared/LexicalMenu.tsx. A `.tsrx` component (auto-slotted
|
|
3
|
+
// hooks). The hooks (useMenuAnchorRef / useDynamicPositioning) + non-component
|
|
4
|
+
// pieces live in the sibling `.ts` files.
|
|
5
|
+
import {
|
|
6
|
+
$splitNodeContainingQuery,
|
|
7
|
+
scrollIntoViewIfNeeded,
|
|
8
|
+
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
|
9
|
+
} from './menuShared';
|
|
10
|
+
import {
|
|
11
|
+
COMMAND_PRIORITY_LOW,
|
|
12
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
13
|
+
KEY_ARROW_UP_COMMAND,
|
|
14
|
+
KEY_ENTER_COMMAND,
|
|
15
|
+
KEY_ESCAPE_COMMAND,
|
|
16
|
+
KEY_TAB_COMMAND,
|
|
17
|
+
mergeRegister,
|
|
18
|
+
} from 'lexical';
|
|
19
|
+
import { createPortal, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'octane';
|
|
20
|
+
|
|
21
|
+
function MenuItem(props) @{
|
|
22
|
+
<li
|
|
23
|
+
tabindex={-1}
|
|
24
|
+
class={props.isSelected ? 'item selected' : 'item'}
|
|
25
|
+
ref={props.option.setRefElement}
|
|
26
|
+
role="option"
|
|
27
|
+
aria-selected={props.isSelected ? 'true' : 'false'}
|
|
28
|
+
id={'typeahead-item-' + props.index}
|
|
29
|
+
onMouseEnter={props.onMouseEnter}
|
|
30
|
+
onClick={props.onClick}
|
|
31
|
+
>
|
|
32
|
+
{props.option.icon}
|
|
33
|
+
<span class="text">{props.option.title}</span>
|
|
34
|
+
</li>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function DefaultMenu(props) @{
|
|
38
|
+
<div class="typeahead-popover mentions-menu">
|
|
39
|
+
<ul>{props.options.map(
|
|
40
|
+
(option, i) => <MenuItem
|
|
41
|
+
key={option.key}
|
|
42
|
+
index={i}
|
|
43
|
+
isSelected={props.selectedIndex === i}
|
|
44
|
+
onClick={() => {
|
|
45
|
+
props.setHighlightedIndex(i);
|
|
46
|
+
props.selectOptionAndCleanUp(option);
|
|
47
|
+
}}
|
|
48
|
+
onMouseEnter={() => props.setHighlightedIndex(i)}
|
|
49
|
+
option={option}
|
|
50
|
+
/>,
|
|
51
|
+
)}</ul>
|
|
52
|
+
</div>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function LexicalMenu(props) {
|
|
56
|
+
const close = props.close;
|
|
57
|
+
const editor = props.editor;
|
|
58
|
+
const anchorElementRef = props.anchorElementRef;
|
|
59
|
+
const resolution = props.resolution;
|
|
60
|
+
const options = props.options;
|
|
61
|
+
const menuRenderFnProp = props.menuRenderFn;
|
|
62
|
+
const onSelectOption = props.onSelectOption;
|
|
63
|
+
const shouldSplitNodeWithQuery = props.shouldSplitNodeWithQuery === true;
|
|
64
|
+
const commandPriority =
|
|
65
|
+
props.commandPriority != null ? props.commandPriority : COMMAND_PRIORITY_LOW;
|
|
66
|
+
const preselectFirstItem = props.preselectFirstItem !== false;
|
|
67
|
+
|
|
68
|
+
const [rawSelectedIndex, setHighlightedIndex] = useState(null);
|
|
69
|
+
const selectedIndex =
|
|
70
|
+
rawSelectedIndex !== null ? Math.min(options.length - 1, rawSelectedIndex) : null;
|
|
71
|
+
|
|
72
|
+
const matchingString = resolution.match && resolution.match.matchingString;
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (preselectFirstItem) {
|
|
76
|
+
setHighlightedIndex(0);
|
|
77
|
+
}
|
|
78
|
+
}, [matchingString, preselectFirstItem]);
|
|
79
|
+
|
|
80
|
+
const selectOptionAndCleanUp = useCallback((selectedEntry) => {
|
|
81
|
+
editor.update(() => {
|
|
82
|
+
const textNodeContainingQuery =
|
|
83
|
+
resolution.match != null && shouldSplitNodeWithQuery
|
|
84
|
+
? $splitNodeContainingQuery(resolution.match)
|
|
85
|
+
: null;
|
|
86
|
+
onSelectOption(
|
|
87
|
+
selectedEntry,
|
|
88
|
+
textNodeContainingQuery,
|
|
89
|
+
close,
|
|
90
|
+
resolution.match ? resolution.match.matchingString : '',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
}, [editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close]);
|
|
94
|
+
|
|
95
|
+
const updateSelectedIndex = useCallback((index) => {
|
|
96
|
+
const rootElem = editor.getRootElement();
|
|
97
|
+
if (rootElem !== null) {
|
|
98
|
+
rootElem.setAttribute('aria-activedescendant', 'typeahead-item-' + index);
|
|
99
|
+
setHighlightedIndex(index);
|
|
100
|
+
}
|
|
101
|
+
}, [editor]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
return () => {
|
|
105
|
+
const rootElem = editor.getRootElement();
|
|
106
|
+
if (rootElem !== null) {
|
|
107
|
+
rootElem.removeAttribute('aria-activedescendant');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}, [editor]);
|
|
111
|
+
|
|
112
|
+
useLayoutEffect(() => {
|
|
113
|
+
if (options === null) {
|
|
114
|
+
setHighlightedIndex(null);
|
|
115
|
+
} else if (selectedIndex === null && preselectFirstItem) {
|
|
116
|
+
updateSelectedIndex(0);
|
|
117
|
+
}
|
|
118
|
+
}, [options, selectedIndex, updateSelectedIndex, preselectFirstItem]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
return mergeRegister(
|
|
122
|
+
editor.registerCommand(
|
|
123
|
+
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
|
124
|
+
({ option }) => {
|
|
125
|
+
if (option.ref && option.ref.current != null) {
|
|
126
|
+
scrollIntoViewIfNeeded(option.ref.current);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
},
|
|
131
|
+
commandPriority,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
}, [editor, updateSelectedIndex, commandPriority]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
return mergeRegister(
|
|
138
|
+
editor.registerCommand(
|
|
139
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
140
|
+
(event) => {
|
|
141
|
+
if (options !== null && options.length) {
|
|
142
|
+
const newSelectedIndex =
|
|
143
|
+
selectedIndex === null
|
|
144
|
+
? 0
|
|
145
|
+
: selectedIndex !== options.length - 1
|
|
146
|
+
? selectedIndex + 1
|
|
147
|
+
: 0;
|
|
148
|
+
updateSelectedIndex(newSelectedIndex);
|
|
149
|
+
const option = options[newSelectedIndex];
|
|
150
|
+
if (!option) {
|
|
151
|
+
updateSelectedIndex(-1);
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
event.stopImmediatePropagation();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (option.ref && option.ref.current) {
|
|
157
|
+
editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, {
|
|
158
|
+
index: newSelectedIndex,
|
|
159
|
+
option,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
event.stopImmediatePropagation();
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
},
|
|
167
|
+
commandPriority,
|
|
168
|
+
),
|
|
169
|
+
editor.registerCommand(
|
|
170
|
+
KEY_ARROW_UP_COMMAND,
|
|
171
|
+
(event) => {
|
|
172
|
+
if (options !== null && options.length) {
|
|
173
|
+
const newSelectedIndex =
|
|
174
|
+
selectedIndex === null
|
|
175
|
+
? options.length - 1
|
|
176
|
+
: selectedIndex !== 0
|
|
177
|
+
? selectedIndex - 1
|
|
178
|
+
: options.length - 1;
|
|
179
|
+
updateSelectedIndex(newSelectedIndex);
|
|
180
|
+
const option = options[newSelectedIndex];
|
|
181
|
+
if (!option) {
|
|
182
|
+
updateSelectedIndex(-1);
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopImmediatePropagation();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (option.ref && option.ref.current) {
|
|
188
|
+
scrollIntoViewIfNeeded(option.ref.current);
|
|
189
|
+
}
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
event.stopImmediatePropagation();
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
},
|
|
195
|
+
commandPriority,
|
|
196
|
+
),
|
|
197
|
+
editor.registerCommand(
|
|
198
|
+
KEY_ESCAPE_COMMAND,
|
|
199
|
+
(event) => {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
event.stopImmediatePropagation();
|
|
202
|
+
close();
|
|
203
|
+
return true;
|
|
204
|
+
},
|
|
205
|
+
commandPriority,
|
|
206
|
+
),
|
|
207
|
+
editor.registerCommand(
|
|
208
|
+
KEY_TAB_COMMAND,
|
|
209
|
+
(event) => {
|
|
210
|
+
if (options === null || selectedIndex === null || options[selectedIndex] == null) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopImmediatePropagation();
|
|
215
|
+
selectOptionAndCleanUp(options[selectedIndex]);
|
|
216
|
+
return true;
|
|
217
|
+
},
|
|
218
|
+
commandPriority,
|
|
219
|
+
),
|
|
220
|
+
editor.registerCommand(
|
|
221
|
+
KEY_ENTER_COMMAND,
|
|
222
|
+
(event) => {
|
|
223
|
+
if (
|
|
224
|
+
options === null || selectedIndex === null || options[selectedIndex] == null ||
|
|
225
|
+
event && event.shiftKey
|
|
226
|
+
) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (event !== null) {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
event.stopImmediatePropagation();
|
|
232
|
+
}
|
|
233
|
+
selectOptionAndCleanUp(options[selectedIndex]);
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
commandPriority,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
}, [
|
|
240
|
+
selectOptionAndCleanUp,
|
|
241
|
+
close,
|
|
242
|
+
editor,
|
|
243
|
+
options,
|
|
244
|
+
selectedIndex,
|
|
245
|
+
updateSelectedIndex,
|
|
246
|
+
commandPriority,
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const listItemProps = useMemo(
|
|
250
|
+
() => ({ options, selectOptionAndCleanUp, selectedIndex, setHighlightedIndex }),
|
|
251
|
+
[selectOptionAndCleanUp, selectedIndex, options],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if (anchorElementRef.current === null) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
// A custom menuRenderFn returns a portal directly (React-compatible); the default
|
|
258
|
+
// renders DefaultMenu into the anchor. octane renders a createPortal VALUE at any
|
|
259
|
+
// position, so no host-element wrapper is needed.
|
|
260
|
+
if (menuRenderFnProp != null) {
|
|
261
|
+
return menuRenderFnProp(
|
|
262
|
+
anchorElementRef,
|
|
263
|
+
listItemProps,
|
|
264
|
+
resolution.match ? resolution.match.matchingString : '',
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return options.length
|
|
268
|
+
? createPortal(DefaultMenu, anchorElementRef.current, {
|
|
269
|
+
options,
|
|
270
|
+
selectedIndex,
|
|
271
|
+
selectOptionAndCleanUp,
|
|
272
|
+
setHighlightedIndex,
|
|
273
|
+
})
|
|
274
|
+
: null;
|
|
275
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Slot mechanics shared by the binding's plain-`.ts` hooks (copied from
|
|
2
|
+
// @octanejs/router / @octanejs/query). The octane compiler injects a per-call-site
|
|
3
|
+
// Symbol slot into every hook call in `.tsrx`/`.tsx`, but these binding files are
|
|
4
|
+
// NOT compiled — so a hook here receives the caller's slot as its trailing argument
|
|
5
|
+
// and derives a distinct sub-slot for each base hook it composes.
|
|
6
|
+
|
|
7
|
+
// Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook so
|
|
8
|
+
// composing multiple base hooks gives each its own identity.
|
|
9
|
+
// Memoized: subSlot runs on EVERY hook call every render, and the naive form
|
|
10
|
+
// pays a string concat + global symbol-registry lookup each time. The cache is
|
|
11
|
+
// keyed by the slot symbol itself; the minted value is byte-identical to the
|
|
12
|
+
// uncached Symbol.for result, so identity is preserved across HMR re-evals and
|
|
13
|
+
// the per-package copies of this helper. Key universe is bounded: slots are
|
|
14
|
+
// per-call-site module constants (never minted per render).
|
|
15
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
16
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
17
|
+
if (slot === undefined) return undefined;
|
|
18
|
+
let byTag = subSlotCache.get(slot);
|
|
19
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
20
|
+
let sym = byTag.get(tag);
|
|
21
|
+
if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
22
|
+
return sym;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Split the compiler-injected trailing slot off a hook's args. Needed for hooks
|
|
26
|
+
// with OPTIONAL user args, where the slot can't be located positionally (the
|
|
27
|
+
// compiler always appends it last). Returns the user args + the slot.
|
|
28
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
29
|
+
const tail = args[args.length - 1];
|
|
30
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
31
|
+
return [slot !== undefined ? args.slice(0, -1) : args, slot];
|
|
32
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { LexicalCommand, LexicalEditor, TextNode } from 'lexical';
|
|
2
|
+
import {
|
|
3
|
+
$getSelection,
|
|
4
|
+
$isRangeSelection,
|
|
5
|
+
CAN_USE_DOM,
|
|
6
|
+
createCommand,
|
|
7
|
+
isDOMShadowRoot,
|
|
8
|
+
} from 'lexical';
|
|
9
|
+
|
|
10
|
+
// Non-hook, non-JSX shared pieces of @lexical/react/src/shared/LexicalMenu.tsx
|
|
11
|
+
// (the hooks live in useMenuAnchorRef.ts / useDynamicPositioning.ts; the LexicalMenu
|
|
12
|
+
// component in LexicalMenu.tsrx). Plain `.ts` — no compiler involvement.
|
|
13
|
+
|
|
14
|
+
export type MenuTextMatch = {
|
|
15
|
+
leadOffset: number;
|
|
16
|
+
matchingString: string;
|
|
17
|
+
replaceableString: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MenuResolution = {
|
|
21
|
+
match?: MenuTextMatch;
|
|
22
|
+
getRect: () => DOMRect;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type MenuRef = { current: HTMLElement | null };
|
|
26
|
+
|
|
27
|
+
export type MenuRenderFn<TOption extends MenuOption> = (
|
|
28
|
+
anchorElementRef: MenuRef,
|
|
29
|
+
itemProps: {
|
|
30
|
+
selectedIndex: number | null;
|
|
31
|
+
selectOptionAndCleanUp: (option: TOption) => void;
|
|
32
|
+
setHighlightedIndex: (index: number) => void;
|
|
33
|
+
options: TOption[];
|
|
34
|
+
},
|
|
35
|
+
matchingString: string,
|
|
36
|
+
) => unknown;
|
|
37
|
+
|
|
38
|
+
export type TriggerFn = (text: string, editor: LexicalEditor) => MenuTextMatch | null;
|
|
39
|
+
|
|
40
|
+
export class MenuOption {
|
|
41
|
+
key: string;
|
|
42
|
+
ref?: MenuRef;
|
|
43
|
+
icon?: unknown;
|
|
44
|
+
title?: unknown;
|
|
45
|
+
|
|
46
|
+
constructor(key: string) {
|
|
47
|
+
this.key = key;
|
|
48
|
+
this.ref = { current: null };
|
|
49
|
+
this.setRefElement = this.setRefElement.bind(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setRefElement(element: HTMLElement | null) {
|
|
53
|
+
this.ref = { current: element };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
|
|
58
|
+
index: number;
|
|
59
|
+
option: MenuOption;
|
|
60
|
+
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND');
|
|
61
|
+
|
|
62
|
+
export const scrollIntoViewIfNeeded = (target: HTMLElement) => {
|
|
63
|
+
const typeaheadContainerNode = target.closest('#typeahead-menu') as HTMLElement | null;
|
|
64
|
+
if (!typeaheadContainerNode) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const typeaheadRect = typeaheadContainerNode.getBoundingClientRect();
|
|
68
|
+
if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) {
|
|
69
|
+
typeaheadContainerNode.scrollIntoView({ block: 'center' });
|
|
70
|
+
}
|
|
71
|
+
if (typeaheadRect.top < 0) {
|
|
72
|
+
typeaheadContainerNode.scrollIntoView({ block: 'center' });
|
|
73
|
+
}
|
|
74
|
+
target.scrollIntoView({ block: 'nearest' });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function getFullMatchOffset(documentText: string, entryText: string, offset: number): number {
|
|
78
|
+
let triggerOffset = offset;
|
|
79
|
+
for (let i = triggerOffset; i <= entryText.length; i++) {
|
|
80
|
+
if (documentText.slice(-i) === entryText.substring(0, i)) {
|
|
81
|
+
triggerOffset = i;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return triggerOffset;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null {
|
|
88
|
+
const selection = $getSelection();
|
|
89
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const anchor = selection.anchor;
|
|
93
|
+
if (anchor.type !== 'text') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const anchorNode = anchor.getNode();
|
|
97
|
+
if (!anchorNode.isSimpleText()) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const selectionOffset = anchor.offset;
|
|
101
|
+
const textContent = anchorNode.getTextContent().slice(0, selectionOffset);
|
|
102
|
+
const characterOffset = match.replaceableString.length;
|
|
103
|
+
const queryOffset = getFullMatchOffset(textContent, match.matchingString, characterOffset);
|
|
104
|
+
const startOffset = selectionOffset - queryOffset;
|
|
105
|
+
if (startOffset < 0) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
let newNode;
|
|
109
|
+
if (startOffset === 0) {
|
|
110
|
+
[newNode] = anchorNode.splitText(selectionOffset);
|
|
111
|
+
} else {
|
|
112
|
+
[, newNode] = anchorNode.splitText(startOffset, selectionOffset);
|
|
113
|
+
}
|
|
114
|
+
return newNode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isTriggerVisibleInNearestScrollContainer(
|
|
118
|
+
targetElement: HTMLElement,
|
|
119
|
+
containerElement: HTMLElement,
|
|
120
|
+
): boolean {
|
|
121
|
+
const tRect = targetElement.getBoundingClientRect();
|
|
122
|
+
const cRect = containerElement.getBoundingClientRect();
|
|
123
|
+
const VISIBILITY_MARGIN_PX = 6;
|
|
124
|
+
return (
|
|
125
|
+
tRect.top >= cRect.top - VISIBILITY_MARGIN_PX &&
|
|
126
|
+
tRect.top <= cRect.bottom + VISIBILITY_MARGIN_PX
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function setContainerDivAttributes(containerDiv: HTMLElement, className?: string) {
|
|
131
|
+
if (className != null) {
|
|
132
|
+
containerDiv.className = className;
|
|
133
|
+
}
|
|
134
|
+
containerDiv.setAttribute('aria-label', 'Typeahead menu');
|
|
135
|
+
containerDiv.setAttribute('role', 'listbox');
|
|
136
|
+
containerDiv.style.display = 'block';
|
|
137
|
+
containerDiv.style.position = 'absolute';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveMenuParent(editor: LexicalEditor): HTMLElement | ShadowRoot | undefined {
|
|
141
|
+
if (!CAN_USE_DOM) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const rootElement = editor.getRootElement();
|
|
145
|
+
if (rootElement !== null) {
|
|
146
|
+
const root = rootElement.getRootNode();
|
|
147
|
+
if (isDOMShadowRoot(root)) {
|
|
148
|
+
return root as ShadowRoot;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return document.body;
|
|
152
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Ported verbatim from @lexical/react/src/shared/point.ts (framework-agnostic).
|
|
2
|
+
export class Point {
|
|
3
|
+
private readonly _x: number;
|
|
4
|
+
private readonly _y: number;
|
|
5
|
+
|
|
6
|
+
constructor(x: number, y: number) {
|
|
7
|
+
this._x = x;
|
|
8
|
+
this._y = y;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get x(): number {
|
|
12
|
+
return this._x;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get y(): number {
|
|
16
|
+
return this._y;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public equals({ x, y }: Point): boolean {
|
|
20
|
+
return this.x === x && this.y === y;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public calcDeltaXTo({ x }: Point): number {
|
|
24
|
+
return this.x - x;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public calcDeltaYTo({ y }: Point): number {
|
|
28
|
+
return this.y - y;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public calcHorizontalDistanceTo(point: Point): number {
|
|
32
|
+
return Math.abs(this.calcDeltaXTo(point));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public calcVerticalDistance(point: Point): number {
|
|
36
|
+
return Math.abs(this.calcDeltaYTo(point));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public calcDistanceTo(point: Point): number {
|
|
40
|
+
return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isPoint(x: unknown): x is Point {
|
|
45
|
+
return x instanceof Point;
|
|
46
|
+
}
|