@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
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
+ }