@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.
- package/dist/field/context/shared-on-change-context.js +1 -1
- package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
- package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
- package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
- package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
- package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
- package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
- package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
- package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
- package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
- package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
- package/dist/field/extensions/inline-image/populate.d.ts +22 -0
- package/dist/field/extensions/inline-image/populate.js +19 -1
- package/dist/field/extensions/inline-image/types.d.ts +7 -0
- package/dist/field/extensions/link/link-href.d.ts +30 -0
- package/dist/field/extensions/link/link-href.js +14 -0
- package/dist/field/extensions/link/link-modal.js +96 -288
- package/dist/field/extensions/link/link-target-fields.css +59 -0
- package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
- package/dist/field/extensions/link/link-target-fields.js +227 -0
- package/dist/field/extensions/link/link-target-state.d.ts +54 -0
- package/dist/field/extensions/link/link-target-state.js +70 -0
- package/dist/field/extensions/link/populate.d.ts +15 -1
- package/dist/field/extensions/link/populate.js +55 -50
- package/dist/field/markdown/lexical-to-markdown.js +6 -1
- package/dist/field/plugins/toolbar-plugin/index.js +2 -1
- package/dist/field/utils/getSelectedNode.js +1 -1
- package/dist/field/utils/rect.js +1 -2
- package/dist/richtext-field.js +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -2
- package/dist/validate/createEmptyEditorState.d.ts +2 -2
- package/dist/validate/hasText.d.ts +2 -2
- package/package.json +27 -26
- package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
- package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
- package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
- package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
- package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
- package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
- package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
- package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
- package/src/field/extensions/inline-image/node-types.ts +19 -0
- package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
- package/src/field/extensions/inline-image/populate.ts +44 -0
- package/src/field/extensions/inline-image/types.ts +7 -0
- package/src/field/extensions/link/link-href.test.node.ts +78 -0
- package/src/field/extensions/link/link-href.ts +52 -0
- package/src/field/extensions/link/link-modal.tsx +86 -325
- package/src/field/extensions/link/link-target-fields.css +81 -0
- package/src/field/extensions/link/link-target-fields.tsx +275 -0
- package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
- package/src/field/extensions/link/link-target-state.ts +158 -0
- package/src/field/extensions/link/populate.ts +94 -67
- package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
- package/src/field/markdown/lexical-to-markdown.ts +10 -1
- package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
- package/src/richtext-field.tsx +1 -1
- package/src/server.ts +11 -5
- package/src/validate/createEmptyEditorState.ts +2 -7
- package/src/validate/hasText.test.ts +1 -2
- package/src/validate/hasText.ts +2 -9
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "6.
|
|
6
|
+
"version": "6.5.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -62,39 +62,40 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@lexical/code": "0.
|
|
66
|
-
"@lexical/
|
|
67
|
-
"@lexical/
|
|
68
|
-
"@lexical/
|
|
69
|
-
"@lexical/
|
|
70
|
-
"@lexical/
|
|
71
|
-
"@lexical/
|
|
72
|
-
"@lexical/
|
|
73
|
-
"@lexical/
|
|
74
|
-
"@lexical/
|
|
75
|
-
"@lexical/
|
|
76
|
-
"@lexical/
|
|
77
|
-
"@lexical/
|
|
65
|
+
"@lexical/code": "0.51.0",
|
|
66
|
+
"@lexical/code-prism": "0.51.0",
|
|
67
|
+
"@lexical/extension": "0.51.0",
|
|
68
|
+
"@lexical/headless": "0.51.0",
|
|
69
|
+
"@lexical/link": "0.51.0",
|
|
70
|
+
"@lexical/list": "0.51.0",
|
|
71
|
+
"@lexical/mark": "0.51.0",
|
|
72
|
+
"@lexical/markdown": "0.51.0",
|
|
73
|
+
"@lexical/overflow": "0.51.0",
|
|
74
|
+
"@lexical/react": "0.51.0",
|
|
75
|
+
"@lexical/rich-text": "0.51.0",
|
|
76
|
+
"@lexical/selection": "0.51.0",
|
|
77
|
+
"@lexical/table": "0.51.0",
|
|
78
|
+
"@lexical/utils": "0.51.0",
|
|
78
79
|
"clsx": "^2.1.1",
|
|
79
|
-
"lexical": "0.
|
|
80
|
+
"lexical": "0.51.0",
|
|
80
81
|
"react-error-boundary": "^6.1.5",
|
|
81
|
-
"@byline/
|
|
82
|
-
"@byline/
|
|
83
|
-
"@byline/
|
|
84
|
-
"@byline/
|
|
85
|
-
"@byline/
|
|
82
|
+
"@byline/admin": "6.5.0",
|
|
83
|
+
"@byline/core": "6.5.0",
|
|
84
|
+
"@byline/i18n": "6.5.0",
|
|
85
|
+
"@byline/client": "6.5.0",
|
|
86
|
+
"@byline/ui": "6.5.0"
|
|
86
87
|
},
|
|
87
88
|
"peerDependencies": {
|
|
88
89
|
"react": "^19.0.0",
|
|
89
90
|
"react-dom": "^19.0.0"
|
|
90
91
|
},
|
|
91
92
|
"devDependencies": {
|
|
92
|
-
"@biomejs/biome": "2.5.
|
|
93
|
-
"@lexical/clipboard": "0.
|
|
94
|
-
"@lexical/html": "0.
|
|
93
|
+
"@biomejs/biome": "2.5.14",
|
|
94
|
+
"@lexical/clipboard": "0.51.0",
|
|
95
|
+
"@lexical/html": "0.51.0",
|
|
95
96
|
"@rsbuild/plugin-react": "^2.1.0",
|
|
96
|
-
"@rslib/core": "^1.0.
|
|
97
|
-
"@types/node": "^26.
|
|
97
|
+
"@rslib/core": "^1.0.1",
|
|
98
|
+
"@types/node": "^26.6.2",
|
|
98
99
|
"@types/react": "19.3.0",
|
|
99
100
|
"@types/react-dom": "19.3.0",
|
|
100
101
|
"@vitejs/plugin-react": "^6.1.1",
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
"typescript": "^7.0.2",
|
|
105
106
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
106
107
|
"vite": "^8.3.0",
|
|
107
|
-
"vitest": "^5.0.
|
|
108
|
+
"vitest": "^5.0.1"
|
|
108
109
|
},
|
|
109
110
|
"publishConfig": {
|
|
110
111
|
"access": "public",
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
* Portions Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { CodeHighlightNode, CodeNode
|
|
11
|
+
import { CodeHighlightNode, CodeNode } from '@lexical/code'
|
|
12
|
+
// Lexical 0.51 removed the Prism re-exports from `@lexical/code`, which is
|
|
13
|
+
// now a thin facade over `@lexical/code-core` with no highlighter of its
|
|
14
|
+
// own. The tokenizer lives in a sibling package per highlighter —
|
|
15
|
+
// `@lexical/code-prism` keeps the Prism behaviour this editor already had;
|
|
16
|
+
// `@lexical/code-shiki` is the alternative and does not ship the language
|
|
17
|
+
// maps the toolbar reads.
|
|
18
|
+
import { registerCodeHighlighting } from '@lexical/code-prism'
|
|
12
19
|
import { defineExtension } from 'lexical'
|
|
13
20
|
|
|
14
21
|
export const CodeHighlightExtension = defineExtension({
|
|
@@ -105,6 +105,7 @@ function readNodeAsInitialData(
|
|
|
105
105
|
altText: node.getAltText(),
|
|
106
106
|
position: node.getPosition(),
|
|
107
107
|
showCaption: node.getShowCaption(),
|
|
108
|
+
link: node.getLink(),
|
|
108
109
|
}
|
|
109
110
|
})
|
|
110
111
|
return data
|
|
@@ -181,6 +182,10 @@ export function InlineImagePlugin(): React.JSX.Element {
|
|
|
181
182
|
width: data.width,
|
|
182
183
|
height: data.height,
|
|
183
184
|
showCaption: data.showCaption,
|
|
185
|
+
// Spread unconditionally — `InlineImageNode.update()` keys on the
|
|
186
|
+
// presence of `link`, so omitting it would make "remove this link"
|
|
187
|
+
// a silent no-op.
|
|
188
|
+
link: data.link,
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
if (modalState.mode === 'edit' && modalState.nodeKey != null) {
|
|
@@ -57,3 +57,103 @@
|
|
|
57
57
|
font-size: 0.875rem;
|
|
58
58
|
color: var(--text-subtle);
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Tab strip — matched to the admin document editor's tabs.
|
|
63
|
+
*
|
|
64
|
+
* The shared `@byline/ui` Tabs ships a boxed look (bordered root, bordered
|
|
65
|
+
* list). Inside this modal we want the admin surface's idiom instead:
|
|
66
|
+
* borderless triggers on a hairline rule, muted by default, primary text
|
|
67
|
+
* plus a 2px primary underline when selected. Rather than change the
|
|
68
|
+
* shared component — which every other Tabs consumer would inherit — the
|
|
69
|
+
* overrides are scoped to `.inline-image-modal-tabs`.
|
|
70
|
+
*
|
|
71
|
+
* Base UI 1.8 marks the selected trigger with `data-active` (not
|
|
72
|
+
* `data-selected`). Colours, weights and transitions mirror
|
|
73
|
+
* `packages/admin/src/presentation/tabs.module.css` so the two strips stay
|
|
74
|
+
* recognisably the same control.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
.inline-image-modal-tabs.byline-tabs-root {
|
|
78
|
+
padding: 0;
|
|
79
|
+
gap: 0;
|
|
80
|
+
border: none;
|
|
81
|
+
border-radius: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.inline-image-modal-tabs .byline-tabs-list {
|
|
85
|
+
gap: 1.25rem;
|
|
86
|
+
padding: 0;
|
|
87
|
+
border: none;
|
|
88
|
+
border-bottom: 1px solid var(--border-color);
|
|
89
|
+
border-radius: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.inline-image-modal-tabs .byline-tabs-trigger {
|
|
93
|
+
position: relative;
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
flex: none;
|
|
96
|
+
width: auto;
|
|
97
|
+
justify-content: flex-start;
|
|
98
|
+
padding: 0.5rem 0;
|
|
99
|
+
/* Sits on top of the list's hairline so the active underline covers it. */
|
|
100
|
+
margin-bottom: -1px;
|
|
101
|
+
border: none;
|
|
102
|
+
border-bottom: 2px solid transparent;
|
|
103
|
+
background: none;
|
|
104
|
+
color: var(--gray-500);
|
|
105
|
+
font-size: 1rem;
|
|
106
|
+
font-weight: var(--font-weight-medium);
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
transition:
|
|
109
|
+
color 150ms ease,
|
|
110
|
+
border-color 150ms ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.inline-image-modal-tabs .byline-tabs-trigger:hover {
|
|
114
|
+
color: var(--gray-800);
|
|
115
|
+
border-bottom-color: var(--gray-300);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.inline-image-modal-tabs .byline-tabs-trigger:focus-visible {
|
|
119
|
+
outline: none;
|
|
120
|
+
box-shadow: inset 0 0 0 2px var(--blue-500);
|
|
121
|
+
border-radius: var(--radius-sm, 3px);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.inline-image-modal-tabs .byline-tabs-trigger[data-active] {
|
|
125
|
+
color: var(--primary-600);
|
|
126
|
+
border-bottom-color: var(--primary-400);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.inline-image-modal-tabs .byline-tabs-trigger[data-active]:hover {
|
|
130
|
+
color: var(--primary-600);
|
|
131
|
+
border-bottom-color: var(--primary-400);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* Reserve the height of the tallest link state. Measured: 118px with no
|
|
136
|
+
* link, 231px for a custom URL, 301px for a document pick — so choosing a
|
|
137
|
+
* link type would otherwise shift the dialog by ~180px under the cursor.
|
|
138
|
+
* The floor holds the panel steady across all three.
|
|
139
|
+
*/
|
|
140
|
+
.inline-image-modal-tabs .inline-image-modal-link-panel {
|
|
141
|
+
min-height: 20rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
:is([data-theme="dark"], .dark) {
|
|
145
|
+
.inline-image-modal-tabs .byline-tabs-trigger {
|
|
146
|
+
color: var(--gray-400);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.inline-image-modal-tabs .byline-tabs-trigger:hover {
|
|
150
|
+
color: var(--gray-200);
|
|
151
|
+
border-bottom-color: var(--gray-600);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.inline-image-modal-tabs .byline-tabs-trigger[data-active],
|
|
155
|
+
.inline-image-modal-tabs .byline-tabs-trigger[data-active]:hover {
|
|
156
|
+
color: var(--primary-200);
|
|
157
|
+
border-bottom-color: var(--primary-400);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -13,7 +13,7 @@ import { useMemo, useState } from 'react'
|
|
|
13
13
|
|
|
14
14
|
import { RelationPicker } from '@byline/admin/react'
|
|
15
15
|
import type { MultiCollectionDefinition, StoredFileValue } from '@byline/core'
|
|
16
|
-
import { getCollectionDefinition, isSingleton } from '@byline/core'
|
|
16
|
+
import { getAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core'
|
|
17
17
|
import {
|
|
18
18
|
Button,
|
|
19
19
|
Checkbox,
|
|
@@ -25,9 +25,17 @@ import {
|
|
|
25
25
|
Modal,
|
|
26
26
|
RadioGroup,
|
|
27
27
|
RadioGroupItem,
|
|
28
|
+
Tabs,
|
|
28
29
|
} from '@byline/ui/react'
|
|
29
30
|
|
|
30
31
|
import { useModalFormState } from '../../shared/useModalFormState'
|
|
32
|
+
import { LinkTargetFields } from '../link/link-target-fields'
|
|
33
|
+
import {
|
|
34
|
+
fromLinkAttributes,
|
|
35
|
+
type LinkTargetState,
|
|
36
|
+
toLinkAttributes,
|
|
37
|
+
validateLinkTarget,
|
|
38
|
+
} from '../link/link-target-state'
|
|
31
39
|
import { isAltTextValid, positionOptions } from './fields'
|
|
32
40
|
import { deriveImageSizes, getPreferredSize } from './utils'
|
|
33
41
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
@@ -41,24 +49,38 @@ interface FormState {
|
|
|
41
49
|
altText: string
|
|
42
50
|
position: Position
|
|
43
51
|
showCaption: boolean
|
|
52
|
+
/** Optional click-through target — see `../link/link-target-state.ts`. */
|
|
53
|
+
target: LinkTargetState
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Unlike a link node, an inline image is perfectly valid with no click
|
|
58
|
+
* target, so the shared sub-form runs with `allowNone` and `toLinkAttributes`
|
|
59
|
+
* is allowed to return undefined.
|
|
60
|
+
*/
|
|
61
|
+
const LINK_TARGET_OPTIONS = { allowNone: true } as const
|
|
62
|
+
|
|
63
|
+
function emptyState(linkable: MultiCollectionDefinition[]): FormState {
|
|
47
64
|
return {
|
|
48
65
|
documentRelation: null,
|
|
49
66
|
altText: '',
|
|
50
67
|
position: 'full',
|
|
51
68
|
showCaption: false,
|
|
69
|
+
target: fromLinkAttributes(undefined, linkable, LINK_TARGET_OPTIONS),
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
function fromInlineImageData(
|
|
56
|
-
|
|
73
|
+
function fromInlineImageData(
|
|
74
|
+
data: InlineImageData | undefined,
|
|
75
|
+
linkable: MultiCollectionDefinition[]
|
|
76
|
+
): FormState {
|
|
77
|
+
if (!data) return emptyState(linkable)
|
|
57
78
|
return {
|
|
58
79
|
documentRelation: data.documentRelation ?? null,
|
|
59
80
|
altText: data.altText ?? '',
|
|
60
81
|
position: data.position ?? 'full',
|
|
61
82
|
showCaption: data.showCaption ?? false,
|
|
83
|
+
target: fromLinkAttributes(data.link, linkable, LINK_TARGET_OPTIONS),
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
|
|
@@ -96,13 +118,29 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
96
118
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
97
119
|
const [altError, setAltError] = useState<string | null>(null)
|
|
98
120
|
const [imageError, setImageError] = useState<string | null>(null)
|
|
121
|
+
const [linkError, setLinkError] = useState<string | null>(null)
|
|
122
|
+
// Which tab is showing. Validation can move this: an error on a hidden
|
|
123
|
+
// panel would otherwise be invisible, and the user would just see Save
|
|
124
|
+
// do nothing.
|
|
125
|
+
const [activeTab, setActiveTab] = useState<'image' | 'link'>('image')
|
|
126
|
+
|
|
127
|
+
const linkable = useMemo<MultiCollectionDefinition[]>(
|
|
128
|
+
() =>
|
|
129
|
+
getAdminConfig().collections.filter(
|
|
130
|
+
(collection): collection is MultiCollectionDefinition =>
|
|
131
|
+
!isSingleton(collection) && collection.linksInEditor === true
|
|
132
|
+
),
|
|
133
|
+
[]
|
|
134
|
+
)
|
|
99
135
|
|
|
100
136
|
const [state, setState] = useModalFormState<FormState>(
|
|
101
137
|
isOpen,
|
|
102
|
-
() => fromInlineImageData(dataFromProps),
|
|
138
|
+
() => fromInlineImageData(dataFromProps, linkable),
|
|
103
139
|
() => {
|
|
104
140
|
setAltError(null)
|
|
105
141
|
setImageError(null)
|
|
142
|
+
setLinkError(null)
|
|
143
|
+
setActiveTab('image')
|
|
106
144
|
}
|
|
107
145
|
)
|
|
108
146
|
|
|
@@ -175,10 +213,18 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
175
213
|
const handleSave = () => {
|
|
176
214
|
if (!state.documentRelation || !pickedImage) {
|
|
177
215
|
setImageError('Pick an image')
|
|
216
|
+
setActiveTab('image')
|
|
178
217
|
return
|
|
179
218
|
}
|
|
180
219
|
if (!isAltTextValid(state.altText)) {
|
|
181
220
|
setAltError('Alt text is required')
|
|
221
|
+
setActiveTab('image')
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const linkProblem = validateLinkTarget(state.target)
|
|
225
|
+
if (linkProblem != null) {
|
|
226
|
+
setLinkError(linkProblem)
|
|
227
|
+
setActiveTab('link')
|
|
182
228
|
return
|
|
183
229
|
}
|
|
184
230
|
|
|
@@ -196,6 +242,9 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
196
242
|
width: preferred?.width,
|
|
197
243
|
height: preferred?.height,
|
|
198
244
|
showCaption: state.showCaption,
|
|
245
|
+
// Always present, so `InlineImageNode.update()` sees the key and can
|
|
246
|
+
// CLEAR an existing link when the editor chooses "No link".
|
|
247
|
+
link: toLinkAttributes(state.target),
|
|
199
248
|
}
|
|
200
249
|
onSubmit(data)
|
|
201
250
|
onClose()
|
|
@@ -214,91 +263,135 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
|
|
|
214
263
|
</IconButton>
|
|
215
264
|
</Modal.Header>
|
|
216
265
|
<Modal.Content>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
266
|
+
{/*
|
|
267
|
+
Two tabs rather than one long column. The link sub-form adds a
|
|
268
|
+
collection select, a picker row and a checkbox, which together
|
|
269
|
+
pushed the modal past the viewport on shorter screens. Tabs keep
|
|
270
|
+
the dialog a fixed, predictable height.
|
|
271
|
+
|
|
272
|
+
`Tabs.Content` keeps both panels mounted, so switching away and
|
|
273
|
+
back does not discard a half-filled form — and `handleSave`
|
|
274
|
+
switches `activeTab` to whichever panel holds a validation
|
|
275
|
+
error, so Save never appears to do nothing.
|
|
276
|
+
*/}
|
|
277
|
+
<Tabs
|
|
278
|
+
className="inline-image-modal-tabs"
|
|
279
|
+
value={activeTab}
|
|
280
|
+
onValueChange={(value) => setActiveTab(value as 'image' | 'link')}
|
|
281
|
+
>
|
|
282
|
+
<Tabs.List className="mb-4">
|
|
283
|
+
<Tabs.Trigger value="image">Image</Tabs.Trigger>
|
|
284
|
+
<Tabs.Trigger value="link">Link</Tabs.Trigger>
|
|
285
|
+
</Tabs.List>
|
|
286
|
+
|
|
287
|
+
<Tabs.Content value="image">
|
|
288
|
+
<div className="flex flex-col gap-4">
|
|
289
|
+
<div className="flex flex-col gap-2">
|
|
290
|
+
<span className="text-sm font-medium">Image</span>
|
|
291
|
+
<div className="inline-image-modal-picker">
|
|
292
|
+
{pickedThumbUrl ? (
|
|
293
|
+
<img
|
|
294
|
+
src={pickedThumbUrl}
|
|
295
|
+
alt={pickedTitle ?? ''}
|
|
296
|
+
className="inline-image-modal-thumb"
|
|
297
|
+
/>
|
|
298
|
+
) : (
|
|
299
|
+
<div className="inline-image-modal-thumb-placeholder">—</div>
|
|
300
|
+
)}
|
|
301
|
+
<div className="inline-image-modal-picker-details">
|
|
302
|
+
{pickedTitle && (
|
|
303
|
+
<span className="inline-image-modal-title">{pickedTitle}</span>
|
|
304
|
+
)}
|
|
305
|
+
<Button
|
|
306
|
+
size="sm"
|
|
307
|
+
className="inline-image-modal-change-btn"
|
|
308
|
+
variant="outlined"
|
|
309
|
+
intent="noeffect"
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => setPickerOpen(true)}
|
|
312
|
+
>
|
|
313
|
+
{state.documentRelation
|
|
314
|
+
? 'Change image…'
|
|
315
|
+
: `Pick ${targetDef?.labels.singular ?? 'image'}…`}
|
|
316
|
+
</Button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
{imageError && <ErrorText id="image-error" text={imageError} />}
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<Input
|
|
323
|
+
id="inline-image-alt"
|
|
324
|
+
name="altText"
|
|
325
|
+
label="Alt text"
|
|
326
|
+
required
|
|
327
|
+
placeholder="Describe the image for screen readers"
|
|
328
|
+
value={state.altText}
|
|
329
|
+
error={altError != null}
|
|
330
|
+
errorText={altError ?? undefined}
|
|
331
|
+
onChange={(e) => {
|
|
332
|
+
setAltError(null)
|
|
333
|
+
setState((s) => ({ ...s, altText: e.target.value }))
|
|
334
|
+
}}
|
|
335
|
+
/>
|
|
336
|
+
|
|
337
|
+
<div className="flex flex-col gap-2 mb-3">
|
|
338
|
+
<Label
|
|
339
|
+
htmlFor="inline-image-position"
|
|
340
|
+
id="inline-image-position-label"
|
|
341
|
+
className="text-sm font-medium"
|
|
342
|
+
label="Position"
|
|
226
343
|
/>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
intent="noeffect"
|
|
237
|
-
type="button"
|
|
238
|
-
onClick={() => setPickerOpen(true)}
|
|
344
|
+
<RadioGroup
|
|
345
|
+
id="inline-image-position"
|
|
346
|
+
name="position"
|
|
347
|
+
aria-labelledby="inline-image-position-label"
|
|
348
|
+
direction="row"
|
|
349
|
+
value={state.position ?? 'full'}
|
|
350
|
+
onValueChange={(value) =>
|
|
351
|
+
setState((s) => ({ ...s, position: value as Position }))
|
|
352
|
+
}
|
|
239
353
|
>
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
354
|
+
{positionOptions.map((opt) => (
|
|
355
|
+
<RadioGroupItem
|
|
356
|
+
key={String(opt.value)}
|
|
357
|
+
id={`inline-image-position-${opt.value}`}
|
|
358
|
+
value={String(opt.value)}
|
|
359
|
+
label={opt.label}
|
|
360
|
+
/>
|
|
361
|
+
))}
|
|
362
|
+
</RadioGroup>
|
|
244
363
|
</div>
|
|
364
|
+
|
|
365
|
+
<Checkbox
|
|
366
|
+
id="inline-image-caption"
|
|
367
|
+
name="showCaption"
|
|
368
|
+
label="Show caption"
|
|
369
|
+
checked={state.showCaption}
|
|
370
|
+
onCheckedChange={(checked) =>
|
|
371
|
+
setState((s) => ({ ...s, showCaption: checked === true }))
|
|
372
|
+
}
|
|
373
|
+
/>
|
|
245
374
|
</div>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
<div className="flex flex-col gap-2 mb-3">
|
|
265
|
-
<Label
|
|
266
|
-
htmlFor="inline-image-position"
|
|
267
|
-
id="inline-image-position-label"
|
|
268
|
-
className="text-sm font-medium"
|
|
269
|
-
label="Position"
|
|
375
|
+
</Tabs.Content>
|
|
376
|
+
|
|
377
|
+
<Tabs.Content value="link" className="inline-image-modal-link-panel">
|
|
378
|
+
<p className="mt-0 mb-3 text-sm text-accent-400">
|
|
379
|
+
Give the image a click target. Leave this as “No link” for a plain image.
|
|
380
|
+
</p>
|
|
381
|
+
<LinkTargetFields
|
|
382
|
+
idPrefix="inline-image-link"
|
|
383
|
+
state={state.target}
|
|
384
|
+
onChange={(target) => {
|
|
385
|
+
setLinkError(null)
|
|
386
|
+
setState((s) => ({ ...s, target }))
|
|
387
|
+
}}
|
|
388
|
+
linkable={linkable}
|
|
389
|
+
allowNone
|
|
390
|
+
error={linkError}
|
|
391
|
+
legend="Link to"
|
|
270
392
|
/>
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
name="position"
|
|
274
|
-
aria-labelledby="inline-image-position-label"
|
|
275
|
-
direction="row"
|
|
276
|
-
value={state.position ?? 'full'}
|
|
277
|
-
onValueChange={(value) =>
|
|
278
|
-
setState((s) => ({ ...s, position: value as Position }))
|
|
279
|
-
}
|
|
280
|
-
>
|
|
281
|
-
{positionOptions.map((opt) => (
|
|
282
|
-
<RadioGroupItem
|
|
283
|
-
key={String(opt.value)}
|
|
284
|
-
id={`inline-image-position-${opt.value}`}
|
|
285
|
-
value={String(opt.value)}
|
|
286
|
-
label={opt.label}
|
|
287
|
-
/>
|
|
288
|
-
))}
|
|
289
|
-
</RadioGroup>
|
|
290
|
-
</div>
|
|
291
|
-
|
|
292
|
-
<Checkbox
|
|
293
|
-
id="inline-image-caption"
|
|
294
|
-
name="showCaption"
|
|
295
|
-
label="Show caption"
|
|
296
|
-
checked={state.showCaption}
|
|
297
|
-
onCheckedChange={(checked) =>
|
|
298
|
-
setState((s) => ({ ...s, showCaption: checked === true }))
|
|
299
|
-
}
|
|
300
|
-
/>
|
|
301
|
-
</div>
|
|
393
|
+
</Tabs.Content>
|
|
394
|
+
</Tabs>
|
|
302
395
|
</Modal.Content>
|
|
303
396
|
<Modal.Actions className="flex gap-3">
|
|
304
397
|
<Button
|
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
cursor: default;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/*
|
|
15
|
+
* A linked image gets the pointer cursor, which is honest because
|
|
16
|
+
* cmd/ctrl-click opens the target (see the CLICK_COMMAND handler in
|
|
17
|
+
* inline-image-node-component.tsx). A plain click still selects the node,
|
|
18
|
+
* so the hover hint is paired with a real action rather than promising
|
|
19
|
+
* navigation the editor never performs.
|
|
20
|
+
*/
|
|
21
|
+
.LexicalEditor__inline-image.has-link img {
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Selection and dragging outrank the link hint. */
|
|
26
|
+
.LexicalEditor__inline-image.has-link img.focused.draggable {
|
|
27
|
+
cursor: grab;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
.LexicalEditor__inline-image img.focused {
|
|
15
31
|
outline: 2px solid rgb(60, 132, 244);
|
|
16
32
|
/* user-select: none; */
|
|
@@ -69,6 +85,34 @@
|
|
|
69
85
|
background-color: rgba(60, 132, 244, 0.5);
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
.LexicalEditor__inline-image .image-link-badge {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 4px;
|
|
92
|
+
position: absolute;
|
|
93
|
+
top: 12px;
|
|
94
|
+
left: 12px;
|
|
95
|
+
max-width: calc(100% - 90px);
|
|
96
|
+
padding: 1px 6px;
|
|
97
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
98
|
+
border-radius: 5px;
|
|
99
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
100
|
+
font-size: 0.8rem;
|
|
101
|
+
color: #fff;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
user-select: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.LexicalEditor__inline-image .image-link-badge:hover {
|
|
107
|
+
background-color: rgba(60, 132, 244, 0.5);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.LexicalEditor__inline-image .image-link-badge__label {
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
text-overflow: ellipsis;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
.LexicalEditor__inline-image .InlineImageNode__caption_container {
|
|
73
117
|
display: block;
|
|
74
118
|
position: relative;
|