@rimori/react-client 0.4.9 → 0.4.10-next.1
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/components/ai/Assistant.js +2 -2
- package/dist/components/audio/Playbutton.js +6 -2
- package/dist/components/editor/ImageUploadExtension.d.ts +13 -0
- package/dist/components/editor/ImageUploadExtension.js +93 -0
- package/dist/components/editor/MarkdownEditor.d.ts +47 -0
- package/dist/components/editor/MarkdownEditor.js +207 -0
- package/dist/components/editor/imageUtils.d.ts +10 -0
- package/dist/components/editor/imageUtils.js +52 -0
- package/dist/hooks/I18nHooks.js +3 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/style.css +59 -74
- package/dist/style.css.map +1 -1
- package/package.json +17 -5
- package/src/components/ai/Assistant.tsx +2 -2
- package/src/components/audio/Playbutton.tsx +7 -2
- package/src/components/editor/ImageUploadExtension.ts +88 -0
- package/src/components/editor/MarkdownEditor.tsx +621 -0
- package/src/components/editor/imageUtils.ts +58 -0
- package/src/hooks/I18nHooks.ts +3 -1
- package/src/index.ts +3 -0
- package/src/style.scss +58 -82
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useRimori } from '../../providers/PluginProvider';
|
|
3
|
+
import { Markdown } from 'tiptap-markdown';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import Table from '@tiptap/extension-table';
|
|
6
|
+
import TableCell from '@tiptap/extension-table-cell';
|
|
7
|
+
import TableHeader from '@tiptap/extension-table-header';
|
|
8
|
+
import TableRow from '@tiptap/extension-table-row';
|
|
9
|
+
import Link from '@tiptap/extension-link';
|
|
10
|
+
import Youtube from '@tiptap/extension-youtube';
|
|
11
|
+
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
|
12
|
+
import type { Level } from '@tiptap/extension-heading';
|
|
13
|
+
import { PiCodeBlock } from 'react-icons/pi';
|
|
14
|
+
import {
|
|
15
|
+
TbBlockquote,
|
|
16
|
+
TbTable,
|
|
17
|
+
TbColumnInsertRight,
|
|
18
|
+
TbRowInsertBottom,
|
|
19
|
+
TbColumnRemove,
|
|
20
|
+
TbRowRemove,
|
|
21
|
+
TbArrowMergeBoth,
|
|
22
|
+
TbBrandYoutube,
|
|
23
|
+
TbPhoto,
|
|
24
|
+
} from 'react-icons/tb';
|
|
25
|
+
import { GoListOrdered } from 'react-icons/go';
|
|
26
|
+
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
|
27
|
+
import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuUnlink } from 'react-icons/lu';
|
|
28
|
+
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
29
|
+
import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
|
|
30
|
+
|
|
31
|
+
function getMarkdown(editor: Editor): string {
|
|
32
|
+
return (editor.storage as { markdown: { getMarkdown: () => string } }).markdown.getMarkdown();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// EditorButton
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface EditorButtonProps {
|
|
40
|
+
editor: Editor;
|
|
41
|
+
action: string;
|
|
42
|
+
isActive?: boolean;
|
|
43
|
+
label: React.ReactNode;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
title?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const EditorButton = ({ editor, action, isActive, label, disabled, title }: EditorButtonProps): JSX.Element => {
|
|
49
|
+
const baseClass =
|
|
50
|
+
'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
51
|
+
(isActive
|
|
52
|
+
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground'
|
|
53
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground');
|
|
54
|
+
|
|
55
|
+
if (action.toLowerCase().includes('heading')) {
|
|
56
|
+
const level = parseInt(action[action.length - 1]);
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
title={title}
|
|
61
|
+
onClick={() =>
|
|
62
|
+
editor
|
|
63
|
+
.chain()
|
|
64
|
+
.focus()
|
|
65
|
+
.toggleHeading({ level: level as Level })
|
|
66
|
+
.run()
|
|
67
|
+
}
|
|
68
|
+
className={baseClass}
|
|
69
|
+
>
|
|
70
|
+
{label}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
title={title}
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
onClick={() => (editor.chain().focus() as any)[action]().run()}
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
disabled={disabled ? !(editor.can().chain() as any)[action]().run() : false}
|
|
83
|
+
className={baseClass}
|
|
84
|
+
>
|
|
85
|
+
{label}
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Inline panels (no Radix required)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
type PanelType = 'link' | 'youtube' | 'markdown' | null;
|
|
95
|
+
|
|
96
|
+
interface InlinePanelProps {
|
|
97
|
+
panel: PanelType;
|
|
98
|
+
onClose: () => void;
|
|
99
|
+
editor: Editor;
|
|
100
|
+
onUpdate: (content: string) => void;
|
|
101
|
+
labels: Required<EditorLabels>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelProps): JSX.Element | null => {
|
|
105
|
+
const [value, setValue] = useState('');
|
|
106
|
+
|
|
107
|
+
// Reset value when panel changes
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (panel === 'link') {
|
|
110
|
+
const href = editor.getAttributes('link').href as string | undefined;
|
|
111
|
+
setValue(typeof href === 'string' ? href : 'https://');
|
|
112
|
+
} else {
|
|
113
|
+
setValue('');
|
|
114
|
+
}
|
|
115
|
+
}, [panel, editor]);
|
|
116
|
+
|
|
117
|
+
if (!panel) return null;
|
|
118
|
+
|
|
119
|
+
const handleConfirm = (): void => {
|
|
120
|
+
if (panel === 'link') {
|
|
121
|
+
const href = value.trim();
|
|
122
|
+
if (href && href !== 'https://') editor.chain().focus().setLink({ href }).run();
|
|
123
|
+
onClose();
|
|
124
|
+
} else if (panel === 'youtube') {
|
|
125
|
+
const src = value.trim();
|
|
126
|
+
if (src) editor.commands.setYoutubeVideo({ src });
|
|
127
|
+
onClose();
|
|
128
|
+
} else if (panel === 'markdown') {
|
|
129
|
+
if (!value.trim()) return;
|
|
130
|
+
const current = getMarkdown(editor);
|
|
131
|
+
const combined = current + '\n\n' + value.trim();
|
|
132
|
+
editor.commands.setContent(combined);
|
|
133
|
+
editor.commands.focus('end');
|
|
134
|
+
onUpdate(combined);
|
|
135
|
+
onClose();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
|
140
|
+
if (e.key === 'Enter') handleConfirm();
|
|
141
|
+
if (e.key === 'Escape') onClose();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const isMarkdown = panel === 'markdown';
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="bg-muted/80 border-b border-border px-2 py-1.5 flex flex-col gap-1.5">
|
|
148
|
+
<p className="text-xs text-muted-foreground">
|
|
149
|
+
{panel === 'link'
|
|
150
|
+
? labels.setLinkTitle
|
|
151
|
+
: panel === 'youtube'
|
|
152
|
+
? labels.addYoutubeTitle
|
|
153
|
+
: labels.appendMarkdownTitle}
|
|
154
|
+
</p>
|
|
155
|
+
{isMarkdown ? (
|
|
156
|
+
<textarea
|
|
157
|
+
autoFocus
|
|
158
|
+
rows={4}
|
|
159
|
+
value={value}
|
|
160
|
+
onChange={(e) => setValue(e.target.value)}
|
|
161
|
+
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
|
162
|
+
placeholder={labels.appendMarkdownPlaceholder}
|
|
163
|
+
className="w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 resize-y outline-none focus:ring-1 focus:ring-ring"
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
<input
|
|
167
|
+
autoFocus
|
|
168
|
+
type="url"
|
|
169
|
+
value={value}
|
|
170
|
+
onChange={(e) => setValue(e.target.value)}
|
|
171
|
+
onKeyDown={handleKeyDown}
|
|
172
|
+
placeholder={panel === 'link' ? labels.setLinkUrlPlaceholder : labels.addYoutubeUrlPlaceholder}
|
|
173
|
+
className="w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 outline-none focus:ring-1 focus:ring-ring"
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
<div className="flex gap-2 justify-end">
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={onClose}
|
|
180
|
+
className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors"
|
|
181
|
+
>
|
|
182
|
+
{labels.cancel}
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={handleConfirm}
|
|
187
|
+
disabled={!value.trim() || (panel === 'link' && value.trim() === 'https://')}
|
|
188
|
+
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
|
189
|
+
>
|
|
190
|
+
{panel === 'link'
|
|
191
|
+
? labels.setLinkConfirm
|
|
192
|
+
: panel === 'youtube'
|
|
193
|
+
? labels.addYoutubeConfirm
|
|
194
|
+
: labels.appendMarkdownConfirm}
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// MenuBar
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
interface MenuBarProps {
|
|
206
|
+
editor: Editor;
|
|
207
|
+
onUpdate: (content: string) => void;
|
|
208
|
+
uploadImage?: (pngBlob: Blob) => Promise<string | null>;
|
|
209
|
+
labels: Required<EditorLabels>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const MenuBar = ({ editor, onUpdate, uploadImage, labels }: MenuBarProps): JSX.Element => {
|
|
213
|
+
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
214
|
+
|
|
215
|
+
const toggle = (panel: PanelType): void => setActivePanel((prev) => (prev === panel ? null : panel));
|
|
216
|
+
|
|
217
|
+
const inTable = editor.isActive('table');
|
|
218
|
+
const isLink = editor.isActive('link');
|
|
219
|
+
|
|
220
|
+
const tableBtnClass =
|
|
221
|
+
'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
222
|
+
'text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none';
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<div className="bg-muted/50 border-b border-border text-base flex flex-row flex-wrap items-center gap-0.5 p-1.5">
|
|
227
|
+
{/* Text formatting */}
|
|
228
|
+
<EditorButton
|
|
229
|
+
editor={editor}
|
|
230
|
+
action="toggleBold"
|
|
231
|
+
isActive={editor.isActive('bold')}
|
|
232
|
+
label={<FaBold />}
|
|
233
|
+
disabled
|
|
234
|
+
title={labels.bold}
|
|
235
|
+
/>
|
|
236
|
+
<EditorButton
|
|
237
|
+
editor={editor}
|
|
238
|
+
action="toggleItalic"
|
|
239
|
+
isActive={editor.isActive('italic')}
|
|
240
|
+
label={<FaItalic />}
|
|
241
|
+
disabled
|
|
242
|
+
title={labels.italic}
|
|
243
|
+
/>
|
|
244
|
+
<EditorButton
|
|
245
|
+
editor={editor}
|
|
246
|
+
action="toggleStrike"
|
|
247
|
+
isActive={editor.isActive('strike')}
|
|
248
|
+
label={<FaStrikethrough />}
|
|
249
|
+
disabled
|
|
250
|
+
title={labels.strike}
|
|
251
|
+
/>
|
|
252
|
+
<EditorButton
|
|
253
|
+
editor={editor}
|
|
254
|
+
action="toggleCode"
|
|
255
|
+
isActive={editor.isActive('code')}
|
|
256
|
+
label={<FaCode />}
|
|
257
|
+
disabled
|
|
258
|
+
title={labels.code}
|
|
259
|
+
/>
|
|
260
|
+
<EditorButton
|
|
261
|
+
editor={editor}
|
|
262
|
+
action="setParagraph"
|
|
263
|
+
isActive={editor.isActive('paragraph')}
|
|
264
|
+
label={<FaParagraph />}
|
|
265
|
+
title={labels.paragraph}
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
{/* Headings */}
|
|
269
|
+
<EditorButton
|
|
270
|
+
editor={editor}
|
|
271
|
+
action="setHeading1"
|
|
272
|
+
isActive={editor.isActive('heading', { level: 1 })}
|
|
273
|
+
label={<LuHeading1 size="24px" />}
|
|
274
|
+
title={labels.heading1}
|
|
275
|
+
/>
|
|
276
|
+
<EditorButton
|
|
277
|
+
editor={editor}
|
|
278
|
+
action="setHeading2"
|
|
279
|
+
isActive={editor.isActive('heading', { level: 2 })}
|
|
280
|
+
label={<LuHeading2 size="24px" />}
|
|
281
|
+
title={labels.heading2}
|
|
282
|
+
/>
|
|
283
|
+
<EditorButton
|
|
284
|
+
editor={editor}
|
|
285
|
+
action="setHeading3"
|
|
286
|
+
isActive={editor.isActive('heading', { level: 3 })}
|
|
287
|
+
label={<LuHeading3 size="24px" />}
|
|
288
|
+
title={labels.heading3}
|
|
289
|
+
/>
|
|
290
|
+
|
|
291
|
+
{/* Lists */}
|
|
292
|
+
<EditorButton
|
|
293
|
+
editor={editor}
|
|
294
|
+
action="toggleBulletList"
|
|
295
|
+
isActive={editor.isActive('bulletList')}
|
|
296
|
+
label={<AiOutlineUnorderedList size="24px" />}
|
|
297
|
+
title={labels.bulletList}
|
|
298
|
+
/>
|
|
299
|
+
<EditorButton
|
|
300
|
+
editor={editor}
|
|
301
|
+
action="toggleOrderedList"
|
|
302
|
+
isActive={editor.isActive('orderedList')}
|
|
303
|
+
label={<GoListOrdered size="24px" />}
|
|
304
|
+
title={labels.orderedList}
|
|
305
|
+
/>
|
|
306
|
+
|
|
307
|
+
{/* Block elements */}
|
|
308
|
+
<EditorButton
|
|
309
|
+
editor={editor}
|
|
310
|
+
action="toggleCodeBlock"
|
|
311
|
+
isActive={editor.isActive('codeBlock')}
|
|
312
|
+
label={<PiCodeBlock size="24px" />}
|
|
313
|
+
title={labels.codeBlock}
|
|
314
|
+
/>
|
|
315
|
+
<EditorButton
|
|
316
|
+
editor={editor}
|
|
317
|
+
action="toggleBlockquote"
|
|
318
|
+
isActive={editor.isActive('blockquote')}
|
|
319
|
+
label={<TbBlockquote size="24px" />}
|
|
320
|
+
title={labels.blockquote}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
<div className="w-px h-5 bg-border mx-0.5" />
|
|
324
|
+
|
|
325
|
+
{/* Link buttons */}
|
|
326
|
+
<div className="inline-flex rounded-md border border-border bg-transparent overflow-hidden">
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
onClick={() => toggle('link')}
|
|
330
|
+
title={labels.setLink}
|
|
331
|
+
className={
|
|
332
|
+
'w-8 h-8 flex items-center justify-center rounded-none first:rounded-l-md last:rounded-r-md transition-colors duration-150 ' +
|
|
333
|
+
'text-muted-foreground hover:bg-accent hover:text-accent-foreground border-r border-border last:border-r-0' +
|
|
334
|
+
(isLink ? ' bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground' : '')
|
|
335
|
+
}
|
|
336
|
+
>
|
|
337
|
+
<LuLink size={18} />
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => editor.chain().focus().unsetLink().run()}
|
|
342
|
+
disabled={!isLink}
|
|
343
|
+
title={labels.unsetLink}
|
|
344
|
+
className="w-8 h-8 flex items-center justify-center rounded-none first:rounded-l-md last:rounded-r-md transition-colors duration-150 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none border-r border-border last:border-r-0"
|
|
345
|
+
>
|
|
346
|
+
<LuUnlink size={18} />
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* YouTube */}
|
|
351
|
+
<button type="button" onClick={() => toggle('youtube')} className={tableBtnClass} title={labels.addYoutube}>
|
|
352
|
+
<TbBrandYoutube size={18} />
|
|
353
|
+
</button>
|
|
354
|
+
|
|
355
|
+
{/* Image upload (only when callback provided) */}
|
|
356
|
+
{uploadImage && (
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
onClick={() => triggerImageUpload(uploadImage, editor)}
|
|
360
|
+
className={tableBtnClass}
|
|
361
|
+
title={labels.insertImage}
|
|
362
|
+
>
|
|
363
|
+
<TbPhoto size={18} />
|
|
364
|
+
</button>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
<div className="w-px h-5 bg-border mx-0.5" />
|
|
368
|
+
|
|
369
|
+
{/* Table controls */}
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
|
373
|
+
className={tableBtnClass}
|
|
374
|
+
title={labels.insertTable}
|
|
375
|
+
>
|
|
376
|
+
<TbTable size={18} />
|
|
377
|
+
</button>
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
381
|
+
className={tableBtnClass}
|
|
382
|
+
disabled={!inTable}
|
|
383
|
+
title={labels.addColumnAfter}
|
|
384
|
+
>
|
|
385
|
+
<TbColumnInsertRight size={18} />
|
|
386
|
+
</button>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
390
|
+
className={tableBtnClass}
|
|
391
|
+
disabled={!inTable}
|
|
392
|
+
title={labels.addRowAfter}
|
|
393
|
+
>
|
|
394
|
+
<TbRowInsertBottom size={18} />
|
|
395
|
+
</button>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={() => editor.chain().focus().deleteColumn().run()}
|
|
399
|
+
className={tableBtnClass}
|
|
400
|
+
disabled={!inTable}
|
|
401
|
+
title={labels.deleteColumn}
|
|
402
|
+
>
|
|
403
|
+
<TbColumnRemove size={18} />
|
|
404
|
+
</button>
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={() => editor.chain().focus().deleteRow().run()}
|
|
408
|
+
className={tableBtnClass}
|
|
409
|
+
disabled={!inTable}
|
|
410
|
+
title={labels.deleteRow}
|
|
411
|
+
>
|
|
412
|
+
<TbRowRemove size={18} />
|
|
413
|
+
</button>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
onClick={() => editor.chain().focus().mergeOrSplit().run()}
|
|
417
|
+
className={tableBtnClass}
|
|
418
|
+
disabled={!inTable}
|
|
419
|
+
title={labels.mergeOrSplit}
|
|
420
|
+
>
|
|
421
|
+
<TbArrowMergeBoth size={18} />
|
|
422
|
+
</button>
|
|
423
|
+
|
|
424
|
+
<div className="w-px h-5 bg-border mx-0.5" />
|
|
425
|
+
|
|
426
|
+
{/* Append raw markdown */}
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={() => toggle('markdown')}
|
|
430
|
+
className={tableBtnClass}
|
|
431
|
+
title={labels.appendMarkdown}
|
|
432
|
+
>
|
|
433
|
+
<LuClipboardPaste size={18} />
|
|
434
|
+
</button>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<InlinePanel
|
|
438
|
+
panel={activePanel}
|
|
439
|
+
onClose={() => setActivePanel(null)}
|
|
440
|
+
editor={editor}
|
|
441
|
+
onUpdate={onUpdate}
|
|
442
|
+
labels={labels}
|
|
443
|
+
/>
|
|
444
|
+
</>
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Label defaults
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
export interface EditorLabels {
|
|
453
|
+
bold?: string;
|
|
454
|
+
italic?: string;
|
|
455
|
+
strike?: string;
|
|
456
|
+
code?: string;
|
|
457
|
+
paragraph?: string;
|
|
458
|
+
heading1?: string;
|
|
459
|
+
heading2?: string;
|
|
460
|
+
heading3?: string;
|
|
461
|
+
bulletList?: string;
|
|
462
|
+
orderedList?: string;
|
|
463
|
+
codeBlock?: string;
|
|
464
|
+
blockquote?: string;
|
|
465
|
+
setLink?: string;
|
|
466
|
+
setLinkTitle?: string;
|
|
467
|
+
setLinkUrlPlaceholder?: string;
|
|
468
|
+
setLinkConfirm?: string;
|
|
469
|
+
unsetLink?: string;
|
|
470
|
+
insertImage?: string;
|
|
471
|
+
addYoutube?: string;
|
|
472
|
+
addYoutubeTitle?: string;
|
|
473
|
+
addYoutubeUrlPlaceholder?: string;
|
|
474
|
+
addYoutubeConfirm?: string;
|
|
475
|
+
insertTable?: string;
|
|
476
|
+
addColumnAfter?: string;
|
|
477
|
+
addRowAfter?: string;
|
|
478
|
+
deleteColumn?: string;
|
|
479
|
+
deleteRow?: string;
|
|
480
|
+
mergeOrSplit?: string;
|
|
481
|
+
appendMarkdown?: string;
|
|
482
|
+
appendMarkdownTitle?: string;
|
|
483
|
+
appendMarkdownPlaceholder?: string;
|
|
484
|
+
appendMarkdownConfirm?: string;
|
|
485
|
+
cancel?: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const DEFAULT_LABELS: Required<EditorLabels> = {
|
|
489
|
+
bold: 'Bold',
|
|
490
|
+
italic: 'Italic',
|
|
491
|
+
strike: 'Strikethrough',
|
|
492
|
+
code: 'Inline code',
|
|
493
|
+
paragraph: 'Paragraph',
|
|
494
|
+
heading1: 'Heading 1',
|
|
495
|
+
heading2: 'Heading 2',
|
|
496
|
+
heading3: 'Heading 3',
|
|
497
|
+
bulletList: 'Bullet list',
|
|
498
|
+
orderedList: 'Ordered list',
|
|
499
|
+
codeBlock: 'Code block',
|
|
500
|
+
blockquote: 'Blockquote',
|
|
501
|
+
setLink: 'Set link',
|
|
502
|
+
setLinkTitle: 'Set link',
|
|
503
|
+
setLinkUrlPlaceholder: 'https://example.com',
|
|
504
|
+
setLinkConfirm: 'Apply',
|
|
505
|
+
unsetLink: 'Remove link',
|
|
506
|
+
insertImage: 'Insert image',
|
|
507
|
+
addYoutube: 'Add YouTube video',
|
|
508
|
+
addYoutubeTitle: 'Add YouTube video',
|
|
509
|
+
addYoutubeUrlPlaceholder: 'https://www.youtube.com/watch?v=…',
|
|
510
|
+
addYoutubeConfirm: 'Insert',
|
|
511
|
+
insertTable: 'Insert table',
|
|
512
|
+
addColumnAfter: 'Add column',
|
|
513
|
+
addRowAfter: 'Add row',
|
|
514
|
+
deleteColumn: 'Delete column',
|
|
515
|
+
deleteRow: 'Delete row',
|
|
516
|
+
mergeOrSplit: 'Merge or split cells',
|
|
517
|
+
appendMarkdown: 'Append markdown',
|
|
518
|
+
appendMarkdownTitle: 'Append Markdown',
|
|
519
|
+
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
520
|
+
appendMarkdownConfirm: 'Append',
|
|
521
|
+
cancel: 'Cancel',
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// MarkdownEditor
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
export interface MarkdownEditorProps {
|
|
529
|
+
content?: string;
|
|
530
|
+
editable: boolean;
|
|
531
|
+
className?: string;
|
|
532
|
+
onUpdate?: (content: string) => void;
|
|
533
|
+
/** Override any subset of toolbar/dialog labels. */
|
|
534
|
+
labels?: EditorLabels;
|
|
535
|
+
/** Called when the user clicks anywhere inside the editor area (read-only mode). */
|
|
536
|
+
onContentClick?: () => void;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export const MarkdownEditor = ({
|
|
540
|
+
content,
|
|
541
|
+
editable,
|
|
542
|
+
className,
|
|
543
|
+
onUpdate,
|
|
544
|
+
labels,
|
|
545
|
+
onContentClick,
|
|
546
|
+
}: MarkdownEditorProps): JSX.Element => {
|
|
547
|
+
const { storage } = useRimori();
|
|
548
|
+
const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
|
|
549
|
+
const lastEmittedRef = useRef(content ?? '');
|
|
550
|
+
|
|
551
|
+
const stableUpload = useCallback(
|
|
552
|
+
async (pngBlob: Blob): Promise<string | null> => {
|
|
553
|
+
const { data, error } = await storage.uploadImage(pngBlob);
|
|
554
|
+
if (error) return null;
|
|
555
|
+
return data.url;
|
|
556
|
+
},
|
|
557
|
+
[storage],
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const extensions = useMemo(
|
|
561
|
+
() => [
|
|
562
|
+
StarterKit,
|
|
563
|
+
Table.configure({ resizable: false }),
|
|
564
|
+
TableRow,
|
|
565
|
+
TableHeader,
|
|
566
|
+
TableCell,
|
|
567
|
+
Link.configure({ defaultProtocol: 'https' }),
|
|
568
|
+
Youtube,
|
|
569
|
+
Markdown,
|
|
570
|
+
ImageUploadExtension(stableUpload),
|
|
571
|
+
],
|
|
572
|
+
[stableUpload],
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const editor = useEditor({
|
|
576
|
+
extensions,
|
|
577
|
+
content,
|
|
578
|
+
editable,
|
|
579
|
+
autofocus: false,
|
|
580
|
+
editorProps: {
|
|
581
|
+
attributes: { class: (editable ? 'p-3 min-h-[200px] ' : '') + 'outline-none' },
|
|
582
|
+
},
|
|
583
|
+
onUpdate: ({ editor: ed }) => {
|
|
584
|
+
const markdown = getMarkdown(ed);
|
|
585
|
+
lastEmittedRef.current = markdown;
|
|
586
|
+
onUpdate?.(markdown);
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Sync external content changes (e.g. AI autofill) without triggering update loop
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
if (!editor) return;
|
|
593
|
+
const incoming = content ?? '';
|
|
594
|
+
if (incoming === lastEmittedRef.current) return;
|
|
595
|
+
lastEmittedRef.current = incoming;
|
|
596
|
+
editor.commands.setContent(incoming);
|
|
597
|
+
}, [editor, content]);
|
|
598
|
+
|
|
599
|
+
// Sync editable prop
|
|
600
|
+
useEffect(() => {
|
|
601
|
+
if (!editor) return;
|
|
602
|
+
editor.setEditable(editable);
|
|
603
|
+
}, [editor, editable]);
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
<div
|
|
607
|
+
className={
|
|
608
|
+
'text-md overflow-hidden rounded-lg ' +
|
|
609
|
+
(editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
|
|
610
|
+
' ' +
|
|
611
|
+
(className ?? '')
|
|
612
|
+
}
|
|
613
|
+
onClick={onContentClick}
|
|
614
|
+
>
|
|
615
|
+
{editor && editable && (
|
|
616
|
+
<MenuBar editor={editor} onUpdate={onUpdate ?? (() => {})} uploadImage={stableUpload} labels={mergedLabels} />
|
|
617
|
+
)}
|
|
618
|
+
<EditorContent editor={editor} />
|
|
619
|
+
</div>
|
|
620
|
+
);
|
|
621
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const MAX_WIDTH = 1920;
|
|
2
|
+
const MAX_HEIGHT = 1080;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert any image File/Blob to a PNG Blob, downscaled to max full-HD.
|
|
6
|
+
* Uses native Canvas API – no third-party packages.
|
|
7
|
+
*/
|
|
8
|
+
export function convertToPng(file: File | Blob): Promise<Blob> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const img = new Image();
|
|
11
|
+
const objectUrl = URL.createObjectURL(file);
|
|
12
|
+
|
|
13
|
+
img.onload = () => {
|
|
14
|
+
URL.revokeObjectURL(objectUrl);
|
|
15
|
+
|
|
16
|
+
let { width, height } = img;
|
|
17
|
+
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
|
18
|
+
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height);
|
|
19
|
+
width = Math.round(width * ratio);
|
|
20
|
+
height = Math.round(height * ratio);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const canvas = document.createElement('canvas');
|
|
24
|
+
canvas.width = width;
|
|
25
|
+
canvas.height = height;
|
|
26
|
+
|
|
27
|
+
const ctx = canvas.getContext('2d');
|
|
28
|
+
if (!ctx) return reject(new Error('Could not get 2D canvas context'));
|
|
29
|
+
|
|
30
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
31
|
+
canvas.toBlob((blob) => {
|
|
32
|
+
if (blob) resolve(blob);
|
|
33
|
+
else reject(new Error('canvas.toBlob returned null'));
|
|
34
|
+
}, 'image/png');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
img.onerror = () => {
|
|
38
|
+
URL.revokeObjectURL(objectUrl);
|
|
39
|
+
reject(new Error('Failed to load image for conversion'));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
img.src = objectUrl;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract all image URLs from a markdown string (standard `` syntax).
|
|
48
|
+
* Use this to collect URLs before calling `plugin.storage.confirmImages`.
|
|
49
|
+
*/
|
|
50
|
+
export function extractImageUrls(markdown: string): string[] {
|
|
51
|
+
const regex = /!\[[^\]]*\]\(([^)\s]+)\)/g;
|
|
52
|
+
const urls: string[] = [];
|
|
53
|
+
let match: RegExpExecArray | null;
|
|
54
|
+
while ((match = regex.exec(markdown)) !== null) {
|
|
55
|
+
urls.push(match[1]);
|
|
56
|
+
}
|
|
57
|
+
return urls;
|
|
58
|
+
}
|
package/src/hooks/I18nHooks.ts
CHANGED
|
@@ -17,7 +17,9 @@ export function useTranslation(): { t: TranslatorFn; ready: boolean } {
|
|
|
17
17
|
useEffect(() => {
|
|
18
18
|
void plugin.getTranslator().then((translator) => {
|
|
19
19
|
setTranslatorInstance(translator);
|
|
20
|
-
translator.onLanguageChanged(() =>
|
|
20
|
+
translator.onLanguageChanged(() => {
|
|
21
|
+
setUpdateCount((count) => count + 1);
|
|
22
|
+
});
|
|
21
23
|
});
|
|
22
24
|
}, [plugin]);
|
|
23
25
|
|
package/src/index.ts
CHANGED
|
@@ -7,3 +7,6 @@ export { FirstMessages } from './components/ai/utils';
|
|
|
7
7
|
export { useTranslation } from './hooks/I18nHooks';
|
|
8
8
|
export { Avatar } from './components/ai/Avatar';
|
|
9
9
|
export { VoiceRecorder } from './components/ai/EmbeddedAssistent/VoiceRecorder';
|
|
10
|
+
export { MarkdownEditor } from './components/editor/MarkdownEditor';
|
|
11
|
+
export type { MarkdownEditorProps, EditorLabels } from './components/editor/MarkdownEditor';
|
|
12
|
+
export { extractImageUrls } from './components/editor/imageUtils';
|