@byline/richtext-lexical 6.3.0 → 6.5.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 (62) hide show
  1. package/dist/field/context/shared-on-change-context.js +1 -1
  2. package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
  3. package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
  4. package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
  5. package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
  6. package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
  7. package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
  8. package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
  9. package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
  10. package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
  11. package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
  12. package/dist/field/extensions/inline-image/populate.d.ts +22 -0
  13. package/dist/field/extensions/inline-image/populate.js +19 -1
  14. package/dist/field/extensions/inline-image/types.d.ts +7 -0
  15. package/dist/field/extensions/link/link-href.d.ts +30 -0
  16. package/dist/field/extensions/link/link-href.js +14 -0
  17. package/dist/field/extensions/link/link-modal.js +96 -288
  18. package/dist/field/extensions/link/link-target-fields.css +59 -0
  19. package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
  20. package/dist/field/extensions/link/link-target-fields.js +227 -0
  21. package/dist/field/extensions/link/link-target-state.d.ts +54 -0
  22. package/dist/field/extensions/link/link-target-state.js +70 -0
  23. package/dist/field/extensions/link/populate.d.ts +15 -1
  24. package/dist/field/extensions/link/populate.js +55 -50
  25. package/dist/field/markdown/lexical-to-markdown.js +6 -1
  26. package/dist/field/plugins/toolbar-plugin/index.js +2 -1
  27. package/dist/field/utils/getSelectedNode.js +1 -1
  28. package/dist/field/utils/rect.js +1 -2
  29. package/dist/richtext-field.js +1 -1
  30. package/dist/server.d.ts +2 -2
  31. package/dist/server.js +4 -2
  32. package/dist/validate/createEmptyEditorState.d.ts +2 -2
  33. package/dist/validate/hasText.d.ts +2 -2
  34. package/package.json +27 -26
  35. package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
  36. package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
  37. package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
  38. package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
  39. package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
  40. package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
  41. package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
  42. package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
  43. package/src/field/extensions/inline-image/node-types.ts +19 -0
  44. package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
  45. package/src/field/extensions/inline-image/populate.ts +44 -0
  46. package/src/field/extensions/inline-image/types.ts +7 -0
  47. package/src/field/extensions/link/link-href.test.node.ts +78 -0
  48. package/src/field/extensions/link/link-href.ts +52 -0
  49. package/src/field/extensions/link/link-modal.tsx +86 -325
  50. package/src/field/extensions/link/link-target-fields.css +81 -0
  51. package/src/field/extensions/link/link-target-fields.tsx +275 -0
  52. package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
  53. package/src/field/extensions/link/link-target-state.ts +158 -0
  54. package/src/field/extensions/link/populate.ts +94 -67
  55. package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
  56. package/src/field/markdown/lexical-to-markdown.ts +10 -1
  57. package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
  58. package/src/richtext-field.tsx +1 -1
  59. package/src/server.ts +11 -5
  60. package/src/validate/createEmptyEditorState.ts +2 -7
  61. package/src/validate/hasText.test.ts +1 -2
  62. package/src/validate/hasText.ts +2 -9
