@vendure/dashboard 3.4.3-master-202509180227 → 3.4.3-master-202509190229
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/package.json +11 -7
- package/src/app/common/duplicate-bulk-action.tsx +37 -23
- package/src/app/common/duplicate-entity-dialog.tsx +117 -0
- package/src/lib/components/data-input/rich-text-input.tsx +2 -115
- package/src/lib/components/shared/rich-text-editor/image-dialog.tsx +223 -0
- package/src/lib/components/shared/rich-text-editor/link-dialog.tsx +151 -0
- package/src/lib/components/shared/rich-text-editor/responsive-toolbar.tsx +439 -0
- package/src/lib/components/shared/rich-text-editor/rich-text-editor.tsx +338 -0
- package/src/lib/components/shared/rich-text-editor/table-delete-menu.tsx +104 -0
- package/src/lib/components/shared/rich-text-editor/table-edit-icons.tsx +225 -0
- package/src/lib/graphql/common-operations.ts +19 -0
- package/src/lib/graphql/fragments.ts +23 -13
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
2
|
+
import { Editor } from '@tiptap/react';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Button } from '../../ui/button.js';
|
|
5
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog.js';
|
|
6
|
+
import { Input } from '../../ui/input.js';
|
|
7
|
+
import { Label } from '../../ui/label.js';
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select.js';
|
|
9
|
+
|
|
10
|
+
export interface LinkDialogProps {
|
|
11
|
+
editor: Editor;
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LinkDialog({ editor, isOpen, onClose }: Readonly<LinkDialogProps>) {
|
|
17
|
+
const [href, setHref] = useState('');
|
|
18
|
+
const [title, setTitle] = useState('');
|
|
19
|
+
const [target, setTarget] = useState<'_self' | '_blank'>('_self');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (isOpen) {
|
|
23
|
+
// Get current link attributes if editing existing link
|
|
24
|
+
const {
|
|
25
|
+
href: currentHref,
|
|
26
|
+
target: currentTarget,
|
|
27
|
+
title: currentTitle,
|
|
28
|
+
} = editor.getAttributes('link');
|
|
29
|
+
|
|
30
|
+
setHref(currentHref || '');
|
|
31
|
+
setTitle(currentTitle || '');
|
|
32
|
+
setTarget(currentTarget === '_blank' ? '_blank' : '_self');
|
|
33
|
+
|
|
34
|
+
// If text is selected but no link, use selection as initial href if it looks like a URL
|
|
35
|
+
if (!currentHref) {
|
|
36
|
+
const { from, to } = editor.state.selection;
|
|
37
|
+
const selectedText = editor.state.doc.textBetween(from, to);
|
|
38
|
+
if (selectedText && (selectedText.startsWith('http') || selectedText.startsWith('www'))) {
|
|
39
|
+
setHref(selectedText);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, [isOpen, editor]);
|
|
44
|
+
|
|
45
|
+
const handleSetLink = () => {
|
|
46
|
+
if (!href) {
|
|
47
|
+
// Remove link if href is empty
|
|
48
|
+
editor.chain().focus().unsetLink().run();
|
|
49
|
+
} else {
|
|
50
|
+
// Set link with all attributes
|
|
51
|
+
editor
|
|
52
|
+
.chain()
|
|
53
|
+
.focus()
|
|
54
|
+
.extendMarkRange('link')
|
|
55
|
+
.setLink({
|
|
56
|
+
href,
|
|
57
|
+
target,
|
|
58
|
+
rel: target === '_blank' ? 'noopener noreferrer' : undefined,
|
|
59
|
+
})
|
|
60
|
+
.run();
|
|
61
|
+
}
|
|
62
|
+
handleClose();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleClose = () => {
|
|
66
|
+
setHref('');
|
|
67
|
+
setTitle('');
|
|
68
|
+
setTarget('_self');
|
|
69
|
+
onClose();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleRemoveLink = () => {
|
|
73
|
+
editor.chain().focus().unsetLink().run();
|
|
74
|
+
handleClose();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const isEditing = editor.isActive('link');
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
|
|
81
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>
|
|
84
|
+
{isEditing ? <Trans>Edit link</Trans> : <Trans>Insert link</Trans>}
|
|
85
|
+
</DialogTitle>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
<div className="grid gap-4 py-4">
|
|
88
|
+
<div className="grid grid-cols-4 items-center gap-4">
|
|
89
|
+
<Label htmlFor="link-href" className="text-right">
|
|
90
|
+
<Trans>Link href</Trans>
|
|
91
|
+
</Label>
|
|
92
|
+
<Input
|
|
93
|
+
id="link-href"
|
|
94
|
+
value={href}
|
|
95
|
+
onChange={e => setHref(e.target.value)}
|
|
96
|
+
placeholder="https://example.com"
|
|
97
|
+
className="col-span-3"
|
|
98
|
+
autoFocus
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="grid grid-cols-4 items-center gap-4">
|
|
102
|
+
<Label htmlFor="link-title" className="text-right">
|
|
103
|
+
<Trans>Link title</Trans>
|
|
104
|
+
</Label>
|
|
105
|
+
<Input
|
|
106
|
+
id="link-title"
|
|
107
|
+
value={title}
|
|
108
|
+
onChange={e => setTitle(e.target.value)}
|
|
109
|
+
placeholder=""
|
|
110
|
+
className="col-span-3"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="grid grid-cols-4 items-center gap-4">
|
|
114
|
+
<Label htmlFor="link-target" className="text-right">
|
|
115
|
+
<Trans>Link target</Trans>
|
|
116
|
+
</Label>
|
|
117
|
+
<Select
|
|
118
|
+
value={target}
|
|
119
|
+
onValueChange={value => setTarget(value as '_self' | '_blank')}
|
|
120
|
+
>
|
|
121
|
+
<SelectTrigger className="col-span-3">
|
|
122
|
+
<SelectValue />
|
|
123
|
+
</SelectTrigger>
|
|
124
|
+
<SelectContent>
|
|
125
|
+
<SelectItem value="_self">
|
|
126
|
+
<Trans>Same window</Trans>
|
|
127
|
+
</SelectItem>
|
|
128
|
+
<SelectItem value="_blank">
|
|
129
|
+
<Trans>New window</Trans>
|
|
130
|
+
</SelectItem>
|
|
131
|
+
</SelectContent>
|
|
132
|
+
</Select>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<DialogFooter>
|
|
136
|
+
{isEditing && (
|
|
137
|
+
<Button type="button" variant="destructive" onClick={handleRemoveLink}>
|
|
138
|
+
<Trans>Remove link</Trans>
|
|
139
|
+
</Button>
|
|
140
|
+
)}
|
|
141
|
+
<Button type="button" variant="outline" onClick={handleClose}>
|
|
142
|
+
<Trans>Cancel</Trans>
|
|
143
|
+
</Button>
|
|
144
|
+
<Button type="button" onClick={handleSetLink}>
|
|
145
|
+
<Trans>Set link</Trans>
|
|
146
|
+
</Button>
|
|
147
|
+
</DialogFooter>
|
|
148
|
+
</DialogContent>
|
|
149
|
+
</Dialog>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
2
|
+
import { Editor } from '@tiptap/react';
|
|
3
|
+
import {
|
|
4
|
+
BoldIcon,
|
|
5
|
+
ImageIcon,
|
|
6
|
+
ItalicIcon,
|
|
7
|
+
LinkIcon,
|
|
8
|
+
ListIcon,
|
|
9
|
+
ListOrderedIcon,
|
|
10
|
+
MoreHorizontalIcon,
|
|
11
|
+
QuoteIcon,
|
|
12
|
+
Redo2Icon,
|
|
13
|
+
StrikethroughIcon,
|
|
14
|
+
TableIcon,
|
|
15
|
+
Undo2Icon,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
import { Button } from '../../ui/button.js';
|
|
19
|
+
import {
|
|
20
|
+
DropdownMenu,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from '../../ui/dropdown-menu.js';
|
|
26
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select.js';
|
|
27
|
+
import { Separator } from '../../ui/separator.js';
|
|
28
|
+
import { ImageDialog } from './image-dialog.js';
|
|
29
|
+
import { LinkDialog } from './link-dialog.js';
|
|
30
|
+
|
|
31
|
+
export interface ResponsiveToolbarProps {
|
|
32
|
+
editor: Editor | null;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ToolbarItem {
|
|
37
|
+
id: string;
|
|
38
|
+
priority: number;
|
|
39
|
+
element: React.ReactNode;
|
|
40
|
+
action?: () => void;
|
|
41
|
+
label: string;
|
|
42
|
+
isActive?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ResponsiveToolbar({ editor, disabled }: Readonly<ResponsiveToolbarProps>) {
|
|
46
|
+
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
47
|
+
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
|
48
|
+
const [visibleItems, setVisibleItems] = useState<string[]>([]);
|
|
49
|
+
const [overflowItems, setOverflowItems] = useState<string[]>([]);
|
|
50
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
const handleHeadingChange = useCallback(
|
|
53
|
+
(value: string) => {
|
|
54
|
+
if (!editor) return;
|
|
55
|
+
if (value === 'paragraph') {
|
|
56
|
+
editor.chain().focus().setParagraph().run();
|
|
57
|
+
} else {
|
|
58
|
+
const level = parseInt(value.replace('h', '')) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
59
|
+
editor.chain().focus().toggleHeading({ level }).run();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
[editor],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const getCurrentHeading = useCallback(() => {
|
|
66
|
+
if (!editor) return 'paragraph';
|
|
67
|
+
for (let level = 1; level <= 6; level++) {
|
|
68
|
+
if (editor.isActive('heading', { level })) return `h${level}`;
|
|
69
|
+
}
|
|
70
|
+
return 'paragraph';
|
|
71
|
+
}, [editor]);
|
|
72
|
+
|
|
73
|
+
const headingOptions = useMemo(
|
|
74
|
+
() => [
|
|
75
|
+
{ value: 'paragraph', label: <Trans>Normal</Trans> },
|
|
76
|
+
{ value: 'h1', label: <Trans>Heading 1</Trans> },
|
|
77
|
+
{ value: 'h2', label: <Trans>Heading 2</Trans> },
|
|
78
|
+
{ value: 'h3', label: <Trans>Heading 3</Trans> },
|
|
79
|
+
{ value: 'h4', label: <Trans>Heading 4</Trans> },
|
|
80
|
+
{ value: 'h5', label: <Trans>Heading 5</Trans> },
|
|
81
|
+
{ value: 'h6', label: <Trans>Heading 6</Trans> },
|
|
82
|
+
],
|
|
83
|
+
[],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const toolbarItems: ToolbarItem[] = useMemo(() => {
|
|
87
|
+
if (!editor) return [];
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
id: 'bold',
|
|
92
|
+
priority: 1,
|
|
93
|
+
label: 'Bold',
|
|
94
|
+
isActive: editor.isActive('bold'),
|
|
95
|
+
action: () => editor.chain().focus().toggleBold().run(),
|
|
96
|
+
element: (
|
|
97
|
+
<Button
|
|
98
|
+
key="bold"
|
|
99
|
+
type="button"
|
|
100
|
+
variant="ghost"
|
|
101
|
+
size="sm"
|
|
102
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
103
|
+
className={`h-8 px-2 ${editor.isActive('bold') ? 'bg-accent' : ''}`}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
>
|
|
106
|
+
<BoldIcon className="h-4 w-4" />
|
|
107
|
+
</Button>
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'italic',
|
|
112
|
+
priority: 2,
|
|
113
|
+
label: 'Italic',
|
|
114
|
+
isActive: editor.isActive('italic'),
|
|
115
|
+
action: () => editor.chain().focus().toggleItalic().run(),
|
|
116
|
+
element: (
|
|
117
|
+
<Button
|
|
118
|
+
key="italic"
|
|
119
|
+
type="button"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
size="sm"
|
|
122
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
123
|
+
className={`h-8 px-2 ${editor.isActive('italic') ? 'bg-accent' : ''}`}
|
|
124
|
+
disabled={disabled}
|
|
125
|
+
>
|
|
126
|
+
<ItalicIcon className="h-4 w-4" />
|
|
127
|
+
</Button>
|
|
128
|
+
),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'strike',
|
|
132
|
+
priority: 3,
|
|
133
|
+
label: 'Strikethrough',
|
|
134
|
+
isActive: editor.isActive('strike'),
|
|
135
|
+
action: () => editor.chain().focus().toggleStrike().run(),
|
|
136
|
+
element: (
|
|
137
|
+
<Button
|
|
138
|
+
key="strike"
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
143
|
+
className={`h-8 px-2 ${editor.isActive('strike') ? 'bg-accent' : ''}`}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
>
|
|
146
|
+
<StrikethroughIcon className="h-4 w-4" />
|
|
147
|
+
</Button>
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'bulletList',
|
|
152
|
+
priority: 4,
|
|
153
|
+
label: 'Bullet List',
|
|
154
|
+
isActive: editor.isActive('bulletList'),
|
|
155
|
+
action: () => editor.chain().focus().toggleBulletList().run(),
|
|
156
|
+
element: (
|
|
157
|
+
<Button
|
|
158
|
+
key="bulletList"
|
|
159
|
+
type="button"
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="sm"
|
|
162
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
163
|
+
className={`h-8 px-2 ${editor.isActive('bulletList') ? 'bg-accent' : ''}`}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
>
|
|
166
|
+
<ListIcon className="h-4 w-4" />
|
|
167
|
+
</Button>
|
|
168
|
+
),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'orderedList',
|
|
172
|
+
priority: 5,
|
|
173
|
+
label: 'Ordered List',
|
|
174
|
+
isActive: editor.isActive('orderedList'),
|
|
175
|
+
action: () => editor.chain().focus().toggleOrderedList().run(),
|
|
176
|
+
element: (
|
|
177
|
+
<Button
|
|
178
|
+
key="orderedList"
|
|
179
|
+
type="button"
|
|
180
|
+
variant="ghost"
|
|
181
|
+
size="sm"
|
|
182
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
183
|
+
className={`h-8 px-2 ${editor.isActive('orderedList') ? 'bg-accent' : ''}`}
|
|
184
|
+
disabled={disabled}
|
|
185
|
+
>
|
|
186
|
+
<ListOrderedIcon className="h-4 w-4" />
|
|
187
|
+
</Button>
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'link',
|
|
192
|
+
priority: 6,
|
|
193
|
+
label: 'Link',
|
|
194
|
+
isActive: editor.isActive('link'),
|
|
195
|
+
action: () => setLinkDialogOpen(true),
|
|
196
|
+
element: (
|
|
197
|
+
<Button
|
|
198
|
+
key="link"
|
|
199
|
+
type="button"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
size="sm"
|
|
202
|
+
onClick={() => setLinkDialogOpen(true)}
|
|
203
|
+
className={`h-8 px-2 ${editor.isActive('link') ? 'bg-accent' : ''}`}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
>
|
|
206
|
+
<LinkIcon className="h-4 w-4" />
|
|
207
|
+
</Button>
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'image',
|
|
212
|
+
priority: 7,
|
|
213
|
+
label: 'Image',
|
|
214
|
+
isActive: editor.isActive('image'),
|
|
215
|
+
action: () => setImageDialogOpen(true),
|
|
216
|
+
element: (
|
|
217
|
+
<Button
|
|
218
|
+
key="image"
|
|
219
|
+
type="button"
|
|
220
|
+
variant="ghost"
|
|
221
|
+
size="sm"
|
|
222
|
+
onClick={() => setImageDialogOpen(true)}
|
|
223
|
+
className={`h-8 px-2 ${editor.isActive('image') ? 'bg-accent' : ''}`}
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
>
|
|
226
|
+
<ImageIcon className="h-4 w-4" />
|
|
227
|
+
</Button>
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'blockquote',
|
|
232
|
+
priority: 8,
|
|
233
|
+
label: 'Blockquote',
|
|
234
|
+
isActive: editor.isActive('blockquote'),
|
|
235
|
+
action: () => editor.chain().focus().toggleBlockquote().run(),
|
|
236
|
+
element: (
|
|
237
|
+
<Button
|
|
238
|
+
key="blockquote"
|
|
239
|
+
type="button"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="sm"
|
|
242
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
243
|
+
className={`h-8 px-2 ${editor.isActive('blockquote') ? 'bg-accent' : ''}`}
|
|
244
|
+
disabled={disabled}
|
|
245
|
+
>
|
|
246
|
+
<QuoteIcon className="h-4 w-4" />
|
|
247
|
+
</Button>
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: 'table',
|
|
252
|
+
priority: 9,
|
|
253
|
+
label: 'Table',
|
|
254
|
+
isActive: editor.isActive('table'),
|
|
255
|
+
action: () =>
|
|
256
|
+
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
|
257
|
+
element: (
|
|
258
|
+
<Button
|
|
259
|
+
key="table"
|
|
260
|
+
type="button"
|
|
261
|
+
variant="ghost"
|
|
262
|
+
size="sm"
|
|
263
|
+
onClick={() =>
|
|
264
|
+
editor
|
|
265
|
+
.chain()
|
|
266
|
+
.focus()
|
|
267
|
+
.insertTable({
|
|
268
|
+
rows: 3,
|
|
269
|
+
cols: 3,
|
|
270
|
+
withHeaderRow: true,
|
|
271
|
+
})
|
|
272
|
+
.run()
|
|
273
|
+
}
|
|
274
|
+
className={`h-8 px-2 ${editor.isActive('table') ? 'bg-accent' : ''}`}
|
|
275
|
+
disabled={disabled || !editor.can().insertTable()}
|
|
276
|
+
>
|
|
277
|
+
<TableIcon className="h-4 w-4" />
|
|
278
|
+
</Button>
|
|
279
|
+
),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'undo',
|
|
283
|
+
priority: 10,
|
|
284
|
+
label: 'Undo',
|
|
285
|
+
action: () => editor.chain().focus().undo().run(),
|
|
286
|
+
element: (
|
|
287
|
+
<Button
|
|
288
|
+
key="undo"
|
|
289
|
+
type="button"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
size="sm"
|
|
292
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
293
|
+
disabled={disabled || !editor.can().undo()}
|
|
294
|
+
className="h-8 px-2"
|
|
295
|
+
>
|
|
296
|
+
<Undo2Icon className="h-4 w-4" />
|
|
297
|
+
</Button>
|
|
298
|
+
),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'redo',
|
|
302
|
+
priority: 11,
|
|
303
|
+
label: 'Redo',
|
|
304
|
+
action: () => editor.chain().focus().redo().run(),
|
|
305
|
+
element: (
|
|
306
|
+
<Button
|
|
307
|
+
key="redo"
|
|
308
|
+
type="button"
|
|
309
|
+
variant="ghost"
|
|
310
|
+
size="sm"
|
|
311
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
312
|
+
disabled={disabled || !editor.can().redo()}
|
|
313
|
+
className="h-8 px-2"
|
|
314
|
+
>
|
|
315
|
+
<Redo2Icon className="h-4 w-4" />
|
|
316
|
+
</Button>
|
|
317
|
+
),
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
}, [editor, disabled, linkDialogOpen, imageDialogOpen]);
|
|
321
|
+
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const calculateVisibleItems = () => {
|
|
324
|
+
if (!toolbarRef.current) return;
|
|
325
|
+
|
|
326
|
+
const toolbar = toolbarRef.current;
|
|
327
|
+
const toolbarWidth = toolbar.clientWidth;
|
|
328
|
+
const headingSelectWidth = 130; // Fixed width for heading select
|
|
329
|
+
const separatorWidth = 20; // Approximate separator width
|
|
330
|
+
const overflowButtonWidth = 40; // Width for overflow button
|
|
331
|
+
const padding = 16; // Toolbar padding
|
|
332
|
+
|
|
333
|
+
let usedWidth = headingSelectWidth + separatorWidth + padding;
|
|
334
|
+
const visible: string[] = [];
|
|
335
|
+
const overflow: string[] = [];
|
|
336
|
+
|
|
337
|
+
// Always show heading select, so start with remaining width
|
|
338
|
+
let remainingWidth = toolbarWidth - usedWidth;
|
|
339
|
+
|
|
340
|
+
// Sort items by priority (lower number = higher priority)
|
|
341
|
+
const sortedItems = [...toolbarItems].sort((a, b) => a.priority - b.priority);
|
|
342
|
+
|
|
343
|
+
for (const item of sortedItems) {
|
|
344
|
+
const itemWidth = 40; // Approximate button width
|
|
345
|
+
|
|
346
|
+
if (remainingWidth >= itemWidth + (overflow.length === 0 ? 0 : overflowButtonWidth)) {
|
|
347
|
+
visible.push(item.id);
|
|
348
|
+
remainingWidth -= itemWidth;
|
|
349
|
+
} else {
|
|
350
|
+
overflow.push(item.id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setVisibleItems(visible);
|
|
355
|
+
setOverflowItems(overflow);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
calculateVisibleItems();
|
|
359
|
+
|
|
360
|
+
const resizeObserver = new ResizeObserver(calculateVisibleItems);
|
|
361
|
+
if (toolbarRef.current) {
|
|
362
|
+
resizeObserver.observe(toolbarRef.current);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return () => {
|
|
366
|
+
resizeObserver.disconnect();
|
|
367
|
+
};
|
|
368
|
+
}, [toolbarItems.length, editor]);
|
|
369
|
+
|
|
370
|
+
const visibleElements = toolbarItems
|
|
371
|
+
.filter(item => visibleItems.includes(item.id))
|
|
372
|
+
.sort((a, b) => a.priority - b.priority)
|
|
373
|
+
.map(item => item.element);
|
|
374
|
+
|
|
375
|
+
const overflowElements = toolbarItems.filter(item => overflowItems.includes(item.id));
|
|
376
|
+
|
|
377
|
+
if (!editor) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div ref={toolbarRef} className="flex items-center gap-1 p-2 border-b bg-muted/30">
|
|
383
|
+
<Select value={getCurrentHeading()} onValueChange={handleHeadingChange} disabled={disabled}>
|
|
384
|
+
<SelectTrigger className="h-8 w-[130px]">
|
|
385
|
+
<SelectValue />
|
|
386
|
+
</SelectTrigger>
|
|
387
|
+
<SelectContent>
|
|
388
|
+
{headingOptions.map(option => (
|
|
389
|
+
<SelectItem key={option.value} value={option.value}>
|
|
390
|
+
<span className="text-xs">{option.label}</span>
|
|
391
|
+
</SelectItem>
|
|
392
|
+
))}
|
|
393
|
+
</SelectContent>
|
|
394
|
+
</Select>
|
|
395
|
+
|
|
396
|
+
<Separator orientation="vertical" className="mx-1 h-6" />
|
|
397
|
+
|
|
398
|
+
{visibleElements}
|
|
399
|
+
|
|
400
|
+
{overflowElements.length > 0 && (
|
|
401
|
+
<DropdownMenu>
|
|
402
|
+
<DropdownMenuTrigger asChild>
|
|
403
|
+
<Button
|
|
404
|
+
type="button"
|
|
405
|
+
variant="ghost"
|
|
406
|
+
size="sm"
|
|
407
|
+
className="h-8 px-2"
|
|
408
|
+
disabled={disabled}
|
|
409
|
+
>
|
|
410
|
+
<MoreHorizontalIcon className="h-4 w-4" />
|
|
411
|
+
</Button>
|
|
412
|
+
</DropdownMenuTrigger>
|
|
413
|
+
<DropdownMenuContent align="end">
|
|
414
|
+
{overflowElements.map((item, index) => (
|
|
415
|
+
<div key={item.id}>
|
|
416
|
+
<DropdownMenuItem
|
|
417
|
+
onClick={item.action}
|
|
418
|
+
disabled={disabled}
|
|
419
|
+
className={item.isActive ? 'bg-accent' : ''}
|
|
420
|
+
>
|
|
421
|
+
<Trans>{item.label}</Trans>
|
|
422
|
+
</DropdownMenuItem>
|
|
423
|
+
{(index < overflowElements.length - 1 && index === 2) ||
|
|
424
|
+
index === 4 ||
|
|
425
|
+
index === 7 ? (
|
|
426
|
+
<DropdownMenuSeparator />
|
|
427
|
+
) : null}
|
|
428
|
+
</div>
|
|
429
|
+
))}
|
|
430
|
+
</DropdownMenuContent>
|
|
431
|
+
</DropdownMenu>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
<LinkDialog editor={editor} isOpen={linkDialogOpen} onClose={() => setLinkDialogOpen(false)} />
|
|
435
|
+
|
|
436
|
+
<ImageDialog editor={editor} isOpen={imageDialogOpen} onClose={() => setImageDialogOpen(false)} />
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|