Skip to content

Commit 9b15ecc

Browse files
feat(dashboard): Improve toolbar display of rich text editor (#3817)
1 parent 2902e30 commit 9b15ecc

File tree

9 files changed

+53369
-51919
lines changed

9 files changed

+53369
-51919
lines changed

package-lock.json

Lines changed: 51880 additions & 51801 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dashboard/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,13 @@
9494
"@tanstack/react-table": "^8.21.2",
9595
"@tanstack/router-devtools": "^1.105.0",
9696
"@tanstack/router-plugin": "^1.105.0",
97-
"@tiptap/pm": "^2.11.5",
98-
"@tiptap/react": "^2.11.5",
99-
"@tiptap/starter-kit": "^2.11.5",
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",
100104
"@types/react": "^19.0.10",
101105
"@types/react-dom": "^19.0.4",
102106
"@uidotdev/usehooks": "^2.4.1",
Lines changed: 2 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,6 @@
11
import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
22
import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
3-
import TextStyle from '@tiptap/extension-text-style';
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';
244

255
/**
266
* @description
@@ -31,99 +11,6 @@ const extensions = [
3111
*/
3212
export function RichTextInput({ value, onChange, fieldDef }: Readonly<DashboardFormComponentProps>) {
3313
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-
}
9014

91-
function CustomBubbleMenu({ editor, disabled }: { editor: Editor | null; disabled?: boolean }) {
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} />;
12916
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)