@octane-xplat/lexical 0.0.1 → 0.11.0

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.
@@ -0,0 +1,143 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useState, useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
+ import { RichTextEditor, supported as leafSupported } from '@octane-xplat/richtext'
4
+ import type { RichTextEditorHandle } from '@octane-xplat/richtext'
5
+ import type { LexicalEditorHandle, LexicalEditorProps, LexicalJSON } from './types'
6
+ import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from './json-bridge'
7
+
8
+ export const supported = leafSupported
9
+
10
+ /** Unified editor facade, native half. Rendering/editing run through the
11
+ * Aztec leaf; the lexical document model (headless createEditor +
12
+ * @lexical/html over zeed-dom) handles serialized-state interchange on top
13
+ * of it. This is a read/write model, not a live editor — no
14
+ * dispatchCommand, node transforms, or plugin behavior on native. */
15
+ export function LexicalEditor(props: LexicalEditorProps) @{
16
+ const inner = useRef<RichTextEditorHandle | null>(null)
17
+ const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
18
+ useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
19
+ const lastJSON = useRef<string | undefined>(undefined)
20
+ const appliedHost = useRef<RichTextEditorHandle | null>(null)
21
+ // Latest doc requested for the host — by `json` prop or `setJSON`. Kept
22
+ // after a successful push so a rebound (recreated) host still gets it.
23
+ const desiredDoc = useRef<LexicalJSON | undefined>(undefined)
24
+
25
+ // Push the requested doc once both halves are up — the JSON bridge
26
+ // (async module load) and the bound host (ref/onReady timing). Skips the
27
+ // stamp the host already shows, so editor-emitted echoes and repeat props
28
+ // never reach setHTML; a different host always gets its own copy.
29
+ const applyDoc = () => {
30
+ const host = inner.current
31
+ const doc = desiredDoc.current
32
+ if (!host || doc === undefined || !jsonBridgeReady()) return
33
+ const stamp = JSON.stringify(doc)
34
+ if (stamp === lastJSON.current && appliedHost.current === host) return
35
+ const html = jsonToHTML(doc)
36
+ if (html != null) {
37
+ lastJSON.current = stamp
38
+ appliedHost.current = host
39
+ host.setHTML(html)
40
+ }
41
+ }
42
+
43
+ const bindHost = useCallback((h: RichTextEditorHandle | null) => {
44
+ inner.current = h
45
+ if (h) {
46
+ // The ref binds inside the leaf's commit, before its own `value`
47
+ // seed — defer so `json` keeps precedence over `value`.
48
+ queueMicrotask(applyDoc)
49
+ }
50
+
51
+ setHandle((previous) => previous?.native === h?.native ? previous : h ? ({
52
+ getHTML: () => h.getHTML(),
53
+ setHTML: (html: string) => h.setHTML(html),
54
+ getJSON: () => (jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null),
55
+ setJSON: (doc) => {
56
+ desiredDoc.current = doc
57
+ applyDoc()
58
+ },
59
+ apply: (format) => h.apply(format),
60
+ linkTo: (url, anchor) => h.linkTo(url, anchor),
61
+ removeLink: () => h.removeLink(),
62
+ isActive: (format) => h.isActive(format),
63
+ undo: () => h.undo(),
64
+ redo: () => h.redo(),
65
+ focus: () => h.focus(),
66
+ blur: () => h.blur(),
67
+ isFocused: () => h.isFocused(),
68
+ native: h.native,
69
+ } satisfies LexicalEditorHandle) : null)
70
+ }, [])
71
+
72
+ // The bridge loads once per process — dynamic imports of the DOM-free
73
+ // lexical slices, plus the zeed-dom parse shim. A doc that arrived before
74
+ // it settles is applied here, not dropped.
75
+ useEffect(() => {
76
+ let alive = true
77
+ ensureJSONBridge().then((ok) => {
78
+ if (!alive) return
79
+ props.onJSONReady?.(ok)
80
+ applyDoc()
81
+ })
82
+
83
+ return () => {
84
+ alive = false
85
+ }
86
+ }, [])
87
+
88
+ // `json` prop → editor content. It may land before the bridge settles or
89
+ // before the host binds — park it in desiredDoc and let the readiness
90
+ // flushes apply it.
91
+ useEffect(() => {
92
+ if (!props.json) return
93
+ desiredDoc.current = props.json
94
+ applyDoc()
95
+ }, [props.json])
96
+
97
+ const emitJSON = () => {
98
+ if (!inner.current || !jsonBridgeReady()) return
99
+ const doc = htmlToJSON(inner.current.getHTML())
100
+ if (doc) {
101
+ lastJSON.current = JSON.stringify(doc)
102
+ appliedHost.current = inner.current
103
+ props.onJSONChange?.(doc)
104
+ }
105
+ }
106
+
107
+ <RichTextEditor
108
+ id={props.id}
109
+ className={props.className}
110
+ style={props.style}
111
+ value={props.value}
112
+ placeholder={props.placeholder}
113
+ editable={props.editable}
114
+ autofocus={props.autofocus}
115
+ onReady={() => {
116
+ // Host signalled ready after the leaf's own initial content seed —
117
+ // the last apply point that still outranks a `value` prop.
118
+ applyDoc()
119
+ props.onReady?.()
120
+ }}
121
+ onChange={(html) => {
122
+ props.onChange?.(html)
123
+ emitJSON()
124
+ }}
125
+ onSelectionChange={props.onSelectionChange}
126
+ onFocus={props.onFocus}
127
+ onBlur={props.onBlur}
128
+ ref={bindHost}
129
+ android={props.android}
130
+ ios={props.ios}
131
+ row={props.row}
132
+ col={props.col}
133
+ rowSpan={props.rowSpan}
134
+ colSpan={props.colSpan}
135
+ dock={props.dock}
136
+ left={props.left}
137
+ top={props.top}
138
+ flexGrow={props.flexGrow}
139
+ flexShrink={props.flexShrink}
140
+ alignSelf={props.alignSelf}
141
+ order={props.order}
142
+ />;
143
+ }
@@ -0,0 +1,111 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { useLexicalComposerContext } from '@octanejs/lexical/LexicalComposerContext'
5
+ import { LexicalEditor } from './LexicalEditor.web.tsrx'
6
+ import type { LexicalEditorHandle } from './types'
7
+ import { BadgeNode } from '../tests/browser/BadgeNode'
8
+
9
+ const roots: any[] = []
10
+ function UpdatePlugin() {
11
+ useLexicalComposerContext()
12
+ return <span data-plugin-mounted="true" />
13
+ }
14
+
15
+ function mount(props: any = {}) {
16
+ const el = document.createElement('div')
17
+ document.body.append(el)
18
+ const root = createRoot(el)
19
+ roots.push(root)
20
+ let handle: LexicalEditorHandle | null = null
21
+ const render = (next: any) =>
22
+ act(() =>
23
+ root.render(
24
+ <LexicalEditor
25
+ {...next}
26
+ ref={(value: LexicalEditorHandle | null) => {
27
+ handle = value
28
+ }}
29
+ />,
30
+ ),
31
+ )
32
+ render(props)
33
+ return { el, root, render, get handle() { return handle } }
34
+ }
35
+
36
+ afterEach(() => {
37
+ for (const root of roots.splice(0)) act(() => root.unmount())
38
+ document.body.innerHTML = ''
39
+ vi.restoreAllMocks()
40
+ })
41
+
42
+ it('reports the live JSON and editor handles once when ready', () => {
43
+ const onJSONReady = vi.fn()
44
+ const onReady = vi.fn()
45
+ const { handle } = mount({ value: '<p>Travel notes</p>', onJSONReady, onReady })
46
+
47
+ expect(handle).not.toBeNull()
48
+ expect(handle!.getJSON()).not.toBeNull()
49
+ expect(onJSONReady).toHaveBeenCalledExactlyOnceWith(true)
50
+ expect(onReady).toHaveBeenCalledTimes(1)
51
+ })
52
+
53
+ it('wires focus and blur callbacks from the real editable element', () => {
54
+ const onFocus = vi.fn()
55
+ const onBlur = vi.fn()
56
+ const { el } = mount({ onFocus, onBlur })
57
+ const editable = el.querySelector('[contenteditable]') as HTMLElement
58
+
59
+ editable.focus()
60
+ expect(onFocus).toHaveBeenCalledTimes(1)
61
+ editable.blur()
62
+ expect(onBlur).toHaveBeenCalledTimes(1)
63
+ })
64
+
65
+ it('accepts controlled HTML updates and read-only changes', () => {
66
+ const mounted = mount({ value: '<p>First</p>', editable: false })
67
+ const editable = mounted.el.querySelector('[contenteditable]') as HTMLElement
68
+ expect(editable.getAttribute('contenteditable')).toBe('false')
69
+
70
+ mounted.render({ value: '<p>Second</p>', editable: true })
71
+ expect(mounted.handle!.getHTML()).toContain('Second')
72
+ expect(
73
+ (mounted.el.querySelector('[contenteditable]') as HTMLElement).getAttribute('contenteditable'),
74
+ ).toBe('true')
75
+ })
76
+
77
+ it('adds custom nodes to defaults and round-trips their JSON and HTML', () => {
78
+ const { handle } = mount({
79
+ value: '<p><span data-badge>Voyager</span></p><h1>Default heading</h1>',
80
+ web: { nodes: [BadgeNode] },
81
+ })
82
+
83
+ const json = handle!.getJSON()!
84
+ expect(JSON.stringify(json)).toContain('"type":"badge"')
85
+ expect(handle!.getHTML()).toContain('data-badge')
86
+ expect(handle!.getHTML()).toContain('<h1><span')
87
+
88
+ handle!.setJSON(json)
89
+ expect(handle!.getHTML()).toContain('Voyager')
90
+ expect(handle!.getHTML()).toContain('data-badge')
91
+ })
92
+
93
+ it('renders custom plugins inside the composer context', () => {
94
+ const { el } = mount({ web: { plugins: <UpdatePlugin /> } })
95
+ expect(el.querySelector('[data-plugin-mounted]')).not.toBeNull()
96
+ })
97
+
98
+ it('can replace the default custom-node registry', () => {
99
+ const { handle } = mount({
100
+ value: '<p><span data-badge>Caller schema</span></p>',
101
+ web: {
102
+ replaceNodes: true,
103
+ nodes: [BadgeNode],
104
+ replacePlugins: true,
105
+ plugins: <UpdatePlugin />,
106
+ },
107
+ })
108
+ expect(handle).not.toBeNull()
109
+ expect((handle!.native as any).getRegisteredNode(BadgeNode)).toBeTruthy()
110
+ expect(handle!.getJSON()!.root).toBeTruthy()
111
+ })
@@ -0,0 +1,413 @@
1
+ import { useState, useImperativeHandle, useEffect, useRef } from 'octane'
2
+ import {
3
+ AutoFocusPlugin,
4
+ ContentEditable,
5
+ EditorRefPlugin,
6
+ HistoryPlugin,
7
+ LexicalComposer,
8
+ LexicalErrorBoundary,
9
+ LinkPlugin,
10
+ ListPlugin,
11
+ OnChangePlugin,
12
+ RichTextPlugin,
13
+ } from '@octanejs/lexical'
14
+
15
+ import {
16
+ $createParagraphNode,
17
+ $createTextNode,
18
+ $findMatchingParent,
19
+ $getRoot,
20
+ $getSelection,
21
+ $isNodeSelection,
22
+ $isRangeSelection,
23
+ FORMAT_ELEMENT_COMMAND,
24
+ FORMAT_TEXT_COMMAND,
25
+ REDO_COMMAND,
26
+ UNDO_COMMAND,
27
+ } from 'lexical'
28
+
29
+ import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, QuoteNode } from '@lexical/rich-text'
30
+ import {
31
+ $isListItemNode,
32
+ $isListNode,
33
+ INSERT_CHECK_LIST_COMMAND,
34
+ INSERT_ORDERED_LIST_COMMAND,
35
+ INSERT_UNORDERED_LIST_COMMAND,
36
+ ListItemNode,
37
+ ListNode,
38
+ } from '@lexical/list'
39
+
40
+ import { $createLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
41
+ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code'
42
+ import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND, $isHorizontalRuleNode } from '@lexical/extension'
43
+ import { $setBlocksType } from '@lexical/selection'
44
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
45
+ import type { LexicalEditorHandle, LexicalEditorProps, LexicalFormat } from './types'
46
+
47
+ export const supported = true
48
+
49
+ /** The facade's default node set — Aztec's capability surface plus
50
+ * AutoLinkNode (parsing safety for docs produced by fuller editors). */
51
+ const NODES = [
52
+ HeadingNode,
53
+ QuoteNode,
54
+ ListNode,
55
+ ListItemNode,
56
+ LinkNode,
57
+ AutoLinkNode,
58
+ CodeNode,
59
+ HorizontalRuleNode,
60
+ ]
61
+
62
+ const HEADING_TAG: Partial<Record<LexicalFormat, 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>> = {
63
+ heading1: 'h1',
64
+ heading2: 'h2',
65
+ heading3: 'h3',
66
+ heading4: 'h4',
67
+ heading5: 'h5',
68
+ heading6: 'h6',
69
+ }
70
+
71
+ const TEXT_FORMAT: Partial<Record<LexicalFormat, string>> = {
72
+ bold: 'bold',
73
+ italic: 'italic',
74
+ underline: 'underline',
75
+ strikethrough: 'strikethrough',
76
+ code: 'code',
77
+ highlight: 'highlight',
78
+ subscript: 'subscript',
79
+ superscript: 'superscript',
80
+ }
81
+
82
+ const ELEMENT_FORMAT: Partial<Record<LexicalFormat, string>> = {
83
+ alignLeft: 'left',
84
+ alignCenter: 'center',
85
+ alignRight: 'right',
86
+ }
87
+
88
+ /** Toggle a block type at the selection — reverts to paragraph when the
89
+ * selection already sits in that block type. */
90
+ function setBlock(editor: any, is: (n: any) => boolean, create: () => any, match?: (n: any) => boolean): void {
91
+ editor.update(() => {
92
+ const sel = $getSelection()
93
+ if (!$isRangeSelection(sel)) return
94
+ const block = $findMatchingParent(sel.anchor.getNode(), is)
95
+ const active = block != null && (match ? match(block) : true)
96
+ $setBlocksType(sel, () => (active ? $createParagraphNode() : create()))
97
+ })
98
+ }
99
+
100
+ function applyFormat(editor: any, format: LexicalFormat): void {
101
+ const text = TEXT_FORMAT[format]
102
+ if (text) {
103
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, text)
104
+ return
105
+ }
106
+
107
+ const el = ELEMENT_FORMAT[format]
108
+ if (el) {
109
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, el)
110
+ return
111
+ }
112
+
113
+ const tag = HEADING_TAG[format]
114
+ if (tag) {
115
+ setBlock(editor, $isHeadingNode, () => $createHeadingNode(tag), (n) => n.getTag() === tag)
116
+ return
117
+ }
118
+
119
+ switch (format) {
120
+ case 'paragraph':
121
+ setBlock(editor, () => true, () => $createParagraphNode(), () => false)
122
+ break
123
+ case 'blockquote':
124
+ setBlock(editor, $isQuoteNode, () => $createQuoteNode())
125
+ break
126
+ case 'codeBlock':
127
+ setBlock(editor, $isCodeNode, () => $createCodeNode())
128
+ break
129
+ case 'bulletList':
130
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
131
+ break
132
+ case 'orderedList':
133
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
134
+ break
135
+ case 'taskList':
136
+ editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
137
+ break
138
+ case 'horizontalRule':
139
+ editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
140
+ break
141
+ }
142
+ }
143
+
144
+ function isActive(editor: any, format: LexicalFormat): boolean {
145
+ return editor.read(() => {
146
+ const sel = $getSelection()
147
+ if (sel == null) return false
148
+
149
+ const text = TEXT_FORMAT[format]
150
+ if (text) {
151
+ return $isRangeSelection(sel) && sel.hasFormat(text as any)
152
+ }
153
+
154
+ const el = ELEMENT_FORMAT[format]
155
+ if (el) {
156
+ const nodes = sel.getNodes()
157
+ return nodes.some((n: any) => {
158
+ const block = $findMatchingParent(n, (p: any) => p.getFormatType != null)
159
+ return block != null && (block as any).getFormatType() === el
160
+ })
161
+ }
162
+
163
+ const tag = HEADING_TAG[format]
164
+ if (tag) {
165
+ return sel.getNodes().some((n: any) => {
166
+ const h = $findMatchingParent(n, $isHeadingNode)
167
+ return h?.getTag() === tag
168
+ })
169
+ }
170
+
171
+ switch (format) {
172
+ case 'blockquote':
173
+ return sel.getNodes().some((n: any) => $findMatchingParent(n, $isQuoteNode) != null)
174
+ case 'codeBlock':
175
+ return sel.getNodes().some((n: any) => $findMatchingParent(n, $isCodeNode) != null)
176
+ case 'bulletList':
177
+ case 'orderedList':
178
+ case 'taskList': {
179
+ const want = format === 'bulletList' ? 'bullet' : format === 'orderedList' ? 'number' : 'check'
180
+ return sel.getNodes().some((n: any) => {
181
+ const l = $findMatchingParent(n, $isListNode)
182
+ return l?.getListType() === want
183
+ })
184
+ }
185
+ case 'link':
186
+ return sel.getNodes().some(
187
+ (n: any) => $isLinkNode(n) || $isLinkNode($findMatchingParent(n, $isLinkNode)),
188
+ )
189
+ case 'horizontalRule':
190
+ return $isNodeSelection(sel) && sel.getNodes().some($isHorizontalRuleNode)
191
+ case 'paragraph':
192
+ return sel.getNodes().some((n: any) =>
193
+ $findMatchingParent(n, (p: any) => p.getType?.() === 'paragraph'),
194
+ )
195
+ default:
196
+ return false
197
+ }
198
+ })
199
+ }
200
+
201
+ /** Plain-text offset of a selection point — DFS accumulating text size.
202
+ * Block separators aren't counted; positions are best-effort parity with
203
+ * the native flat-offset contract, not ProseMirror-style coordinates. */
204
+ function flatOffset(root: any, key: string, offset: number): number {
205
+ const contains = (node: any): boolean =>
206
+ node.getKey() === key || (node.getChildren?.() ?? []).some(contains)
207
+
208
+ let acc = 0
209
+ const visit = (node: any): boolean => {
210
+ if (node.getKey() === key) {
211
+ acc += offset
212
+ return true
213
+ }
214
+
215
+ for (const c of node.getChildren?.() ?? []) {
216
+ if (contains(c)) {
217
+ if (visit(c)) return true
218
+ } else {
219
+ acc += c.getTextContentSize?.() ?? 0
220
+ }
221
+ }
222
+
223
+ return false
224
+ }
225
+
226
+ visit(root)
227
+ return acc
228
+ }
229
+
230
+ function activeFormats(editor: any): LexicalFormat[] {
231
+ const out: LexicalFormat[] = []
232
+ for (const f of Object.keys(TEXT_FORMAT) as LexicalFormat[]) {
233
+ if (isActive(editor, f)) out.push(f)
234
+ }
235
+
236
+ for (const f of ['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock', 'link'] as LexicalFormat[]) {
237
+ if (isActive(editor, f)) out.push(f)
238
+ }
239
+
240
+ return out
241
+ }
242
+
243
+ /** Unified editor facade, web half — a LexicalComposer over the published
244
+ * `@octanejs/lexical` binding, with optional web-only node and plugin composition. */
245
+ export function LexicalEditor(props: LexicalEditorProps) @{
246
+ const editorRef = useRef<any>(null)
247
+ const lastEmitted = useRef<string | undefined>(undefined)
248
+ const lastJSON = useRef<string | undefined>(undefined)
249
+ const lastHTMLInput = useRef(JSON.stringify([props.json !== undefined, props.value]))
250
+ const lastJSONInput = useRef(JSON.stringify(props.json))
251
+ const bound = useRef<LexicalEditorHandle | undefined>(undefined)
252
+ const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
253
+ useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
254
+
255
+ const initialConfig = {
256
+ namespace: 'octane-xplat/lexical',
257
+ nodes: props.web?.replaceNodes
258
+ ? props.web.nodes ?? []
259
+ : [...new Set([...NODES, ...(props.web?.nodes ?? [])])],
260
+ editable: props.editable !== false,
261
+ editorState: props.json != null
262
+ ? JSON.stringify(props.json)
263
+ : props.value != null
264
+ ? (editor: any) => {
265
+ const dom = new DOMParser().parseFromString(props.value!, 'text/html')
266
+ const nodes = $generateNodesFromDOM(editor, dom)
267
+ $getRoot().append(...nodes)
268
+ }
269
+ : undefined,
270
+ onError: (e: Error) => {
271
+ throw e
272
+ },
273
+ }
274
+
275
+ const attach = (editor: any) => {
276
+ if (!editor || bound.current?.native === editor) return
277
+ editorRef.current = editor
278
+ bound.current = {
279
+ getHTML: () => editor.read(() => $generateHtmlFromNodes(editor)),
280
+ setHTML: (html: string) =>
281
+ editor.update(() => {
282
+ const dom = new DOMParser().parseFromString(html, 'text/html')
283
+ const nodes = $generateNodesFromDOM(editor, dom)
284
+ $getRoot().clear().append(...nodes)
285
+ }),
286
+ getJSON: () => editor.getEditorState().toJSON(),
287
+ setJSON: (doc) =>
288
+ editor.setEditorState(editor.parseEditorState(JSON.stringify(doc))),
289
+ apply: (format) => {
290
+ editor.focus()
291
+ applyFormat(editor, format)
292
+ },
293
+ linkTo: (url, anchor) => {
294
+ if (anchor != null && anchor !== '') {
295
+ editor.update(() => {
296
+ const link = $createLinkNode(url)
297
+ link.append($createTextNode(anchor))
298
+ const sel = $getSelection()
299
+ if ($isRangeSelection(sel)) sel.insertNodes([link])
300
+ })
301
+ } else {
302
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url)
303
+ }
304
+ },
305
+ removeLink: () => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null),
306
+ isActive: (format) => isActive(editor, format),
307
+ undo: () => editor.dispatchCommand(UNDO_COMMAND, undefined),
308
+ redo: () => editor.dispatchCommand(REDO_COMMAND, undefined),
309
+ focus: () => editor.focus(),
310
+ blur: () => editor.blur(),
311
+ isFocused: () => {
312
+ const root = editor.getRootElement()
313
+ return root != null && root === document.activeElement
314
+ },
315
+ native: editor,
316
+ }
317
+
318
+ setHandle(bound.current!)
319
+ props.onJSONReady?.(true)
320
+ props.onReady?.()
321
+ }
322
+
323
+ // External `value`/`json` changes re-push; editor-emitted docs are skipped
324
+ // (the update already owns the doc).
325
+ useEffect(() => {
326
+ const e = editorRef.current
327
+ if (!e) return
328
+ const stamp = JSON.stringify([props.json !== undefined, props.value])
329
+ if (stamp === lastHTMLInput.current) return
330
+ lastHTMLInput.current = stamp
331
+ if (props.json !== undefined || props.value === undefined || props.value === lastEmitted.current) return
332
+ bound.current?.setHTML(props.value)
333
+ }, [exposedHandle, props.value, props.json])
334
+
335
+ useEffect(() => {
336
+ const e = editorRef.current
337
+ if (!e) return
338
+ const stamp = JSON.stringify(props.json)
339
+ if (stamp === lastJSONInput.current) return
340
+ lastJSONInput.current = stamp
341
+ if (props.json === undefined || stamp === lastJSON.current) return
342
+ bound.current?.setJSON(props.json)
343
+ }, [exposedHandle, props.json])
344
+
345
+ useEffect(() => {
346
+ editorRef.current?.setEditable(props.editable !== false)
347
+ }, [exposedHandle, props.editable])
348
+
349
+ const emitChange = (state: any, editor: any) => {
350
+ const html = state.read(() => $generateHtmlFromNodes(editor))
351
+ lastEmitted.current = html
352
+ props.onChange?.(html)
353
+ const doc = state.toJSON()
354
+ lastJSON.current = JSON.stringify(doc)
355
+ props.onJSONChange?.(doc)
356
+ state.read(() => {
357
+ const sel = $getSelection()
358
+ if ($isRangeSelection(sel) && props.onSelectionChange) {
359
+ const root = $getRoot()
360
+ props.onSelectionChange({
361
+ start: flatOffset(root, sel.anchor.key, sel.anchor.offset),
362
+ end: flatOffset(root, sel.focus.key, sel.focus.offset),
363
+ active: activeFormats(editor),
364
+ })
365
+ }
366
+ })
367
+ }
368
+
369
+ <div className={props.className} style={{ position: 'relative', ...props.style }}>
370
+ <LexicalComposer initialConfig={initialConfig}>
371
+ <RichTextPlugin
372
+ contentEditable={
373
+ <ContentEditable
374
+ id={props.id}
375
+ aria-placeholder={props.placeholder ?? ''}
376
+ placeholder={null}
377
+ className="vx-lexical-input"
378
+ style={{ outline: 'none' }}
379
+ onFocus={props.onFocus}
380
+ onBlur={props.onBlur}
381
+ />
382
+ }
383
+ placeholder={
384
+ props.placeholder ? (
385
+ <div
386
+ className="vx-lexical-placeholder"
387
+ style={{
388
+ position: 'absolute',
389
+ inset: '8px auto auto 10px',
390
+ pointerEvents: 'none',
391
+ opacity: 0.5,
392
+ }}
393
+ >
394
+ {props.placeholder}
395
+ </div>
396
+ ) : null
397
+ }
398
+ ErrorBoundary={LexicalErrorBoundary}
399
+ />
400
+ {!props.web?.replacePlugins ? (
401
+ <>
402
+ <HistoryPlugin />
403
+ <ListPlugin />
404
+ <LinkPlugin />
405
+ {props.autofocus ? <AutoFocusPlugin /> : null}
406
+ </>
407
+ ) : null}
408
+ <OnChangePlugin onChange={emitChange} />
409
+ <EditorRefPlugin editorRef={attach} />
410
+ {props.web?.plugins}
411
+ </LexicalComposer>
412
+ </div>
413
+ }
@@ -0,0 +1,5 @@
1
+ import { createRoot } from 'octane'
2
+ import { LexicalEditor } from './LexicalEditor.web.tsrx'
3
+ import { installEditorDocument } from '@octane-xplat/richtext/browser-transport'
4
+ const root = createRoot(document.getElementById('root')!)
5
+ installEditorDocument((props) => root.render(<LexicalEditor {...props} />))
@@ -0,0 +1,13 @@
1
+ export { LexicalEditor } from './Editor.macos'
2
+ /** The AppKit/WKWebView backend is implemented. */
3
+ export const supported = true
4
+ export type * from './types'
5
+
6
+ /** The local bundle includes its JSON model; wait for onReady before reading a document. */
7
+ export async function ensureJSONBridge(): Promise<boolean> {
8
+ return true
9
+ }
10
+
11
+ export function jsonBridgeReady(): boolean {
12
+ return true
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { LexicalEditor, supported } from './LexicalEditor.tsrx'
2
+ export { ensureJSONBridge, jsonBridgeReady } from './json-bridge'
3
+ export type * from './types'
@@ -0,0 +1,12 @@
1
+ export { LexicalEditor, supported } from './LexicalEditor.web.tsrx'
2
+ export type * from './types'
3
+
4
+ /** Web needs no lazy bridge — lexical is in the bundle already. Kept for
5
+ * call-site parity with the native entry. */
6
+ export function ensureJSONBridge(): Promise<boolean> {
7
+ return Promise.resolve(true)
8
+ }
9
+
10
+ export function jsonBridgeReady(): boolean {
11
+ return true
12
+ }