@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.
@@ -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
+ }