@@ -0,0 +1,275 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * The "where does this point?" sub-form, shared by the link modal and the
13
+ * inline-image modal.
14
+ *
15
+ * Purely presentational — every decision lives in `link-target-state.ts`,
16
+ * which is where the rules are tested. This file owns the markup, the
17
+ * picker's open/closed state, and nothing else.
18
+ *
19
+ * `idPrefix` is required rather than defaulted: two of these can be
20
+ * mounted in one document (an image modal over a link modal), and
21
+ * duplicate element ids break the `Label`/`htmlFor` association that
22
+ * screen readers depend on.
23
+ */
24
+
25
+ import type * as React from 'react'
26
+ import { useMemo, useState } from 'react'
27
+
28
+ import { RelationPicker } from '@byline/admin/react'
29
+ import type { MultiCollectionDefinition } from '@byline/core'
30
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
31
+ import {
32
+ Button,
33
+ Checkbox,
34
+ CloseIcon,
35
+ EditIcon,
36
+ IconButton,
37
+ Input,
38
+ Label,
39
+ RadioGroup,
40
+ RadioGroupItem,
41
+ Select,
42
+ type SelectValue,
43
+ } from '@byline/ui/react'
44
+
45
+ import type { LinkTargetState } from './link-target-state'
46
+
47
+ import './link-target-fields.css'
48
+
49
+ export interface LinkTargetFieldsProps {
50
+ idPrefix: string
51
+ state: LinkTargetState
52
+ onChange: (next: LinkTargetState) => void
53
+ /** Collections with `linksInEditor: true`. Empty disables the picker. */
54
+ linkable: MultiCollectionDefinition[]
55
+ /** Offer a "no link" choice. See `LinkTargetOptions.allowNone`. */
56
+ allowNone: boolean
57
+ /** Validation message from `validateLinkTarget`, if the form was submitted. */
58
+ error?: string | null
59
+ /** Label above the kind radio. */
60
+ legend?: string
61
+ }
62
+
63
+ export const LinkTargetFields: React.FC<LinkTargetFieldsProps> = ({
64
+ idPrefix,
65
+ state,
66
+ onChange,
67
+ linkable,
68
+ allowNone,
69
+ error,
70
+ legend = 'Link',
71
+ }) => {
72
+ const [pickerOpen, setPickerOpen] = useState(false)
73
+
74
+ const targetDefinition = state.targetCollection
75
+ ? getCollectionDefinition(state.targetCollection)
76
+ : null
77
+ const targetDef: MultiCollectionDefinition | null =
78
+ targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null
79
+
80
+ const collectionItems: SelectValue<string>[] = useMemo(
81
+ () => linkable.map((c) => ({ label: c.labels.singular, value: c.path })),
82
+ [linkable]
83
+ )
84
+
85
+ const pickedLabel: string | null = useMemo(() => {
86
+ if (state.kind !== 'internal' || !state.picked) return null
87
+ const title = state.picked.document?.title
88
+ if (typeof title === 'string' && title.length > 0) return title
89
+ const pickedDefinition = getCollectionDefinition(state.picked.targetCollectionPath)
90
+ const pickedDef =
91
+ pickedDefinition != null && !isSingleton(pickedDefinition) ? pickedDefinition : null
92
+ const short = state.picked.targetDocumentId.slice(0, 8)
93
+ return `${pickedDef?.labels.singular ?? state.picked.targetCollectionPath} · ${short}…`
94
+ }, [state.kind, state.picked])
95
+
96
+ const handlePickerSelect = (selection: {
97
+ targetDocumentId: string
98
+ targetCollectionId: string
99
+ record?: Record<string, any>
100
+ }) => {
101
+ setPickerOpen(false)
102
+ const targetCollection = state.targetCollection as string
103
+ // Normalise the picked record into a small `{ title, path }` envelope —
104
+ // everything a renderer needs to build a link without a round-trip.
105
+ // `useAsTitle` is always in the picker projection; `path` is top-level
106
+ // metadata on every list response.
107
+ const titleField = getCollectionDefinition(targetCollection)?.useAsTitle
108
+ const title = titleField ? selection.record?.fields?.[titleField] : undefined
109
+ const path = selection.record?.path
110
+ const document: Record<string, any> = {}
111
+ if (typeof title === 'string' && title.length > 0) document.title = title
112
+ if (typeof path === 'string' && path.length > 0) document.path = path
113
+ onChange({
114
+ ...state,
115
+ picked: {
116
+ targetDocumentId: selection.targetDocumentId,
117
+ targetCollectionId: selection.targetCollectionId,
118
+ targetCollectionPath: targetCollection,
119
+ document: Object.keys(document).length > 0 ? document : undefined,
120
+ },
121
+ })
122
+ }
123
+
124
+ return (
125
+ <>
126
+ <div className="flex flex-col gap-3">
127
+ <Label
128
+ id={`${idPrefix}-kind-label`}
129
+ htmlFor={`${idPrefix}-kind`}
130
+ className="text-sm font-medium"
131
+ label={legend}
132
+ />
133
+ <RadioGroup
134
+ id={`${idPrefix}-kind`}
135
+ name={`${idPrefix}-kind`}
136
+ aria-labelledby={`${idPrefix}-kind-label`}
137
+ direction="row"
138
+ value={state.kind}
139
+ onValueChange={(value) => onChange({ ...state, kind: value as LinkTargetState['kind'] })}
140
+ >
141
+ {allowNone && (
142
+ <RadioGroupItem id={`${idPrefix}-kind-none`} value="none" label="No link" />
143
+ )}
144
+ {linkable.length > 0 && (
145
+ <RadioGroupItem id={`${idPrefix}-kind-internal`} value="internal" label="Document" />
146
+ )}
147
+ <RadioGroupItem id={`${idPrefix}-kind-custom`} value="custom" label="Custom URL" />
148
+ </RadioGroup>
149
+
150
+ {state.kind === 'custom' && (
151
+ <Input
152
+ id={`${idPrefix}-url`}
153
+ name="url"
154
+ label="URL"
155
+ placeholder="https://example.com or /path"
156
+ value={state.url}
157
+ error={error != null}
158
+ errorText={error ?? undefined}
159
+ onChange={(e) => onChange({ ...state, url: e.target.value })}
160
+ />
161
+ )}
162
+
163
+ {state.kind === 'internal' && (
164
+ <div className="flex flex-col gap-3">
165
+ {linkable.length > 1 && (
166
+ <div>
167
+ <Label
168
+ id={`${idPrefix}-collection-label`}
169
+ htmlFor={`${idPrefix}-collection`}
170
+ className="mb-1"
171
+ label="Target collection"
172
+ />
173
+ <Select<string>
174
+ size="sm"
175
+ items={collectionItems}
176
+ placeholder="Target collection"
177
+ value={state.targetCollection ?? undefined}
178
+ onValueChange={(value) => {
179
+ if (value == null) return
180
+ // Browsing other collections is exploratory — the
181
+ // current pick survives until the picker replaces it.
182
+ onChange({ ...state, targetCollection: value })
183
+ }}
184
+ />
185
+ </div>
186
+ )}
187
+
188
+ <div>
189
+ <Label
190
+ id={`${idPrefix}-document-label`}
191
+ htmlFor={`${idPrefix}-document`}
192
+ className="mb-1"
193
+ label="Target document"
194
+ />
195
+ {/*
196
+ Frame → tile → icon actions, matching the admin relation
197
+ field. A chosen document is a summary line with edit and
198
+ remove icons, not a text button whose label changes; an
199
+ empty one is a single outlined select button.
200
+ */}
201
+ <div className="byline-link-target-frame">
202
+ {state.picked ? (
203
+ <div className="byline-link-target-tile">
204
+ <span className="byline-link-target-summary">
205
+ <span className="byline-link-target-kind">
206
+ {targetDef?.labels.singular ?? state.picked.targetCollectionPath}
207
+ </span>
208
+ <span className="byline-link-target-value">{pickedLabel}</span>
209
+ </span>
210
+ <span className="byline-link-target-actions">
211
+ <IconButton
212
+ id={`${idPrefix}-document`}
213
+ type="button"
214
+ intent="noeffect"
215
+ size="xs"
216
+ aria-label={`Change ${targetDef?.labels.singular ?? 'document'}`}
217
+ onClick={() => setPickerOpen(true)}
218
+ disabled={!state.targetCollection}
219
+ >
220
+ <EditIcon width="15px" height="15px" />
221
+ </IconButton>
222
+ <IconButton
223
+ type="button"
224
+ intent="noeffect"
225
+ size="xs"
226
+ aria-label={`Remove ${targetDef?.labels.singular ?? 'document'}`}
227
+ onClick={() => onChange({ ...state, picked: null })}
228
+ >
229
+ <CloseIcon width="15px" height="15px" />
230
+ </IconButton>
231
+ </span>
232
+ </div>
233
+ ) : (
234
+ <Button
235
+ id={`${idPrefix}-document`}
236
+ size="xs"
237
+ variant="outlined"
238
+ intent="noeffect"
239
+ type="button"
240
+ onClick={() => setPickerOpen(true)}
241
+ disabled={!state.targetCollection}
242
+ >
243
+ {`Select ${targetDef?.labels.singular ?? 'document'}`}
244
+ </Button>
245
+ )}
246
+ </div>
247
+ </div>
248
+
249
+ {error && <span className="text-xs text-red-400">{error}</span>}
250
+ </div>
251
+ )}
252
+
253
+ {state.kind !== 'none' && (
254
+ <Checkbox
255
+ id={`${idPrefix}-new-tab`}
256
+ name="newTab"
257
+ label="Open in new tab"
258
+ checked={state.newTab}
259
+ onCheckedChange={(checked) => onChange({ ...state, newTab: checked === true })}
260
+ />
261
+ )}
262
+ </div>
263
+
264
+ {state.kind === 'internal' && state.targetCollection && (
265
+ <RelationPicker
266
+ targetCollectionPath={state.targetCollection}
267
+ targetDefinition={targetDef}
268
+ isOpen={pickerOpen}
269
+ onSelect={handlePickerSelect}
270
+ onDismiss={() => setPickerOpen(false)}
271
+ />
272
+ )}
273
+ </>
274
+ )
275
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * The link-target form's state machine, extracted from `LinkModal` so both
11
+ * it and the inline-image modal drive the same rules — and so those rules
12
+ * are testable without rendering React (this repo carries no React
13
+ * testing library; the JSX shell is verified in the browser).
14
+ *
15
+ * Two callers with one asymmetry: a `LinkNode` always has a target, so
16
+ * `allowNone` is false there. An inline image's target is optional, so
17
+ * `allowNone` is true and `toLinkAttributes` can return undefined — which
18
+ * is how an editor removes a link.
19
+ */
20
+
21
+ import { describe, expect, it } from 'vitest'
22
+
23
+ import { fromLinkAttributes, toLinkAttributes, validateLinkTarget } from './link-target-state'
24
+ import type { DocumentRelation } from '../../nodes/document-relation'
25
+ import type { LinkAttributes } from '.'
26
+
27
+ const linkable = [
28
+ { path: 'pages', labels: { singular: 'Page', plural: 'Pages' } },
29
+ { path: 'posts', labels: { singular: 'Post', plural: 'Posts' } },
30
+ ] as any[]
31
+
32
+ const picked: DocumentRelation = {
33
+ targetDocumentId: 'doc-1',
34
+ targetCollectionId: 'coll-pages',
35
+ targetCollectionPath: 'pages',
36
+ document: { title: 'About Us', path: '/pages/about' },
37
+ }
38
+
39
+ const internalLink: LinkAttributes = {
40
+ linkType: 'internal',
41
+ newTab: false,
42
+ targetDocumentId: 'doc-1',
43
+ targetCollectionId: 'coll-pages',
44
+ targetCollectionPath: 'pages',
45
+ document: { title: 'About Us', path: '/pages/about' },
46
+ }
47
+
48
+ const customLink: LinkAttributes = {
49
+ linkType: 'custom',
50
+ url: 'https://example.com/report',
51
+ newTab: true,
52
+ }
53
+
54
+ /** The empty state is reached through the module's one entry point. */
55
+ const empty = (linkable: any[], options: { allowNone: boolean }) =>
56
+ fromLinkAttributes(undefined, linkable as never, options)
57
+
58
+ describe('the starting state', () => {
59
+ it('starts on "none" when the caller allows an absent target', () => {
60
+ expect(empty(linkable, { allowNone: true }).kind).toBe('none')
61
+ })
62
+
63
+ it('starts on "internal" when a target is required and collections are linkable', () => {
64
+ expect(empty(linkable, { allowNone: false }).kind).toBe('internal')
65
+ })
66
+
67
+ it('falls back to "custom" when no collection opts into the editor picker', () => {
68
+ expect(empty([], { allowNone: false }).kind).toBe('custom')
69
+ })
70
+
71
+ it('seeds the picker onto the first linkable collection', () => {
72
+ expect(empty(linkable, { allowNone: false }).targetCollection).toBe('pages')
73
+ })
74
+ })
75
+
76
+ describe('fromLinkAttributes', () => {
77
+ it('reads an absent link as "none" when that is allowed', () => {
78
+ expect(fromLinkAttributes(undefined, linkable, { allowNone: true }).kind).toBe('none')
79
+ })
80
+
81
+ it('loads a custom URL', () => {
82
+ const state = fromLinkAttributes(customLink, linkable, { allowNone: true })
83
+ expect(state.kind).toBe('custom')
84
+ expect(state.url).toBe('https://example.com/report')
85
+ expect(state.newTab).toBe(true)
86
+ })
87
+
88
+ it('loads an internal relation and points the picker at its collection', () => {
89
+ const state = fromLinkAttributes(internalLink, linkable, { allowNone: true })
90
+ expect(state.kind).toBe('internal')
91
+ expect(state.picked).toEqual(picked)
92
+ expect(state.targetCollection).toBe('pages')
93
+ })
94
+
95
+ // LinkModal's existing behaviour: the toolbar inserts a placeholder link
96
+ // with `https://`, which means "the user has not chosen yet" — so prefer
97
+ // the document picker rather than showing a URL box pre-filled with junk.
98
+ it.each(['', 'https://'])(
99
+ 'treats the placeholder url %j as undecided and prefers the picker',
100
+ (url) => {
101
+ const state = fromLinkAttributes({ linkType: 'custom', url }, linkable, { allowNone: false })
102
+ expect(state.kind).toBe('internal')
103
+ expect(state.url).toBe('')
104
+ }
105
+ )
106
+
107
+ it('does not surface the placeholder url in the input', () => {
108
+ const state = fromLinkAttributes({ linkType: 'custom', url: 'https://' }, [], {
109
+ allowNone: false,
110
+ })
111
+ expect(state.kind).toBe('custom')
112
+ expect(state.url).toBe('')
113
+ })
114
+ })
115
+
116
+ describe('toLinkAttributes', () => {
117
+ it('returns undefined for "none" — this is how a link is removed', () => {
118
+ const state = empty(linkable, { allowNone: true })
119
+ expect(toLinkAttributes(state)).toBeUndefined()
120
+ })
121
+
122
+ it('builds a custom link', () => {
123
+ const state = {
124
+ ...empty([], { allowNone: true }),
125
+ kind: 'custom' as const,
126
+ url: 'https://example.com/report',
127
+ newTab: true,
128
+ }
129
+ expect(toLinkAttributes(state)).toEqual(customLink)
130
+ })
131
+
132
+ it('builds an internal link carrying the picked relation envelope', () => {
133
+ const state = {
134
+ ...empty(linkable, { allowNone: true }),
135
+ kind: 'internal' as const,
136
+ picked,
137
+ newTab: false,
138
+ }
139
+ expect(toLinkAttributes(state)).toEqual(internalLink)
140
+ })
141
+
142
+ it('returns undefined for an internal selection with nothing picked', () => {
143
+ const state = {
144
+ ...empty(linkable, { allowNone: true }),
145
+ kind: 'internal' as const,
146
+ }
147
+ expect(toLinkAttributes(state)).toBeUndefined()
148
+ })
149
+
150
+ it.each([customLink, internalLink])('round-trips %#', (link) => {
151
+ expect(toLinkAttributes(fromLinkAttributes(link, linkable, { allowNone: true }))).toEqual(link)
152
+ })
153
+ })
154
+
155
+ describe('validateLinkTarget', () => {
156
+ it('accepts "none"', () => {
157
+ expect(validateLinkTarget(empty(linkable, { allowNone: true }))).toBeNull()
158
+ })
159
+
160
+ it.each(['https://example.com', '/docs/getting-started', 'mailto:a@example.com'])(
161
+ 'accepts %s as a custom target',
162
+ (url) => {
163
+ const state = {
164
+ ...empty([], { allowNone: true }),
165
+ kind: 'custom' as const,
166
+ url,
167
+ }
168
+ expect(validateLinkTarget(state)).toBeNull()
169
+ }
170
+ )
171
+
172
+ it('rejects an empty custom URL', () => {
173
+ const state = {
174
+ ...empty([], { allowNone: true }),
175
+ kind: 'custom' as const,
176
+ url: '',
177
+ }
178
+ expect(validateLinkTarget(state)).toBeTruthy()
179
+ })
180
+
181
+ it('rejects an internal selection with no document picked', () => {
182
+ const state = {
183
+ ...empty(linkable, { allowNone: true }),
184
+ kind: 'internal' as const,
185
+ }
186
+ expect(validateLinkTarget(state)).toBeTruthy()
187
+ })
188
+
189
+ it('accepts an internal selection once a document is picked', () => {
190
+ const state = {
191
+ ...empty(linkable, { allowNone: true }),
192
+ kind: 'internal' as const,
193
+ picked,
194
+ }
195
+ expect(validateLinkTarget(state)).toBeNull()
196
+ })
197
+ })
@@ -0,0 +1,158 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * The state machine behind "where does this link point?" — shared by the
11
+ * link modal and the inline-image modal so both offer the same choice of
12
+ * a Byline document or an arbitrary URL, with the same validation.
13
+ *
14
+ * React-free on purpose. The JSX shell lives in `link-target-fields.tsx`;
15
+ * everything that decides anything lives here, where it can be tested
16
+ * without a DOM.
17
+ *
18
+ * The one asymmetry between callers is `allowNone`. A `LinkNode` exists
19
+ * only because it has a target, so its form always resolves to a link. An
20
+ * inline image's target is optional, so its form adds a "no link" choice
21
+ * and `toLinkAttributes` may return `undefined` — which is precisely how
22
+ * an editor removes a link that is already there.
23
+ */
24
+
25
+ import type { MultiCollectionDefinition } from '@byline/core'
26
+
27
+ import { validateUrl } from '../../utils/url'
28
+ import type { DocumentRelation } from '../../nodes/document-relation'
29
+ import type { LinkAttributes } from '.'
30
+
31
+ export interface LinkTargetState {
32
+ /** Narrow with `LinkTargetState['kind']` rather than a separate alias. */
33
+ kind: 'none' | 'custom' | 'internal'
34
+ /** Custom-URL text. Meaningful only when `kind === 'custom'`. */
35
+ url: string
36
+ newTab: boolean
37
+ /**
38
+ * Which collection the picker is currently pointed at — UI state only,
39
+ * and deliberately independent of `picked.targetCollectionPath` so the
40
+ * user can browse other collections without losing their selection.
41
+ */
42
+ targetCollection: string | null
43
+ /** The chosen document. Meaningful only when `kind === 'internal'`. */
44
+ picked: DocumentRelation | null
45
+ }
46
+
47
+ export interface LinkTargetOptions {
48
+ /** Whether "no link at all" is a valid outcome. */
49
+ allowNone: boolean
50
+ }
51
+
52
+ /**
53
+ * A link the toolbar inserted as a placeholder, before the editor has
54
+ * chosen anything. Shown as an empty form rather than a URL box pre-filled
55
+ * with `https://`.
56
+ */
57
+ function isPlaceholderUrl(url: string | undefined): boolean {
58
+ return url == null || url === '' || url === 'https://'
59
+ }
60
+
61
+ /**
62
+ * The starting state for a form with nothing chosen yet. Module-private:
63
+ * `fromLinkAttributes(undefined, …)` returns exactly this, so callers have
64
+ * one way in rather than two that must agree.
65
+ */
66
+ function emptyLinkTargetState(
67
+ linkable: MultiCollectionDefinition[],
68
+ options: LinkTargetOptions
69
+ ): LinkTargetState {
70
+ const firstLinkable = linkable[0]?.path ?? null
71
+ const kind: LinkTargetState['kind'] = options.allowNone
72
+ ? 'none'
73
+ : linkable.length > 0
74
+ ? 'internal'
75
+ : 'custom'
76
+ return {
77
+ kind,
78
+ url: '',
79
+ newTab: false,
80
+ targetCollection: firstLinkable,
81
+ picked: null,
82
+ }
83
+ }
84
+
85
+ export function fromLinkAttributes(
86
+ link: LinkAttributes | undefined,
87
+ linkable: MultiCollectionDefinition[],
88
+ options: LinkTargetOptions
89
+ ): LinkTargetState {
90
+ const base = emptyLinkTargetState(linkable, options)
91
+ if (link == null) return base
92
+
93
+ if (link.linkType === 'internal') {
94
+ const picked: DocumentRelation = {
95
+ targetDocumentId: link.targetDocumentId,
96
+ targetCollectionId: link.targetCollectionId,
97
+ targetCollectionPath: link.targetCollectionPath,
98
+ document: link.document,
99
+ }
100
+ return {
101
+ ...base,
102
+ kind: 'internal',
103
+ newTab: link.newTab ?? false,
104
+ targetCollection: picked.targetCollectionPath || base.targetCollection,
105
+ picked,
106
+ }
107
+ }
108
+
109
+ // Custom. An undecided placeholder prefers the document picker when any
110
+ // collection offers one; otherwise it stays on the (empty) URL field.
111
+ const placeholder = isPlaceholderUrl(link.url)
112
+ const kind: LinkTargetState['kind'] = placeholder && linkable.length > 0 ? 'internal' : 'custom'
113
+ return {
114
+ ...base,
115
+ kind,
116
+ url: placeholder ? '' : (link.url ?? ''),
117
+ newTab: link.newTab ?? false,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Collapse the form back to a `LinkAttributes`, or `undefined` when there
123
+ * is no usable target. Callers that require a link should run
124
+ * `validateLinkTarget` first — this function reports "nothing to store"
125
+ * rather than "the user made a mistake".
126
+ */
127
+ export function toLinkAttributes(state: LinkTargetState): LinkAttributes | undefined {
128
+ if (state.kind === 'none') return undefined
129
+ if (state.kind === 'internal') {
130
+ if (state.picked == null) return undefined
131
+ return {
132
+ linkType: 'internal',
133
+ newTab: state.newTab,
134
+ targetDocumentId: state.picked.targetDocumentId,
135
+ targetCollectionId: state.picked.targetCollectionId,
136
+ targetCollectionPath: state.picked.targetCollectionPath,
137
+ document: state.picked.document,
138
+ }
139
+ }
140
+ if (state.url.length === 0) return undefined
141
+ return { linkType: 'custom', url: state.url, newTab: state.newTab }
142
+ }
143
+
144
+ /** Returns a human-readable problem, or `null` when the form is usable. */
145
+ export function validateLinkTarget(state: LinkTargetState): string | null {
146
+ if (state.kind === 'none') return null
147
+ if (state.kind === 'internal') {
148
+ return state.picked == null ? 'Pick a target document' : null
149
+ }
150
+ if (state.url.length === 0) {
151
+ return 'Enter a URL or a root-relative path starting with /'
152
+ }
153
+ // Root-relative paths bypass `validateUrl`, which expects a scheme.
154
+ if (!state.url.startsWith('/') && !validateUrl(state.url)) {
155
+ return 'Enter a valid URL or a root-relative path starting with /'
156
+ }
157
+ return null
158
+ }