@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.
@@ -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
+ })
@@ -46,9 +46,8 @@ import type { LexicalEditorHandle, LexicalEditorProps, LexicalFormat } from './t
46
46
 
47
47
  export const supported = true
48
48
 
49
- /** The facade's fixed node set — Aztec's capability surface plus
50
- * AutoLinkNode (parsing safety for docs produced by fuller editors). Apps
51
- * needing custom nodes or plugins import @octanejs/lexical directly. */
49
+ /** The facade's default node set — Aztec's capability surface plus
50
+ * AutoLinkNode (parsing safety for docs produced by fuller editors). */
52
51
  const NODES = [
53
52
  HeadingNode,
54
53
  QuoteNode,
@@ -241,10 +240,8 @@ function activeFormats(editor: any): LexicalFormat[] {
241
240
  return out
242
241
  }
243
242
 
244
- /** Unified editor facade, web half — a fixed-plugin LexicalComposer over
245
- * the published `@octanejs/lexical` binding. The composition is sealed on
246
- * purpose: the facade's contract is the native-compatible vocabulary, and
247
- * apps that need arbitrary plugins use the upstream package directly. */
243
+ /** Unified editor facade, web half — a LexicalComposer over the published
244
+ * `@octanejs/lexical` binding, with optional web-only node and plugin composition. */
248
245
  export function LexicalEditor(props: LexicalEditorProps) @{
249
246
  const editorRef = useRef<any>(null)
250
247
  const lastEmitted = useRef<string | undefined>(undefined)
@@ -257,7 +254,9 @@ export function LexicalEditor(props: LexicalEditorProps) @{
257
254
 
258
255
  const initialConfig = {
259
256
  namespace: 'octane-xplat/lexical',
260
- nodes: NODES,
257
+ nodes: props.web?.replaceNodes
258
+ ? props.web.nodes ?? []
259
+ : [...new Set([...NODES, ...(props.web?.nodes ?? [])])],
261
260
  editable: props.editable !== false,
262
261
  editorState: props.json != null
263
262
  ? JSON.stringify(props.json)
@@ -317,6 +316,7 @@ export function LexicalEditor(props: LexicalEditorProps) @{
317
316
  }
318
317
 
319
318
  setHandle(bound.current!)
319
+ props.onJSONReady?.(true)
320
320
  props.onReady?.()
321
321
  }
322
322
 
@@ -376,6 +376,8 @@ export function LexicalEditor(props: LexicalEditorProps) @{
376
376
  placeholder={null}
377
377
  className="vx-lexical-input"
378
378
  style={{ outline: 'none' }}
379
+ onFocus={props.onFocus}
380
+ onBlur={props.onBlur}
379
381
  />
380
382
  }
381
383
  placeholder={
@@ -395,12 +397,17 @@ export function LexicalEditor(props: LexicalEditorProps) @{
395
397
  }
396
398
  ErrorBoundary={LexicalErrorBoundary}
397
399
  />
398
- <HistoryPlugin />
399
- <ListPlugin />
400
- <LinkPlugin />
401
- {props.autofocus ? <AutoFocusPlugin /> : null}
400
+ {!props.web?.replacePlugins ? (
401
+ <>
402
+ <HistoryPlugin />
403
+ <ListPlugin />
404
+ <LinkPlugin />
405
+ {props.autofocus ? <AutoFocusPlugin /> : null}
406
+ </>
407
+ ) : null}
402
408
  <OnChangePlugin onChange={emitChange} />
403
409
  <EditorRefPlugin editorRef={attach} />
410
+ {props.web?.plugins}
404
411
  </LexicalComposer>
405
412
  </div>
406
413
  }
package/src/types.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Octane } from 'octane/jsx-runtime'
2
+ import type { OctaneNode } from 'octane'
3
+ import type { Klass, LexicalNode, LexicalNodeReplacement } from 'lexical'
2
4
 
3
5
  /** Format vocabulary shared by the web (lexical core + registered nodes)
4
6
  * and native (Aztec) backends. On web, `taskList`/`highlight`/`subscript`/
@@ -37,6 +39,27 @@ export type LexicalFormat =
37
39
  * lexical's own schema on both platforms. */
38
40
  export type LexicalJSON = { root: Record<string, unknown> } & Record<string, unknown>
39
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
+
40
63
  export interface LexicalEditorHandle {
41
64
  /** AppKit: returns the latest WebKit snapshot; commands are asynchronous. */
42
65
  getHTML(): string
@@ -71,23 +94,26 @@ export interface LexicalEditorProps {
71
94
  /** Document HTML. Seed at mount; external changes re-push. */
72
95
  value?: string
73
96
  /** Serialized editor state. Takes precedence over `value` when both
74
- * arrive. On native it waits for the doc-model modules (see
75
- * `onJSONReady`). */
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. */
76
100
  json?: LexicalJSON
77
101
  placeholder?: string
78
102
  editable?: boolean
79
103
  autofocus?: boolean
80
104
  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. */
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`. */
85
109
  onJSONReady?: (ready: boolean) => void
86
110
  onChange?: (html: string) => void
87
111
  onJSONChange?: (doc: LexicalJSON) => void
88
112
  onSelectionChange?: (event: { start: number; end: number; active: LexicalFormat[] }) => void
89
113
  onFocus?: () => void
90
114
  onBlur?: () => void
115
+ /** Web-only Lexical node and plugin composition. Ignored by native backends. */
116
+ web?: LexicalWebOptions
91
117
  ref?: Octane.Ref<LexicalEditorHandle>
92
118
  android?: Record<string, any>
93
119
  ios?: Record<string, any>