@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,349 @@
1
+ import { Fragment, __s, bag10, bag3, child, clone, componentSlot, createScopedElement, createScopedValue, drainFrag, hookSlots, markChildrenBlock, positionalChildren, setStyle, sibling, template, useEffect, useImperativeHandle, useRef, useState } from "octane";
2
+ import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
3
+ import { AutoFocusPlugin, ContentEditable, EditorRefPlugin, HistoryPlugin, LexicalComposer, LexicalErrorBoundary, LinkPlugin, ListPlugin, OnChangePlugin, RichTextPlugin } from "@octanejs/lexical";
4
+ import { $createParagraphNode, $createTextNode, $findMatchingParent, $getRoot, $getSelection, $isNodeSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, REDO_COMMAND, UNDO_COMMAND } from "lexical";
5
+ import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, QuoteNode } from "@lexical/rich-text";
6
+ import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode } from "@lexical/list";
7
+ import { $createLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
8
+ import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code";
9
+ import { $isHorizontalRuleNode, HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension";
10
+ import { $setBlocksType } from "@lexical/selection";
11
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
12
+ //#region src/LexicalEditor.web.tsrx
13
+ enableSignalBindings(1, true);
14
+ var _t$0 = /* @__PURE__ */ template("<!><!><!><!><!>", 3, 1);
15
+ var _t$1 = /* @__PURE__ */ template("<div></div>");
16
+ var _hs$ = /* @__PURE__ */ hookSlots(11);
17
+ var __childrenBody = {};
18
+ var supported = true;
19
+ var NODES = [
20
+ HeadingNode,
21
+ QuoteNode,
22
+ ListNode,
23
+ ListItemNode,
24
+ LinkNode,
25
+ AutoLinkNode,
26
+ CodeNode,
27
+ HorizontalRuleNode
28
+ ];
29
+ var HEADING_TAG = {
30
+ heading1: "h1",
31
+ heading2: "h2",
32
+ heading3: "h3",
33
+ heading4: "h4",
34
+ heading5: "h5",
35
+ heading6: "h6"
36
+ };
37
+ var TEXT_FORMAT = {
38
+ bold: "bold",
39
+ italic: "italic",
40
+ underline: "underline",
41
+ strikethrough: "strikethrough",
42
+ code: "code",
43
+ highlight: "highlight",
44
+ subscript: "subscript",
45
+ superscript: "superscript"
46
+ };
47
+ var ELEMENT_FORMAT = {
48
+ alignLeft: "left",
49
+ alignCenter: "center",
50
+ alignRight: "right"
51
+ };
52
+ function setBlock(editor, is, create, match) {
53
+ editor.update(() => {
54
+ const sel = $getSelection();
55
+ if (!$isRangeSelection(sel)) return;
56
+ const block = $findMatchingParent(sel.anchor.getNode(), is);
57
+ const active = block != null && (match ? match(block) : true);
58
+ $setBlocksType(sel, () => active ? $createParagraphNode() : create());
59
+ });
60
+ }
61
+ function applyFormat(editor, format) {
62
+ const text = TEXT_FORMAT[format];
63
+ if (text) {
64
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, text);
65
+ return;
66
+ }
67
+ const el = ELEMENT_FORMAT[format];
68
+ if (el) {
69
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, el);
70
+ return;
71
+ }
72
+ const tag = HEADING_TAG[format];
73
+ if (tag) {
74
+ setBlock(editor, $isHeadingNode, () => $createHeadingNode(tag), (n) => n.getTag() === tag);
75
+ return;
76
+ }
77
+ switch (format) {
78
+ case "paragraph":
79
+ setBlock(editor, () => true, () => $createParagraphNode(), () => false);
80
+ break;
81
+ case "blockquote":
82
+ setBlock(editor, $isQuoteNode, () => $createQuoteNode());
83
+ break;
84
+ case "codeBlock":
85
+ setBlock(editor, $isCodeNode, () => $createCodeNode());
86
+ break;
87
+ case "bulletList":
88
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, void 0);
89
+ break;
90
+ case "orderedList":
91
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, void 0);
92
+ break;
93
+ case "taskList":
94
+ editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, void 0);
95
+ break;
96
+ case "horizontalRule": editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, void 0);
97
+ }
98
+ }
99
+ function isActive(editor, format) {
100
+ return editor.read(() => {
101
+ const sel = $getSelection();
102
+ if (sel == null) return false;
103
+ const text = TEXT_FORMAT[format];
104
+ if (text) return $isRangeSelection(sel) && sel.hasFormat(text);
105
+ const el = ELEMENT_FORMAT[format];
106
+ if (el) return sel.getNodes().some((n) => {
107
+ const block = $findMatchingParent(n, (p) => p.getFormatType != null);
108
+ return block != null && block.getFormatType() === el;
109
+ });
110
+ const tag = HEADING_TAG[format];
111
+ if (tag) return sel.getNodes().some((n) => {
112
+ return $findMatchingParent(n, $isHeadingNode)?.getTag() === tag;
113
+ });
114
+ switch (format) {
115
+ case "blockquote": return sel.getNodes().some((n) => $findMatchingParent(n, $isQuoteNode) != null);
116
+ case "codeBlock": return sel.getNodes().some((n) => $findMatchingParent(n, $isCodeNode) != null);
117
+ case "bulletList":
118
+ case "orderedList":
119
+ case "taskList": {
120
+ const want = format === "bulletList" ? "bullet" : format === "orderedList" ? "number" : "check";
121
+ return sel.getNodes().some((n) => {
122
+ return $findMatchingParent(n, $isListNode)?.getListType() === want;
123
+ });
124
+ }
125
+ case "link": return sel.getNodes().some((n) => $isLinkNode(n) || $isLinkNode($findMatchingParent(n, $isLinkNode)));
126
+ case "horizontalRule": return $isNodeSelection(sel) && sel.getNodes().some($isHorizontalRuleNode);
127
+ case "paragraph": return sel.getNodes().some((n) => $findMatchingParent(n, (p) => p.getType?.() === "paragraph"));
128
+ default: return false;
129
+ }
130
+ });
131
+ }
132
+ function flatOffset(root, key, offset) {
133
+ const contains = (node) => node.getKey() === key || (node.getChildren?.() ?? []).some(contains);
134
+ let acc = 0;
135
+ const visit = (node) => {
136
+ if (node.getKey() === key) {
137
+ acc += offset;
138
+ return true;
139
+ }
140
+ for (const c of node.getChildren?.() ?? []) if (contains(c)) {
141
+ if (visit(c)) return true;
142
+ } else acc += c.getTextContentSize?.() ?? 0;
143
+ return false;
144
+ };
145
+ visit(root);
146
+ return acc;
147
+ }
148
+ function activeFormats(editor) {
149
+ const out = [];
150
+ for (const f of Object.keys(TEXT_FORMAT)) if (isActive(editor, f)) out.push(f);
151
+ for (const f of [
152
+ "paragraph",
153
+ "heading1",
154
+ "heading2",
155
+ "heading3",
156
+ "heading4",
157
+ "heading5",
158
+ "heading6",
159
+ "blockquote",
160
+ "bulletList",
161
+ "orderedList",
162
+ "taskList",
163
+ "codeBlock",
164
+ "link"
165
+ ]) if (isActive(editor, f)) out.push(f);
166
+ return out;
167
+ }
168
+ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __extra) {
169
+ __s.block;
170
+ const editorRef = useRef(null, _hs$ + 0);
171
+ const lastEmitted = useRef(void 0, _hs$ + 1);
172
+ const lastJSON = useRef(void 0, _hs$ + 2);
173
+ const lastHTMLInput = useRef(JSON.stringify([props.json !== void 0, props.value]), _hs$ + 3);
174
+ const lastJSONInput = useRef(JSON.stringify(props.json), _hs$ + 4);
175
+ const bound = useRef(void 0, _hs$ + 5);
176
+ const [exposedHandle, setHandle] = useState(null, _hs$ + 6);
177
+ useImperativeHandle(props.ref, () => exposedHandle, [exposedHandle], _hs$ + 7);
178
+ const initialConfig = {
179
+ namespace: "octane-xplat/lexical",
180
+ nodes: props.web?.replaceNodes ? props.web.nodes ?? [] : [.../* @__PURE__ */ new Set([...NODES, ...props.web?.nodes ?? []])],
181
+ editable: props.editable !== false,
182
+ editorState: props.json != null ? JSON.stringify(props.json) : props.value != null ? (editor) => {
183
+ const dom = new DOMParser().parseFromString(props.value, "text/html");
184
+ const nodes = $generateNodesFromDOM(editor, dom);
185
+ $getRoot().append(...nodes);
186
+ } : void 0,
187
+ onError: (e) => {
188
+ throw e;
189
+ }
190
+ };
191
+ const attach = (editor) => {
192
+ if (!editor || bound.current?.native === editor) return;
193
+ editorRef.current = editor;
194
+ bound.current = {
195
+ getHTML: () => editor.read(() => $generateHtmlFromNodes(editor)),
196
+ setHTML: (html) => editor.update(() => {
197
+ const dom = new DOMParser().parseFromString(html, "text/html");
198
+ const nodes = $generateNodesFromDOM(editor, dom);
199
+ $getRoot().clear().append(...nodes);
200
+ }),
201
+ getJSON: () => editor.getEditorState().toJSON(),
202
+ setJSON: (doc) => editor.setEditorState(editor.parseEditorState(JSON.stringify(doc))),
203
+ apply: (format) => {
204
+ editor.focus();
205
+ applyFormat(editor, format);
206
+ },
207
+ linkTo: (url, anchor) => {
208
+ if (anchor != null && anchor !== "") editor.update(() => {
209
+ const link = $createLinkNode(url);
210
+ link.append($createTextNode(anchor));
211
+ const sel = $getSelection();
212
+ if ($isRangeSelection(sel)) sel.insertNodes([link]);
213
+ });
214
+ else editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
215
+ },
216
+ removeLink: () => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null),
217
+ isActive: (format) => isActive(editor, format),
218
+ undo: () => editor.dispatchCommand(UNDO_COMMAND, void 0),
219
+ redo: () => editor.dispatchCommand(REDO_COMMAND, void 0),
220
+ focus: () => editor.focus(),
221
+ blur: () => editor.blur(),
222
+ isFocused: () => {
223
+ const root = editor.getRootElement();
224
+ return root != null && root === document.activeElement;
225
+ },
226
+ native: editor
227
+ };
228
+ setHandle(bound.current);
229
+ props.onJSONReady?.(true);
230
+ props.onReady?.();
231
+ };
232
+ useEffect(() => {
233
+ if (!editorRef.current) return;
234
+ const stamp = JSON.stringify([props.json !== void 0, props.value]);
235
+ if (stamp === lastHTMLInput.current) return;
236
+ lastHTMLInput.current = stamp;
237
+ if (props.json !== void 0 || props.value === void 0 || props.value === lastEmitted.current) return;
238
+ bound.current?.setHTML(props.value);
239
+ }, [
240
+ exposedHandle,
241
+ props.value,
242
+ props.json
243
+ ], _hs$ + 8);
244
+ useEffect(() => {
245
+ if (!editorRef.current) return;
246
+ const stamp = JSON.stringify(props.json);
247
+ if (stamp === lastJSONInput.current) return;
248
+ lastJSONInput.current = stamp;
249
+ if (props.json === void 0 || stamp === lastJSON.current) return;
250
+ bound.current?.setJSON(props.json);
251
+ }, [exposedHandle, props.json], _hs$ + 9);
252
+ useEffect(() => {
253
+ editorRef.current?.setEditable(props.editable !== false);
254
+ }, [exposedHandle, props.editable], _hs$ + 10);
255
+ const emitChange = (state, editor) => {
256
+ const html = state.read(() => $generateHtmlFromNodes(editor));
257
+ lastEmitted.current = html;
258
+ props.onChange?.(html);
259
+ const doc = state.toJSON();
260
+ lastJSON.current = JSON.stringify(doc);
261
+ props.onJSONChange?.(doc);
262
+ state.read(() => {
263
+ const sel = $getSelection();
264
+ if ($isRangeSelection(sel) && props.onSelectionChange) {
265
+ const root = $getRoot();
266
+ props.onSelectionChange({
267
+ start: flatOffset(root, sel.anchor.key, sel.anchor.offset),
268
+ end: flatOffset(root, sel.focus.key, sel.focus.offset),
269
+ active: activeFormats(editor)
270
+ });
271
+ }
272
+ });
273
+ };
274
+ if (__s.block.pending && !__s.block.crossRenderUpdate) return;
275
+ function __children$1(__props, __s, __extra) {
276
+ const __block = __s.block;
277
+ let _b = __s.slots[0];
278
+ if (_b === void 0) {
279
+ let _m0, _m1, _m2, _m5, _m6, _m7;
280
+ const _root = clone(_t$0);
281
+ _m0 = __block.parentNode;
282
+ const _el0 = child(_root);
283
+ _m1 = _el0;
284
+ const _el1 = sibling(_el0, 1);
285
+ _m2 = _el1;
286
+ const _el2 = sibling(_el1, 1);
287
+ _m5 = _el2;
288
+ const _el3 = sibling(_el2, 1);
289
+ _m6 = _el3;
290
+ _m7 = sibling(_el3, 1);
291
+ drainFrag(_root, __block.parentNode, __block.endMarker);
292
+ _b = bag10(__s, null, _m0, _m1, _m2, null, void 0, _m5, _m6, _m7, null, void 0);
293
+ }
294
+ componentSlot(__s, 1, _b.a, RichTextPlugin, {
295
+ "contentEditable": createScopedValue(() => createElementFromConfig("c:8bd4dc5e", ContentEditable, {
296
+ id: props.id,
297
+ "aria-placeholder": props.placeholder ?? "",
298
+ placeholder: null,
299
+ className: "vx-lexical-input",
300
+ style: { outline: "none" },
301
+ onFocus: props.onFocus,
302
+ onBlur: props.onBlur
303
+ })),
304
+ "placeholder": props.placeholder ? createScopedValue(() => createScopedElement("div", {
305
+ className: "vx-lexical-placeholder",
306
+ style: {
307
+ position: "absolute",
308
+ inset: "8px auto auto 10px",
309
+ pointerEvents: "none",
310
+ opacity: .5
311
+ }
312
+ }, () => props.placeholder)) : null,
313
+ "ErrorBoundary": LexicalErrorBoundary
314
+ }, _b.b, void 0, 2, void 0, void 0, "c:1283dc87");
315
+ _b.k = bindSignalChild(__s, _b.k, 2, _b.a, !props.web?.replacePlugins ? createScopedElement(Fragment, {}, () => positionalChildren([
316
+ createElementFromConfig("c:2ac25b31", HistoryPlugin, {}),
317
+ createElementFromConfig("c:8c987b0a", ListPlugin, {}),
318
+ createElementFromConfig("c:c85acd4c", LinkPlugin, {}),
319
+ props.autofocus ? createElementFromConfig("c:4e4f8e9d", AutoFocusPlugin, {}) : null
320
+ ])) : null, "b:d23e6e54", _b.c, true, void 0, void 0, void 0);
321
+ componentSlot(__s, 3, _b.a, OnChangePlugin, { "onChange": emitChange }, _b.f, void 0, 2, void 0, void 0, "c:fbe62b73");
322
+ componentSlot(__s, 4, _b.a, EditorRefPlugin, { "editorRef": attach }, _b.g, void 0, 2, void 0, void 0, "c:bcb623dd");
323
+ _b.l = bindSignalChild(__s, _b.l, 5, _b.a, props.web?.plugins, "b:6078ff9a", _b.h, true, void 0, void 0, void 0);
324
+ }
325
+ let _b = __s.slots[0];
326
+ if (_b === void 0) {
327
+ let _m1;
328
+ const _root = clone(_t$1);
329
+ _m1 = _root;
330
+ _b = bag3(__s, _root, void 0, _m1, void 0);
331
+ }
332
+ _b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", props.className, "b:748f4137", "class");
333
+ {
334
+ const _v = {
335
+ position: "relative",
336
+ ...props.style
337
+ };
338
+ if (_b.c !== _v) {
339
+ setStyle(_b.b, _v, _b.c);
340
+ _b.c = _v;
341
+ }
342
+ }
343
+ componentSlot(__s, 1, _b.b, LexicalComposer, {
344
+ "initialConfig": initialConfig,
345
+ "children": markChildrenBlock(__children$1, __childrenBody)
346
+ }, void 0, void 0, void 0, void 0, void 0, "c:9d1ef90e");
347
+ });
348
+ //#endregion
349
+ export { LexicalEditor, supported };
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "732a6a63-dfe4-4d61-bc19-af20265044e1",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,11 +1,99 @@
1
1
  {
2
2
  "name": "@octane-xplat/lexical",
3
- "version": "0.0.1",
4
- "description": "Placeholder stub for @octane-xplat/lexical — no supported API yet.",
3
+ "version": "0.11.0",
4
+ "description": "Unified rich-text editor facade for Octane xplat — live lexical on web/AppKit, Aztec + lexical document model on Android",
5
+ "keywords": [
6
+ "cross-platform",
7
+ "editor",
8
+ "lexical",
9
+ "octane",
10
+ "rich-text",
11
+ "richtext",
12
+ "xplat"
13
+ ],
14
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/lexical",
5
15
  "license": "MIT",
6
16
  "repository": {
7
17
  "type": "git",
8
18
  "url": "https://github.com/octane-xplat/octane-xplat",
9
19
  "directory": "packages/lexical"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "types",
24
+ "src",
25
+ "platforms"
26
+ ],
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "macos": {
31
+ "types": "./types/index.macos.d.ts",
32
+ "default": "./src/index.macos.ts"
33
+ },
34
+ "web": {
35
+ "types": "./types/index.d.ts",
36
+ "default": "./dist/web/index.js"
37
+ },
38
+ "native": {
39
+ "types": "./types/index.d.ts",
40
+ "default": "./dist/native/index.js"
41
+ },
42
+ "types": "./types/index.d.ts",
43
+ "default": "./dist/native/index.js"
44
+ }
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@lexical/code": "0.51.0",
51
+ "@lexical/extension": "0.51.0",
52
+ "@lexical/html": "0.51.0",
53
+ "@lexical/link": "0.51.0",
54
+ "@lexical/list": "0.51.0",
55
+ "@lexical/rich-text": "0.51.0",
56
+ "@lexical/selection": "0.51.0",
57
+ "@lexical/utils": "0.51.0",
58
+ "@octane-xplat/richtext": "0.11.0",
59
+ "@octanejs/lexical": "0.2.0",
60
+ "lexical": "0.51.0",
61
+ "zeed-dom": "0.18.0"
62
+ },
63
+ "devDependencies": {
64
+ "@nativescript-community/octane": "0.2.4",
65
+ "@nativescript/core": "9.1.2",
66
+ "@octane-xplat/macos-renderer": "0.11.0",
67
+ "@octanejs/vite-plugin": "0.1.61",
68
+ "octane": "0.6.3",
69
+ "typescript": "5.9.3",
70
+ "vite": "8.3.0",
71
+ "vitest": "5.0.1"
72
+ },
73
+ "peerDependencies": {
74
+ "@nativescript-community/octane": ">=0.2.1 <1",
75
+ "@nativescript/core": ">=9.1.0 <10",
76
+ "octane": ">=0.6.3 <1"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "@nativescript-community/octane": {
80
+ "optional": true
81
+ },
82
+ "@nativescript/core": {
83
+ "optional": true
84
+ },
85
+ "octane": {
86
+ "optional": true
87
+ }
88
+ },
89
+ "nativescript": {
90
+ "platforms": {
91
+ "android": "9.1.0",
92
+ "ios": "9.1.0"
93
+ }
94
+ },
95
+ "scripts": {
96
+ "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs",
97
+ "test:native": "vitest run --config vitest.native.config.mts"
10
98
  }
