@payload-onpage/lexical 0.0.0-stage → 0.1.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,542 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * In-place rich-text editing with Lexical. The editable surface is laid over
5
+ * the rendered element, takes its classes (so the site's prose styles apply)
6
+ * and hides the original. Clicking outside, or "Done", commits; Escape cancels.
7
+ * Only a real change is written: the value is compared with what the same
8
+ * editor produced for the untouched content.
9
+ */
10
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
11
+ import { LexicalComposer } from '@lexical/react/LexicalComposer'
12
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
13
+ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
14
+ import { ContentEditable } from '@lexical/react/LexicalContentEditable'
15
+ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
16
+ import { ListPlugin } from '@lexical/react/LexicalListPlugin'
17
+ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
18
+ import {
19
+ $createHeadingNode,
20
+ $createQuoteNode,
21
+ $isHeadingNode,
22
+ $isQuoteNode,
23
+ } from '@lexical/rich-text'
24
+ import {
25
+ $isListNode,
26
+ INSERT_ORDERED_LIST_COMMAND,
27
+ INSERT_UNORDERED_LIST_COMMAND,
28
+ REMOVE_LIST_COMMAND,
29
+ } from '@lexical/list'
30
+ import { $setBlocksType } from '@lexical/selection'
31
+ import { $findMatchingParent } from '@lexical/utils'
32
+ import {
33
+ $createParagraphNode,
34
+ $createRangeSelectionFromDom,
35
+ $getNearestNodeFromDOMNode,
36
+ $getRoot,
37
+ $getSelection,
38
+ $isRangeSelection,
39
+ $isTextNode,
40
+ $setSelection,
41
+ FORMAT_TEXT_COMMAND,
42
+ type BaseSelection,
43
+ REDO_COMMAND,
44
+ UNDO_COMMAND,
45
+ type LexicalEditor,
46
+ } from 'lexical'
47
+ import { eachLexicalNode, newRowId, safeLinkUrl } from '@payload-onpage/core'
48
+ import {
49
+ HIDDEN,
50
+ caretAt,
51
+ placeOver,
52
+ useEvent,
53
+ useTrackElement,
54
+ type FieldEditorProps,
55
+ type Translate,
56
+ } from '@payload-onpage/react/editor-kit'
57
+ import { $createPayloadLinkNode, $isPayloadLinkNode, type PayloadLinkNode } from './nodes.tsx'
58
+ import { RICH_TEXT_NODES } from './nodeList.ts'
59
+ import { fromEditorState, plainText, toEditorState, type RichTextState } from './convert.ts'
60
+
61
+ export { RICH_TEXT_NODES }
62
+
63
+ interface ToolbarState {
64
+ bold: boolean
65
+ italic: boolean
66
+ underline: boolean
67
+ block: string
68
+ link: boolean
69
+ }
70
+
71
+ function readToolbarState(): ToolbarState {
72
+ const sel = $getSelection()
73
+ if (!$isRangeSelection(sel))
74
+ return { bold: false, italic: false, underline: false, block: 'paragraph', link: false }
75
+ const anchor = sel.anchor.getNode()
76
+ const top = anchor.getKey() === 'root' ? anchor : anchor.getTopLevelElementOrThrow()
77
+ const list = $findMatchingParent(anchor, $isListNode)
78
+ const block = list
79
+ ? `list-${list.getListType()}`
80
+ : $isHeadingNode(top)
81
+ ? top.getTag()
82
+ : $isQuoteNode(top)
83
+ ? 'quote'
84
+ : 'paragraph'
85
+ return {
86
+ bold: sel.hasFormat('bold'),
87
+ italic: sel.hasFormat('italic'),
88
+ underline: sel.hasFormat('underline'),
89
+ block,
90
+ link: !!$findMatchingParent(anchor, $isPayloadLinkNode),
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Pull the browser's current selection into Lexical. Keyboard-extended
96
+ * selections (⌥⇧→ and friends) don't always reach Lexical's model before a
97
+ * toolbar click, so every toolbar action syncs first and acts on what the user
98
+ * actually sees selected.
99
+ */
100
+ function syncSelection(editor: LexicalEditor) {
101
+ const dom = window.getSelection()
102
+ const root = editor.getRootElement()
103
+ if (!dom || !root || !dom.anchorNode || !root.contains(dom.anchorNode)) return
104
+ editor.update(
105
+ () => {
106
+ const sel = $createRangeSelectionFromDom(dom, editor)
107
+ if (sel) $setSelection(sel)
108
+ },
109
+ { discrete: true },
110
+ )
111
+ }
112
+
113
+ /** The URL of the link around the selection, if any. */
114
+ function currentLinkUrl(editor: LexicalEditor): string | undefined {
115
+ let url: string | undefined
116
+ editor.getEditorState().read(() => {
117
+ const sel = $getSelection()
118
+ if ($isRangeSelection(sel))
119
+ url = $findMatchingParent(sel.anchor.getNode(), $isPayloadLinkNode)?.getFields().url
120
+ })
121
+ return url
122
+ }
123
+
124
+ /** Link the selection to `url`; an empty url removes the link around it. */
125
+ /** Link the selection; returns false (and changes nothing) for an unsafe URL such as `javascript:`. */
126
+ function applyLink(editor: LexicalEditor, rawUrl: string, saved: BaseSelection | null): boolean {
127
+ const url = rawUrl.trim()
128
+ if (url && !safeLinkUrl(url)) return false
129
+ editor.update(() => {
130
+ // Focus moved to the URL field; put back the selection it was opened for.
131
+ if (saved) $setSelection(saved.clone())
132
+ const sel = $getSelection()
133
+ if (!$isRangeSelection(sel)) return
134
+ const nodes = sel.extract()
135
+ const existing = nodes.map((n) => $findMatchingParent(n, $isPayloadLinkNode)).find(Boolean) as
136
+ PayloadLinkNode | undefined
137
+ if (existing) {
138
+ if (!url) {
139
+ for (const child of existing.getChildren()) existing.insertBefore(child)
140
+ existing.remove()
141
+ } else {
142
+ existing.setFields({ ...existing.getFields(), url })
143
+ }
144
+ return
145
+ }
146
+ if (!url) return
147
+ let link: PayloadLinkNode | null = null
148
+ for (const node of nodes) {
149
+ if (!$isTextNode(node) || $findMatchingParent(node, $isPayloadLinkNode)) continue
150
+ if (!link || node.getPreviousSibling() !== link) {
151
+ // Payload's own link node carries an id; so do ours.
152
+ link = $createPayloadLinkNode(
153
+ { url, newTab: /^https?:/.test(url), linkType: 'custom' },
154
+ newRowId(),
155
+ )
156
+ node.insertBefore(link)
157
+ }
158
+ link.append(node)
159
+ }
160
+ })
161
+ return true
162
+ }
163
+
164
+ /** A toolbar button that keeps the editor's selection (and syncs a keyboard selection first). */
165
+ function ToolButton({
166
+ label,
167
+ title,
168
+ pressed = false,
169
+ onAction,
170
+ id,
171
+ }: {
172
+ label: React.ReactNode
173
+ title: string
174
+ pressed?: boolean
175
+ onAction: () => void
176
+ id: string
177
+ }) {
178
+ const [editor] = useLexicalComposerContext()
179
+ return (
180
+ <button
181
+ type="button"
182
+ className="po-btn"
183
+ title={title}
184
+ aria-label={title}
185
+ aria-pressed={pressed}
186
+ data-testid={`onpage-rt-${id}`}
187
+ onMouseDown={(e) => e.preventDefault() /* keep the editor's selection */}
188
+ onClick={() => {
189
+ syncSelection(editor)
190
+ onAction()
191
+ }}
192
+ >
193
+ {label}
194
+ </button>
195
+ )
196
+ }
197
+
198
+ function Toolbar({ onDone, t }: { onDone: () => void; t: Translate }) {
199
+ const [editor] = useLexicalComposerContext()
200
+ const [state, setState] = useState<ToolbarState>({
201
+ bold: false,
202
+ italic: false,
203
+ underline: false,
204
+ block: 'paragraph',
205
+ link: false,
206
+ })
207
+ const [linkDraft, setLinkDraft] = useState<string | null>(null)
208
+ const savedSelection = useRef<BaseSelection | null>(null)
209
+ const [linkError, setLinkError] = useState(false)
210
+ const openLink = () => {
211
+ editor.getEditorState().read(() => {
212
+ savedSelection.current = $getSelection()?.clone() ?? null
213
+ })
214
+ setLinkDraft(currentLinkUrl(editor) ?? 'https://')
215
+ }
216
+ const saveLink = () => {
217
+ if (linkDraft === null) return
218
+ if (!applyLink(editor, linkDraft === 'https://' ? '' : linkDraft, savedSelection.current)) {
219
+ setLinkError(true)
220
+ return
221
+ }
222
+ setLinkError(false)
223
+ setLinkDraft(null)
224
+ editor.focus()
225
+ }
226
+ useEffect(
227
+ () =>
228
+ editor.registerUpdateListener(({ editorState }) =>
229
+ editorState.read(() => setState(readToolbarState())),
230
+ ),
231
+ [editor],
232
+ )
233
+
234
+ const setBlock = (kind: 'paragraph' | 'h2' | 'h3' | 'quote') =>
235
+ editor.update(() => {
236
+ const sel = $getSelection()
237
+ if (!$isRangeSelection(sel)) return
238
+ const target = state.block === kind ? 'paragraph' : kind
239
+ $setBlocksType(sel, () =>
240
+ target === 'paragraph'
241
+ ? $createParagraphNode()
242
+ : target === 'quote'
243
+ ? $createQuoteNode()
244
+ : $createHeadingNode(target),
245
+ )
246
+ })
247
+ const list = (type: 'bullet' | 'number') => {
248
+ if (state.block === `list-${type}`) editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
249
+ else
250
+ editor.dispatchCommand(
251
+ type === 'bullet' ? INSERT_UNORDERED_LIST_COMMAND : INSERT_ORDERED_LIST_COMMAND,
252
+ undefined,
253
+ )
254
+ }
255
+ return (
256
+ <div className="po-rt-toolbar" role="toolbar" aria-label={t('rt.toolbar')}>
257
+ <ToolButton
258
+ label={<b>B</b>}
259
+ title={t('rt.bold')}
260
+ pressed={state.bold}
261
+ onAction={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}
262
+ id="bold"
263
+ />
264
+ <ToolButton
265
+ label={<i>I</i>}
266
+ title={t('rt.italic')}
267
+ pressed={state.italic}
268
+ onAction={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')}
269
+ id="italic"
270
+ />
271
+ <ToolButton
272
+ label={<u>U</u>}
273
+ title={t('rt.underline')}
274
+ pressed={state.underline}
275
+ onAction={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')}
276
+ id="underline"
277
+ />
278
+ <ToolButton
279
+ label="🔗"
280
+ title={t('rt.link')}
281
+ pressed={state.link}
282
+ onAction={openLink}
283
+ id="link"
284
+ />
285
+ <ToolButton
286
+ label="H2"
287
+ title={t('rt.h2')}
288
+ pressed={state.block === 'h2'}
289
+ onAction={() => setBlock('h2')}
290
+ id="h2"
291
+ />
292
+ <ToolButton
293
+ label="H3"
294
+ title={t('rt.h3')}
295
+ pressed={state.block === 'h3'}
296
+ onAction={() => setBlock('h3')}
297
+ id="h3"
298
+ />
299
+ <ToolButton
300
+ label="•"
301
+ title={t('rt.ul')}
302
+ pressed={state.block === 'list-bullet'}
303
+ onAction={() => list('bullet')}
304
+ id="ul"
305
+ />
306
+ <ToolButton
307
+ label="1."
308
+ title={t('rt.ol')}
309
+ pressed={state.block === 'list-number'}
310
+ onAction={() => list('number')}
311
+ id="ol"
312
+ />
313
+ <ToolButton
314
+ label="❝"
315
+ title={t('rt.quote')}
316
+ pressed={state.block === 'quote'}
317
+ onAction={() => setBlock('quote')}
318
+ id="quote"
319
+ />
320
+ <ToolButton
321
+ label="↶"
322
+ title={t('dock.undo')}
323
+ onAction={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
324
+ id="undo"
325
+ />
326
+ <ToolButton
327
+ label="↷"
328
+ title={t('dock.redo')}
329
+ onAction={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
330
+ id="redo"
331
+ />
332
+ <ToolButton label={t('rt.done')} title={t('rt.done')} onAction={onDone} id="done" />
333
+ {linkDraft !== null && (
334
+ <span style={{ display: 'inline-flex', gap: 4, marginLeft: 6 }}>
335
+ <input
336
+ className="po-input"
337
+ style={{ width: 260, height: 28 }}
338
+ value={linkDraft}
339
+ aria-invalid={linkError || undefined}
340
+ title={linkError ? t('rt.linkInvalid') : undefined}
341
+ ref={(el) => el?.focus()}
342
+ placeholder={t('rt.linkPlaceholder')}
343
+ onChange={(e) => setLinkDraft(e.target.value)}
344
+ onKeyDown={(e) => {
345
+ if (e.key === 'Enter') {
346
+ e.preventDefault()
347
+ saveLink()
348
+ } else if (e.key === 'Escape') {
349
+ e.preventDefault()
350
+ e.stopPropagation()
351
+ setLinkDraft(null)
352
+ }
353
+ }}
354
+ data-testid="onpage-rt-link-input"
355
+ />
356
+ <button
357
+ type="button"
358
+ className="po-btn"
359
+ onMouseDown={(e) => e.preventDefault()}
360
+ onClick={saveLink}
361
+ data-testid="onpage-rt-link-apply"
362
+ >
363
+ Apply
364
+ </button>
365
+ </span>
366
+ )}
367
+ </div>
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Grabs the editor instance and puts the caret where the user clicked —
373
+ * through Lexical's own selection (setting only the DOM selection leaves
374
+ * Lexical's model at the end of the text, so keys and toolbar act there).
375
+ */
376
+ function Bridge({
377
+ onReady,
378
+ point,
379
+ }: {
380
+ onReady: (editor: LexicalEditor) => void
381
+ point?: { x: number; y: number }
382
+ }) {
383
+ const [editor] = useLexicalComposerContext()
384
+ useEffect(() => {
385
+ onReady(editor)
386
+ const root = editor.getRootElement()
387
+ if (!root) return
388
+ // Wait two frames so the surface sits exactly over the original text.
389
+ let raf = requestAnimationFrame(
390
+ () =>
391
+ (raf = requestAnimationFrame(() => {
392
+ const hit = point ? caretAt(point, root) : null
393
+ // Focus first: Lexical may restore its own selection on focus, which would
394
+ // undo the caret placement if it came before.
395
+ root.focus({ preventScroll: true })
396
+ editor.update(
397
+ () => {
398
+ const node = hit ? $getNearestNodeFromDOMNode(hit.node) : null
399
+ if (node && $isTextNode(node)) node.select(hit!.offset, hit!.offset)
400
+ else $getRoot().selectEnd()
401
+ },
402
+ { discrete: true },
403
+ )
404
+ })),
405
+ )
406
+ return () => cancelAnimationFrame(raf)
407
+ }, [editor, onReady, point])
408
+ return null
409
+ }
410
+
411
+ export function InlineRichText({
412
+ field,
413
+ value,
414
+ element,
415
+ commit,
416
+ close,
417
+ ghost,
418
+ live,
419
+ point,
420
+ t,
421
+ }: FieldEditorProps) {
422
+ const box = useRef<HTMLDivElement>(null)
423
+ const editorRef = useRef<LexicalEditor | null>(null)
424
+ const baseline = useRef<string | null>(null)
425
+ const done = useRef(false)
426
+ // The surface takes the element's classes, so the site's prose styles apply.
427
+ const [className] = useState(() => {
428
+ const cls = element()?.className
429
+ return typeof cls === 'string' ? cls : ''
430
+ })
431
+ const onReady = useCallback((editor: LexicalEditor) => {
432
+ editorRef.current = editor
433
+ baseline.current = JSON.stringify(editor.getEditorState().toJSON())
434
+ }, [])
435
+ const initialState = useMemo(() => JSON.stringify(toEditorState(value)), []) // eslint-disable-line react-hooks/exhaustive-deps
436
+
437
+ useLayoutEffect(() => {
438
+ const el = element()
439
+ if (el) {
440
+ // Position before first paint, so the caret hit-test finds the surface.
441
+ if (box.current) placeOver(box.current, el)
442
+ el.setAttribute(HIDDEN, 'true')
443
+ }
444
+ return () => element()?.removeAttribute(HIDDEN)
445
+ // eslint-disable-next-line react-hooks/exhaustive-deps
446
+ }, [])
447
+
448
+ // Follow the original element's box as the page scrolls and reflows.
449
+ useTrackElement(element, (el) => {
450
+ if (!box.current) return
451
+ if (!el.hasAttribute(HIDDEN)) el.setAttribute(HIDDEN, 'true')
452
+ placeOver(box.current, el)
453
+ })
454
+
455
+ const finish = (save: boolean) => {
456
+ if (done.current) return
457
+ done.current = true
458
+ const editor = editorRef.current
459
+ if (save && editor) {
460
+ const json = editor.getEditorState().toJSON() as unknown as RichTextState
461
+ if (JSON.stringify(json) !== baseline.current) {
462
+ const next = fromEditorState(json)
463
+ commit(plainText(next) || hasOpaque(next) ? next : null)
464
+ const surface = editor.getRootElement()
465
+ if (!live && surface) ghost({ html: surface.innerHTML, className })
466
+ }
467
+ }
468
+ close()
469
+ }
470
+
471
+ // Commit when the user clicks anywhere outside the editor.
472
+ // Escape (inside the editor) cancels.
473
+ const onOutside = useEvent((e: PointerEvent) => {
474
+ if (box.current && !box.current.contains(e.target as Node)) finish(true)
475
+ })
476
+ const onEscape = useEvent((e: KeyboardEvent) => {
477
+ if (e.key === 'Escape' && box.current?.contains(document.activeElement)) {
478
+ e.preventDefault()
479
+ finish(false)
480
+ }
481
+ })
482
+ useEffect(() => {
483
+ const onDown = (e: PointerEvent) => onOutside(e)
484
+ const onKey = (e: KeyboardEvent) => onEscape(e)
485
+ document.addEventListener('pointerdown', onDown, true)
486
+ document.addEventListener('keydown', onKey)
487
+ return () => {
488
+ document.removeEventListener('pointerdown', onDown, true)
489
+ document.removeEventListener('keydown', onKey)
490
+ }
491
+ }, [onEscape, onOutside])
492
+
493
+ return (
494
+ <div
495
+ ref={box}
496
+ className="po-rt"
497
+ role="group"
498
+ aria-label={t('inline.label', { field: field.label })}
499
+ data-testid="onpage-inline-richtext"
500
+ >
501
+ <LexicalComposer
502
+ initialConfig={{
503
+ namespace: 'payload-onpage',
504
+ nodes: RICH_TEXT_NODES,
505
+ editorState: initialState,
506
+ onError: (error) => console.error('[payload-onpage] rich text', error),
507
+ theme: { text: { bold: 'po-b', italic: 'po-i', underline: 'po-u' } },
508
+ }}
509
+ >
510
+ <Toolbar onDone={() => finish(true)} t={t} />
511
+ <RichTextPlugin
512
+ contentEditable={
513
+ <ContentEditable
514
+ className={`${className} po-rt-surface`}
515
+ aria-label={t('inline.label', { field: field.label })}
516
+ />
517
+ }
518
+ ErrorBoundary={LexicalErrorBoundary}
519
+ />
520
+ <HistoryPlugin />
521
+ <ListPlugin />
522
+ <Bridge onReady={onReady} point={point} />
523
+ </LexicalComposer>
524
+ </div>
525
+ )
526
+ }
527
+
528
+ /** Does the value hold nodes the editor shows as placeholders (uploads, blocks…)? Then it isn't empty. */
529
+ function hasOpaque(state: RichTextState): boolean {
530
+ let found = false
531
+ eachLexicalNode(state, (n) => {
532
+ if (
533
+ n.type !== 'root' &&
534
+ n.type !== 'paragraph' &&
535
+ n.type !== 'text' &&
536
+ n.type !== 'linebreak' &&
537
+ !n.children
538
+ )
539
+ found = true
540
+ })
541
+ return found
542
+ }
@@ -0,0 +1,2 @@
1
+ /** Node type the inline editor uses for Payload nodes it keeps as-is (uploads, blocks…). */
2
+ export const OPAQUE_TYPE = 'onpage-opaque'
package/src/convert.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Payload rich text ⇄ the inline editor's Lexical state. Node types we edit go
3
+ * through as-is; everything else is wrapped in an OpaqueNode on the way in and
4
+ * unwrapped on the way out, byte-for-byte.
5
+ */
6
+ import { eachLexicalNode, type LexicalNode, type LexicalState } from '@payload-onpage/core'
7
+ import { OPAQUE_TYPE } from './constants.ts'
8
+
9
+ export const EDITABLE_NODE_TYPES = new Set([
10
+ 'root',
11
+ 'paragraph',
12
+ 'heading',
13
+ 'quote',
14
+ 'list',
15
+ 'listitem',
16
+ 'text',
17
+ 'linebreak',
18
+ 'tab',
19
+ 'link',
20
+ ])
21
+
22
+ type Node = LexicalNode
23
+ export type RichTextState = LexicalState
24
+
25
+ export const EMPTY_STATE: RichTextState = {
26
+ root: {
27
+ type: 'root',
28
+ version: 1,
29
+ format: '',
30
+ indent: 0,
31
+ direction: null,
32
+ children: [
33
+ { type: 'paragraph', version: 1, format: '', indent: 0, direction: null, children: [] },
34
+ ],
35
+ },
36
+ }
37
+
38
+ function wrap(node: Node): Node {
39
+ if (!EDITABLE_NODE_TYPES.has(node.type)) return { type: OPAQUE_TYPE, version: 1, data: node }
40
+ return Array.isArray(node.children) ? { ...node, children: node.children.map(wrap) } : node
41
+ }
42
+
43
+ function unwrap(node: Node): Node {
44
+ if (node.type === OPAQUE_TYPE) return node.data as Node
45
+ return Array.isArray(node.children) ? { ...node, children: node.children.map(unwrap) } : node
46
+ }
47
+
48
+ export function isRichTextState(value: unknown): value is RichTextState {
49
+ return (
50
+ !!value && typeof value === 'object' && (value as Partial<RichTextState>).root?.type === 'root'
51
+ )
52
+ }
53
+
54
+ export function toEditorState(value: unknown): RichTextState {
55
+ const state = isRichTextState(value) ? value : EMPTY_STATE
56
+ const root = wrap(state.root)
57
+ // Lexical needs at least one block child to place a caret.
58
+ if (!root.children?.length) root.children = EMPTY_STATE.root.children
59
+ return { root }
60
+ }
61
+
62
+ export function fromEditorState(state: RichTextState): RichTextState {
63
+ return { root: unwrap(state.root) }
64
+ }
65
+
66
+ /** Plain text of a state (used to treat a blank editor as "no value"). */
67
+ export function plainText(state: RichTextState): string {
68
+ const parts: string[] = []
69
+ eachLexicalNode(state, (n) => {
70
+ if (typeof n.text === 'string') parts.push(n.text)
71
+ })
72
+ return parts.join('').trim()
73
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** The rich-text editor component and its Lexical nodes (statically imported; prefer `lexicalEditors` for lazy loading). */
2
+ export { InlineRichText, RICH_TEXT_NODES } from './InlineRichText.tsx'
3
+ export {
4
+ PayloadLinkNode,
5
+ OpaqueNode,
6
+ $createPayloadLinkNode,
7
+ $isPayloadLinkNode,
8
+ describeNode,
9
+ type OpaqueData,
10
+ } from './nodes.tsx'
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * @payload-onpage/lexical — inline rich-text editing for Payload's Lexical
5
+ * richText fields.
6
+ *
7
+ * <OnPageEditor editors={lexicalEditors} … /> // the Next adapter includes it by default
8
+ *
9
+ * Light by design: the Lexical editor itself (~75 KB gz with Lexical) loads
10
+ * only when someone opens a rich-text field. It reads and writes Payload's
11
+ * Lexical JSON; links keep Payload's shape, and nodes it doesn't edit
12
+ * (uploads, blocks, relationships…) are kept as-is and shown as placeholders.
13
+ * The editor component and its nodes are in `@payload-onpage/lexical/editor`.
14
+ */
15
+ import type { EditorRegistry } from '@payload-onpage/react/editor-kit'
16
+ import { lazy } from 'react'
17
+
18
+ const InlineRichText = lazy(() =>
19
+ import('./InlineRichText.tsx').then((m) => ({ default: m.InlineRichText })),
20
+ )
21
+
22
+ export const lexicalEditors: EditorRegistry = {
23
+ richText: { mode: 'inline', component: InlineRichText },
24
+ }
25
+
26
+ export {
27
+ toEditorState,
28
+ fromEditorState,
29
+ plainText,
30
+ isRichTextState,
31
+ type RichTextState,
32
+ } from './convert.ts'
33
+ export { OPAQUE_TYPE } from './constants.ts'
@@ -0,0 +1,13 @@
1
+ /** The node classes the inline editor registers (no React UI imported here). */
2
+ import { HeadingNode, QuoteNode } from '@lexical/rich-text'
3
+ import { ListItemNode, ListNode } from '@lexical/list'
4
+ import { OpaqueNode, PayloadLinkNode } from './nodes.tsx'
5
+
6
+ export const RICH_TEXT_NODES = [
7
+ HeadingNode,
8
+ QuoteNode,
9
+ ListNode,
10
+ ListItemNode,
11
+ PayloadLinkNode,
12
+ OpaqueNode,
13
+ ]