@octane-xplat/lexical 0.10.0 → 0.11.1
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/README.md +93 -7
- package/dist/macos/browser.js +1 -1
- package/dist/native/index3.js +38 -21
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/index2.js +24 -21
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +7 -5
- package/src/LexicalEditor.mobile.test.ts +229 -0
- package/src/LexicalEditor.tsrx +52 -20
- package/src/LexicalEditor.web.test.tsrx +111 -0
- package/src/LexicalEditor.web.tsrx +19 -12
- package/src/types.ts +32 -6
package/dist/native/index3.js
CHANGED
|
@@ -2,7 +2,7 @@ import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from "./ind
|
|
|
2
2
|
import { defineUniversalComponent, hookSlots, universalComponent, universalPlan, universalProps, universalValue, useCallback, useEffect, useImperativeHandle, useRef, useState } from "@nativescript-community/octane";
|
|
3
3
|
import { RichTextEditor, supported } from "@octane-xplat/richtext";
|
|
4
4
|
//#region src/LexicalEditor.tsrx
|
|
5
|
-
var _hs$ = /* @__PURE__ */ hookSlots(
|
|
5
|
+
var _hs$ = /* @__PURE__ */ hookSlots(9);
|
|
6
6
|
var _h$0 = /* @__PURE__ */ Symbol(_hs$);
|
|
7
7
|
var _h$1 = /* @__PURE__ */ Symbol(_hs$ + 1);
|
|
8
8
|
var _h$2 = /* @__PURE__ */ Symbol(_hs$ + 2);
|
|
@@ -11,6 +11,7 @@ var _h$4 = /* @__PURE__ */ Symbol(_hs$ + 4);
|
|
|
11
11
|
var _h$5 = /* @__PURE__ */ Symbol(_hs$ + 5);
|
|
12
12
|
var _h$6 = /* @__PURE__ */ Symbol(_hs$ + 6);
|
|
13
13
|
var _h$7 = /* @__PURE__ */ Symbol(_hs$ + 7);
|
|
14
|
+
var _h$8 = /* @__PURE__ */ Symbol(_hs$ + 8);
|
|
14
15
|
var __octaneUniversalPlan0 = universalPlan("nativescript", {
|
|
15
16
|
"kind": "slot",
|
|
16
17
|
"slot": 0
|
|
@@ -20,20 +21,32 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
|
|
|
20
21
|
const inner = useRef(null, _h$0);
|
|
21
22
|
const [exposedHandle, setHandle] = useState(null, _h$1);
|
|
22
23
|
useImperativeHandle(props.ref, () => exposedHandle, [exposedHandle], _h$2);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
24
|
+
const lastJSON = useRef(void 0, _h$3);
|
|
25
|
+
const appliedHost = useRef(null, _h$4);
|
|
26
|
+
const desiredDoc = useRef(void 0, _h$5);
|
|
27
|
+
const applyDoc = () => {
|
|
28
|
+
const host = inner.current;
|
|
29
|
+
const doc = desiredDoc.current;
|
|
30
|
+
if (!host || doc === void 0 || !jsonBridgeReady()) return;
|
|
31
|
+
const stamp = JSON.stringify(doc);
|
|
32
|
+
if (stamp === lastJSON.current && appliedHost.current === host) return;
|
|
33
|
+
const html = jsonToHTML(doc);
|
|
34
|
+
if (html != null) {
|
|
35
|
+
lastJSON.current = stamp;
|
|
36
|
+
appliedHost.current = host;
|
|
37
|
+
host.setHTML(html);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
25
40
|
const bindHost = useCallback((h) => {
|
|
26
41
|
inner.current = h;
|
|
42
|
+
if (h) queueMicrotask(applyDoc);
|
|
27
43
|
setHandle((previous) => previous?.native === h?.native ? previous : h ? {
|
|
28
44
|
getHTML: () => h.getHTML(),
|
|
29
45
|
setHTML: (html) => h.setHTML(html),
|
|
30
46
|
getJSON: () => jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null,
|
|
31
47
|
setJSON: (doc) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
lastJSON.current = JSON.stringify(doc);
|
|
35
|
-
h.setHTML(html);
|
|
36
|
-
}
|
|
48
|
+
desiredDoc.current = doc;
|
|
49
|
+
applyDoc();
|
|
37
50
|
},
|
|
38
51
|
apply: (format) => h.apply(format),
|
|
39
52
|
linkTo: (url, anchor) => h.linkTo(url, anchor),
|
|
@@ -46,28 +59,29 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
|
|
|
46
59
|
isFocused: () => h.isFocused(),
|
|
47
60
|
native: h.native
|
|
48
61
|
} : null);
|
|
49
|
-
}, [], _h$
|
|
62
|
+
}, [], _h$6);
|
|
50
63
|
useEffect(() => {
|
|
64
|
+
let alive = true;
|
|
51
65
|
ensureJSONBridge().then((ok) => {
|
|
52
|
-
|
|
66
|
+
if (!alive) return;
|
|
53
67
|
props.onJSONReady?.(ok);
|
|
68
|
+
applyDoc();
|
|
54
69
|
});
|
|
55
|
-
|
|
70
|
+
return () => {
|
|
71
|
+
alive = false;
|
|
72
|
+
};
|
|
73
|
+
}, [], _h$7);
|
|
56
74
|
useEffect(() => {
|
|
57
|
-
if (!props.json
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (html != null) {
|
|
62
|
-
lastJSON.current = stamp;
|
|
63
|
-
inner.current.setHTML(html);
|
|
64
|
-
}
|
|
65
|
-
}, [props.json], _h$7);
|
|
75
|
+
if (!props.json) return;
|
|
76
|
+
desiredDoc.current = props.json;
|
|
77
|
+
applyDoc();
|
|
78
|
+
}, [props.json], _h$8);
|
|
66
79
|
const emitJSON = () => {
|
|
67
80
|
if (!inner.current || !jsonBridgeReady()) return;
|
|
68
81
|
const doc = htmlToJSON(inner.current.getHTML());
|
|
69
82
|
if (doc) {
|
|
70
83
|
lastJSON.current = JSON.stringify(doc);
|
|
84
|
+
appliedHost.current = inner.current;
|
|
71
85
|
props.onJSONChange?.(doc);
|
|
72
86
|
}
|
|
73
87
|
};
|
|
@@ -110,7 +124,10 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
|
|
|
110
124
|
[
|
|
111
125
|
"set",
|
|
112
126
|
"onReady",
|
|
113
|
-
|
|
127
|
+
() => {
|
|
128
|
+
applyDoc();
|
|
129
|
+
props.onReady?.();
|
|
130
|
+
}
|
|
114
131
|
],
|
|
115
132
|
[
|
|
116
133
|
"set",
|
package/dist/web/index2.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { __s, bag10, bag3, child, clone, componentSlot, createScopedElement, createScopedValue, drainFrag, hookSlots, markChildrenBlock, setStyle, sibling, template, useEffect, useImperativeHandle, useRef, useState } from "octane";
|
|
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
2
|
import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
|
|
3
3
|
import { AutoFocusPlugin, ContentEditable, EditorRefPlugin, HistoryPlugin, LexicalComposer, LexicalErrorBoundary, LinkPlugin, ListPlugin, OnChangePlugin, RichTextPlugin } from "@octanejs/lexical";
|
|
4
4
|
import { $createParagraphNode, $createTextNode, $findMatchingParent, $getRoot, $getSelection, $isNodeSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, REDO_COMMAND, UNDO_COMMAND } from "lexical";
|
|
@@ -11,7 +11,7 @@ import { $setBlocksType } from "@lexical/selection";
|
|
|
11
11
|
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
|
|
12
12
|
//#region src/LexicalEditor.web.tsrx
|
|
13
13
|
enableSignalBindings(1, true);
|
|
14
|
-
var _t$0 = /* @__PURE__ */ template("
|
|
14
|
+
var _t$0 = /* @__PURE__ */ template("<!><!><!><!><!>", 3, 1);
|
|
15
15
|
var _t$1 = /* @__PURE__ */ template("<div></div>");
|
|
16
16
|
var _hs$ = /* @__PURE__ */ hookSlots(11);
|
|
17
17
|
var __childrenBody = {};
|
|
@@ -177,7 +177,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
177
177
|
useImperativeHandle(props.ref, () => exposedHandle, [exposedHandle], _hs$ + 7);
|
|
178
178
|
const initialConfig = {
|
|
179
179
|
namespace: "octane-xplat/lexical",
|
|
180
|
-
nodes: NODES,
|
|
180
|
+
nodes: props.web?.replaceNodes ? props.web.nodes ?? [] : [.../* @__PURE__ */ new Set([...NODES, ...props.web?.nodes ?? []])],
|
|
181
181
|
editable: props.editable !== false,
|
|
182
182
|
editorState: props.json != null ? JSON.stringify(props.json) : props.value != null ? (editor) => {
|
|
183
183
|
const dom = new DOMParser().parseFromString(props.value, "text/html");
|
|
@@ -226,6 +226,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
226
226
|
native: editor
|
|
227
227
|
};
|
|
228
228
|
setHandle(bound.current);
|
|
229
|
+
props.onJSONReady?.(true);
|
|
229
230
|
props.onReady?.();
|
|
230
231
|
};
|
|
231
232
|
useEffect(() => {
|
|
@@ -270,11 +271,12 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
270
271
|
}
|
|
271
272
|
});
|
|
272
273
|
};
|
|
274
|
+
if (__s.block.pending && !__s.block.crossRenderUpdate) return;
|
|
273
275
|
function __children$1(__props, __s, __extra) {
|
|
274
276
|
const __block = __s.block;
|
|
275
277
|
let _b = __s.slots[0];
|
|
276
278
|
if (_b === void 0) {
|
|
277
|
-
let _m0, _m1, _m2,
|
|
279
|
+
let _m0, _m1, _m2, _m5, _m6, _m7;
|
|
278
280
|
const _root = clone(_t$0);
|
|
279
281
|
_m0 = __block.parentNode;
|
|
280
282
|
const _el0 = child(_root);
|
|
@@ -282,24 +284,22 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
282
284
|
const _el1 = sibling(_el0, 1);
|
|
283
285
|
_m2 = _el1;
|
|
284
286
|
const _el2 = sibling(_el1, 1);
|
|
285
|
-
|
|
287
|
+
_m5 = _el2;
|
|
286
288
|
const _el3 = sibling(_el2, 1);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
_m5 = _el4;
|
|
290
|
-
const _el5 = sibling(_el4, 1);
|
|
291
|
-
_m8 = _el5;
|
|
292
|
-
_m9 = sibling(_el5, 1);
|
|
289
|
+
_m6 = _el3;
|
|
290
|
+
_m7 = sibling(_el3, 1);
|
|
293
291
|
drainFrag(_root, __block.parentNode, __block.endMarker);
|
|
294
|
-
_b = bag10(__s, null, _m0, _m1, _m2,
|
|
292
|
+
_b = bag10(__s, null, _m0, _m1, _m2, null, void 0, _m5, _m6, _m7, null, void 0);
|
|
295
293
|
}
|
|
296
294
|
componentSlot(__s, 1, _b.a, RichTextPlugin, {
|
|
297
|
-
"contentEditable": createScopedValue(() => createElementFromConfig("c:
|
|
295
|
+
"contentEditable": createScopedValue(() => createElementFromConfig("c:8bd4dc5e", ContentEditable, {
|
|
298
296
|
id: props.id,
|
|
299
297
|
"aria-placeholder": props.placeholder ?? "",
|
|
300
298
|
placeholder: null,
|
|
301
299
|
className: "vx-lexical-input",
|
|
302
|
-
style: { outline: "none" }
|
|
300
|
+
style: { outline: "none" },
|
|
301
|
+
onFocus: props.onFocus,
|
|
302
|
+
onBlur: props.onBlur
|
|
303
303
|
})),
|
|
304
304
|
"placeholder": props.placeholder ? createScopedValue(() => createScopedElement("div", {
|
|
305
305
|
className: "vx-lexical-placeholder",
|
|
@@ -312,12 +312,15 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
312
312
|
}, () => props.placeholder)) : null,
|
|
313
313
|
"ErrorBoundary": LexicalErrorBoundary
|
|
314
314
|
}, _b.b, void 0, 2, void 0, void 0, "c:1283dc87");
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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);
|
|
321
324
|
}
|
|
322
325
|
let _b = __s.slots[0];
|
|
323
326
|
if (_b === void 0) {
|
|
@@ -326,7 +329,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
|
|
|
326
329
|
_m1 = _root;
|
|
327
330
|
_b = bag3(__s, _root, void 0, _m1, void 0);
|
|
328
331
|
}
|
|
329
|
-
_b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", props.className, "b:
|
|
332
|
+
_b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", props.className, "b:748f4137", "class");
|
|
330
333
|
{
|
|
331
334
|
const _v = {
|
|
332
335
|
position: "relative",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/lexical",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Unified rich-text editor facade for Octane xplat — live lexical on web/AppKit, Aztec + lexical document model on Android",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cross-platform",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@lexical/rich-text": "0.51.0",
|
|
56
56
|
"@lexical/selection": "0.51.0",
|
|
57
57
|
"@lexical/utils": "0.51.0",
|
|
58
|
-
"@octane-xplat/richtext": "0.
|
|
58
|
+
"@octane-xplat/richtext": "0.11.1",
|
|
59
59
|
"@octanejs/lexical": "0.2.0",
|
|
60
60
|
"lexical": "0.51.0",
|
|
61
61
|
"zeed-dom": "0.18.0"
|
|
@@ -63,11 +63,12 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@nativescript-community/octane": "0.2.4",
|
|
65
65
|
"@nativescript/core": "9.1.2",
|
|
66
|
-
"@octane-xplat/macos-renderer": "0.
|
|
66
|
+
"@octane-xplat/macos-renderer": "0.11.1",
|
|
67
67
|
"@octanejs/vite-plugin": "0.1.61",
|
|
68
68
|
"octane": "0.6.3",
|
|
69
69
|
"typescript": "5.9.3",
|
|
70
|
-
"vite": "8.3.0"
|
|
70
|
+
"vite": "8.3.0",
|
|
71
|
+
"vitest": "5.0.1"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
|
73
74
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
}
|
|
93
94
|
},
|
|
94
95
|
"scripts": {
|
|
95
|
-
"build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs"
|
|
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"
|
|
96
98
|
}
|
|
97
99
|
}
|
|
@@ -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
|
+
})
|
package/src/LexicalEditor.tsrx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useState, useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
3
3
|
import { RichTextEditor, supported as leafSupported } from '@octane-xplat/richtext'
|
|
4
4
|
import type { RichTextEditorHandle } from '@octane-xplat/richtext'
|
|
5
|
-
import type { LexicalEditorHandle, LexicalEditorProps } from './types'
|
|
5
|
+
import type { LexicalEditorHandle, LexicalEditorProps, LexicalJSON } from './types'
|
|
6
6
|
import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from './json-bridge'
|
|
7
7
|
|
|
8
8
|
export const supported = leafSupported
|
|
@@ -16,22 +16,45 @@ export function LexicalEditor(props: LexicalEditorProps) @{
|
|
|
16
16
|
const inner = useRef<RichTextEditorHandle | null>(null)
|
|
17
17
|
const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
|
|
18
18
|
useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
|
|
19
|
-
const jsonReady = useRef(false)
|
|
20
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
|
+
}
|
|
21
42
|
|
|
22
43
|
const bindHost = useCallback((h: RichTextEditorHandle | null) => {
|
|
23
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
|
+
}
|
|
24
50
|
|
|
25
51
|
setHandle((previous) => previous?.native === h?.native ? previous : h ? ({
|
|
26
52
|
getHTML: () => h.getHTML(),
|
|
27
53
|
setHTML: (html: string) => h.setHTML(html),
|
|
28
54
|
getJSON: () => (jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null),
|
|
29
55
|
setJSON: (doc) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
lastJSON.current = JSON.stringify(doc)
|
|
33
|
-
h.setHTML(html)
|
|
34
|
-
}
|
|
56
|
+
desiredDoc.current = doc
|
|
57
|
+
applyDoc()
|
|
35
58
|
},
|
|
36
59
|
apply: (format) => h.apply(format),
|
|
37
60
|
linkTo: (url, anchor) => h.linkTo(url, anchor),
|
|
@@ -47,25 +70,28 @@ export function LexicalEditor(props: LexicalEditorProps) @{
|
|
|
47
70
|
}, [])
|
|
48
71
|
|
|
49
72
|
// The bridge loads once per process — dynamic imports of the DOM-free
|
|
50
|
-
// lexical slices, plus the zeed-dom parse shim.
|
|
73
|
+
// lexical slices, plus the zeed-dom parse shim. A doc that arrived before
|
|
74
|
+
// it settles is applied here, not dropped.
|
|
51
75
|
useEffect(() => {
|
|
76
|
+
let alive = true
|
|
52
77
|
ensureJSONBridge().then((ok) => {
|
|
53
|
-
|
|
78
|
+
if (!alive) return
|
|
54
79
|
props.onJSONReady?.(ok)
|
|
80
|
+
applyDoc()
|
|
55
81
|
})
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
alive = false
|
|
85
|
+
}
|
|
56
86
|
}, [])
|
|
57
87
|
|
|
58
|
-
// `json` prop → editor content
|
|
59
|
-
//
|
|
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.
|
|
60
91
|
useEffect(() => {
|
|
61
|
-
if (!props.json
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const html = jsonToHTML(props.json)
|
|
65
|
-
if (html != null) {
|
|
66
|
-
lastJSON.current = stamp
|
|
67
|
-
inner.current.setHTML(html)
|
|
68
|
-
}
|
|
92
|
+
if (!props.json) return
|
|
93
|
+
desiredDoc.current = props.json
|
|
94
|
+
applyDoc()
|
|
69
95
|
}, [props.json])
|
|
70
96
|
|
|
71
97
|
const emitJSON = () => {
|
|
@@ -73,6 +99,7 @@ export function LexicalEditor(props: LexicalEditorProps) @{
|
|
|
73
99
|
const doc = htmlToJSON(inner.current.getHTML())
|
|
74
100
|
if (doc) {
|
|
75
101
|
lastJSON.current = JSON.stringify(doc)
|
|
102
|
+
appliedHost.current = inner.current
|
|
76
103
|
props.onJSONChange?.(doc)
|
|
77
104
|
}
|
|
78
105
|
}
|
|
@@ -85,7 +112,12 @@ export function LexicalEditor(props: LexicalEditorProps) @{
|
|
|
85
112
|
placeholder={props.placeholder}
|
|
86
113
|
editable={props.editable}
|
|
87
114
|
autofocus={props.autofocus}
|
|
88
|
-
onReady={
|
|
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
|
+
}}
|
|
89
121
|
onChange={(html) => {
|
|
90
122
|
props.onChange?.(html)
|
|
91
123
|
emitJSON()
|