@byline/richtext-lexical 6.4.0 → 6.6.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.
Files changed (57) hide show
  1. package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
  2. package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
  3. package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
  4. package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
  5. package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
  6. package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
  7. package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
  8. package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
  9. package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
  10. package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
  11. package/dist/field/extensions/inline-image/populate.d.ts +22 -0
  12. package/dist/field/extensions/inline-image/populate.js +19 -1
  13. package/dist/field/extensions/inline-image/types.d.ts +7 -0
  14. package/dist/field/extensions/link/link-href.d.ts +30 -0
  15. package/dist/field/extensions/link/link-href.js +14 -0
  16. package/dist/field/extensions/link/link-modal.js +96 -288
  17. package/dist/field/extensions/link/link-target-fields.css +59 -0
  18. package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
  19. package/dist/field/extensions/link/link-target-fields.js +227 -0
  20. package/dist/field/extensions/link/link-target-state.d.ts +54 -0
  21. package/dist/field/extensions/link/link-target-state.js +70 -0
  22. package/dist/field/extensions/link/populate.d.ts +15 -1
  23. package/dist/field/extensions/link/populate.js +55 -50
  24. package/dist/field/markdown/lexical-to-markdown.js +6 -1
  25. package/dist/field/plugins/toolbar-plugin/index.js +2 -1
  26. package/dist/server.d.ts +2 -2
  27. package/dist/server.js +4 -2
  28. package/dist/validate/createEmptyEditorState.d.ts +2 -2
  29. package/dist/validate/hasText.d.ts +2 -2
  30. package/package.json +25 -24
  31. package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
  32. package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
  33. package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
  34. package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
  35. package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
  36. package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
  37. package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
  38. package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
  39. package/src/field/extensions/inline-image/node-types.ts +19 -0
  40. package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
  41. package/src/field/extensions/inline-image/populate.ts +44 -0
  42. package/src/field/extensions/inline-image/types.ts +7 -0
  43. package/src/field/extensions/link/link-href.test.node.ts +78 -0
  44. package/src/field/extensions/link/link-href.ts +52 -0
  45. package/src/field/extensions/link/link-modal.tsx +86 -325
  46. package/src/field/extensions/link/link-target-fields.css +81 -0
  47. package/src/field/extensions/link/link-target-fields.tsx +275 -0
  48. package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
  49. package/src/field/extensions/link/link-target-state.ts +158 -0
  50. package/src/field/extensions/link/populate.ts +94 -67
  51. package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
  52. package/src/field/markdown/lexical-to-markdown.ts +10 -1
  53. package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
  54. package/src/server.ts +11 -5
  55. package/src/validate/createEmptyEditorState.ts +2 -7
  56. package/src/validate/hasText.test.ts +1 -2
  57. package/src/validate/hasText.ts +2 -9
@@ -11,92 +11,32 @@
11
11
  import type * as React from 'react'
12
12
  import { useMemo, useState } from 'react'
13
13
 
14
- import { RelationPicker } from '@byline/admin/react'
15
14
  import type { MultiCollectionDefinition } from '@byline/core'
