@rimori/react-client 0.4.9 → 0.4.10

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,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 `![alt](url)` 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
+ }
@@ -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(() => setUpdateCount(updateCount + 1));
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';