@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.
- package/LICENSE +21 -0
- package/README.md +171 -3
- package/dist/macos/browser.d.ts +1 -0
- package/dist/macos/browser.js +2 -0
- package/dist/native/index.js +3 -0
- package/dist/native/index2.js +232 -0
- package/dist/native/index3.js +228 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/index.js +12 -0
- package/dist/web/index2.js +349 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +91 -3
- package/src/Editor.macos.tsx +9 -0
- package/src/LexicalEditor.mobile.test.ts +229 -0
- package/src/LexicalEditor.tsrx +143 -0
- package/src/LexicalEditor.web.test.tsrx +111 -0
- package/src/LexicalEditor.web.tsrx +413 -0
- package/src/browser-entry.web.tsx +5 -0
- package/src/index.macos.ts +13 -0
- package/src/index.ts +3 -0
- package/src/index.web.ts +12 -0
- package/src/json-bridge.ts +335 -0
- package/src/types.ts +131 -0
- package/types/index.d.ts +8 -0
- package/types/index.macos.d.ts +7 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { useState, useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
|
|
3
|
+
import { RichTextEditor, supported as leafSupported } from '@octane-xplat/richtext'
|
|
4
|
+
import type { RichTextEditorHandle } from '@octane-xplat/richtext'
|
|
5
|
+
import type { LexicalEditorHandle, LexicalEditorProps, LexicalJSON } from './types'
|
|
6
|
+
import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from './json-bridge'
|
|
7
|
+
|
|
8
|
+
export const supported = leafSupported
|
|
9
|
+
|
|
10
|
+
/** Unified editor facade, native half. Rendering/editing run through the
|
|
11
|
+
* Aztec leaf; the lexical document model (headless createEditor +
|
|
12
|
+
* @lexical/html over zeed-dom) handles serialized-state interchange on top
|
|
13
|
+
* of it. This is a read/write model, not a live editor — no
|
|
14
|
+
* dispatchCommand, node transforms, or plugin behavior on native. */
|
|
15
|
+
export function LexicalEditor(props: LexicalEditorProps) @{
|
|
16
|
+
const inner = useRef<RichTextEditorHandle | null>(null)
|
|
17
|
+
const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
|
|
18
|
+
useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
const bindHost = useCallback((h: RichTextEditorHandle | null) => {
|
|
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
|
+
}
|
|
50
|
+
|
|
51
|
+
setHandle((previous) => previous?.native === h?.native ? previous : h ? ({
|
|
52
|
+
getHTML: () => h.getHTML(),
|
|
53
|
+
setHTML: (html: string) => h.setHTML(html),
|
|
54
|
+
getJSON: () => (jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null),
|
|
55
|
+
setJSON: (doc) => {
|
|
56
|
+
desiredDoc.current = doc
|
|
57
|
+
applyDoc()
|
|
58
|
+
},
|
|
59
|
+
apply: (format) => h.apply(format),
|
|
60
|
+
linkTo: (url, anchor) => h.linkTo(url, anchor),
|
|
61
|
+
removeLink: () => h.removeLink(),
|
|
62
|
+
isActive: (format) => h.isActive(format),
|
|
63
|
+
undo: () => h.undo(),
|
|
64
|
+
redo: () => h.redo(),
|
|
65
|
+
focus: () => h.focus(),
|
|
66
|
+
blur: () => h.blur(),
|
|
67
|
+
isFocused: () => h.isFocused(),
|
|
68
|
+
native: h.native,
|
|
69
|
+
} satisfies LexicalEditorHandle) : null)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
// The bridge loads once per process — dynamic imports of the DOM-free
|
|
73
|
+
// lexical slices, plus the zeed-dom parse shim. A doc that arrived before
|
|
74
|
+
// it settles is applied here, not dropped.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
let alive = true
|
|
77
|
+
ensureJSONBridge().then((ok) => {
|
|
78
|
+
if (!alive) return
|
|
79
|
+
props.onJSONReady?.(ok)
|
|
80
|
+
applyDoc()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
alive = false
|
|
85
|
+
}
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
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.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!props.json) return
|
|
93
|
+
desiredDoc.current = props.json
|
|
94
|
+
applyDoc()
|
|
95
|
+
}, [props.json])
|
|
96
|
+
|
|
97
|
+
const emitJSON = () => {
|
|
98
|
+
if (!inner.current || !jsonBridgeReady()) return
|
|
99
|
+
const doc = htmlToJSON(inner.current.getHTML())
|
|
100
|
+
if (doc) {
|
|
101
|
+
lastJSON.current = JSON.stringify(doc)
|
|
102
|
+
appliedHost.current = inner.current
|
|
103
|
+
props.onJSONChange?.(doc)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
<RichTextEditor
|
|
108
|
+
id={props.id}
|
|
109
|
+
className={props.className}
|
|
110
|
+
style={props.style}
|
|
111
|
+
value={props.value}
|
|
112
|
+
placeholder={props.placeholder}
|
|
113
|
+
editable={props.editable}
|
|
114
|
+
autofocus={props.autofocus}
|
|
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
|
+
}}
|
|
121
|
+
onChange={(html) => {
|
|
122
|
+
props.onChange?.(html)
|
|
123
|
+
emitJSON()
|
|
124
|
+
}}
|
|
125
|
+
onSelectionChange={props.onSelectionChange}
|
|
126
|
+
onFocus={props.onFocus}
|
|
127
|
+
onBlur={props.onBlur}
|
|
128
|
+
ref={bindHost}
|
|
129
|
+
android={props.android}
|
|
130
|
+
ios={props.ios}
|
|
131
|
+
row={props.row}
|
|
132
|
+
col={props.col}
|
|
133
|
+
rowSpan={props.rowSpan}
|
|
134
|
+
colSpan={props.colSpan}
|
|
135
|
+
dock={props.dock}
|
|
136
|
+
left={props.left}
|
|
137
|
+
top={props.top}
|
|
138
|
+
flexGrow={props.flexGrow}
|
|
139
|
+
flexShrink={props.flexShrink}
|
|
140
|
+
alignSelf={props.alignSelf}
|
|
141
|
+
order={props.order}
|
|
142
|
+
/>;
|
|
143
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,413 @@
|
|
|
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 default node set — Aztec's capability surface plus
|
|
50
|
+
* AutoLinkNode (parsing safety for docs produced by fuller editors). */
|
|
51
|
+
const NODES = [
|
|
52
|
+
HeadingNode,
|
|
53
|
+
QuoteNode,
|
|
54
|
+
ListNode,
|
|
55
|
+
ListItemNode,
|
|
56
|
+
LinkNode,
|
|
57
|
+
AutoLinkNode,
|
|
58
|
+
CodeNode,
|
|
59
|
+
HorizontalRuleNode,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const HEADING_TAG: Partial<Record<LexicalFormat, 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>> = {
|
|
63
|
+
heading1: 'h1',
|
|
64
|
+
heading2: 'h2',
|
|
65
|
+
heading3: 'h3',
|
|
66
|
+
heading4: 'h4',
|
|
67
|
+
heading5: 'h5',
|
|
68
|
+
heading6: 'h6',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TEXT_FORMAT: Partial<Record<LexicalFormat, string>> = {
|
|
72
|
+
bold: 'bold',
|
|
73
|
+
italic: 'italic',
|
|
74
|
+
underline: 'underline',
|
|
75
|
+
strikethrough: 'strikethrough',
|
|
76
|
+
code: 'code',
|
|
77
|
+
highlight: 'highlight',
|
|
78
|
+
subscript: 'subscript',
|
|
79
|
+
superscript: 'superscript',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ELEMENT_FORMAT: Partial<Record<LexicalFormat, string>> = {
|
|
83
|
+
alignLeft: 'left',
|
|
84
|
+
alignCenter: 'center',
|
|
85
|
+
alignRight: 'right',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Toggle a block type at the selection — reverts to paragraph when the
|
|
89
|
+
* selection already sits in that block type. */
|
|
90
|
+
function setBlock(editor: any, is: (n: any) => boolean, create: () => any, match?: (n: any) => boolean): void {
|
|
91
|
+
editor.update(() => {
|
|
92
|
+
const sel = $getSelection()
|
|
93
|
+
if (!$isRangeSelection(sel)) return
|
|
94
|
+
const block = $findMatchingParent(sel.anchor.getNode(), is)
|
|
95
|
+
const active = block != null && (match ? match(block) : true)
|
|
96
|
+
$setBlocksType(sel, () => (active ? $createParagraphNode() : create()))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyFormat(editor: any, format: LexicalFormat): void {
|
|
101
|
+
const text = TEXT_FORMAT[format]
|
|
102
|
+
if (text) {
|
|
103
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, text)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const el = ELEMENT_FORMAT[format]
|
|
108
|
+
if (el) {
|
|
109
|
+
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, el)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tag = HEADING_TAG[format]
|
|
114
|
+
if (tag) {
|
|
115
|
+
setBlock(editor, $isHeadingNode, () => $createHeadingNode(tag), (n) => n.getTag() === tag)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
switch (format) {
|
|
120
|
+
case 'paragraph':
|
|
121
|
+
setBlock(editor, () => true, () => $createParagraphNode(), () => false)
|
|
122
|
+
break
|
|
123
|
+
case 'blockquote':
|
|
124
|
+
setBlock(editor, $isQuoteNode, () => $createQuoteNode())
|
|
125
|
+
break
|
|
126
|
+
case 'codeBlock':
|
|
127
|
+
setBlock(editor, $isCodeNode, () => $createCodeNode())
|
|
128
|
+
break
|
|
129
|
+
case 'bulletList':
|
|
130
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
|
131
|
+
break
|
|
132
|
+
case 'orderedList':
|
|
133
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
|
|
134
|
+
break
|
|
135
|
+
case 'taskList':
|
|
136
|
+
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
|
|
137
|
+
break
|
|
138
|
+
case 'horizontalRule':
|
|
139
|
+
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isActive(editor: any, format: LexicalFormat): boolean {
|
|
145
|
+
return editor.read(() => {
|
|
146
|
+
const sel = $getSelection()
|
|
147
|
+
if (sel == null) return false
|
|
148
|
+
|
|
149
|
+
const text = TEXT_FORMAT[format]
|
|
150
|
+
if (text) {
|
|
151
|
+
return $isRangeSelection(sel) && sel.hasFormat(text as any)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const el = ELEMENT_FORMAT[format]
|
|
155
|
+
if (el) {
|
|
156
|
+
const nodes = sel.getNodes()
|
|
157
|
+
return nodes.some((n: any) => {
|
|
158
|
+
const block = $findMatchingParent(n, (p: any) => p.getFormatType != null)
|
|
159
|
+
return block != null && (block as any).getFormatType() === el
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const tag = HEADING_TAG[format]
|
|
164
|
+
if (tag) {
|
|
165
|
+
return sel.getNodes().some((n: any) => {
|
|
166
|
+
const h = $findMatchingParent(n, $isHeadingNode)
|
|
167
|
+
return h?.getTag() === tag
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
switch (format) {
|
|
172
|
+
case 'blockquote':
|
|
173
|
+
return sel.getNodes().some((n: any) => $findMatchingParent(n, $isQuoteNode) != null)
|
|
174
|
+
case 'codeBlock':
|
|
175
|
+
return sel.getNodes().some((n: any) => $findMatchingParent(n, $isCodeNode) != null)
|
|
176
|
+
case 'bulletList':
|
|
177
|
+
case 'orderedList':
|
|
178
|
+
case 'taskList': {
|
|
179
|
+
const want = format === 'bulletList' ? 'bullet' : format === 'orderedList' ? 'number' : 'check'
|
|
180
|
+
return sel.getNodes().some((n: any) => {
|
|
181
|
+
const l = $findMatchingParent(n, $isListNode)
|
|
182
|
+
return l?.getListType() === want
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
case 'link':
|
|
186
|
+
return sel.getNodes().some(
|
|
187
|
+
(n: any) => $isLinkNode(n) || $isLinkNode($findMatchingParent(n, $isLinkNode)),
|
|
188
|
+
)
|
|
189
|
+
case 'horizontalRule':
|
|
190
|
+
return $isNodeSelection(sel) && sel.getNodes().some($isHorizontalRuleNode)
|
|
191
|
+
case 'paragraph':
|
|
192
|
+
return sel.getNodes().some((n: any) =>
|
|
193
|
+
$findMatchingParent(n, (p: any) => p.getType?.() === 'paragraph'),
|
|
194
|
+
)
|
|
195
|
+
default:
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Plain-text offset of a selection point — DFS accumulating text size.
|
|
202
|
+
* Block separators aren't counted; positions are best-effort parity with
|
|
203
|
+
* the native flat-offset contract, not ProseMirror-style coordinates. */
|
|
204
|
+
function flatOffset(root: any, key: string, offset: number): number {
|
|
205
|
+
const contains = (node: any): boolean =>
|
|
206
|
+
node.getKey() === key || (node.getChildren?.() ?? []).some(contains)
|
|
207
|
+
|
|
208
|
+
let acc = 0
|
|
209
|
+
const visit = (node: any): boolean => {
|
|
210
|
+
if (node.getKey() === key) {
|
|
211
|
+
acc += offset
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const c of node.getChildren?.() ?? []) {
|
|
216
|
+
if (contains(c)) {
|
|
217
|
+
if (visit(c)) return true
|
|
218
|
+
} else {
|
|
219
|
+
acc += c.getTextContentSize?.() ?? 0
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
visit(root)
|
|
227
|
+
return acc
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function activeFormats(editor: any): LexicalFormat[] {
|
|
231
|
+
const out: LexicalFormat[] = []
|
|
232
|
+
for (const f of Object.keys(TEXT_FORMAT) as LexicalFormat[]) {
|
|
233
|
+
if (isActive(editor, f)) out.push(f)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const f of ['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock', 'link'] as LexicalFormat[]) {
|
|
237
|
+
if (isActive(editor, f)) out.push(f)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Unified editor facade, web half — a LexicalComposer over the published
|
|
244
|
+
* `@octanejs/lexical` binding, with optional web-only node and plugin composition. */
|
|
245
|
+
export function LexicalEditor(props: LexicalEditorProps) @{
|
|
246
|
+
const editorRef = useRef<any>(null)
|
|
247
|
+
const lastEmitted = useRef<string | undefined>(undefined)
|
|
248
|
+
const lastJSON = useRef<string | undefined>(undefined)
|
|
249
|
+
const lastHTMLInput = useRef(JSON.stringify([props.json !== undefined, props.value]))
|
|
250
|
+
const lastJSONInput = useRef(JSON.stringify(props.json))
|
|
251
|
+
const bound = useRef<LexicalEditorHandle | undefined>(undefined)
|
|
252
|
+
const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
|
|
253
|
+
useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
|
|
254
|
+
|
|
255
|
+
const initialConfig = {
|
|
256
|
+
namespace: 'octane-xplat/lexical',
|
|
257
|
+
nodes: props.web?.replaceNodes
|
|
258
|
+
? props.web.nodes ?? []
|
|
259
|
+
: [...new Set([...NODES, ...(props.web?.nodes ?? [])])],
|
|
260
|
+
editable: props.editable !== false,
|
|
261
|
+
editorState: props.json != null
|
|
262
|
+
? JSON.stringify(props.json)
|
|
263
|
+
: props.value != null
|
|
264
|
+
? (editor: any) => {
|
|
265
|
+
const dom = new DOMParser().parseFromString(props.value!, 'text/html')
|
|
266
|
+
const nodes = $generateNodesFromDOM(editor, dom)
|
|
267
|
+
$getRoot().append(...nodes)
|
|
268
|
+
}
|
|
269
|
+
: undefined,
|
|
270
|
+
onError: (e: Error) => {
|
|
271
|
+
throw e
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const attach = (editor: any) => {
|
|
276
|
+
if (!editor || bound.current?.native === editor) return
|
|
277
|
+
editorRef.current = editor
|
|
278
|
+
bound.current = {
|
|
279
|
+
getHTML: () => editor.read(() => $generateHtmlFromNodes(editor)),
|
|
280
|
+
setHTML: (html: string) =>
|
|
281
|
+
editor.update(() => {
|
|
282
|
+
const dom = new DOMParser().parseFromString(html, 'text/html')
|
|
283
|
+
const nodes = $generateNodesFromDOM(editor, dom)
|
|
284
|
+
$getRoot().clear().append(...nodes)
|
|
285
|
+
}),
|
|
286
|
+
getJSON: () => editor.getEditorState().toJSON(),
|
|
287
|
+
setJSON: (doc) =>
|
|
288
|
+
editor.setEditorState(editor.parseEditorState(JSON.stringify(doc))),
|
|
289
|
+
apply: (format) => {
|
|
290
|
+
editor.focus()
|
|
291
|
+
applyFormat(editor, format)
|
|
292
|
+
},
|
|
293
|
+
linkTo: (url, anchor) => {
|
|
294
|
+
if (anchor != null && anchor !== '') {
|
|
295
|
+
editor.update(() => {
|
|
296
|
+
const link = $createLinkNode(url)
|
|
297
|
+
link.append($createTextNode(anchor))
|
|
298
|
+
const sel = $getSelection()
|
|
299
|
+
if ($isRangeSelection(sel)) sel.insertNodes([link])
|
|
300
|
+
})
|
|
301
|
+
} else {
|
|
302
|
+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url)
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
removeLink: () => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null),
|
|
306
|
+
isActive: (format) => isActive(editor, format),
|
|
307
|
+
undo: () => editor.dispatchCommand(UNDO_COMMAND, undefined),
|
|
308
|
+
redo: () => editor.dispatchCommand(REDO_COMMAND, undefined),
|
|
309
|
+
focus: () => editor.focus(),
|
|
310
|
+
blur: () => editor.blur(),
|
|
311
|
+
isFocused: () => {
|
|
312
|
+
const root = editor.getRootElement()
|
|
313
|
+
return root != null && root === document.activeElement
|
|
314
|
+
},
|
|
315
|
+
native: editor,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
setHandle(bound.current!)
|
|
319
|
+
props.onJSONReady?.(true)
|
|
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
|
+
onFocus={props.onFocus}
|
|
380
|
+
onBlur={props.onBlur}
|
|
381
|
+
/>
|
|
382
|
+
}
|
|
383
|
+
placeholder={
|
|
384
|
+
props.placeholder ? (
|
|
385
|
+
<div
|
|
386
|
+
className="vx-lexical-placeholder"
|
|
387
|
+
style={{
|
|
388
|
+
position: 'absolute',
|
|
389
|
+
inset: '8px auto auto 10px',
|
|
390
|
+
pointerEvents: 'none',
|
|
391
|
+
opacity: 0.5,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
{props.placeholder}
|
|
395
|
+
</div>
|
|
396
|
+
) : null
|
|
397
|
+
}
|
|
398
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
399
|
+
/>
|
|
400
|
+
{!props.web?.replacePlugins ? (
|
|
401
|
+
<>
|
|
402
|
+
<HistoryPlugin />
|
|
403
|
+
<ListPlugin />
|
|
404
|
+
<LinkPlugin />
|
|
405
|
+
{props.autofocus ? <AutoFocusPlugin /> : null}
|
|
406
|
+
</>
|
|
407
|
+
) : null}
|
|
408
|
+
<OnChangePlugin onChange={emitChange} />
|
|
409
|
+
<EditorRefPlugin editorRef={attach} />
|
|
410
|
+
{props.web?.plugins}
|
|
411
|
+
</LexicalComposer>
|
|
412
|
+
</div>
|
|
413
|
+
}
|
|
@@ -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
package/src/index.web.ts
ADDED
|
@@ -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
|
+
}
|