@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,406 @@
1
+ import { useState, useImperativeHandle, useEffect, useRef } from 'octane'
2
+ import {
3
+ AutoFocusPlugin,
4
+ ContentEditable,
5
+ EditorRefPlugin,
6
+ HistoryPlugin,
7
+ LexicalComposer,
8
+ LexicalErrorBoundary,
9
+ LinkPlugin,
10
+ ListPlugin,
11
+ OnChangePlugin,
12
+ RichTextPlugin,
13
+ } from '@octanejs/lexical'
14
+
15
+ import {
16
+ $createParagraphNode,
17
+ $createTextNode,
18
+ $findMatchingParent,
19
+ $getRoot,
20
+ $getSelection,
21
+ $isNodeSelection,
22
+ $isRangeSelection,
23
+ FORMAT_ELEMENT_COMMAND,
24
+ FORMAT_TEXT_COMMAND,
25
+ REDO_COMMAND,
26
+ UNDO_COMMAND,
27
+ } from 'lexical'
28
+
29
+ import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, QuoteNode } from '@lexical/rich-text'
30
+ import {
31
+ $isListItemNode,
32
+ $isListNode,
33
+ INSERT_CHECK_LIST_COMMAND,
34
+ INSERT_ORDERED_LIST_COMMAND,
35
+ INSERT_UNORDERED_LIST_COMMAND,
36
+ ListItemNode,
37
+ ListNode,
38
+ } from '@lexical/list'
39
+
40
+ import { $createLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
41
+ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code'
42
+ import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND, $isHorizontalRuleNode } from '@lexical/extension'
43
+ import { $setBlocksType } from '@lexical/selection'
44
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
45
+ import type { LexicalEditorHandle, LexicalEditorProps, LexicalFormat } from './types'
46
+
47
+ export const supported = true
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. */
52
+ const NODES = [
53
+ HeadingNode,
54
+ QuoteNode,
55
+ ListNode,
56
+ ListItemNode,
57
+ LinkNode,
58
+ AutoLinkNode,
59
+ CodeNode,
60
+ HorizontalRuleNode,
61
+ ]
62
+
63
+ const HEADING_TAG: Partial<Record<LexicalFormat, 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>> = {
64
+ heading1: 'h1',
65
+ heading2: 'h2',
66
+ heading3: 'h3',
67
+ heading4: 'h4',
68
+ heading5: 'h5',
69
+ heading6: 'h6',
70
+ }
71
+
72
+ const TEXT_FORMAT: Partial<Record<LexicalFormat, string>> = {
73
+ bold: 'bold',
74
+ italic: 'italic',
75
+ underline: 'underline',
76
+ strikethrough: 'strikethrough',
77
+ code: 'code',
78
+ highlight: 'highlight',
79
+ subscript: 'subscript',
80
+ superscript: 'superscript',
81
+ }
82
+
83
+ const ELEMENT_FORMAT: Partial<Record<LexicalFormat, string>> = {
84
+ alignLeft: 'left',
85
+ alignCenter: 'center',
86
+ alignRight: 'right',
87
+ }
88
+
89
+ /** Toggle a block type at the selection — reverts to paragraph when the
90
+ * selection already sits in that block type. */
91
+ function setBlock(editor: any, is: (n: any) => boolean, create: () => any, match?: (n: any) => boolean): void {
92
+ editor.update(() => {
93
+ const sel = $getSelection()
94
+ if (!$isRangeSelection(sel)) return
95
+ const block = $findMatchingParent(sel.anchor.getNode(), is)
96
+ const active = block != null && (match ? match(block) : true)
97
+ $setBlocksType(sel, () => (active ? $createParagraphNode() : create()))
98
+ })
99
+ }
100
+
101
+ function applyFormat(editor: any, format: LexicalFormat): void {
102
+ const text = TEXT_FORMAT[format]
103
+ if (text) {
104
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, text)
105
+ return
106
+ }
107
+
108
+ const el = ELEMENT_FORMAT[format]
109
+ if (el) {
110
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, el)
111
+ return
112
+ }
113
+
114
+ const tag = HEADING_TAG[format]
115
+ if (tag) {
116
+ setBlock(editor, $isHeadingNode, () => $createHeadingNode(tag), (n) => n.getTag() === tag)
117
+ return
118
+ }
119
+
120
+ switch (format) {
121
+ case 'paragraph':
122
+ setBlock(editor, () => true, () => $createParagraphNode(), () => false)
123
+ break
124
+ case 'blockquote':
125
+ setBlock(editor, $isQuoteNode, () => $createQuoteNode())
126
+ break
127
+ case 'codeBlock':
128
+ setBlock(editor, $isCodeNode, () => $createCodeNode())
129
+ break
130
+ case 'bulletList':
131
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
132
+ break
133
+ case 'orderedList':
134
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
135
+ break
136
+ case 'taskList':
137
+ editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
138
+ break
139
+ case 'horizontalRule':
140
+ editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
141
+ break
142
+ }
143
+ }
144
+
145
+ function isActive(editor: any, format: LexicalFormat): boolean {
146
+ return editor.read(() => {
147
+ const sel = $getSelection()
148
+ if (sel == null) return false
149
+
150
+ const text = TEXT_FORMAT[format]
151
+ if (text) {
152
+ return $isRangeSelection(sel) && sel.hasFormat(text as any)
153
+ }
154
+
155
+ const el = ELEMENT_FORMAT[format]
156
+ if (el) {
157
+ const nodes = sel.getNodes()
158
+ return nodes.some((n: any) => {
159
+ const block = $findMatchingParent(n, (p: any) => p.getFormatType != null)
160
+ return block != null && (block as any).getFormatType() === el
161
+ })
162
+ }
163
+
164
+ const tag = HEADING_TAG[format]
165
+ if (tag) {
166
+ return sel.getNodes().some((n: any) => {
167
+ const h = $findMatchingParent(n, $isHeadingNode)
168
+ return h?.getTag() === tag
169
+ })
170
+ }
171
+
172
+ switch (format) {
173
+ case 'blockquote':
174
+ return sel.getNodes().some((n: any) => $findMatchingParent(n, $isQuoteNode) != null)
175
+ case 'codeBlock':
176
+ return sel.getNodes().some((n: any) => $findMatchingParent(n, $isCodeNode) != null)
177
+ case 'bulletList':
178
+ case 'orderedList':
179
+ case 'taskList': {
180
+ const want = format === 'bulletList' ? 'bullet' : format === 'orderedList' ? 'number' : 'check'
181
+ return sel.getNodes().some((n: any) => {
182
+ const l = $findMatchingParent(n, $isListNode)
183
+ return l?.getListType() === want
184
+ })
185
+ }
186
+ case 'link':
187
+ return sel.getNodes().some(
188
+ (n: any) => $isLinkNode(n) || $isLinkNode($findMatchingParent(n, $isLinkNode)),
189
+ )
190
+ case 'horizontalRule':
191
+ return $isNodeSelection(sel) && sel.getNodes().some($isHorizontalRuleNode)
192
+ case 'paragraph':
193
+ return sel.getNodes().some((n: any) =>
194
+ $findMatchingParent(n, (p: any) => p.getType?.() === 'paragraph'),
195
+ )
196
+ default:
197
+ return false
198
+ }
199
+ })
200
+ }
201
+
202
+ /** Plain-text offset of a selection point — DFS accumulating text size.
203
+ * Block separators aren't counted; positions are best-effort parity with
204
+ * the native flat-offset contract, not ProseMirror-style coordinates. */
205
+ function flatOffset(root: any, key: string, offset: number): number {
206
+ const contains = (node: any): boolean =>
207
+ node.getKey() === key || (node.getChildren?.() ?? []).some(contains)
208
+
209
+ let acc = 0
210
+ const visit = (node: any): boolean => {
211
+ if (node.getKey() === key) {
212
+ acc += offset
213
+ return true
214
+ }
215
+
216
+ for (const c of node.getChildren?.() ?? []) {
217
+ if (contains(c)) {
218
+ if (visit(c)) return true
219
+ } else {
220
+ acc += c.getTextContentSize?.() ?? 0
221
+ }
222
+ }
223
+
224
+ return false
225
+ }
226
+
227
+ visit(root)
228
+ return acc
229
+ }
230
+
231
+ function activeFormats(editor: any): LexicalFormat[] {
232
+ const out: LexicalFormat[] = []
233
+ for (const f of Object.keys(TEXT_FORMAT) as LexicalFormat[]) {
234
+ if (isActive(editor, f)) out.push(f)
235
+ }
236
+
237
+ for (const f of ['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock', 'link'] as LexicalFormat[]) {
238
+ if (isActive(editor, f)) out.push(f)
239
+ }
240
+
241
+ return out
242
+ }
243
+
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. */
248
+ export function LexicalEditor(props: LexicalEditorProps) @{
249
+ const editorRef = useRef<any>(null)
250
+ const lastEmitted = useRef<string | undefined>(undefined)
251
+ const lastJSON = useRef<string | undefined>(undefined)
252
+ const lastHTMLInput = useRef(JSON.stringify([props.json !== undefined, props.value]))
253
+ const lastJSONInput = useRef(JSON.stringify(props.json))
254
+ const bound = useRef<LexicalEditorHandle | undefined>(undefined)
255
+ const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
256
+ useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
257
+
258
+ const initialConfig = {
259
+ namespace: 'octane-xplat/lexical',
260
+ nodes: NODES,
261
+ editable: props.editable !== false,
262
+ editorState: props.json != null
263
+ ? JSON.stringify(props.json)
264
+ : props.value != null
265
+ ? (editor: any) => {
266
+ const dom = new DOMParser().parseFromString(props.value!, 'text/html')
267
+ const nodes = $generateNodesFromDOM(editor, dom)
268
+ $getRoot().append(...nodes)
269
+ }
270
+ : undefined,
271
+ onError: (e: Error) => {
272
+ throw e
273
+ },
274
+ }
275
+
276
+ const attach = (editor: any) => {
277
+ if (!editor || bound.current?.native === editor) return
278
+ editorRef.current = editor
279
+ bound.current = {
280
+ getHTML: () => editor.read(() => $generateHtmlFromNodes(editor)),
281
+ setHTML: (html: string) =>
282
+ editor.update(() => {
283
+ const dom = new DOMParser().parseFromString(html, 'text/html')
284
+ const nodes = $generateNodesFromDOM(editor, dom)
285
+ $getRoot().clear().append(...nodes)
286
+ }),
287
+ getJSON: () => editor.getEditorState().toJSON(),
288
+ setJSON: (doc) =>
289
+ editor.setEditorState(editor.parseEditorState(JSON.stringify(doc))),
290
+ apply: (format) => {
291
+ editor.focus()
292
+ applyFormat(editor, format)
293
+ },
294
+ linkTo: (url, anchor) => {
295
+ if (anchor != null && anchor !== '') {
296
+ editor.update(() => {
297
+ const link = $createLinkNode(url)
298
+ link.append($createTextNode(anchor))
299
+ const sel = $getSelection()
300
+ if ($isRangeSelection(sel)) sel.insertNodes([link])
301
+ })
302
+ } else {
303
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url)
304
+ }
305
+ },
306
+ removeLink: () => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null),
307
+ isActive: (format) => isActive(editor, format),
308
+ undo: () => editor.dispatchCommand(UNDO_COMMAND, undefined),
309
+ redo: () => editor.dispatchCommand(REDO_COMMAND, undefined),
310
+ focus: () => editor.focus(),
311
+ blur: () => editor.blur(),
312
+ isFocused: () => {
313
+ const root = editor.getRootElement()
314
+ return root != null && root === document.activeElement
315
+ },
316
+ native: editor,
317
+ }
318
+
319
+ setHandle(bound.current!)
320
+ props.onReady?.()
321
+ }
322
+
323
+ // External `value`/`json` changes re-push; editor-emitted docs are skipped
324
+ // (the update already owns the doc).
325
+ useEffect(() => {
326
+ const e = editorRef.current
327
+ if (!e) return
328
+ const stamp = JSON.stringify([props.json !== undefined, props.value])
329
+ if (stamp === lastHTMLInput.current) return
330
+ lastHTMLInput.current = stamp
331
+ if (props.json !== undefined || props.value === undefined || props.value === lastEmitted.current) return
332
+ bound.current?.setHTML(props.value)
333
+ }, [exposedHandle, props.value, props.json])
334
+
335
+ useEffect(() => {
336
+ const e = editorRef.current
337
+ if (!e) return
338
+ const stamp = JSON.stringify(props.json)
339
+ if (stamp === lastJSONInput.current) return
340
+ lastJSONInput.current = stamp
341
+ if (props.json === undefined || stamp === lastJSON.current) return
342
+ bound.current?.setJSON(props.json)
343
+ }, [exposedHandle, props.json])
344
+
345
+ useEffect(() => {
346
+ editorRef.current?.setEditable(props.editable !== false)
347
+ }, [exposedHandle, props.editable])
348
+
349
+ const emitChange = (state: any, editor: any) => {
350
+ const html = state.read(() => $generateHtmlFromNodes(editor))
351
+ lastEmitted.current = html
352
+ props.onChange?.(html)
353
+ const doc = state.toJSON()
354
+ lastJSON.current = JSON.stringify(doc)
355
+ props.onJSONChange?.(doc)
356
+ state.read(() => {
357
+ const sel = $getSelection()
358
+ if ($isRangeSelection(sel) && props.onSelectionChange) {
359
+ const root = $getRoot()
360
+ props.onSelectionChange({
361
+ start: flatOffset(root, sel.anchor.key, sel.anchor.offset),
362
+ end: flatOffset(root, sel.focus.key, sel.focus.offset),
363
+ active: activeFormats(editor),
364
+ })
365
+ }
366
+ })
367
+ }
368
+
369
+ <div className={props.className} style={{ position: 'relative', ...props.style }}>
370
+ <LexicalComposer initialConfig={initialConfig}>
371
+ <RichTextPlugin
372
+ contentEditable={
373
+ <ContentEditable
374
+ id={props.id}
375
+ aria-placeholder={props.placeholder ?? ''}
376
+ placeholder={null}
377
+ className="vx-lexical-input"
378
+ style={{ outline: 'none' }}
379
+ />
380
+ }
381
+ placeholder={
382
+ props.placeholder ? (
383
+ <div
384
+ className="vx-lexical-placeholder"
385
+ style={{
386
+ position: 'absolute',
387
+ inset: '8px auto auto 10px',
388
+ pointerEvents: 'none',
389
+ opacity: 0.5,
390
+ }}
391
+ >
392
+ {props.placeholder}
393
+ </div>
394
+ ) : null
395
+ }
396
+ ErrorBoundary={LexicalErrorBoundary}
397
+ />
398
+ <HistoryPlugin />
399
+ <ListPlugin />
400
+ <LinkPlugin />
401
+ {props.autofocus ? <AutoFocusPlugin /> : null}
402
+ <OnChangePlugin onChange={emitChange} />
403
+ <EditorRefPlugin editorRef={attach} />
404
+ </LexicalComposer>
405
+ </div>
406
+ }
@@ -0,0 +1,5 @@
1
+ import { createRoot } from 'octane'
2
+ import { LexicalEditor } from './LexicalEditor.web.tsrx'
3
+ import { installEditorDocument } from '@octane-xplat/richtext/browser-transport'
4
+ const root = createRoot(document.getElementById('root')!)
5
+ installEditorDocument((props) => root.render(<LexicalEditor {...props} />))
@@ -0,0 +1,13 @@
1
+ export { LexicalEditor } from './Editor.macos'
2
+ /** The AppKit/WKWebView backend is implemented. */
3
+ export const supported = true
4
+ export type * from './types'
5
+
6
+ /** The local bundle includes its JSON model; wait for onReady before reading a document. */
7
+ export async function ensureJSONBridge(): Promise<boolean> {
8
+ return true
9
+ }
10
+
11
+ export function jsonBridgeReady(): boolean {
12
+ return true
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { LexicalEditor, supported } from './LexicalEditor.tsrx'
2
+ export { ensureJSONBridge, jsonBridgeReady } from './json-bridge'
3
+ export type * from './types'
@@ -0,0 +1,12 @@
1
+ export { LexicalEditor, supported } from './LexicalEditor.web.tsrx'
2
+ export type * from './types'
3
+
4
+ /** Web needs no lazy bridge — lexical is in the bundle already. Kept for
5
+ * call-site parity with the native entry. */
6
+ export function ensureJSONBridge(): Promise<boolean> {
7
+ return Promise.resolve(true)
8
+ }
9
+
10
+ export function jsonBridgeReady(): boolean {
11
+ return true
12
+ }