@octane-xplat/lexical 0.0.1 → 0.10.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,335 @@
1
+ /** Lazy lexical document-model bridge for native runtimes. The DOM-free
2
+ * slices (lexical core + the Aztec-shaped node packages, @lexical/html,
3
+ * zeed-dom as the parser host) load on demand — on a runtime that can't
4
+ * host them the bridge settles to unavailable and callers get null/no-op.
5
+ *
6
+ * Conversions run on a single headless `createEditor` (no `setRootElement`,
7
+ * no @lexical/headless — that package pulls happy-dom). Each call swaps the
8
+ * editor state wholesale, so the shared instance is a pure converter.
9
+ *
10
+ * Import behavior was proven by the demos probe (see packages/demos
11
+ * LexicalProbe): all slices load on both NS engines and the round-trip
12
+ * preserves links/alignment on 14/14 steps; the failure mode left is a
13
+ * future version or bundler regression, which the catch below degrades
14
+ * instead of crashing. */
15
+
16
+ import type { LexicalJSON } from './types'
17
+
18
+ interface Bridge {
19
+ editor: any
20
+ lexical: any
21
+ html: any
22
+ zeed: any
23
+ }
24
+
25
+ let bridge: Bridge | null | undefined
26
+
27
+ const kebab = (k: string) => k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
28
+
29
+ function chainDesc(proto: object, key: string): PropertyDescriptor | undefined {
30
+ let o: object | null = proto
31
+ while (o && o !== Object.prototype) {
32
+ const d = Object.getOwnPropertyDescriptor(o, key)
33
+ if (d) {
34
+ return d
35
+ }
36
+
37
+ o = Object.getPrototypeOf(o)
38
+ }
39
+
40
+ return undefined
41
+ }
42
+
43
+ function cssText(el: any): string {
44
+ return el.getAttribute('style') ?? ''
45
+ }
46
+
47
+ function writeStyleProp(el: any, key: string, value: string): void {
48
+ const rules = cssText(el)
49
+ .split(';')
50
+ .map((s: string) => s.trim())
51
+ .filter(Boolean)
52
+ .filter((r: string) => !r.startsWith(kebab(key) + ':'))
53
+
54
+ if (value !== '' && value != null) {
55
+ rules.push(`${kebab(key)}: ${value}`)
56
+ }
57
+
58
+ el.setAttribute('style', rules.join('; '))
59
+ }
60
+
61
+ /** zeed-dom is a vdom, not a DOM — lexical's walkers and exportDOM need four
62
+ * categories of patches beyond the classList/DOMParser install the tiptap
63
+ * bridge carries:
64
+ *
65
+ * 1. `parentElement` (absent pre-0.18 zeed-dom; guarded either way).
66
+ * 2. `firstChild`/`lastChild`/sibling accessors return `undefined` where the
67
+ * DOM spec says `null`, and the getters live on an ancestor proto —
68
+ * chainDesc finds them so the coercion wraps the real getter.
69
+ * 3. `style`: DOM reads of unset props return `''`, and lexical's exportDOM
70
+ * writes via property assignment and `setProperty` — neither serializes
71
+ * in zeed. The proxy defaults missing props to `''` and writes through
72
+ * to the element's `style` attribute.
73
+ * 4. Attribute-valued property assignments (`element.href = …`) must land
74
+ * as attributes — LinkNode.exportDOM sets `anchor.href`, dropping the
75
+ * link target entirely without this.
76
+ *
77
+ * Plus the `Node` constants global (lexical's helpers read
78
+ * `Node.TEXT_NODE`). */
79
+ function installLexicalDomShim(zeed: any): void {
80
+ const bootDoc = zeed.createHTMLDocument()
81
+ bootDoc.body.innerHTML = '<p>x</p>'
82
+ const elProto = Object.getPrototypeOf(bootDoc.body.childNodes[0])
83
+ const textProto = Object.getPrototypeOf(bootDoc.body.childNodes[0].childNodes[0])
84
+
85
+ for (const p of [elProto, textProto]) {
86
+ if (!chainDesc(p, 'parentElement')) {
87
+ Object.defineProperty(p, 'parentElement', {
88
+ configurable: true,
89
+ get(this: any) {
90
+ const par = this.parentNode
91
+ return par && par.nodeType === 1 ? par : null
92
+ },
93
+ })
94
+ }
95
+
96
+ for (const k of ['firstChild', 'lastChild', 'nextSibling', 'previousSibling']) {
97
+ const d = chainDesc(p, k)
98
+ Object.defineProperty(p, k, {
99
+ configurable: true,
100
+ get(this: any) {
101
+ const v = d?.get ? d.get.call(this) : undefined
102
+ return v ?? null
103
+ },
104
+ set: d?.set,
105
+ })
106
+ }
107
+ }
108
+
109
+ const styleCache = new WeakMap()
110
+ Object.defineProperty(elProto, 'style', {
111
+ configurable: true,
112
+ get(this: any) {
113
+ let proxy = styleCache.get(this)
114
+ if (!proxy) {
115
+ const el = this
116
+ proxy = new Proxy(
117
+ {},
118
+ {
119
+ get: (_t, k) => {
120
+ if (k === 'setProperty') {
121
+ return (prop: string, value: string) => writeStyleProp(el, prop, value)
122
+ }
123
+
124
+ if (k === 'removeProperty') {
125
+ return (prop: string) => {
126
+ writeStyleProp(el, prop, '')
127
+ return ''
128
+ }
129
+ }
130
+
131
+ if (k === 'cssText') {
132
+ return cssText(el)
133
+ }
134
+
135
+ if (k === 'getPropertyValue') {
136
+ return (prop: string) => {
137
+ const raw = cssText(el)
138
+ .split(';')
139
+ .map((s: string) => s.trim())
140
+ .find((r: string) => r.startsWith(prop + ':'))
141
+
142
+ return raw ? raw.slice(raw.indexOf(':') + 1).trim() : ''
143
+ }
144
+ }
145
+
146
+ if (typeof k === 'string') {
147
+ const raw = cssText(el)
148
+ .split(';')
149
+ .map((s: string) => s.trim())
150
+ .find((r: string) => r.startsWith(kebab(k) + ':'))
151
+
152
+ return raw ? raw.slice(raw.indexOf(':') + 1).trim() : ''
153
+ }
154
+
155
+ return undefined
156
+ },
157
+ set: (_t, k, v) => {
158
+ if (typeof k === 'string') {
159
+ writeStyleProp(el, k, v)
160
+ }
161
+
162
+ return true
163
+ },
164
+ },
165
+ )
166
+
167
+ styleCache.set(this, proxy)
168
+ }
169
+
170
+ return proxy
171
+ },
172
+ })
173
+
174
+ for (const attr of ['href', 'target', 'rel', 'title']) {
175
+ const d = chainDesc(elProto, attr)
176
+ if (!d?.get && !d?.set) {
177
+ Object.defineProperty(elProto, attr, {
178
+ configurable: true,
179
+ get(this: any) {
180
+ return this.getAttribute(attr) ?? ''
181
+ },
182
+ set(this: any, v: string) {
183
+ this.setAttribute(attr, v)
184
+ },
185
+ })
186
+ }
187
+ }
188
+
189
+ const clDesc = chainDesc(elProto, 'classList')
190
+ if (clDesc?.get) {
191
+ Object.defineProperty(elProto, 'classList', {
192
+ configurable: true,
193
+ get(this: any) {
194
+ const cl = clDesc.get!.call(this)
195
+ if (cl && typeof cl[Symbol.iterator] !== 'function') {
196
+ const tokens = String(this.getAttribute('class') ?? '')
197
+ .split(/\s+/)
198
+ .filter(Boolean)
199
+
200
+ return Object.assign(tokens, cl)
201
+ }
202
+
203
+ return cl
204
+ },
205
+ set: clDesc.set,
206
+ })
207
+ }
208
+
209
+ const g = globalThis as any
210
+ if (!g.window?.DOMParser) {
211
+ class ZeedDOMParser {
212
+ parseFromString(html: string) {
213
+ const doc = zeed.createHTMLDocument()
214
+ doc.body.innerHTML = html
215
+ return doc
216
+ }
217
+ }
218
+
219
+ g.window = { DOMParser: ZeedDOMParser }
220
+ g.document = zeed.createHTMLDocument()
221
+ }
222
+
223
+ g.Node = g.Node ?? {
224
+ ELEMENT_NODE: 1,
225
+ ATTRIBUTE_NODE: 2,
226
+ TEXT_NODE: 3,
227
+ CDATA_SECTION_NODE: 4,
228
+ PROCESSING_INSTRUCTION_NODE: 7,
229
+ COMMENT_NODE: 8,
230
+ DOCUMENT_NODE: 9,
231
+ DOCUMENT_TYPE_NODE: 10,
232
+ DOCUMENT_FRAGMENT_NODE: 11,
233
+ }
234
+ }
235
+
236
+ export async function ensureJSONBridge(): Promise<boolean> {
237
+ if (bridge !== undefined) {
238
+ return bridge !== null
239
+ }
240
+
241
+ try {
242
+ const [lexical, html, rt, list, link, code, ext, zeed] = await Promise.all([
243
+ import('lexical'),
244
+ import('@lexical/html'),
245
+ import('@lexical/rich-text'),
246
+ import('@lexical/list'),
247
+ import('@lexical/link'),
248
+ import('@lexical/code'),
249
+ import('@lexical/extension'),
250
+ import('zeed-dom'),
251
+ ])
252
+
253
+ installLexicalDomShim(zeed)
254
+
255
+ // The leaf's native node set — Aztec's capability surface in lexical
256
+ // form. taskList/highlight/sub/sup formats have no lexical node here
257
+ // and stay no-ops on the facade.
258
+ const editor = lexical.createEditor({
259
+ namespace: 'octane-xplat/lexical',
260
+ nodes: [
261
+ rt.HeadingNode,
262
+ rt.QuoteNode,
263
+ list.ListNode,
264
+ list.ListItemNode,
265
+ link.LinkNode,
266
+ code.CodeNode,
267
+ ext.HorizontalRuleNode,
268
+ ],
269
+ onError: () => undefined,
270
+ })
271
+
272
+ bridge = { editor, lexical, html, zeed }
273
+
274
+ return true
275
+ } catch {
276
+ bridge = null
277
+ return false
278
+ }
279
+ }
280
+
281
+ export function jsonBridgeReady(): boolean {
282
+ return bridge != null
283
+ }
284
+
285
+ function parseHTML(htmlText: string): any {
286
+ const doc = bridge!.zeed.createHTMLDocument()
287
+ doc.body.innerHTML = htmlText
288
+ return doc
289
+ }
290
+
291
+ /** HTML → serialized editor state through lexical's own DOM import rules.
292
+ * Null when the bridge hasn't loaded (call ensureJSONBridge first / check
293
+ * `jsonBridgeReady`). */
294
+ export function htmlToJSON(htmlText: string): LexicalJSON | null {
295
+ if (!bridge) {
296
+ return null
297
+ }
298
+
299
+ try {
300
+ bridge.editor.update(
301
+ () => {
302
+ const nodes = bridge!.html.$generateNodesFromDOM(bridge!.editor, parseHTML(htmlText))
303
+ bridge!.lexical
304
+ .$getRoot()
305
+ .clear()
306
+ .append(...nodes)
307
+ },
308
+ { discrete: true },
309
+ )
310
+
311
+ return bridge.editor.getEditorState().toJSON() as LexicalJSON
312
+ } catch {
313
+ return null
314
+ }
315
+ }
316
+
317
+ /** Serialized editor state → HTML via lexical's own exportDOM rules (no
318
+ * browser DOM — the zeed-dom shim stands in). */
319
+ export function jsonToHTML(doc: LexicalJSON): string | null {
320
+ if (!bridge) {
321
+ return null
322
+ }
323
+
324
+ try {
325
+ bridge.editor.setEditorState(bridge.editor.parseEditorState(JSON.stringify(doc)))
326
+ let out = ''
327
+ bridge.editor.read(() => {
328
+ out = bridge!.html.$generateHtmlFromNodes(bridge!.editor)
329
+ })
330
+
331
+ return out
332
+ } catch {
333
+ return null
334
+ }
335
+ }
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { Octane } from 'octane/jsx-runtime'
2
+
3
+ /** Format vocabulary shared by the web (lexical core + registered nodes)
4
+ * and native (Aztec) backends. On web, `taskList`/`highlight`/`subscript`/
5
+ * `superscript` are only active when their nodes are registered — the
6
+ * default node set doesn't include them, so they no-op there and map to
7
+ * Aztec formats on Android. */
8
+ export type LexicalFormat =
9
+ | 'bold'
10
+ | 'italic'
11
+ | 'underline'
12
+ | 'strikethrough'
13
+ | 'code'
14
+ | 'blockquote'
15
+ | 'bulletList'
16
+ | 'orderedList'
17
+ | 'taskList'
18
+ | 'codeBlock'
19
+ | 'horizontalRule'
20
+ | 'highlight'
21
+ | 'subscript'
22
+ | 'superscript'
23
+ | 'paragraph'
24
+ | 'heading1'
25
+ | 'heading2'
26
+ | 'heading3'
27
+ | 'heading4'
28
+ | 'heading5'
29
+ | 'heading6'
30
+ | 'alignLeft'
31
+ | 'alignCenter'
32
+ | 'alignRight'
33
+ | 'link'
34
+
35
+ /** Lexical serialized editor state (`{ root: { children: [...] } }` — the
36
+ * `EditorState.toJSON()` shape). Opaque here — callers round-trip through
37
+ * lexical's own schema on both platforms. */
38
+ export type LexicalJSON = { root: Record<string, unknown> } & Record<string, unknown>
39
+
40
+ export interface LexicalEditorHandle {
41
+ /** AppKit: returns the latest WebKit snapshot; commands are asynchronous. */
42
+ getHTML(): string
43
+ /** Web: `setEditorState` inside an update (undo-aware). Native: Aztec
44
+ * `fromHtml` — resets undo history. */
45
+ setHTML(html: string): void
46
+ /** Serialized editor state, or null while the native doc-model modules
47
+ * are still loading / on a runtime that can't host them (`onJSONReady`
48
+ * reports the outcome). Web is always ready. */
49
+ /** AppKit: latest live engine JSON snapshot; null before onReady. */
50
+ getJSON(): LexicalJSON | null
51
+ setJSON(doc: LexicalJSON): void
52
+ apply(format: LexicalFormat): void
53
+ linkTo(url: string, anchor?: string): void
54
+ removeLink(): void
55
+ isActive(format: LexicalFormat): boolean
56
+ undo(): void
57
+ redo(): void
58
+ focus(): void
59
+ blur(): void
60
+ isFocused(): boolean
61
+ /** The platform surface — the lexical `LexicalEditor` on web,
62
+ * `AztecText` on Android. */
63
+ /** AppKit: the XplatEditorHost transport, not the browser engine instance. */
64
+ native: any
65
+ }
66
+
67
+ export interface LexicalEditorProps {
68
+ id?: string
69
+ className?: string
70
+ style?: any
71
+ /** Document HTML. Seed at mount; external changes re-push. */
72
+ value?: string
73
+ /** Serialized editor state. Takes precedence over `value` when both
74
+ * arrive. On native it waits for the doc-model modules (see
75
+ * `onJSONReady`). */
76
+ json?: LexicalJSON
77
+ placeholder?: string
78
+ editable?: boolean
79
+ autofocus?: boolean
80
+ onReady?: () => void
81
+ /** Fires once when the native lexical bridge settles — `true` if the
82
+ * DOM-free lexical modules loaded, `false` on runtimes that can't host
83
+ * them (JSON calls then no-op / return null). Never fires on web. */
84
+ /** AppKit: fires true with mounted engine readiness; no headless conversion bridge. */
85
+ onJSONReady?: (ready: boolean) => void
86
+ onChange?: (html: string) => void
87
+ onJSONChange?: (doc: LexicalJSON) => void
88
+ onSelectionChange?: (event: { start: number; end: number; active: LexicalFormat[] }) => void
89
+ onFocus?: () => void
90
+ onBlur?: () => void
91
+ ref?: Octane.Ref<LexicalEditorHandle>
92
+ android?: Record<string, any>
93
+ ios?: Record<string, any>
94
+ row?: number | string
95
+ col?: number | string
96
+ rowSpan?: number
97
+ colSpan?: number
98
+ dock?: 'left' | 'top' | 'right' | 'bottom'
99
+ left?: number
100
+ top?: number
101
+ flexGrow?: number
102
+ flexShrink?: number
103
+ alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'
104
+ order?: number
105
+ }
@@ -0,0 +1,8 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { LexicalEditorProps, LexicalJSON } from '../src/types'
3
+
4
+ export declare const LexicalEditor: UniversalComponent<LexicalEditorProps>
5
+ export declare const supported: boolean
6
+ export declare function ensureJSONBridge(): Promise<boolean>
7
+ export declare function jsonBridgeReady(): boolean
8
+ export type * from '../src/types'
@@ -0,0 +1,7 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { LexicalEditorProps } from '../src/types'
3
+ export declare const LexicalEditor: UniversalComponent<LexicalEditorProps>
4
+ export declare const supported: boolean
5
+ export type * from '../src/types'
6
+ export declare function ensureJSONBridge(): Promise<boolean>
7
+ export declare function jsonBridgeReady(): boolean