@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.4.3-master-
|
|
4
|
+
"version": "3.4.3-master-202509190229",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -94,14 +94,18 @@
|
|
|
94
94
|
"@tanstack/react-table": "^8.21.2",
|
|
95
95
|
"@tanstack/router-devtools": "^1.105.0",
|
|
96
96
|
"@tanstack/router-plugin": "^1.105.0",
|
|
97
|
-
"@tiptap/
|
|
98
|
-
"@tiptap/
|
|
99
|
-
"@tiptap/
|
|
97
|
+
"@tiptap/extension-floating-menu": "^3.4.4",
|
|
98
|
+
"@tiptap/extension-image": "^3.4.4",
|
|
99
|
+
"@tiptap/extension-table": "^3.4.4",
|
|
100
|
+
"@tiptap/extension-text-style": "^3.4.4",
|
|
101
|
+
"@tiptap/pm": "^3.4.4",
|
|
102
|
+
"@tiptap/react": "^3.4.4",
|
|
103
|
+
"@tiptap/starter-kit": "^3.4.4",
|
|
100
104
|
"@types/react": "^19.0.10",
|
|
101
105
|
"@types/react-dom": "^19.0.4",
|
|
102
106
|
"@uidotdev/usehooks": "^2.4.1",
|
|
103
|
-
"@vendure/common": "^3.4.3-master-
|
|
104
|
-
"@vendure/core": "^3.4.3-master-
|
|
107
|
+
"@vendure/common": "^3.4.3-master-202509190229",
|
|
108
|
+
"@vendure/core": "^3.4.3-master-202509190229",
|
|
105
109
|
"@vitejs/plugin-react": "^4.3.4",
|
|
106
110
|
"acorn": "^8.11.3",
|
|
107
111
|
"acorn-walk": "^8.3.2",
|
|
@@ -152,5 +156,5 @@
|
|
|
152
156
|
"lightningcss-linux-arm64-musl": "^1.29.3",
|
|
153
157
|
"lightningcss-linux-x64-musl": "^1.29.1"
|
|
154
158
|
},
|
|
155
|
-
"gitHead": "
|
|
159
|
+
"gitHead": "1a94626a2e07d33d881a751ff394f8351c3521b4"
|
|
156
160
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMutation } from '@tanstack/react-query';
|
|
2
|
+
import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
|
|
2
3
|
import { CopyIcon } from 'lucide-react';
|
|
3
4
|
import { useState } from 'react';
|
|
4
5
|
import { toast } from 'sonner';
|
|
@@ -8,13 +9,13 @@ import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-ta
|
|
|
8
9
|
import { api } from '@/vdb/graphql/api.js';
|
|
9
10
|
import { duplicateEntityDocument } from '@/vdb/graphql/common-operations.js';
|
|
10
11
|
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
12
|
+
import { DuplicateEntityDialog } from './duplicate-entity-dialog.js';
|
|
11
13
|
|
|
12
14
|
interface DuplicateBulkActionProps {
|
|
13
15
|
entityType: 'Product' | 'Collection' | 'Facet' | 'Promotion';
|
|
14
16
|
duplicatorCode: string;
|
|
15
|
-
duplicatorArguments?: Array<{ name: string; value: string }>;
|
|
16
17
|
requiredPermissions: string[];
|
|
17
|
-
entityName: string;
|
|
18
|
+
entityName: string;
|
|
18
19
|
onSuccess?: () => void;
|
|
19
20
|
selection: any[];
|
|
20
21
|
table: any;
|
|
@@ -23,23 +24,28 @@ interface DuplicateBulkActionProps {
|
|
|
23
24
|
export function DuplicateBulkAction({
|
|
24
25
|
entityType,
|
|
25
26
|
duplicatorCode,
|
|
26
|
-
duplicatorArguments = [],
|
|
27
27
|
requiredPermissions,
|
|
28
28
|
entityName,
|
|
29
29
|
onSuccess,
|
|
30
30
|
selection,
|
|
31
31
|
table,
|
|
32
|
-
}: DuplicateBulkActionProps) {
|
|
32
|
+
}: Readonly<DuplicateBulkActionProps>) {
|
|
33
33
|
const { refetchPaginatedList } = usePaginatedList();
|
|
34
34
|
const { i18n } = useLingui();
|
|
35
35
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
|
36
36
|
const [progress, setProgress] = useState({ completed: 0, total: 0 });
|
|
37
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
37
38
|
|
|
38
39
|
const { mutateAsync } = useMutation({
|
|
39
40
|
mutationFn: api.mutate(duplicateEntityDocument),
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
+
const handleStartDuplication = () => {
|
|
44
|
+
if (isDuplicating) return;
|
|
45
|
+
setDialogOpen(true);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleConfirmDuplication = async (duplicatorInput: ConfigurableOperationInput) => {
|
|
43
49
|
if (isDuplicating) return;
|
|
44
50
|
|
|
45
51
|
setIsDuplicating(true);
|
|
@@ -61,10 +67,7 @@ export function DuplicateBulkAction({
|
|
|
61
67
|
input: {
|
|
62
68
|
entityName: entityType,
|
|
63
69
|
entityId: entity.id,
|
|
64
|
-
duplicatorInput
|
|
65
|
-
code: duplicatorCode,
|
|
66
|
-
arguments: duplicatorArguments,
|
|
67
|
-
},
|
|
70
|
+
duplicatorInput,
|
|
68
71
|
},
|
|
69
72
|
});
|
|
70
73
|
|
|
@@ -116,19 +119,30 @@ export function DuplicateBulkAction({
|
|
|
116
119
|
};
|
|
117
120
|
|
|
118
121
|
return (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
<>
|
|
123
|
+
<DataTableBulkActionItem
|
|
124
|
+
requiresPermission={requiredPermissions}
|
|
125
|
+
onClick={handleStartDuplication}
|
|
126
|
+
label={
|
|
127
|
+
isDuplicating ? (
|
|
128
|
+
<Trans>
|
|
129
|
+
Duplicating... ({progress.completed}/{progress.total})
|
|
130
|
+
</Trans>
|
|
131
|
+
) : (
|
|
132
|
+
<Trans>Duplicate</Trans>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
icon={CopyIcon}
|
|
136
|
+
/>
|
|
137
|
+
<DuplicateEntityDialog
|
|
138
|
+
open={dialogOpen}
|
|
139
|
+
onOpenChange={setDialogOpen}
|
|
140
|
+
entityType={entityType}
|
|
141
|
+
entityName={entityName}
|
|
142
|
+
entities={selection}
|
|
143
|
+
duplicatorCode={duplicatorCode}
|
|
144
|
+
onConfirm={handleConfirmDuplication}
|
|
145
|
+
/>
|
|
146
|
+
</>
|
|
133
147
|
);
|
|
134
148
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ConfigurableOperationInput as ConfigurableOperationInputComponent } from '@/vdb/components/shared/configurable-operation-input.js';
|
|
2
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '@/vdb/components/ui/dialog.js';
|
|
10
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
11
|
+
import { getEntityDuplicatorsDocument } from '@/vdb/graphql/common-operations.js';
|
|
12
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
13
|
+
import { useQuery } from '@tanstack/react-query';
|
|
14
|
+
import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
|
|
15
|
+
import React, { useState } from 'react';
|
|
16
|
+
|
|
17
|
+
interface DuplicateEntityDialogProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
entityType: 'Product' | 'Collection' | 'Facet' | 'Promotion';
|
|
21
|
+
entityName: string;
|
|
22
|
+
entities: Array<{ id: string; name?: string }>;
|
|
23
|
+
duplicatorCode: string;
|
|
24
|
+
onConfirm: (duplicatorInput: ConfigurableOperationInput) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DuplicateEntityDialog({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
entityType,
|
|
31
|
+
entityName,
|
|
32
|
+
duplicatorCode,
|
|
33
|
+
onConfirm,
|
|
34
|
+
}: Readonly<DuplicateEntityDialogProps>) {
|
|
35
|
+
const [selectedDuplicator, setSelectedDuplicator] = useState<ConfigurableOperationInput | undefined>();
|
|
36
|
+
|
|
37
|
+
const { data } = useQuery({
|
|
38
|
+
queryKey: ['entityDuplicators'],
|
|
39
|
+
queryFn: () => api.query(getEntityDuplicatorsDocument),
|
|
40
|
+
staleTime: 1000 * 60 * 60 * 5,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Find the duplicator that matches the provided code and supports this entity type
|
|
44
|
+
const matchingDuplicator = data?.entityDuplicators?.find(
|
|
45
|
+
duplicator => duplicator.code === duplicatorCode && duplicator.forEntities.includes(entityType),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Auto-initialize the duplicator when found
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
if (matchingDuplicator && !selectedDuplicator) {
|
|
51
|
+
setSelectedDuplicator({
|
|
52
|
+
code: matchingDuplicator.code,
|
|
53
|
+
arguments:
|
|
54
|
+
matchingDuplicator.args?.map(arg => ({
|
|
55
|
+
name: arg.name,
|
|
56
|
+
value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
|
|
57
|
+
})) || [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}, [matchingDuplicator, selectedDuplicator]);
|
|
61
|
+
|
|
62
|
+
const onDuplicatorValueChange = (newVal: ConfigurableOperationInput) => {
|
|
63
|
+
setSelectedDuplicator(newVal);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleConfirm = () => {
|
|
67
|
+
if (!selectedDuplicator) return;
|
|
68
|
+
onConfirm(selectedDuplicator);
|
|
69
|
+
onOpenChange(false);
|
|
70
|
+
setSelectedDuplicator(undefined);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleCancel = () => {
|
|
74
|
+
onOpenChange(false);
|
|
75
|
+
setSelectedDuplicator(undefined);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
80
|
+
<DialogContent className="sm:max-w-lg">
|
|
81
|
+
<DialogHeader>
|
|
82
|
+
<DialogTitle>
|
|
83
|
+
<Trans>Duplicate {entityName.toLowerCase()}s</Trans>
|
|
84
|
+
</DialogTitle>
|
|
85
|
+
</DialogHeader>
|
|
86
|
+
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
{selectedDuplicator && matchingDuplicator && (
|
|
89
|
+
<ConfigurableOperationInputComponent
|
|
90
|
+
operationDefinition={matchingDuplicator}
|
|
91
|
+
value={selectedDuplicator}
|
|
92
|
+
onChange={onDuplicatorValueChange}
|
|
93
|
+
removable={false}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{!matchingDuplicator && (
|
|
98
|
+
<div className="text-sm text-muted-foreground">
|
|
99
|
+
<Trans>
|
|
100
|
+
No duplicator found with code "{duplicatorCode}" for {entityName}s
|
|
101
|
+
</Trans>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<DialogFooter>
|
|
107
|
+
<Button variant="outline" onClick={handleCancel}>
|
|
108
|
+
<Trans>Cancel</Trans>
|
|
109
|
+
</Button>
|
|
110
|
+
<Button onClick={handleConfirm} disabled={!selectedDuplicator}>
|
|
111
|
+
<Trans>Duplicate</Trans>
|
|
112
|
+
</Button>
|
|
113
|
+
</DialogFooter>
|
|
114
|
+
</DialogContent>
|
|
115
|
+
</Dialog>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -1,26 +1,6 @@
|
|
|
1
1
|
import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
|
|
2
2
|
import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
|
|
3
|
-
import
|
|
4
|
-
import { BubbleMenu, Editor, EditorContent, useEditor } from '@tiptap/react';
|
|
5
|
-
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
-
import { BoldIcon, ItalicIcon, StrikethroughIcon } from 'lucide-react';
|
|
7
|
-
import { useLayoutEffect, useRef } from 'react';
|
|
8
|
-
import { Button } from '../ui/button.js';
|
|
9
|
-
|
|
10
|
-
// define your extension array
|
|
11
|
-
const extensions = [
|
|
12
|
-
TextStyle.configure(),
|
|
13
|
-
StarterKit.configure({
|
|
14
|
-
bulletList: {
|
|
15
|
-
keepMarks: true,
|
|
16
|
-
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
|
|
17
|
-
},
|
|
18
|
-
orderedList: {
|
|
19
|
-
keepMarks: true,
|
|
20
|
-
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
|
|
21
|
-
},
|
|
22
|
-
}),
|
|
23
|
-
];
|
|
3
|
+
import { RichTextEditor } from '../shared/rich-text-editor/rich-text-editor.js';
|
|
24
4
|
|
|
25
5
|
/**
|
|
26
6
|
* @description
|
|
@@ -31,99 +11,6 @@ const extensions = [
|
|
|
31
11
|
*/
|
|
32
12
|
export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
|
|
33
13
|
const readOnly = isReadonlyField(fieldDef);
|
|
34
|
-
const isInternalUpdate = useRef(false);
|
|
35
|
-
|
|
36
|
-
const editor = useEditor({
|
|
37
|
-
parseOptions: {
|
|
38
|
-
preserveWhitespace: 'full',
|
|
39
|
-
},
|
|
40
|
-
extensions: extensions,
|
|
41
|
-
content: value,
|
|
42
|
-
editable: !readOnly,
|
|
43
|
-
onUpdate: ({ editor }) => {
|
|
44
|
-
if (!readOnly) {
|
|
45
|
-
isInternalUpdate.current = true;
|
|
46
|
-
console.log('onUpdate');
|
|
47
|
-
const newValue = editor.getHTML();
|
|
48
|
-
if (value !== newValue) {
|
|
49
|
-
onChange(newValue);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
editorProps: {
|
|
54
|
-
attributes: {
|
|
55
|
-
class: `border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm max-h-[500px] overflow-y-auto ${readOnly ? 'cursor-not-allowed opacity-50' : ''}`,
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
useLayoutEffect(() => {
|
|
61
|
-
if (editor && !isInternalUpdate.current) {
|
|
62
|
-
const currentContent = editor.getHTML();
|
|
63
|
-
if (currentContent !== value) {
|
|
64
|
-
const { from, to } = editor.state.selection;
|
|
65
|
-
editor.commands.setContent(value, false);
|
|
66
|
-
editor.commands.setTextSelection({ from, to });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
isInternalUpdate.current = false;
|
|
70
|
-
}, [value, editor]);
|
|
71
|
-
|
|
72
|
-
// Update editor's editable state when disabled prop changes
|
|
73
|
-
useLayoutEffect(() => {
|
|
74
|
-
if (editor) {
|
|
75
|
-
editor.setEditable(!readOnly, false);
|
|
76
|
-
}
|
|
77
|
-
}, [readOnly, editor]);
|
|
78
|
-
|
|
79
|
-
if (!editor) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<>
|
|
85
|
-
<EditorContent editor={editor} />
|
|
86
|
-
<CustomBubbleMenu editor={editor} disabled={readOnly} />
|
|
87
|
-
</>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
14
|
|
|
91
|
-
|
|
92
|
-
if (!editor || disabled) return null;
|
|
93
|
-
return (
|
|
94
|
-
<BubbleMenu editor={editor}>
|
|
95
|
-
<div className="flex items-center gap-2 bg-background p-2 rounded-md border">
|
|
96
|
-
<Button
|
|
97
|
-
type="button"
|
|
98
|
-
variant="ghost"
|
|
99
|
-
size="icon"
|
|
100
|
-
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
101
|
-
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
|
102
|
-
disabled={disabled}
|
|
103
|
-
>
|
|
104
|
-
<BoldIcon className="w-4 h-4" />
|
|
105
|
-
</Button>
|
|
106
|
-
<Button
|
|
107
|
-
type="button"
|
|
108
|
-
variant="ghost"
|
|
109
|
-
size="icon"
|
|
110
|
-
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
111
|
-
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
|
112
|
-
disabled={disabled}
|
|
113
|
-
>
|
|
114
|
-
<ItalicIcon className="w-4 h-4" />
|
|
115
|
-
</Button>
|
|
116
|
-
<Button
|
|
117
|
-
type="button"
|
|
118
|
-
variant="ghost"
|
|
119
|
-
size="icon"
|
|
120
|
-
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
121
|
-
className={editor.isActive('strike') ? 'bg-accent' : ''}
|
|
122
|
-
disabled={disabled}
|
|
123
|
-
>
|
|
124
|
-
<StrikethroughIcon className="w-4 h-4" />
|
|
125
|
-
</Button>
|
|
126
|
-
</div>
|
|
127
|
-
</BubbleMenu>
|
|
128
|
-
);
|
|
15
|
+
return <RichTextEditor value={value} onChange={onChange} disabled={readOnly} />;
|
|
129
16
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
2
|
+
import { SetImageOptions } from '@tiptap/extension-image';
|
|
3
|
+
import { Editor } from '@tiptap/react';
|
|
4
|
+
import { ImageIcon, PaperclipIcon } from 'lucide-react';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { Button } from '../../ui/button.js';
|
|
7
|
+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog.js';
|
|
8
|
+
import { Input } from '../../ui/input.js';
|
|
9
|
+
import { Label } from '../../ui/label.js';
|
|
10
|
+
import { Asset } from '../asset/asset-gallery.js';
|
|
11
|
+
import { AssetPickerDialog } from '../asset/asset-picker-dialog.js';
|
|
12
|
+
|
|
13
|
+
export interface ImageDialogProps {
|
|
14
|
+
editor: Editor;
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ImageDialog({ editor, isOpen, onClose }: Readonly<ImageDialogProps>) {
|
|
20
|
+
const [src, setSrc] = useState('');
|
|
21
|
+
const [alt, setAlt] = useState('');
|
|
22
|
+
const [title, setTitle] = useState('');
|
|
23
|
+
const [width, setWidth] = useState('');
|
|
24
|
+
const [height, setHeight] = useState('');
|
|
25
|
+
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
|
26
|
+
const [previewUrl, setPreviewUrl] = useState('');
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (isOpen) {
|
|
30
|
+
// Get current image attributes if editing existing image
|
|
31
|
+
const {
|
|
32
|
+
src: currentSrc,
|
|
33
|
+
alt: currentAlt,
|
|
34
|
+
title: currentTitle,
|
|
35
|
+
width: currentWidth,
|
|
36
|
+
height: currentHeight,
|
|
37
|
+
} = editor.getAttributes('image');
|
|
38
|
+
|
|
39
|
+
setSrc(currentSrc || '');
|
|
40
|
+
setAlt(currentAlt || '');
|
|
41
|
+
setTitle(currentTitle || '');
|
|
42
|
+
setWidth(currentWidth || '');
|
|
43
|
+
setHeight(currentHeight || '');
|
|
44
|
+
setPreviewUrl(currentSrc || '');
|
|
45
|
+
}
|
|
46
|
+
}, [isOpen, editor]);
|
|
47
|
+
|
|
48
|
+
const handleInsertImage = () => {
|
|
49
|
+
if (!src) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const attrs: SetImageOptions = {
|
|
54
|
+
src,
|
|
55
|
+
alt: alt || undefined,
|
|
56
|
+
title: title || undefined,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Only add width/height if they are valid numbers
|
|
60
|
+
if (width && !isNaN(Number(width))) {
|
|
61
|
+
attrs.width = Number(width);
|
|
62
|
+
}
|
|
63
|
+
if (height && !isNaN(Number(height))) {
|
|
64
|
+
attrs.height = Number(height);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
editor.chain().focus().setImage(attrs).run();
|
|
68
|
+
|
|
69
|
+
handleClose();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleClose = () => {
|
|
73
|
+
setSrc('');
|
|
74
|
+
setAlt('');
|
|
75
|
+
setTitle('');
|
|
76
|
+
setWidth('');
|
|
77
|
+
setHeight('');
|
|
78
|
+
setPreviewUrl('');
|
|
79
|
+
onClose();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleAssetSelect = (assets: Asset[]) => {
|
|
83
|
+
if (assets.length > 0) {
|
|
84
|
+
const asset = assets[0];
|
|
85
|
+
setSrc(asset.source);
|
|
86
|
+
setPreviewUrl(asset.preview);
|
|
87
|
+
// Set width and height from asset if available
|
|
88
|
+
if (asset.width) {
|
|
89
|
+
setWidth(asset.width.toString());
|
|
90
|
+
}
|
|
91
|
+
if (asset.height) {
|
|
92
|
+
setHeight(asset.height.toString());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
setAssetPickerOpen(false);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const isEditing = editor.isActive('image');
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
|
|
103
|
+
<DialogContent className="sm:max-w-[525px]">
|
|
104
|
+
<DialogHeader>
|
|
105
|
+
<DialogTitle>
|
|
106
|
+
{isEditing ? <Trans>Edit image</Trans> : <Trans>Insert image</Trans>}
|
|
107
|
+
</DialogTitle>
|
|
108
|
+
</DialogHeader>
|
|
109
|
+
<div className="grid gap-4 py-4">
|
|
110
|
+
<div className="flex items-center justify-center">
|
|
111
|
+
{previewUrl ? (
|
|
112
|
+
<img
|
|
113
|
+
src={previewUrl}
|
|
114
|
+
alt={alt || 'Preview'}
|
|
115
|
+
className="max-w-[200px] max-h-[200px] object-contain border rounded"
|
|
116
|
+
/>
|
|
117
|
+
) : (
|
|
118
|
+
<div className="w-[200px] h-[200px] border rounded flex items-center justify-center bg-muted">
|
|
119
|
+
<ImageIcon className="w-16 h-16 text-muted-foreground" />
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="flex items-center justify-center">
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
variant="outline"
|
|
128
|
+
size="sm"
|
|
129
|
+
onClick={() => setAssetPickerOpen(true)}
|
|
130
|
+
>
|
|
131
|
+
<PaperclipIcon className="w-4 h-4 mr-2" />
|
|
132
|
+
<Trans>Add asset</Trans>
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="grid gap-2">
|
|
137
|
+
<Label htmlFor="image-source">
|
|
138
|
+
<Trans>Source</Trans>
|
|
139
|
+
</Label>
|
|
140
|
+
<Input
|
|
141
|
+
id="image-source"
|
|
142
|
+
value={src}
|
|
143
|
+
onChange={e => {
|
|
144
|
+
setSrc(e.target.value);
|
|
145
|
+
setPreviewUrl(e.target.value);
|
|
146
|
+
}}
|
|
147
|
+
placeholder="https://example.com/image.jpg"
|
|
148
|
+
autoFocus
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="grid gap-2">
|
|
153
|
+
<Label htmlFor="image-title">
|
|
154
|
+
<Trans>Title</Trans>
|
|
155
|
+
</Label>
|
|
156
|
+
<Input
|
|
157
|
+
id="image-title"
|
|
158
|
+
value={title}
|
|
159
|
+
onChange={e => setTitle(e.target.value)}
|
|
160
|
+
placeholder=""
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div className="grid gap-2">
|
|
165
|
+
<Label htmlFor="image-alt">
|
|
166
|
+
<Trans>Description (alt)</Trans>
|
|
167
|
+
</Label>
|
|
168
|
+
<Input
|
|
169
|
+
id="image-alt"
|
|
170
|
+
value={alt}
|
|
171
|
+
onChange={e => setAlt(e.target.value)}
|
|
172
|
+
placeholder=""
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="grid grid-cols-2 gap-4">
|
|
177
|
+
<div className="grid gap-2">
|
|
178
|
+
<Label htmlFor="image-width">
|
|
179
|
+
<Trans>Width</Trans>
|
|
180
|
+
</Label>
|
|
181
|
+
<Input
|
|
182
|
+
id="image-width"
|
|
183
|
+
type="number"
|
|
184
|
+
value={width}
|
|
185
|
+
onChange={e => setWidth(e.target.value)}
|
|
186
|
+
placeholder="auto"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="grid gap-2">
|
|
190
|
+
<Label htmlFor="image-height">
|
|
191
|
+
<Trans>Height</Trans>
|
|
192
|
+
</Label>
|
|
193
|
+
<Input
|
|
194
|
+
id="image-height"
|
|
195
|
+
type="number"
|
|
196
|
+
value={height}
|
|
197
|
+
onChange={e => setHeight(e.target.value)}
|
|
198
|
+
placeholder="auto"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
<DialogFooter>
|
|
204
|
+
<Button type="button" variant="outline" onClick={handleClose}>
|
|
205
|
+
<Trans>Cancel</Trans>
|
|
206
|
+
</Button>
|
|
207
|
+
<Button type="button" onClick={handleInsertImage} disabled={!src}>
|
|
208
|
+
<Trans>Insert image</Trans>
|
|
209
|
+
</Button>
|
|
210
|
+
</DialogFooter>
|
|
211
|
+
</DialogContent>
|
|
212
|
+
</Dialog>
|
|
213
|
+
|
|
214
|
+
<AssetPickerDialog
|
|
215
|
+
open={assetPickerOpen}
|
|
216
|
+
onClose={() => setAssetPickerOpen(false)}
|
|
217
|
+
onSelect={handleAssetSelect}
|
|
218
|
+
multiSelect={false}
|
|
219
|
+
title="Select asset"
|
|
220
|
+
/>
|
|
221
|
+
</>
|
|
222
|
+
);
|
|
223
|
+
}
|