16
- import { getAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core'
17
- import {
18
- Button,
19
- Checkbox,
20
- CloseIcon,
21
- IconButton,
22
- Input,
23
- Label,
24
- Modal,
25
- RadioGroup,
26
- RadioGroupItem,
27
- Select,
28
- type SelectValue,
29
- } from '@byline/ui/react'
15
+ import { getAdminConfig, isSingleton } from '@byline/core'
16
+ import { Button, CloseIcon, IconButton, Input, Modal } from '@byline/ui/react'
30
17
 
31
18
  import { useModalFormState } from '../../shared/useModalFormState'
32
- import { validateUrl } from '../../utils/url'
33
- import type { DocumentRelation } from '../../nodes/document-relation'
19
+ import { LinkTargetFields } from './link-target-fields'
20
+ import {
21
+ fromLinkAttributes,
22
+ type LinkTargetState,
23
+ toLinkAttributes,
24
+ validateLinkTarget,
25
+ } from './link-target-state'
34
26
  import type { LinkAttributes } from '.'
35
- import type { LinkData, LinkModalProps } from './link-modal-types'
27
+ import type { LinkModalProps } from './link-modal-types'
36
28
 
37
29
  interface FormState {
38
30
  text: string
39
- linkType: 'custom' | 'internal'
40
- url: string
41
- newTab: boolean
42
- /** Which collection the Select is currently previewing — UI state only. */
43
- targetCollection: string | null
44
- /** Currently chosen document relation. `targetCollectionPath` is the
45
- * source-of-truth collection for this doc and is independent of
46
- * `targetCollection` so the user can explore other collections in the
47
- * Select without losing their pick. */
48
- picked: DocumentRelation | null
31
+ target: LinkTargetState
49
32
  }
50
33
 
51
- function emptyState(linkable: MultiCollectionDefinition[]): FormState {
52
- return {
53
- text: '',
54
- linkType: linkable.length > 0 ? 'internal' : 'custom',
55
- url: '',
56
- newTab: false,
57
- targetCollection: linkable[0]?.path ?? null,
58
- picked: null,
59
- }
60
- }
61
-
62
- function fromLinkData(
63
- data: LinkData | undefined,
64
- linkable: MultiCollectionDefinition[]
65
- ): FormState {
66
- const base = emptyState(linkable)
67
- if (!data) return base
68
- const fields = data.fields
69
- if (!fields) return base
70
- // Default to internal when:
71
- // • the stored data already says internal, or
72
- // • this is a fresh placeholder link from the toolbar (linkType: 'custom'
73
- // with an empty / `https://` url) — in that case the user hasn't
74
- // decided yet, so prefer the picker when any collection has
75
- // `linksInEditor: true`.
76
- const url = fields.linkType === 'internal' ? '' : (fields.url ?? '')
77
- const isPlaceholderUrl = url === '' || url === 'https://'
78
- const wantsInternal = linkable.length > 0 && (fields.linkType === 'internal' || isPlaceholderUrl)
79
- const linkType: 'custom' | 'internal' = wantsInternal ? 'internal' : 'custom'
80
- const picked: DocumentRelation | null =
81
- fields.linkType === 'internal'
82
- ? {
83
- targetDocumentId: fields.targetDocumentId,
84
- targetCollectionId: fields.targetCollectionId,
85
- targetCollectionPath: fields.targetCollectionPath,
86
- document: fields.document,
87
- }
88
- : null
89
- return {
90
- text: data.text ?? '',
91
- linkType,
92
- // Don't surface the placeholder `https://` in the URL input — it makes
93
- // the field look pre-filled with garbage.
94
- url: isPlaceholderUrl ? '' : url,
95
- newTab: fields.newTab ?? false,
96
- targetCollection: picked?.targetCollectionPath ?? linkable[0]?.path ?? null,
97
- picked,
98
- }
99
- }
34
+ /**
35
+ * A link node exists because it points somewhere, so "no target" is not a
36
+ * valid outcome here — unlike the inline-image modal, where the link is
37
+ * optional. Everything else about the target sub-form is shared.
38
+ */
39
+ const LINK_TARGET_OPTIONS = { allowNone: false } as const
100
40
 
101
41
  export const LinkModal: React.FC<LinkModalProps> = ({
102
42
  isOpen = false,
@@ -113,109 +53,32 @@ export const LinkModal: React.FC<LinkModalProps> = ({
113
53
  []
114
54
  )
115
55
 
116
- const [pickerOpen, setPickerOpen] = useState(false)
117
56
  const [urlError, setUrlError] = useState<string | null>(null)
118
57
 
119
58
  const [state, setState] = useModalFormState<FormState>(
120
59
  isOpen,
121
- () => fromLinkData(dataFromProps, linkable),
60
+ () => ({
61
+ text: dataFromProps?.text ?? '',
62
+ target: fromLinkAttributes(dataFromProps?.fields, linkable, LINK_TARGET_OPTIONS),
63
+ }),
122
64
  () => setUrlError(null)
123
65
  )
124
66
 
125
- const targetDefinition = state.targetCollection
126
- ? getCollectionDefinition(state.targetCollection)
127
- : null
128
- const targetDef: MultiCollectionDefinition | null =
129
- targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null
130
-
131
- const collectionItems: SelectValue<string>[] = useMemo(
132
- () => linkable.map((c) => ({ label: c.labels.singular, value: c.path })),
133
- [linkable]
134
- )
135
-
136
- const pickedLabel: string | null = useMemo(() => {
137
- if (state.linkType !== 'internal' || !state.picked) return null
138
- const title = state.picked.document?.title
139
- if (typeof title === 'string' && title.length > 0) return title
140
- // No title cached — show a stable stub keyed off the collection.
141
- const pickedDefinition = getCollectionDefinition(state.picked.targetCollectionPath)
142
- const pickedDef =
143
- pickedDefinition != null && !isSingleton(pickedDefinition) ? pickedDefinition : null
144
- const short = state.picked.targetDocumentId.slice(0, 8)
145
- return `${pickedDef?.labels.singular ?? state.picked.targetCollectionPath} · ${short}…`
146
- }, [state.linkType, state.picked])
147
-
148
- const handlePickerSelect = (selection: {
149
- targetDocumentId: string
150
- targetCollectionId: string
151
- record?: Record<string, any>
152
- }) => {
153
- setPickerOpen(false)
154
- setState((s) => {
155
- const targetCollection = s.targetCollection as string
156
- // Normalise the picked record into a small `{ title, path }` envelope.
157
- // `useAsTitle` is always in the picker projection; `path` is top-level
158
- // metadata on every list response. This is everything the public
159
- // client needs to build a link to the document — no afterRead hook
160
- // needed for the common case.
161
- const titleField = getCollectionDefinition(targetCollection)?.useAsTitle
162
- const title = titleField ? selection.record?.fields?.[titleField] : undefined
163
- const path = selection.record?.path
164
- const document: Record<string, any> = {}
165
- if (typeof title === 'string' && title.length > 0) document.title = title
166
- if (typeof path === 'string' && path.length > 0) document.path = path
167
- return {
168
- ...s,
169
- picked: {
170
- targetDocumentId: selection.targetDocumentId,
171
- targetCollectionId: selection.targetCollectionId,
172
- targetCollectionPath: targetCollection,
173
- document: Object.keys(document).length > 0 ? document : undefined,
174
- },
175
- }
176
- })
177
- }
178
-
179
67
  const handleSave = () => {
180
- if (state.linkType === 'custom') {
181
- const url = state.url
182
- if (!url.startsWith('/') && !validateUrl(url)) {
183
- setUrlError('Enter a valid URL or a root-relative path starting with /')
184
- return
185
- }
68
+ const problem = validateLinkTarget(state.target)
69
+ if (problem != null) {
70
+ setUrlError(problem)
71
+ return
186
72
  }
187
- if (state.linkType === 'internal') {
188
- if (!state.picked) {
189
- setUrlError('Pick a target document')
190
- return
191
- }
73
+ const fields = toLinkAttributes(state.target)
74
+ if (fields == null) {
75
+ setUrlError('Pick a target document')
76
+ return
192
77
  }
193
78
 
194
- const picked = state.picked as DocumentRelation
195
- // Always embed the picker's `{ title, path }` envelope on save —
196
- // the in-editor display reads it back to render a label without a
197
- // round-trip, and the server-side write-time walker
198
- // (`embedRichTextFields` in `@byline/core` + `lexicalEditorEmbedServer`)
199
- // refreshes / canonicalises it on persistence.
200
- const fields: LinkAttributes =
201
- state.linkType === 'custom'
202
- ? {
203
- linkType: 'custom',
204
- url: state.url,
205
- newTab: state.newTab,
206
- }
207
- : {
208
- linkType: 'internal',
209
- newTab: state.newTab,
210
- targetDocumentId: picked.targetDocumentId,
211
- targetCollectionId: picked.targetCollectionId,
212
- targetCollectionPath: picked.targetCollectionPath,
213
- document: picked.document,
214
- }
215
-
216
79
  onSubmit({
217
80
  text: state.text.length > 0 ? state.text : null,
218
- fields,
81
+ fields: fields as LinkAttributes,
219
82
  })
220
83
  onClose()
221
84
  }
@@ -223,163 +86,61 @@ export const LinkModal: React.FC<LinkModalProps> = ({
223
86
  if (!isOpen) return null
224
87
 
225
88
  return (
226
- <>
227
- <Modal isOpen={isOpen} onDismiss={onClose} closeOnOverlayClick={false}>
228
- <Modal.Container style={{ maxWidth: '480px', width: '100%' }}>
229
- <Modal.Header className="flex items-center justify-between pt-4 mb-4">
230
- <h3 className="m-0 text-xl">Edit link</h3>
231
- <IconButton aria-label="Close" size="xs" onClick={onClose}>
232
- <CloseIcon width="15px" height="15px" svgClassName="white-icon" />
233
- </IconButton>
234
- </Modal.Header>
235
- <Modal.Content>
236
- <div className="flex flex-col gap-4">
237
- <Input
238
- id="link-text"
239
- className="mb-2"
240
- name="text"
241
- label="Link text"
242
- placeholder="Visible link text"
243
- value={state.text}
244
- onChange={(e) => setState((s) => ({ ...s, text: e.target.value }))}
245
- />
246
-
247
- {linkable.length > 0 && (
248
- <RadioGroup
249
- id="link-type"
250
- name="linkType"
251
- aria-label="Link type"
252
- direction="row"
253
- value={state.linkType}
254
- onValueChange={(value) =>
255
- setState((s) => ({
256
- ...s,
257
- linkType: value === 'internal' ? 'internal' : 'custom',
258
- }))
259
- }
260
- >
261
- <RadioGroupItem id="link-type-internal" value="internal" label="Document" />
262
- <RadioGroupItem id="link-type-custom" value="custom" label="Custom URL" />
263
- </RadioGroup>
264
- )}
265
-
266
- {state.linkType === 'custom' && (
267
- <Input
268
- id="link-url"
269
- name="url"
270
- label="URL"
271
- placeholder="https://example.com or /path"
272
- value={state.url}
273
- errorText={urlError ?? undefined}
274
- error={urlError != null}
275
- onChange={(e) => {
276
- setUrlError(null)
277
- setState((s) => ({ ...s, url: e.target.value }))
278
- }}
279
- />
280
- )}
281
-
282
- {state.linkType === 'internal' && (
283
- <div className="flex flex-col gap-3 mt-2">
284
- {linkable.length > 1 && (
285
- <div>
286
- <Label
287
- id="link-target-collection"
288
- htmlFor="link-target-collection"
289
- className="mb-1"
290
- label="Target collection"
291
- />
292
- <Select<string>
293
- size="sm"
294
- items={collectionItems}
295
- placeholder="Target collection"
296
- value={state.targetCollection ?? undefined}
297
- onValueChange={(value) => {
298
- if (value == null) return
299
- // Switching the Select is exploratory — we keep the
300
- // currently picked document; only the picker target
301
- // changes. Picking a new document via the picker is
302
- // the only path that replaces `picked`.
303
- setState((s) => ({ ...s, targetCollection: value }))
304
- }}
305
- />
306
- </div>
307
- )}
308
-
309
- <div className="border rounded p-3">
310
- <Label
311
- id="link-target-document"
312
- htmlFor="link-target-document"
313
- className="mb-1"
314
- label="Target document"
315
- />
316
-
317
- <div className="flex items-center justify-between gap-2">
318
- {pickedLabel && (
319
- <span className="text-sm text-accent-400 truncate">{pickedLabel}</span>
320
- )}
321
- <Button
322
- size="sm"
323
- variant="outlined"
324
- intent="noeffect"
325
- type="button"
326
- onClick={() => setPickerOpen(true)}
327
- disabled={!state.targetCollection}
328
- >
329
- {pickedLabel
330
- ? 'Change'
331
- : `Pick ${targetDef?.labels.singular ?? 'document'}…`}
332
- </Button>
333
- </div>
334
- </div>
335
-
336
- {urlError && state.linkType === 'internal' && (
337
- <span className="text-xs text-red-400">{urlError}</span>
338
- )}
339
- </div>
340
- )}
341
-
342
- <Checkbox
343
- id="link-new-tab"
344
- name="newTab"
345
- label="Open in new tab"
346
- checked={state.newTab}
347
- onCheckedChange={(checked) => setState((s) => ({ ...s, newTab: checked === true }))}
348
- />
349
- </div>
350
- </Modal.Content>
351
- <Modal.Actions className="flex gap-3">
352
- <Button
353
- size="sm"
354
- intent="noeffect"
355
- type="button"
356
- onClick={onClose}
357
- className="min-w-[70px]"
358
- >
359
- Cancel
360
- </Button>
361
- <Button
362
- size="sm"
363
- intent="primary"
364
- type="button"
365
- onClick={handleSave}
366
- className="min-w-[70px]"
367
- >
368
- Save
369
- </Button>
370
- </Modal.Actions>
371
- </Modal.Container>
372
- </Modal>
373
-
374
- {state.linkType === 'internal' && state.targetCollection && (
375
- <RelationPicker
376
- targetCollectionPath={state.targetCollection}
377
- targetDefinition={targetDef}
378
- isOpen={pickerOpen}
379
- onSelect={handlePickerSelect}
380
- onDismiss={() => setPickerOpen(false)}
381
- />
382
- )}
383
- </>
89
+ <Modal isOpen={isOpen} onDismiss={onClose} closeOnOverlayClick={false}>
90
+ <Modal.Container style={{ maxWidth: '480px', width: '100%' }}>
91
+ <Modal.Header className="flex items-center justify-between pt-4 mb-4">
92
+ <h3 className="m-0 text-xl">Edit link</h3>
93
+ <IconButton aria-label="Close" size="xs" onClick={onClose}>
94
+ <CloseIcon width="15px" height="15px" svgClassName="white-icon" />
95
+ </IconButton>
96
+ </Modal.Header>
97
+ <Modal.Content>
98
+ <div className="flex flex-col gap-4">
99
+ <Input
100
+ id="link-text"
101
+ className="mb-2"
102
+ name="text"
103
+ label="Link text"
104
+ placeholder="Visible link text"
105
+ value={state.text}
106
+ onChange={(e) => setState((s) => ({ ...s, text: e.target.value }))}
107
+ />
108
+
109
+ <LinkTargetFields
110
+ idPrefix="link"
111
+ state={state.target}
112
+ onChange={(target) => {
113
+ setUrlError(null)
114
+ setState((s) => ({ ...s, target }))
115
+ }}
116
+ linkable={linkable}
117
+ allowNone={false}
118
+ error={urlError}
119
+ legend="Link type"
120
+ />
121
+ </div>
122
+ </Modal.Content>
123
+ <Modal.Actions className="flex gap-3">
124
+ <Button
125
+ size="sm"
126
+ intent="noeffect"
127
+ type="button"
128
+ onClick={onClose}
129
+ className="min-w-[70px]"
130
+ >
131
+ Cancel
132
+ </Button>
133
+ <Button
134
+ size="sm"
135
+ intent="primary"
136
+ type="button"
137
+ onClick={handleSave}
138
+ className="min-w-[70px]"
139
+ >
140
+ Save
141
+ </Button>
142
+ </Modal.Actions>
143
+ </Modal.Container>
144
+ </Modal>
384
145
  )
385
146
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Link-target picker — the "Target document" frame.
3
+ *
4
+ * Deliberately mirrors the admin relation field
5
+ * (`packages/admin/src/fields/relation/relation-field.module.css`), because
6
+ * this is the same job: show the chosen document and offer a way to change
7
+ * it. Same frame token, same primary-bordered tile, same icon-button pair,
8
+ * so the editor's picker reads as the widget editors already know rather
9
+ * than a second dialect.
10
+ *
11
+ * Own class names rather than reusing `.byline-field-relation-*`: this
12
+ * package renders inside hosts that may not load the admin field
13
+ * stylesheet, and silently inheriting another package's internals would
14
+ * break without warning. The values are what keep the two in step.
15
+ */
16
+
17
+ .byline-link-target-frame {
18
+ margin-top: 0.25rem;
19
+ padding: 0.5rem;
20
+ border: var(--border-width-thin, 1px) var(--border-style-solid, solid) var(--field-border);
21
+ border-radius: var(--border-radius-md, 6px);
22
+ }
23
+
24
+ /* The chosen document: summary on the left, actions pinned right. */
25
+ .byline-link-target-tile {
26
+ display: flex;
27
+ align-items: flex-start;
28
+ justify-content: space-between;
29
+ gap: var(--spacing-8, 0.5rem);
30
+ padding: var(--spacing-8, 0.5rem);
31
+ border: var(--border-width-thin, 1px) var(--border-style-solid, solid) var(--primary-500);
32
+ border-radius: var(--border-radius-md, 6px);
33
+ font-size: var(--font-size-xs);
34
+ }
35
+
36
+ .byline-link-target-summary {
37
+ display: flex;
38
+ min-width: 0;
39
+ flex-direction: column;
40
+ gap: 0.125rem;
41
+ }
42
+
43
+ /* Small "type" line above the value — the target collection's label. */
44
+ .byline-link-target-kind {
45
+ color: var(--gray-500);
46
+ }
47
+
48
+ .byline-link-target-value {
49
+ overflow: hidden;
50
+ color: var(--gray-900);
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ }
54
+
55
+ .byline-link-target-actions {
56
+ display: flex;
57
+ flex-shrink: 0;
58
+ align-items: center;
59
+ gap: var(--spacing-4, 0.25rem);
60
+ }
61
+
62
+ .byline-link-target-actions .byline-button {
63
+ color: var(--gray-900);
64
+ }
65
+
66
+ :is([data-theme="dark"], .dark) {
67
+ /* A darker hairline than `--field-border` gives on this surface: the
68
+ modal sits on an already-raised panel, so the default reads as a
69
+ bright outline rather than a division. */
70
+ .byline-link-target-frame {
71
+ border-color: var(--canvas-700, var(--field-border));
72
+ }
73
+
74
+ .byline-link-target-value {
75
+ color: var(--gray-100);
76
+ }
77
+
78
+ .byline-link-target-actions .byline-button {
79
+ color: var(--gray-200);
80
+ }
81
+ }