@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @octanejs/lexical
|
|
2
|
+
|
|
3
|
+
[Lexical](https://lexical.dev) for the [octane](https://github.com/octanejs/octane) renderer.
|
|
4
|
+
|
|
5
|
+
Lexical is an extensible rich-text editor framework. This package reuses Lexical's
|
|
6
|
+
framework-agnostic core (`lexical`, `@lexical/rich-text`, `@lexical/history`,
|
|
7
|
+
`@lexical/list`, …) and reimplements the [`@lexical/react`](https://www.npmjs.com/package/@lexical/react)
|
|
8
|
+
binding layer on octane's hooks. It mirrors `@lexical/react`'s **per-subpath module
|
|
9
|
+
layout** (that package has no root barrel), so existing Lexical code ports by changing
|
|
10
|
+
only the scope:
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
// before
|
|
14
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
15
|
+
// after
|
|
16
|
+
import { useLexicalComposerContext } from '@octanejs/lexical/LexicalComposerContext';
|
|
17
|
+
|
|
18
|
+
function MyPlugin() {
|
|
19
|
+
const [editor] = useLexicalComposerContext();
|
|
20
|
+
// …
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A convenience root barrel (`@octanejs/lexical`) re-exporting everything is also
|
|
25
|
+
provided, but the per-subpath paths above are the drop-in form.
|
|
26
|
+
|
|
27
|
+
Pinned to **Lexical 0.46.0**.
|
|
28
|
+
|
|
29
|
+
## Status
|
|
30
|
+
|
|
31
|
+
Near-complete `@lexical/react` parity — **35 of 39 modules ported** (Lexical 0.46.0).
|
|
32
|
+
Parity is verified two ways: **differential** tests (the same `.tsrx` fixture run on
|
|
33
|
+
octane *and* the real `@lexical/react`, asserting byte-identical DOM) plus ports of
|
|
34
|
+
Lexical's own unit tests onto octane's harness.
|
|
35
|
+
|
|
36
|
+
Landed: the composer + context (`LexicalComposer`, `LexicalComposerContext`,
|
|
37
|
+
`LexicalNestedComposer`), the editable surface (`LexicalContentEditable`,
|
|
38
|
+
`LexicalErrorBoundary`), the text bindings (`LexicalPlainTextPlugin`,
|
|
39
|
+
`LexicalRichTextPlugin`), and the full plugin/menu set — history, list + check-list,
|
|
40
|
+
link + clickable-link, hashtag, tab-indentation, markdown shortcuts, horizontal-rule,
|
|
41
|
+
table, table-of-contents, auto-focus / -link / -embed, clear-editor, character-limit,
|
|
42
|
+
draggable-block, node-event, on-change, selection-always-on-display, and the
|
|
43
|
+
typeahead / node-menu / node-context-menu family — plus the `useLexical*` hooks and
|
|
44
|
+
`LexicalCollaborationContext`.
|
|
45
|
+
|
|
46
|
+
The 4 not-yet-ported modules are each deferred for a specific reason, not merely
|
|
47
|
+
undone:
|
|
48
|
+
|
|
49
|
+
- `LexicalCollaborationPlugin` — real-time Yjs collaboration. A genuine binding-layer
|
|
50
|
+
port (it wraps the framework-agnostic `@lexical/yjs`), deferred until there's a
|
|
51
|
+
two-peer Yjs harness to verify sync: the differential DOM-parity suite the other
|
|
52
|
+
modules rely on can't meaningfully exercise live collaboration.
|
|
53
|
+
- `LexicalExtensionComposer` + `LexicalExtensionEditorComposer` — the newer
|
|
54
|
+
extension-builder composer API. Thin wrappers over a separate React-only subsystem
|
|
55
|
+
(`@lexical/react/ReactExtension` + `ReactProviderExtension`); the classic
|
|
56
|
+
`LexicalComposer` path here is fully supported.
|
|
57
|
+
- `LexicalTreeView` — the debug tree viewer. A thin wrapper over
|
|
58
|
+
`@lexical/devtools-core`'s `TreeView`, which is itself a React component (React is
|
|
59
|
+
its peer dependency) — porting it means porting that separate devtools UI.
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
The agnostic `@lexical/*` packages contain no React code, so they're consumed
|
|
64
|
+
directly. Only `@lexical/react`'s components/hooks are reimplemented — translating
|
|
65
|
+
React hooks to octane's (1:1 in nearly all cases), `forwardRef` to octane's
|
|
66
|
+
ref-as-prop, `@floating-ui/react` to `@floating-ui/dom`, and the class-based
|
|
67
|
+
`LexicalErrorBoundary` to octane's `<ErrorBoundary>`.
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/lexical",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Lexical bindings for the octane renderer — reuses the framework-agnostic @lexical/* core and reimplements the @lexical/react layer on octane's hooks.",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Dominic Gannaway",
|
|
9
|
+
"email": "dg@domgan.com"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
17
|
+
"directory": "packages/lexical"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./LexicalComposerContext": "./src/LexicalComposerContext.ts",
|
|
29
|
+
"./LexicalComposer": "./src/LexicalComposer.tsrx",
|
|
30
|
+
"./LexicalContentEditable": "./src/LexicalContentEditable.tsrx",
|
|
31
|
+
"./LexicalRichTextPlugin": "./src/LexicalRichTextPlugin.tsrx",
|
|
32
|
+
"./LexicalPlainTextPlugin": "./src/LexicalPlainTextPlugin.tsrx",
|
|
33
|
+
"./LexicalErrorBoundary": "./src/LexicalErrorBoundary.tsrx",
|
|
34
|
+
"./LexicalHistoryPlugin": "./src/LexicalHistoryPlugin.tsrx",
|
|
35
|
+
"./LexicalOnChangePlugin": "./src/LexicalOnChangePlugin.tsrx",
|
|
36
|
+
"./LexicalAutoFocusPlugin": "./src/LexicalAutoFocusPlugin.tsrx",
|
|
37
|
+
"./LexicalClearEditorPlugin": "./src/LexicalClearEditorPlugin.tsrx",
|
|
38
|
+
"./LexicalListPlugin": "./src/LexicalListPlugin.tsrx",
|
|
39
|
+
"./LexicalCheckListPlugin": "./src/LexicalCheckListPlugin.tsrx",
|
|
40
|
+
"./LexicalLinkPlugin": "./src/LexicalLinkPlugin.tsrx",
|
|
41
|
+
"./LexicalAutoLinkPlugin": "./src/LexicalAutoLinkPlugin.tsrx",
|
|
42
|
+
"./LexicalHashtagPlugin": "./src/LexicalHashtagPlugin.tsrx",
|
|
43
|
+
"./LexicalTabIndentationPlugin": "./src/LexicalTabIndentationPlugin.tsrx",
|
|
44
|
+
"./LexicalMarkdownShortcutPlugin": "./src/LexicalMarkdownShortcutPlugin.tsrx",
|
|
45
|
+
"./LexicalTablePlugin": "./src/LexicalTablePlugin.tsrx",
|
|
46
|
+
"./LexicalNodeEventPlugin": "./src/LexicalNodeEventPlugin.tsrx",
|
|
47
|
+
"./LexicalEditorRefPlugin": "./src/LexicalEditorRefPlugin.tsrx",
|
|
48
|
+
"./LexicalHorizontalRulePlugin": "./src/LexicalHorizontalRulePlugin.tsrx",
|
|
49
|
+
"./LexicalHorizontalRuleNode": "./src/LexicalHorizontalRuleNode.tsrx",
|
|
50
|
+
"./LexicalTypeaheadMenuPlugin": "./src/LexicalTypeaheadMenuPlugin.tsrx",
|
|
51
|
+
"./LexicalNodeMenuPlugin": "./src/LexicalNodeMenuPlugin.tsrx",
|
|
52
|
+
"./LexicalClickableLinkPlugin": "./src/LexicalClickableLinkPlugin.tsrx",
|
|
53
|
+
"./LexicalSelectionAlwaysOnDisplay": "./src/LexicalSelectionAlwaysOnDisplay.tsrx",
|
|
54
|
+
"./useLexicalEditable": "./src/useLexicalEditable.ts",
|
|
55
|
+
"./useLexicalSubscription": "./src/useLexicalSubscription.ts",
|
|
56
|
+
"./useLexicalNodeSelection": "./src/useLexicalNodeSelection.ts",
|
|
57
|
+
"./useLexicalTextEntity": "./src/useLexicalTextEntity.ts",
|
|
58
|
+
"./useLexicalIsTextContentEmpty": "./src/useLexicalIsTextContentEmpty.ts",
|
|
59
|
+
"./useLexicalSlotRef": "./src/useLexicalSlotRef.ts",
|
|
60
|
+
"./LexicalNestedComposer": "./src/LexicalNestedComposer.tsrx",
|
|
61
|
+
"./LexicalDecoratorBlockNode": "./src/LexicalDecoratorBlockNode.ts",
|
|
62
|
+
"./LexicalBlockWithAlignableContents": "./src/LexicalBlockWithAlignableContents.tsrx",
|
|
63
|
+
"./LexicalCollaborationContext": "./src/LexicalCollaborationContext.ts",
|
|
64
|
+
"./LexicalCharacterLimitPlugin": "./src/LexicalCharacterLimitPlugin.tsrx",
|
|
65
|
+
"./LexicalTableOfContentsPlugin": "./src/LexicalTableOfContentsPlugin.tsrx",
|
|
66
|
+
"./LexicalAutoEmbedPlugin": "./src/LexicalAutoEmbedPlugin.tsrx",
|
|
67
|
+
"./LexicalDraggableBlockPlugin": "./src/LexicalDraggableBlockPlugin.tsrx",
|
|
68
|
+
"./LexicalNodeContextMenuPlugin": "./src/LexicalNodeContextMenuPlugin.tsrx"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@lexical/devtools-core": "0.46.0",
|
|
72
|
+
"@lexical/dragon": "0.46.0",
|
|
73
|
+
"@lexical/extension": "0.46.0",
|
|
74
|
+
"@lexical/hashtag": "0.46.0",
|
|
75
|
+
"@lexical/history": "0.46.0",
|
|
76
|
+
"@lexical/internal": "0.46.0",
|
|
77
|
+
"@lexical/link": "0.46.0",
|
|
78
|
+
"@lexical/list": "0.46.0",
|
|
79
|
+
"@lexical/mark": "0.46.0",
|
|
80
|
+
"@lexical/markdown": "0.46.0",
|
|
81
|
+
"@lexical/overflow": "0.46.0",
|
|
82
|
+
"@lexical/plain-text": "0.46.0",
|
|
83
|
+
"@lexical/rich-text": "0.46.0",
|
|
84
|
+
"@lexical/table": "0.46.0",
|
|
85
|
+
"@lexical/text": "0.46.0",
|
|
86
|
+
"@lexical/utils": "0.46.0",
|
|
87
|
+
"@lexical/yjs": "0.46.0",
|
|
88
|
+
"lexical": "0.46.0",
|
|
89
|
+
"yjs": "^13.6.0",
|
|
90
|
+
"@octanejs/floating-ui": "0.1.2",
|
|
91
|
+
"octane": "0.1.3"
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@lexical/react": "0.46.0",
|
|
95
|
+
"@tsrx/react": "^0.2.33",
|
|
96
|
+
"esbuild": "^0.28.1",
|
|
97
|
+
"react": "^19.2.0",
|
|
98
|
+
"react-dom": "^19.2.0",
|
|
99
|
+
"vitest": "^4.1.9"
|
|
100
|
+
},
|
|
101
|
+
"scripts": {
|
|
102
|
+
"test": "vitest run"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalAutoEmbedPlugin.tsx. Watches pasted
|
|
2
|
+
// AutoLink/Link nodes that match an embed config (YouTube, tweet, …) and shows a
|
|
3
|
+
// node menu offering to replace the link with an embed node. Built on the ported
|
|
4
|
+
// LexicalNodeMenuPlugin. Typed surface + AutoEmbedOption live in autoEmbedShared.ts.
|
|
5
|
+
import { $isLinkNode, AutoLinkNode, LinkNode } from '@lexical/link';
|
|
6
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
7
|
+
import { LexicalNodeMenuPlugin } from './LexicalNodeMenuPlugin.tsrx';
|
|
8
|
+
import {
|
|
9
|
+
$getNodeByKey,
|
|
10
|
+
$getSelection,
|
|
11
|
+
COMMAND_PRIORITY_EDITOR,
|
|
12
|
+
COMMAND_PRIORITY_LOW,
|
|
13
|
+
mergeRegister,
|
|
14
|
+
PASTE_TAG,
|
|
15
|
+
} from 'lexical';
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from 'octane';
|
|
17
|
+
|
|
18
|
+
export { URL_MATCHER, INSERT_EMBED_COMMAND, AutoEmbedOption } from './autoEmbedShared';
|
|
19
|
+
|
|
20
|
+
export function LexicalAutoEmbedPlugin(props) {
|
|
21
|
+
const embedConfigs = props.embedConfigs;
|
|
22
|
+
const onOpenEmbedModalForConfig = props.onOpenEmbedModalForConfig;
|
|
23
|
+
const getMenuOptions = props.getMenuOptions;
|
|
24
|
+
const menuRenderFn = props.menuRenderFn;
|
|
25
|
+
const menuCommandPriority =
|
|
26
|
+
props.menuCommandPriority != null ? props.menuCommandPriority : COMMAND_PRIORITY_LOW;
|
|
27
|
+
|
|
28
|
+
const [editor] = useLexicalComposerContext();
|
|
29
|
+
|
|
30
|
+
const [nodeKey, setNodeKey] = useState(null);
|
|
31
|
+
const [activeEmbedConfig, setActiveEmbedConfig] = useState(null);
|
|
32
|
+
|
|
33
|
+
const reset = useCallback(() => {
|
|
34
|
+
setNodeKey(null);
|
|
35
|
+
setActiveEmbedConfig(null);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const checkIfLinkNodeIsEmbeddable = useCallback(async (key) => {
|
|
39
|
+
const url = editor.read('latest', function () {
|
|
40
|
+
const linkNode = $getNodeByKey(key);
|
|
41
|
+
if ($isLinkNode(linkNode)) {
|
|
42
|
+
return linkNode.getURL();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (url === undefined) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const embedConfig of embedConfigs) {
|
|
49
|
+
const urlMatch = await Promise.resolve(embedConfig.parseUrl(url));
|
|
50
|
+
if (urlMatch != null) {
|
|
51
|
+
setActiveEmbedConfig(embedConfig);
|
|
52
|
+
setNodeKey(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [editor, embedConfigs]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const listener = (nodeMutations, { updateTags, dirtyLeaves }) => {
|
|
59
|
+
for (const [key, mutation] of nodeMutations) {
|
|
60
|
+
if (mutation === 'created' && updateTags.has(PASTE_TAG) && dirtyLeaves.size <= 3) {
|
|
61
|
+
checkIfLinkNodeIsEmbeddable(key);
|
|
62
|
+
} else if (key === nodeKey) {
|
|
63
|
+
reset();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return mergeRegister(
|
|
68
|
+
...[LinkNode, AutoLinkNode].map(
|
|
69
|
+
(Klass) => editor.registerMutationListener(Klass, (...args) => listener(...args), {
|
|
70
|
+
skipInitialization: true,
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}, [checkIfLinkNodeIsEmbeddable, editor, nodeKey, reset]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!onOpenEmbedModalForConfig) return;
|
|
78
|
+
|
|
79
|
+
return editor.registerCommand(
|
|
80
|
+
INSERT_EMBED_COMMAND,
|
|
81
|
+
(embedConfigType) => {
|
|
82
|
+
const embedConfig = embedConfigs.find(({ type }) => type === embedConfigType);
|
|
83
|
+
if (embedConfig) {
|
|
84
|
+
onOpenEmbedModalForConfig(embedConfig);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
},
|
|
89
|
+
COMMAND_PRIORITY_EDITOR,
|
|
90
|
+
);
|
|
91
|
+
}, [editor, embedConfigs, onOpenEmbedModalForConfig]);
|
|
92
|
+
|
|
93
|
+
const embedLinkViaActiveEmbedConfig = useCallback(async function () {
|
|
94
|
+
if (activeEmbedConfig != null && nodeKey != null) {
|
|
95
|
+
const linkNode = editor.read('latest', () => {
|
|
96
|
+
const node = $getNodeByKey(nodeKey);
|
|
97
|
+
if ($isLinkNode(node)) {
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if ($isLinkNode(linkNode)) {
|
|
104
|
+
const result = await Promise.resolve(activeEmbedConfig.parseUrl(linkNode.__url));
|
|
105
|
+
if (result != null) {
|
|
106
|
+
editor.update(() => {
|
|
107
|
+
if (!$getSelection()) {
|
|
108
|
+
linkNode.selectEnd();
|
|
109
|
+
}
|
|
110
|
+
activeEmbedConfig.insertNode(editor, result);
|
|
111
|
+
if (linkNode.isAttached()) {
|
|
112
|
+
linkNode.remove();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}, [activeEmbedConfig, editor, nodeKey]);
|
|
119
|
+
|
|
120
|
+
const options = useMemo(() => {
|
|
121
|
+
return activeEmbedConfig != null && nodeKey != null
|
|
122
|
+
? getMenuOptions(activeEmbedConfig, embedLinkViaActiveEmbedConfig, reset)
|
|
123
|
+
: [];
|
|
124
|
+
}, [activeEmbedConfig, embedLinkViaActiveEmbedConfig, getMenuOptions, nodeKey, reset]);
|
|
125
|
+
|
|
126
|
+
const onSelectOption = useCallback((selectedOption, targetNode, closeMenu) => {
|
|
127
|
+
editor.update(() => {
|
|
128
|
+
selectedOption.onSelect(targetNode);
|
|
129
|
+
closeMenu();
|
|
130
|
+
});
|
|
131
|
+
}, [editor]);
|
|
132
|
+
|
|
133
|
+
if (nodeKey == null) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return <LexicalNodeMenuPlugin
|
|
137
|
+
nodeKey={nodeKey}
|
|
138
|
+
onClose={reset}
|
|
139
|
+
onSelectOption={onSelectOption}
|
|
140
|
+
options={options}
|
|
141
|
+
menuRenderFn={menuRenderFn}
|
|
142
|
+
commandPriority={menuCommandPriority}
|
|
143
|
+
/>;
|
|
144
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalAutoFocusPlugin.ts.
|
|
2
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
3
|
+
import { getActiveElement } from 'lexical';
|
|
4
|
+
import { useEffect } from 'octane';
|
|
5
|
+
|
|
6
|
+
export function AutoFocusPlugin(props) {
|
|
7
|
+
const [editor] = useLexicalComposerContext();
|
|
8
|
+
const defaultSelection = props.defaultSelection;
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
editor.focus(() => {
|
|
12
|
+
const rootElement = editor.getRootElement();
|
|
13
|
+
// getActiveElement rather than document.activeElement (shadow-root safe).
|
|
14
|
+
const activeElement =
|
|
15
|
+
rootElement !== null ? getActiveElement(rootElement) : null;
|
|
16
|
+
if (
|
|
17
|
+
rootElement !== null && (activeElement === null || !rootElement.contains(activeElement))
|
|
18
|
+
) {
|
|
19
|
+
rootElement.focus({ preventScroll: true });
|
|
20
|
+
}
|
|
21
|
+
}, { defaultSelection });
|
|
22
|
+
}, [defaultSelection, editor]);
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalAutoLinkPlugin.ts (useAutoLink inlined).
|
|
2
|
+
import invariant from '@lexical/internal/invariant';
|
|
3
|
+
import { AutoLinkNode, registerAutoLink } from '@lexical/link';
|
|
4
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
5
|
+
import { useEffect } from 'octane';
|
|
6
|
+
|
|
7
|
+
export { createLinkMatcherWithRegExp } from '@lexical/link';
|
|
8
|
+
|
|
9
|
+
export function AutoLinkPlugin(props) {
|
|
10
|
+
const [editor] = useLexicalComposerContext();
|
|
11
|
+
const matchers = props.matchers;
|
|
12
|
+
const onChange = props.onChange;
|
|
13
|
+
const excludeParents = props.excludeParents;
|
|
14
|
+
|
|
15
|
+
// No deps — runs every render (matches @lexical/react).
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!editor.hasNodes([AutoLinkNode])) {
|
|
18
|
+
invariant(false, 'LexicalAutoLinkPlugin: AutoLinkNode not registered on editor');
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
useEffect(
|
|
23
|
+
() => registerAutoLink(editor, {
|
|
24
|
+
changeHandlers: onChange ? [onChange] : [],
|
|
25
|
+
excludeParents: excludeParents ?? [],
|
|
26
|
+
matchers,
|
|
27
|
+
}),
|
|
28
|
+
[editor, matchers, onChange, excludeParents],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalBlockWithAlignableContents.tsx. Wraps a
|
|
2
|
+
// DecoratorBlockNode's contents, syncing node selection + element alignment:
|
|
3
|
+
// reflects the node's `format` alignment, responds to FORMAT_ELEMENT_COMMAND, and
|
|
4
|
+
// toggles selection on click.
|
|
5
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
6
|
+
import { $isDecoratorBlockNode } from './LexicalDecoratorBlockNode';
|
|
7
|
+
import { useLexicalNodeSelection } from './useLexicalNodeSelection';
|
|
8
|
+
import { $getNearestBlockElementAncestorOrThrow } from '@lexical/utils';
|
|
9
|
+
import {
|
|
10
|
+
$getNodeByKey,
|
|
11
|
+
$getSelection,
|
|
12
|
+
$isNodeSelection,
|
|
13
|
+
$isRangeSelection,
|
|
14
|
+
CLICK_COMMAND,
|
|
15
|
+
COMMAND_PRIORITY_LOW,
|
|
16
|
+
FORMAT_ELEMENT_COMMAND,
|
|
17
|
+
getComposedEventTarget,
|
|
18
|
+
mergeRegister,
|
|
19
|
+
} from 'lexical';
|
|
20
|
+
import { useEffect, useRef } from 'octane';
|
|
21
|
+
|
|
22
|
+
export function BlockWithAlignableContents(props) {
|
|
23
|
+
const nodeKey = props.nodeKey;
|
|
24
|
+
const format = props.format;
|
|
25
|
+
const className = props.className;
|
|
26
|
+
|
|
27
|
+
const [editor] = useLexicalComposerContext();
|
|
28
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
29
|
+
const ref = useRef(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return mergeRegister(
|
|
33
|
+
editor.registerCommand(
|
|
34
|
+
FORMAT_ELEMENT_COMMAND,
|
|
35
|
+
(formatType) => {
|
|
36
|
+
if (isSelected) {
|
|
37
|
+
const selection = $getSelection();
|
|
38
|
+
if ($isNodeSelection(selection)) {
|
|
39
|
+
const node = $getNodeByKey(nodeKey);
|
|
40
|
+
if ($isDecoratorBlockNode(node)) {
|
|
41
|
+
node.setFormat(formatType);
|
|
42
|
+
}
|
|
43
|
+
} else if ($isRangeSelection(selection)) {
|
|
44
|
+
const nodes = selection.getNodes();
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
if ($isDecoratorBlockNode(node)) {
|
|
47
|
+
node.setFormat(formatType);
|
|
48
|
+
} else {
|
|
49
|
+
const element = $getNearestBlockElementAncestorOrThrow(node);
|
|
50
|
+
element.setFormat(formatType);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
},
|
|
58
|
+
COMMAND_PRIORITY_LOW,
|
|
59
|
+
),
|
|
60
|
+
editor.registerCommand(
|
|
61
|
+
CLICK_COMMAND,
|
|
62
|
+
(event) => {
|
|
63
|
+
if (getComposedEventTarget(event) === ref.current) {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
if (!event.shiftKey) {
|
|
66
|
+
clearSelection();
|
|
67
|
+
}
|
|
68
|
+
setSelected(!isSelected);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
},
|
|
73
|
+
COMMAND_PRIORITY_LOW,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}, [clearSelection, editor, isSelected, nodeKey, setSelected]);
|
|
77
|
+
|
|
78
|
+
const cls = [className.base, isSelected ? className.focus : null].filter(Boolean).join(' ');
|
|
79
|
+
|
|
80
|
+
return <div
|
|
81
|
+
class={cls}
|
|
82
|
+
ref={ref}
|
|
83
|
+
style={{ textAlign: format ? format : undefined }}
|
|
84
|
+
>{props.children}</div>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalCharacterLimitPlugin.tsx. Tracks text
|
|
2
|
+
// length against maxLength, marks overflow, and renders remaining characters. The
|
|
3
|
+
// renderer is rendered via createElement (octane renders the descriptor) rather
|
|
4
|
+
// than called directly.
|
|
5
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
6
|
+
import { useCharacterLimit } from './shared/useCharacterLimit';
|
|
7
|
+
import { createElement, useMemo, useState } from 'octane';
|
|
8
|
+
|
|
9
|
+
const CHARACTER_LIMIT = 5;
|
|
10
|
+
let textEncoderInstance = null;
|
|
11
|
+
|
|
12
|
+
function textEncoder() {
|
|
13
|
+
if (window.TextEncoder === undefined) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (textEncoderInstance === null) {
|
|
17
|
+
textEncoderInstance = new window.TextEncoder();
|
|
18
|
+
}
|
|
19
|
+
return textEncoderInstance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function utf8Length(text) {
|
|
23
|
+
const currentTextEncoder = textEncoder();
|
|
24
|
+
if (currentTextEncoder === null) {
|
|
25
|
+
const m = encodeURIComponent(text).match(/%[89ABab]/g);
|
|
26
|
+
return text.length + (m ? m.length : 0);
|
|
27
|
+
}
|
|
28
|
+
return currentTextEncoder.encode(text).length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function DefaultRenderer(props) @{
|
|
32
|
+
<span
|
|
33
|
+
class={'characters-limit ' + (props.remainingCharacters < 0 ? 'characters-limit-exceeded' : '')}
|
|
34
|
+
>
|
|
35
|
+
{props.remainingCharacters as string}
|
|
36
|
+
</span>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CharacterLimitPlugin(props) {
|
|
40
|
+
const charset = props.charset ?? 'UTF-16';
|
|
41
|
+
const maxLength = props.maxLength ?? CHARACTER_LIMIT;
|
|
42
|
+
const renderer = props.renderer ?? DefaultRenderer;
|
|
43
|
+
|
|
44
|
+
const [editor] = useLexicalComposerContext();
|
|
45
|
+
const [remainingCharacters, setRemainingCharacters] = useState(maxLength);
|
|
46
|
+
|
|
47
|
+
const characterLimitProps = useMemo(
|
|
48
|
+
() => ({
|
|
49
|
+
remainingCharacters: setRemainingCharacters,
|
|
50
|
+
strlen: (text) => {
|
|
51
|
+
if (charset === 'UTF-8') {
|
|
52
|
+
return utf8Length(text);
|
|
53
|
+
} else if (charset === 'UTF-16') {
|
|
54
|
+
return text.length;
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error('Unrecognized charset');
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
[charset],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useCharacterLimit(editor, maxLength, characterLimitProps);
|
|
64
|
+
|
|
65
|
+
return createElement(renderer, { remainingCharacters });
|
|
66
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalCheckListPlugin.tsx.
|
|
2
|
+
import { registerCheckList } from '@lexical/list';
|
|
3
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
4
|
+
import { useEffect } from 'octane';
|
|
5
|
+
|
|
6
|
+
export function CheckListPlugin(props) {
|
|
7
|
+
const [editor] = useLexicalComposerContext();
|
|
8
|
+
const disableTakeFocusOnClick =
|
|
9
|
+
props.disableTakeFocusOnClick === undefined ? false : props.disableTakeFocusOnClick;
|
|
10
|
+
|
|
11
|
+
useEffect(() => registerCheckList(editor, { disableTakeFocusOnClick }), [
|
|
12
|
+
editor,
|
|
13
|
+
disableTakeFocusOnClick,
|
|
14
|
+
]);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalClearEditorPlugin.ts. registerClearEditor
|
|
2
|
+
// comes from @lexical/extension's framework-agnostic core.
|
|
3
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
4
|
+
import { registerClearEditor } from '@lexical/extension';
|
|
5
|
+
import { useLayoutEffect } from 'octane';
|
|
6
|
+
|
|
7
|
+
export function ClearEditorPlugin(props) {
|
|
8
|
+
const [editor] = useLexicalComposerContext();
|
|
9
|
+
const onClear = props.onClear;
|
|
10
|
+
useLayoutEffect(() => registerClearEditor(editor, onClear), [editor, onClear]);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalClickableLinkPlugin.tsx.
|
|
2
|
+
import { namedSignals } from '@lexical/extension';
|
|
3
|
+
import { registerClickableLink } from '@lexical/link';
|
|
4
|
+
import { useLexicalComposerContext } from './LexicalComposerContext';
|
|
5
|
+
import { useEffect } from 'octane';
|
|
6
|
+
|
|
7
|
+
export function ClickableLinkPlugin(props) {
|
|
8
|
+
const [editor] = useLexicalComposerContext();
|
|
9
|
+
const newTab = props.newTab !== false;
|
|
10
|
+
const disabled = props.disabled === true;
|
|
11
|
+
useEffect(() => registerClickableLink(editor, namedSignals({ disabled, newTab })), [
|
|
12
|
+
editor,
|
|
13
|
+
newTab,
|
|
14
|
+
disabled,
|
|
15
|
+
]);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/LexicalCollaborationContext.tsx — the context +
|
|
2
|
+
// reader hook (both rely only on useContext, which octane keys by identity, so no
|
|
3
|
+
// hook slot is needed and this stays plain `.ts`). The `LexicalCollaboration`
|
|
4
|
+
// provider component lands with P5 (collaboration).
|
|
5
|
+
import type { Doc } from 'yjs';
|
|
6
|
+
|
|
7
|
+
import { createContext, useContext } from 'octane';
|
|
8
|
+
|
|
9
|
+
export type CollaborationContextType = {
|
|
10
|
+
color: string;
|
|
11
|
+
isCollabActive: boolean;
|
|
12
|
+
name: string;
|
|
13
|
+
yjsDocMap: Map<string, Doc>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const entries: [string, string][] = [
|
|
17
|
+
['Cat', 'rgb(125, 50, 0)'],
|
|
18
|
+
['Dog', 'rgb(100, 0, 0)'],
|
|
19
|
+
['Rabbit', 'rgb(150, 0, 0)'],
|
|
20
|
+
['Frog', 'rgb(200, 0, 0)'],
|
|
21
|
+
['Fox', 'rgb(200, 75, 0)'],
|
|
22
|
+
['Hedgehog', 'rgb(0, 75, 0)'],
|
|
23
|
+
['Pigeon', 'rgb(0, 125, 0)'],
|
|
24
|
+
['Squirrel', 'rgb(75, 100, 0)'],
|
|
25
|
+
['Bear', 'rgb(125, 100, 0)'],
|
|
26
|
+
['Tiger', 'rgb(0, 0, 150)'],
|
|
27
|
+
['Leopard', 'rgb(0, 0, 200)'],
|
|
28
|
+
['Zebra', 'rgb(0, 0, 250)'],
|
|
29
|
+
['Wolf', 'rgb(0, 100, 150)'],
|
|
30
|
+
['Owl', 'rgb(0, 100, 100)'],
|
|
31
|
+
['Gull', 'rgb(100, 0, 100)'],
|
|
32
|
+
['Squid', 'rgb(150, 0, 150)'],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const randomEntry = entries[Math.floor(Math.random() * entries.length)];
|
|
36
|
+
|
|
37
|
+
export const CollaborationContext = createContext<CollaborationContextType | null>(null);
|
|
38
|
+
|
|
39
|
+
function newContext(): CollaborationContextType {
|
|
40
|
+
return {
|
|
41
|
+
color: randomEntry[1],
|
|
42
|
+
isCollabActive: false,
|
|
43
|
+
name: randomEntry[0],
|
|
44
|
+
yjsDocMap: new Map(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const UNSAFE_GLOBAL_CONTEXT = newContext();
|
|
49
|
+
|
|
50
|
+
export function useCollaborationContext(
|
|
51
|
+
username?: string,
|
|
52
|
+
color?: string,
|
|
53
|
+
): CollaborationContextType {
|
|
54
|
+
let collabContext = useContext(CollaborationContext);
|
|
55
|
+
|
|
56
|
+
collabContext = collabContext ?? UNSAFE_GLOBAL_CONTEXT;
|
|
57
|
+
|
|
58
|
+
if (username != null) {
|
|
59
|
+
collabContext.name = username;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (color != null) {
|
|
63
|
+
collabContext.color = color;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return collabContext;
|
|
67
|
+
}
|