11
- }
99
+ }
@@ -0,0 +1,9 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import type { LexicalEditorProps } from './types'
3
+ import browser from '../dist/macos/browser.js'
4
+ import { NativeEditor } from '@octane-xplat/richtext/macos-host'
5
+
6
+ /** AppKit mounts the local editor engine in WKWebView. */
7
+ export function LexicalEditor(props: LexicalEditorProps) {
8
+ return <NativeEditor {...props} browser={browser} />
9
+ }
@@ -0,0 +1,229 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ import type { LexicalJSON } from './types'
10
+
11
+ // Controlled fake for the lazy JSON bridge — the real module's dynamic
12
+ // imports aren't needed to test facade timing. `resolved`/`ready` model the
13
+ // bridge settling; docs carry `html` so conversions stay deterministic.
14
+ const bridge = vi.hoisted(() => ({
15
+ ready: false,
16
+ resolved: undefined as boolean | undefined,
17
+ waiters: [] as Array<(ok: boolean) => void>,
18
+ }))
19
+
20
+ vi.mock('./json-bridge', () => ({
21
+ ensureJSONBridge: () =>
22
+ bridge.resolved !== undefined
23
+ ? Promise.resolve(bridge.resolved)
24
+ : new Promise<boolean>((resolve) => bridge.waiters.push(resolve)),
25
+ jsonBridgeReady: () => bridge.ready,
26
+ jsonToHTML: (doc: any) =>
27
+ doc && typeof doc === 'object' && typeof doc.html === 'string' ? doc.html : null,
28
+ htmlToJSON: (html: string) => ({ type: 'doc', html }),
29
+ }))
30
+
31
+ vi.mock('@octane-xplat/richtext', async () => {
32
+ return await import('../tests/fake-richtext.fixture.mobile.tsrx')
33
+ })
34
+
35
+ import { LexicalEditor } from './LexicalEditor.tsrx'
36
+ import { hosts, resetFakeRichtext, setDefer } from '../tests/fake-richtext.fixture.mobile.tsrx'
37
+
38
+ const docA = { type: 'doc', html: '<p>alpha</p>' } as unknown as LexicalJSON
39
+ const docB = { type: 'doc', html: '<p>bravo</p>' } as unknown as LexicalJSON
40
+
41
+ const roots: ReturnType<typeof createUniversalRoot>[] = []
42
+
43
+ function mount(props: any) {
44
+ const container = createObjectContainer('nativescript')
45
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
46
+ roots.push(root)
47
+ let handle: any = null
48
+ root.render(LexicalEditor, {
49
+ ...props,
50
+ ref: (next: any) => {
51
+ handle = next
52
+ },
53
+ })
54
+
55
+ return {
56
+ root,
57
+ container,
58
+ get handle() {
59
+ return handle
60
+ },
61
+ }
62
+ }
63
+
64
+ function settleBridge(ok: boolean) {
65
+ bridge.ready = ok
66
+ bridge.resolved = ok
67
+ for (const resolve of bridge.waiters.splice(0)) {
68
+ resolve(ok)
69
+ }
70
+ }
71
+
72
+ async function settle() {
73
+ for (let i = 0; i < 12; i++) {
74
+ await Promise.resolve()
75
+ }
76
+
77
+ flushUniversalSync(() => {})
78
+ }
79
+
80
+ beforeEach(() => {
81
+ bridge.ready = false
82
+ bridge.resolved = undefined
83
+ bridge.waiters.length = 0
84
+ resetFakeRichtext()
85
+ })
86
+
87
+ afterEach(() => {
88
+ for (const root of roots.splice(0)) {
89
+ root.unmount()
90
+ }
91
+ })
92
+
93
+ describe('LexicalEditor native JSON readiness', () => {
94
+ it('applies an initial props.json once when the bridge settles after mount', async () => {
95
+ const onJSONReady = vi.fn()
96
+ mount({ json: docA, onJSONReady })
97
+ await settle()
98
+ expect(hosts[0].setCalls).toEqual([])
99
+
100
+ settleBridge(true)
101
+ await settle()
102
+ expect(onJSONReady).toHaveBeenCalledWith(true)
103
+ expect(hosts[0].html).toBe('<p>alpha</p>')
104
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
105
+ })
106
+
107
+ it('applies an initial props.json when the host readies after the bridge', async () => {
108
+ bridge.ready = true
109
+ bridge.resolved = true
110
+ setDefer(true)
111
+ mount({ json: docA })
112
+ await settle()
113
+ expect(hosts[0].setCalls).toEqual([])
114
+
115
+ hosts[0].ready()
116
+ await settle()
117
+ expect(hosts[0].html).toBe('<p>alpha</p>')
118
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
119
+ })
120
+
121
+ it('applies an initial props.json once when host and bridge are both late', async () => {
122
+ setDefer(true)
123
+ mount({ json: docA })
124
+ await settle()
125
+ hosts[0].ready()
126
+ await settle()
127
+ expect(hosts[0].setCalls).toEqual([])
128
+
129
+ settleBridge(true)
130
+ await settle()
131
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
132
+ })
133
+
134
+ it('keeps json precedence over value once readiness lands', async () => {
135
+ mount({ value: '<p>value</p>', json: docA })
136
+ await settle()
137
+ settleBridge(true)
138
+ await settle()
139
+ expect(hosts[0].html).toBe('<p>alpha</p>')
140
+ })
141
+
142
+ it('applies later props.json updates and skips identical docs', async () => {
143
+ const view = mount({ json: docA })
144
+ settleBridge(true)
145
+ await settle()
146
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
147
+
148
+ view.root.render(LexicalEditor, { json: docB } as any)
149
+ await settle()
150
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>', '<p>bravo</p>'])
151
+
152
+ view.root.render(LexicalEditor, { json: docB } as any)
153
+ await settle()
154
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>', '<p>bravo</p>'])
155
+ })
156
+
157
+ it('does not push a doc the editor itself emitted back at it', async () => {
158
+ const onJSONChange = vi.fn()
159
+ const view = mount({ onJSONChange })
160
+ settleBridge(true)
161
+ await settle()
162
+
163
+ const rig = hosts[0]
164
+ rig.html = '<p>typed</p>'
165
+ // The leaf calls props.onChange(html); the facade wraps it to emit JSON.
166
+ rig.props.onChange('<p>typed</p>')
167
+ await settle()
168
+ const emitted = onJSONChange.mock.calls[0][0]
169
+ expect(emitted).toEqual({ type: 'doc', html: '<p>typed</p>' })
170
+
171
+ view.root.render(LexicalEditor, { json: emitted, onJSONChange } as any)
172
+ await settle()
173
+ expect(rig.setCalls).toEqual([])
174
+ })
175
+
176
+ it('defers an imperative setJSON issued before the bridge settles', async () => {
177
+ const view = mount({})
178
+ await settle()
179
+ expect(view.handle).not.toBeNull()
180
+
181
+ view.handle.setJSON(docB)
182
+ expect(hosts[0].setCalls).toEqual([])
183
+ settleBridge(true)
184
+ await settle()
185
+ expect(hosts[0].setCalls).toEqual(['<p>bravo</p>'])
186
+ })
187
+
188
+ it('re-applies the current doc when the host rebinds', async () => {
189
+ mount({ json: docA })
190
+ settleBridge(true)
191
+ await settle()
192
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
193
+
194
+ hosts[0].rebind()
195
+ await settle()
196
+ expect(hosts[0].html).toBe('<p>alpha</p>')
197
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
198
+ })
199
+
200
+ it('ignores a bridge that settles after unmount', async () => {
201
+ const onJSONReady = vi.fn()
202
+ const view = mount({ json: docA, onJSONReady })
203
+ await settle()
204
+ view.root.unmount()
205
+
206
+ settleBridge(true)
207
+ await settle()
208
+ expect(onJSONReady).not.toHaveBeenCalled()
209
+ expect(hosts[0].setCalls).toEqual([])
210
+ })
211
+
212
+ it('reports bridge failure through onJSONReady without touching the host', async () => {
213
+ const onJSONReady = vi.fn()
214
+ const view = mount({ json: docA, onJSONReady })
215
+ await settle()
216
+ settleBridge(false)
217
+ await settle()
218
+ expect(onJSONReady).toHaveBeenCalledWith(false)
219
+ expect(hosts[0].setCalls).toEqual([])
220
+ expect(view.handle.getJSON()).toBeNull()
221
+ })
222
+
223
+ it('drops a doc the converter rejects instead of writing null HTML', async () => {
224
+ mount({ json: { type: 'doc', content: [{ type: 'mystery' }] } as any })
225
+ settleBridge(true)
226
+ await settle()
227
+ expect(hosts[0].setCalls).toEqual([])
228
+ })
229
+ })