@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.
package/src/nodes.tsx ADDED
@@ -0,0 +1,213 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Lexical nodes for editing Payload rich text in place.
5
+ *
6
+ * - PayloadLinkNode — Payload stores links as
7
+ * { type: "link", id, fields: { url, newTab, linkType, doc? }, children, … }
8
+ * (not @lexical/link's { url, rel, target }). This node reads and writes that
9
+ * shape, keeping `id` and every key in `fields`.
10
+ * - OpaqueNode — any node type we don't edit (upload, block, relationship,
11
+ * horizontalrule…) is carried through untouched and shown as a locked chip,
12
+ * so editing a paragraph never loses an image or embed.
13
+ */
14
+ import React from 'react'
15
+ import {
16
+ DecoratorNode,
17
+ ElementNode,
18
+ type LexicalNode,
19
+ type NodeKey,
20
+ type SerializedElementNode,
21
+ type SerializedLexicalNode,
22
+ type Spread,
23
+ } from 'lexical'
24
+
25
+ export interface PayloadLinkFields {
26
+ url?: string
27
+ newTab?: boolean
28
+ linkType?: string
29
+ [key: string]: unknown
30
+ }
31
+
32
+ export type SerializedPayloadLinkNode = Spread<
33
+ { type: 'link'; version: number; fields: PayloadLinkFields; id?: string },
34
+ SerializedElementNode
35
+ >
36
+
37
+ export class PayloadLinkNode extends ElementNode {
38
+ __fields: PayloadLinkFields
39
+ __linkId?: string
40
+
41
+ static override getType(): string {
42
+ return 'link'
43
+ }
44
+
45
+ static override clone(node: PayloadLinkNode): PayloadLinkNode {
46
+ return new PayloadLinkNode({ ...node.__fields }, node.__linkId, node.__key)
47
+ }
48
+
49
+ constructor(fields: PayloadLinkFields = {}, id?: string, key?: NodeKey) {
50
+ super(key)
51
+ this.__fields = fields
52
+ this.__linkId = id
53
+ }
54
+
55
+ override createDOM(): HTMLElement {
56
+ const a = document.createElement('a')
57
+ a.setAttribute('href', this.__fields.url || '#')
58
+ if (this.__fields.newTab) {
59
+ a.setAttribute('target', '_blank')
60
+ a.setAttribute('rel', 'noopener noreferrer')
61
+ }
62
+ a.addEventListener('click', (e) => e.preventDefault())
63
+ return a
64
+ }
65
+
66
+ override updateDOM(prev: PayloadLinkNode, dom: HTMLElement): boolean {
67
+ if (prev.__fields.url !== this.__fields.url) dom.setAttribute('href', this.__fields.url || '#')
68
+ if (prev.__fields.newTab !== this.__fields.newTab) {
69
+ if (this.__fields.newTab) {
70
+ dom.setAttribute('target', '_blank')
71
+ dom.setAttribute('rel', 'noopener noreferrer')
72
+ } else {
73
+ dom.removeAttribute('target')
74
+ dom.removeAttribute('rel')
75
+ }
76
+ }
77
+ return false
78
+ }
79
+
80
+ static override importJSON(json: SerializedPayloadLinkNode): PayloadLinkNode {
81
+ return $createPayloadLinkNode(json.fields ?? {}, json.id).updateFromJSON(json)
82
+ }
83
+
84
+ override exportJSON(): SerializedPayloadLinkNode {
85
+ return {
86
+ ...super.exportJSON(),
87
+ type: 'link',
88
+ version: 3,
89
+ fields: { ...this.getLatest().__fields },
90
+ ...(this.getLatest().__linkId ? { id: this.getLatest().__linkId } : {}),
91
+ }
92
+ }
93
+
94
+ getFields(): PayloadLinkFields {
95
+ return this.getLatest().__fields
96
+ }
97
+
98
+ setFields(fields: PayloadLinkFields): this {
99
+ const writable = this.getWritable()
100
+ writable.__fields = fields
101
+ return writable
102
+ }
103
+
104
+ override isInline(): boolean {
105
+ return true
106
+ }
107
+
108
+ override canBeEmpty(): boolean {
109
+ return false
110
+ }
111
+
112
+ override canInsertTextBefore(): boolean {
113
+ return false
114
+ }
115
+
116
+ override canInsertTextAfter(): boolean {
117
+ return false
118
+ }
119
+ }
120
+
121
+ export function $createPayloadLinkNode(fields: PayloadLinkFields, id?: string): PayloadLinkNode {
122
+ return new PayloadLinkNode(fields, id)
123
+ }
124
+
125
+ export function $isPayloadLinkNode(node: LexicalNode | null | undefined): node is PayloadLinkNode {
126
+ return node instanceof PayloadLinkNode
127
+ }
128
+
129
+ // ── Opaque ───────────────────────────────────────────────────────────────
130
+
131
+ import { OPAQUE_TYPE } from './constants.ts'
132
+
133
+ export { OPAQUE_TYPE }
134
+ const INLINE_TYPES = new Set(['inlineBlock', 'autolink'])
135
+
136
+ export type SerializedOpaqueNode = Spread<
137
+ { type: typeof OPAQUE_TYPE; version: 1; data: OpaqueData },
138
+ SerializedLexicalNode
139
+ >
140
+
141
+ /** The Payload node an OpaqueNode carries, untouched. */
142
+ export interface OpaqueData {
143
+ type?: string
144
+ value?: { filename?: unknown } | string | number
145
+ fields?: { blockType?: unknown }
146
+ [key: string]: unknown
147
+ }
148
+
149
+ export function describeNode(data: OpaqueData): string {
150
+ const filename =
151
+ typeof data.value === 'object' && typeof data.value?.filename === 'string'
152
+ ? data.value.filename
153
+ : ''
154
+ const blockType = typeof data.fields?.blockType === 'string' ? data.fields.blockType : 'Block'
155
+ switch (data.type) {
156
+ case 'upload':
157
+ return `Image${filename ? ` · ${filename}` : ''} — edit in admin`
158
+ case 'horizontalrule':
159
+ return 'Divider'
160
+ case 'block':
161
+ case 'inlineBlock':
162
+ return `${blockType} — edit in admin`
163
+ case 'relationship':
164
+ return 'Linked document — edit in admin'
165
+ default:
166
+ return `${data.type ?? 'Content'} — edit in admin`
167
+ }
168
+ }
169
+
170
+ export class OpaqueNode extends DecoratorNode<React.ReactNode> {
171
+ __data: OpaqueData
172
+
173
+ static override getType(): string {
174
+ return OPAQUE_TYPE
175
+ }
176
+
177
+ static override clone(node: OpaqueNode): OpaqueNode {
178
+ return new OpaqueNode(node.__data, node.__key)
179
+ }
180
+
181
+ constructor(data: OpaqueData, key?: NodeKey) {
182
+ super(key)
183
+ this.__data = data
184
+ }
185
+
186
+ override createDOM(): HTMLElement {
187
+ return document.createElement(this.isInline() ? 'span' : 'div')
188
+ }
189
+
190
+ override updateDOM(): boolean {
191
+ return false
192
+ }
193
+
194
+ override isInline(): boolean {
195
+ return INLINE_TYPES.has(this.__data.type ?? '')
196
+ }
197
+
198
+ static override importJSON(json: SerializedOpaqueNode): OpaqueNode {
199
+ return new OpaqueNode(json.data)
200
+ }
201
+
202
+ override exportJSON(): SerializedOpaqueNode {
203
+ return { type: OPAQUE_TYPE, version: 1, data: this.getLatest().__data }
204
+ }
205
+
206
+ override decorate(): React.ReactNode {
207
+ return (
208
+ <span className="po-opaque" contentEditable={false} data-testid="onpage-opaque">
209
+ ⧉ {describeNode(this.__data)}
210
+ </span>
211
+ )
212
+ }
213
+ }