@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,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,131 @@
1
+ import type { Octane } from 'octane/jsx-runtime'
2
+ import type { OctaneNode } from 'octane'
3
+ import type { Klass, LexicalNode, LexicalNodeReplacement } from 'lexical'
4
+
5
+ /** Format vocabulary shared by the web (lexical core + registered nodes)
6
+ * and native (Aztec) backends. On web, `taskList`/`highlight`/`subscript`/
7
+ * `superscript` are only active when their nodes are registered — the
8
+ * default node set doesn't include them, so they no-op there and map to
9
+ * Aztec formats on Android. */
10
+ export type LexicalFormat =
11
+ | 'bold'
12
+ | 'italic'
13
+ | 'underline'
14
+ | 'strikethrough'
15
+ | 'code'
16
+ | 'blockquote'
17
+ | 'bulletList'
18
+ | 'orderedList'
19
+ | 'taskList'
20
+ | 'codeBlock'
21
+ | 'horizontalRule'
22
+ | 'highlight'
23
+ | 'subscript'
24
+ | 'superscript'
25
+ | 'paragraph'
26
+ | 'heading1'
27
+ | 'heading2'
28
+ | 'heading3'
29
+ | 'heading4'
30
+ | 'heading5'
31
+ | 'heading6'
32
+ | 'alignLeft'
33
+ | 'alignCenter'
34
+ | 'alignRight'
35
+ | 'link'
36
+
37
+ /** Lexical serialized editor state (`{ root: { children: [...] } }` — the
38
+ * `EditorState.toJSON()` shape). Opaque here — callers round-trip through
39
+ * lexical's own schema on both platforms. */
40
+ export type LexicalJSON = { root: Record<string, unknown> } & Record<string, unknown>
41
+
42
+ /** Web-only extensions for the live Lexical composer. These options are ignored
43
+ * by native backends; caller plugins, custom nodes, transforms, and browser
44
+ * views require a separate native implementation. */
45
+ export interface LexicalWebOptions {
46
+ /** Additional Lexical node classes or replacements. By default these are
47
+ * added to the facade's built-in node set. Set `replaceNodes` to use this
48
+ * list as the complete custom node registry instead. */
49
+ nodes?: readonly (Klass<LexicalNode> | LexicalNodeReplacement)[]
50
+ /** Replace the facade's built-in custom node registry with `nodes`. Core
51
+ * Lexical nodes remain managed by Lexical. Defaults to `false`. */
52
+ replaceNodes?: boolean
53
+ /** Replace the default history, list, link, and autofocus plugins with the
54
+ * plugin components supplied in `plugins`. Defaults to `false`. The rich
55
+ * text surface and facade change/ref bindings remain installed. */
56
+ replacePlugins?: boolean
57
+ /** Additional Octane plugin components rendered inside the Lexical composer,
58
+ * or the replacement plugin composition when `replacePlugins` is `true`.
59
+ * Use a fragment when composing more than one plugin. */
60
+ plugins?: OctaneNode
61
+ }
62
+
63
+ export interface LexicalEditorHandle {
64
+ /** AppKit: returns the latest WebKit snapshot; commands are asynchronous. */
65
+ getHTML(): string
66
+ /** Web: `setEditorState` inside an update (undo-aware). Native: Aztec
67
+ * `fromHtml` — resets undo history. */
68
+ setHTML(html: string): void
69
+ /** Serialized editor state, or null while the native doc-model modules
70
+ * are still loading / on a runtime that can't host them (`onJSONReady`
71
+ * reports the outcome). Web is always ready. */
72
+ /** AppKit: latest live engine JSON snapshot; null before onReady. */
73
+ getJSON(): LexicalJSON | null
74
+ setJSON(doc: LexicalJSON): void
75
+ apply(format: LexicalFormat): void
76
+ linkTo(url: string, anchor?: string): void
77
+ removeLink(): void
78
+ isActive(format: LexicalFormat): boolean
79
+ undo(): void
80
+ redo(): void
81
+ focus(): void
82
+ blur(): void
83
+ isFocused(): boolean
84
+ /** The platform surface — the lexical `LexicalEditor` on web,
85
+ * `AztecText` on Android. */
86
+ /** AppKit: the XplatEditorHost transport, not the browser engine instance. */
87
+ native: any
88
+ }
89
+
90
+ export interface LexicalEditorProps {
91
+ id?: string
92
+ className?: string
93
+ style?: any
94
+ /** Document HTML. Seed at mount; external changes re-push. */
95
+ value?: string
96
+ /** Serialized editor state. Takes precedence over `value` when both
97
+ * arrive. On native it waits for the doc-model modules and host
98
+ * readiness (see `onJSONReady`); a doc that arrives early is applied
99
+ * once both are up rather than dropped. */
100
+ json?: LexicalJSON
101
+ placeholder?: string
102
+ editable?: boolean
103
+ autofocus?: boolean
104
+ onReady?: () => void
105
+ /** Reports when serialized JSON access is available. Web and AppKit report
106
+ * `true` when their live editor handle exists. Native reports whether its
107
+ * lazy DOM-free conversion bridge loaded; unsupported runtimes report
108
+ * `false` and JSON getters return `null`. */
109
+ onJSONReady?: (ready: boolean) => void
110
+ onChange?: (html: string) => void
111
+ onJSONChange?: (doc: LexicalJSON) => void
112
+ onSelectionChange?: (event: { start: number; end: number; active: LexicalFormat[] }) => void
113
+ onFocus?: () => void
114
+ onBlur?: () => void
115
+ /** Web-only Lexical node and plugin composition. Ignored by native backends. */
116
+ web?: LexicalWebOptions
117
+ ref?: Octane.Ref<LexicalEditorHandle>
118
+ android?: Record<string, any>
119
+ ios?: Record<string, any>
120
+ row?: number | string
121
+ col?: number | string
122
+ rowSpan?: number
123
+ colSpan?: number
124
+ dock?: 'left' | 'top' | 'right' | 'bottom'
125
+ left?: number
126
+ top?: number
127
+ flexGrow?: number
128
+ flexShrink?: number
129
+ alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'
130
+ order?: number
131
+ }
@@ -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