svelte-rtf-editor 1.3.0 → 1.4.0

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.
@@ -11,9 +11,22 @@
11
11
  * showStatusBar — Show the word/char count status bar. Default: true.
12
12
  * minHeight — CSS min-height of the editing area. Default: '40vh'.
13
13
  * readonly — If true, editor is not editable. Default: false.
14
+ * maxImageEdge — Longest edge (px) an inserted image is scaled down to.
15
+ * 0 keeps the original dimensions. Default: 1600.
16
+ * maxImageDisplayWidth
17
+ * — Width (px) a newly inserted image is displayed at, unless
18
+ * the editor column is narrower. Defaults to 624 — 6.5in of
19
+ * page at 96 dpi — so pictures arrive sized to the page
20
+ * rather than to the browser window. 0 fills the column.
21
+ * maxImageBytes — Encoded byte ceiling per inserted image. Pictures over it
22
+ * are re-encoded (and shrunk if needed) until they fit,
23
+ * which is what bounds the RTF payload — it costs two hex
24
+ * characters per byte. 0 disables. Default: 524288 (512 KB,
25
+ * about 1 MB of document per image).
14
26
  *
15
27
  * Events (callback props):
16
- * onchange — Fires on content change with { html, text, wordCount, charCount }.
28
+ * onchange — Fires on content change with
29
+ * { html, text, wordCount, charCount, estimatedRtfBytes }.
17
30
  * onsave — Fires on auto-save or Ctrl+S with { html }.
18
31
  * onimport — Fires after a successful RTF import with { html }.
19
32
  *
@@ -23,6 +36,7 @@
23
36
  * setHTML(html) — Replaces editor content with the given HTML.
24
37
  * getMarkdown() — Returns Markdown conversion of current content.
25
38
  * getRTF() — Returns RTF conversion of current content.
39
+ * getRtfSize() — Returns the exact byte size of that RTF.
26
40
  * clear() — Clears the editor (no confirm dialog).
27
41
  * focus() — Focuses the editor.
28
42
  * exportFile(format) — Downloads as 'html', 'md', or 'rtf'.
@@ -32,15 +46,31 @@
32
46
  import { onMount } from 'svelte';
33
47
  import Toolbar from './Toolbar.svelte';
34
48
  import Modal from './Modal.svelte';
49
+ import ImageOverlay from './ImageOverlay.svelte';
35
50
  import { htmlToMarkdown, downloadFile } from '../utils.js';
36
51
  import { readRtfFile } from '../rtf-parser.js';
37
- import { htmlToRtf } from '../rtf-writer.js';
52
+ import { htmlToRtf, estimateRtfBytes } from '../rtf-writer.js';
53
+ import {
54
+ fileToImageSrc,
55
+ urlToImageSrc,
56
+ isSafeImageUrl,
57
+ stripExtension,
58
+ initialDisplayWidth,
59
+ DEFAULT_MAX_IMAGE_EDGE,
60
+ DEFAULT_MAX_IMAGE_BYTES,
61
+ DEFAULT_MAX_IMAGE_DISPLAY_WIDTH
62
+ } from '../images.js';
38
63
 
39
64
  interface ChangePayload {
40
65
  html: string;
41
66
  text: string;
42
67
  wordCount: number;
43
68
  charCount: number;
69
+ /**
70
+ * Approximate size of this document as RTF. Pictures dominate it and are
71
+ * counted exactly; see getRtfSize() for the precise figure.
72
+ */
73
+ estimatedRtfBytes: number;
44
74
  }
45
75
 
46
76
  interface Props {
@@ -52,6 +82,9 @@
52
82
  showStatusBar?: boolean;
53
83
  minHeight?: string;
54
84
  readonly?: boolean;
85
+ maxImageEdge?: number;
86
+ maxImageBytes?: number;
87
+ maxImageDisplayWidth?: number;
55
88
  onchange?: (payload: ChangePayload) => void;
56
89
  onsave?: (payload: { html: string }) => void;
57
90
  onimport?: (payload: { html: string }) => void;
@@ -67,6 +100,9 @@
67
100
  showStatusBar = true,
68
101
  minHeight = '40vh',
69
102
  readonly = false,
103
+ maxImageEdge = DEFAULT_MAX_IMAGE_EDGE,
104
+ maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
105
+ maxImageDisplayWidth = DEFAULT_MAX_IMAGE_DISPLAY_WIDTH,
70
106
  onchange,
71
107
  onsave,
72
108
  onimport
@@ -74,6 +110,7 @@
74
110
 
75
111
  // ── Internal state ──
76
112
  let editorEl: HTMLDivElement | null = $state(null);
113
+ let contentWrapEl: HTMLDivElement | null = $state(null);
77
114
  let wordCount: number = $state(0);
78
115
  let charCount: number = $state(0);
79
116
  let lastSaved: string = $state('');
@@ -93,8 +130,16 @@
93
130
  let linkText: string = $state('');
94
131
  let imageUrl: string = $state('');
95
132
  let imageAlt: string = $state('');
133
+ let imageCaption: string = $state('');
96
134
  let savedSelection: Range | null = $state(null);
97
135
 
136
+ // Image state
137
+ let imageInputEl: HTMLInputElement | null = $state(null);
138
+ let pendingImageFiles: File[] = $state([]);
139
+ let selectedImage: HTMLImageElement | null = $state(null);
140
+ let overlayRef: { reposition: () => void } | null = $state(null);
141
+ let insertingImages: boolean = $state(false);
142
+
98
143
  // Import state
99
144
  let fileInputEl: HTMLInputElement | null = $state(null);
100
145
  let importing: boolean = $state(false);
@@ -111,6 +156,7 @@
111
156
 
112
157
  export function setHTML(html: string): void {
113
158
  if (editorEl) {
159
+ selectedImage = null;
114
160
  editorEl.innerHTML = html;
115
161
  updateCounts();
116
162
  if (autosave) scheduleAutoSave();
@@ -127,8 +173,21 @@
127
173
  return htmlToRtf(editorEl);
128
174
  }
129
175
 
176
+ /**
177
+ * Exact size of the RTF this document produces, in bytes. The output is
178
+ * ASCII, so this is also its character count — the figure a transport with a
179
+ * field-length limit cares about. Does the full conversion, including
180
+ * hex-encoding every picture, so call it when a number is needed rather than
181
+ * on every keystroke; the onchange payload carries an estimate for that.
182
+ */
183
+ export function getRtfSize(): number {
184
+ if (!editorEl) return 0;
185
+ return htmlToRtf(editorEl).length;
186
+ }
187
+
130
188
  export function clear(): void {
131
189
  if (editorEl) {
190
+ selectedImage = null;
132
191
  editorEl.innerHTML = '<p></p>';
133
192
  try { localStorage.removeItem(storageKey); } catch (e) {}
134
193
  updateCounts();
@@ -240,7 +299,8 @@
240
299
  html: editorEl?.innerHTML || '',
241
300
  text: editorEl?.innerText || '',
242
301
  wordCount,
243
- charCount
302
+ charCount,
303
+ estimatedRtfBytes: editorEl ? estimateRtfBytes(editorEl) : 0
244
304
  });
245
305
  }
246
306
 
@@ -252,7 +312,12 @@
252
312
  try {
253
313
  localStorage.setItem(storageKey, editorEl.innerHTML);
254
314
  lastSaved = `Saved ${new Date().toLocaleTimeString()}`;
255
- } catch (e) {}
315
+ } catch (e) {
316
+ // Embedded images can push a document past the localStorage quota. Say
317
+ // so instead of showing a "Saved" that did not happen — onsave still
318
+ // fires so the host app can persist the content itself.
319
+ lastSaved = 'Too large to auto-save in this browser';
320
+ }
256
321
  onsave?.({ html: editorEl.innerHTML });
257
322
  }
258
323
 
@@ -264,6 +329,10 @@
264
329
  // ── Input handler ──
265
330
  function handleInput(): void {
266
331
  updateToolbarState();
332
+ // Typing means the user is editing text, not the picture. Dropping the
333
+ // selection here keeps Backspace editing that text instead of deleting a
334
+ // still-selected image somewhere else in the document.
335
+ selectedImage = null;
267
336
  fireChange();
268
337
  if (autosave) scheduleAutoSave();
269
338
  }
@@ -300,20 +369,298 @@
300
369
  linkModalOpen = false;
301
370
  }
302
371
 
303
- // ── Image modal ──
372
+ // ── Images ──
304
373
  function openImageModal(): void {
305
374
  saveSelection();
306
375
  imageUrl = '';
307
376
  imageAlt = '';
377
+ imageCaption = '';
378
+ pendingImageFiles = [];
308
379
  imageModalOpen = true;
309
380
  }
310
381
 
311
- function applyImage(): void {
312
- if (!imageUrl) { imageModalOpen = false; return; }
313
- restoreSelection();
314
- editorEl?.focus();
315
- document.execCommand('insertHTML', false, `<img src="${imageUrl}" alt="${imageAlt || ''}" />`);
382
+ function chooseImageFiles(): void {
383
+ imageInputEl?.click();
384
+ }
385
+
386
+ function handleImageFilesChosen(e: Event): void {
387
+ const input = e.target as HTMLInputElement;
388
+ pendingImageFiles = Array.from(input.files || []).filter((f) => f.type.startsWith('image/'));
389
+ input.value = '';
390
+ }
391
+
392
+ async function applyImage(): Promise<void> {
393
+ const files = pendingImageFiles;
394
+ const url = imageUrl.trim();
395
+ const caption = imageCaption.trim();
396
+ const alt = imageAlt.trim();
397
+
316
398
  imageModalOpen = false;
399
+ pendingImageFiles = [];
400
+
401
+ if (files.length > 0) {
402
+ await insertImageFiles(files, caption, alt);
403
+ return;
404
+ }
405
+ if (!url) return;
406
+ if (!isSafeImageUrl(url)) {
407
+ showError('That image address is not supported');
408
+ return;
409
+ }
410
+
411
+ insertingImages = true;
412
+ try {
413
+ const { src, embedded } = await urlToImageSrc(url, imageLimits());
414
+ restoreSelection();
415
+ editorEl?.focus();
416
+ finishInsert(insertFigure(src, alt || caption, caption));
417
+ // It displays either way, but only embedded bytes reach the RTF.
418
+ if (!embedded) {
419
+ showError('That image could not be embedded — it will export as a placeholder');
420
+ }
421
+ } finally {
422
+ insertingImages = false;
423
+ }
424
+ }
425
+
426
+ /** Caps applied to a picture as it is inserted. */
427
+ function imageLimits(): { maxEdge: number; maxBytes: number } {
428
+ return { maxEdge: maxImageEdge, maxBytes: maxImageBytes };
429
+ }
430
+
431
+ /** Width in px available to an image — the editor's content box. */
432
+ function contentWidth(): number {
433
+ if (!editorEl) return 0;
434
+ const styles = getComputedStyle(editorEl);
435
+ const padding = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0');
436
+ return Math.max(0, editorEl.clientWidth - padding);
437
+ }
438
+
439
+ /** The direct child of the editor that contains the caret, if any. */
440
+ function caretBlock(): HTMLElement | null {
441
+ const sel = window.getSelection();
442
+ if (!sel || sel.rangeCount === 0 || !editorEl) return null;
443
+ let node: Node | null = sel.getRangeAt(0).startContainer;
444
+ if (!editorEl.contains(node)) return null;
445
+ while (node && node.parentNode !== editorEl) node = node.parentNode;
446
+ return node instanceof HTMLElement ? node : null;
447
+ }
448
+
449
+ function isEmptyBlock(el: HTMLElement): boolean {
450
+ if (el.querySelector('img, table, hr')) return false;
451
+ return (el.textContent || '').trim().length === 0;
452
+ }
453
+
454
+ function placeCaret(el: HTMLElement, atStart = true): void {
455
+ const range = document.createRange();
456
+ range.selectNodeContents(el);
457
+ range.collapse(atStart);
458
+ const sel = window.getSelection();
459
+ sel?.removeAllRanges();
460
+ sel?.addRange(range);
461
+ savedSelection = range.cloneRange();
462
+ }
463
+
464
+ /**
465
+ * Insert a <figure><img><figcaption> block at the caret. Figures are block
466
+ * level, so they are placed between top-level blocks rather than inside the
467
+ * paragraph the caret happens to be in.
468
+ */
469
+ function insertFigure(
470
+ src: string,
471
+ alt: string,
472
+ caption: string
473
+ ): { img: HTMLImageElement; after: HTMLElement } | null {
474
+ if (!editorEl) return null;
475
+
476
+ const figure = document.createElement('figure');
477
+ const img = document.createElement('img');
478
+ img.src = src;
479
+ if (alt) img.alt = alt;
480
+ const figcaption = document.createElement('figcaption');
481
+ if (caption) figcaption.textContent = caption;
482
+ figure.append(img, figcaption);
483
+
484
+ const block = caretBlock();
485
+ if (block && isEmptyBlock(block)) block.replaceWith(figure);
486
+ else if (block) block.after(figure);
487
+ else editorEl.appendChild(figure);
488
+
489
+ // Guarantee somewhere to keep typing after the image.
490
+ let after = figure.nextElementSibling as HTMLElement | null;
491
+ if (!after || after.tagName === 'FIGURE') {
492
+ const p = document.createElement('p');
493
+ p.appendChild(document.createElement('br'));
494
+ figure.after(p);
495
+ after = p;
496
+ }
497
+
498
+ fitImageWhenLoaded(img);
499
+ return { img, after };
500
+ }
501
+
502
+ /**
503
+ * Size a freshly inserted image: the smallest of its natural width, the
504
+ * editor column and the page cap, never larger. The page cap is what keeps
505
+ * \picwgoal inside the width of the page the document lands on — a browser
506
+ * -wide picture would be written as 12in and overflow it. The user can still
507
+ * enlarge it deliberately with the resize handles.
508
+ */
509
+ function fitImage(img: HTMLImageElement): void {
510
+ const width = initialDisplayWidth(img.naturalWidth, contentWidth(), maxImageDisplayWidth);
511
+ if (!width) return;
512
+ img.style.width = `${width}px`;
513
+ img.style.height = 'auto';
514
+ overlayRef?.reposition();
515
+ }
516
+
517
+ function fitImageWhenLoaded(img: HTMLImageElement): void {
518
+ if (img.complete && img.naturalWidth) {
519
+ fitImage(img);
520
+ return;
521
+ }
522
+ img.addEventListener(
523
+ 'load',
524
+ () => {
525
+ fitImage(img);
526
+ updateCounts();
527
+ fireChange();
528
+ if (autosave) scheduleAutoSave();
529
+ },
530
+ { once: true }
531
+ );
532
+ }
533
+
534
+ function finishInsert(inserted: { img: HTMLImageElement; after: HTMLElement } | null): void {
535
+ if (!inserted) return;
536
+ placeCaret(inserted.after);
537
+ selectedImage = inserted.img;
538
+ updateCounts();
539
+ fireChange();
540
+ if (autosave) scheduleAutoSave();
541
+ }
542
+
543
+ /** Insert one figure per file, in the order they were given. */
544
+ async function insertImageFiles(files: File[], caption = '', alt = ''): Promise<void> {
545
+ if (files.length === 0) return;
546
+ insertingImages = true;
547
+ let inserted: { img: HTMLImageElement; after: HTMLElement } | null = null;
548
+
549
+ try {
550
+ for (const file of files) {
551
+ try {
552
+ const src = await fileToImageSrc(file, imageLimits());
553
+ restoreSelection();
554
+ editorEl?.focus();
555
+ // A single image takes the description typed in the dialog; a batch
556
+ // gets empty captions the user fills in under each picture.
557
+ const isBatch = files.length > 1;
558
+ inserted = insertFigure(
559
+ src,
560
+ alt || caption || stripExtension(file.name),
561
+ isBatch ? '' : caption
562
+ );
563
+ if (inserted) placeCaret(inserted.after);
564
+ } catch (err) {
565
+ showError((err as Error).message || 'Could not insert the image');
566
+ }
567
+ }
568
+ } finally {
569
+ insertingImages = false;
570
+ }
571
+
572
+ finishInsert(inserted);
573
+ }
574
+
575
+ function showError(message: string): void {
576
+ importError = message;
577
+ setTimeout(() => (importError = ''), 4000);
578
+ }
579
+
580
+ // ── Image selection / editing ──
581
+ function handleContentClick(e: MouseEvent): void {
582
+ if (readonly) return;
583
+ const target = e.target as HTMLElement | null;
584
+ selectedImage = target?.tagName === 'IMG' ? (target as HTMLImageElement) : null;
585
+ }
586
+
587
+ function selectionInCaption(): boolean {
588
+ const node = window.getSelection()?.anchorNode;
589
+ if (!node) return false;
590
+ const el = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
591
+ return !!el?.closest('figcaption');
592
+ }
593
+
594
+ function removeSelectedImage(): void {
595
+ if (!selectedImage) return;
596
+ const figure = selectedImage.closest('figure');
597
+ const next = (figure ?? selectedImage).nextElementSibling as HTMLElement | null;
598
+ (figure ?? selectedImage).remove();
599
+ selectedImage = null;
600
+ if (next) placeCaret(next);
601
+ updateCounts();
602
+ fireChange();
603
+ if (autosave) scheduleAutoSave();
604
+ }
605
+
606
+ /** Put the caret in the image's description line, creating it if needed. */
607
+ function focusCaption(img: HTMLImageElement): void {
608
+ const figure = img.closest('figure');
609
+ if (!figure) return;
610
+ let caption = figure.querySelector('figcaption');
611
+ if (!caption) {
612
+ caption = document.createElement('figcaption');
613
+ figure.appendChild(caption);
614
+ }
615
+ editorEl?.focus();
616
+ placeCaret(caption as HTMLElement, false);
617
+ }
618
+
619
+ function handleImageEdited(): void {
620
+ updateCounts();
621
+ fireChange();
622
+ if (autosave) scheduleAutoSave();
623
+ }
624
+
625
+ function handleDragOver(e: DragEvent): void {
626
+ if (readonly) return;
627
+ if (Array.from(e.dataTransfer?.types || []).includes('Files')) e.preventDefault();
628
+ }
629
+
630
+ function handleDrop(e: DragEvent): void {
631
+ if (readonly) return;
632
+ const files = Array.from(e.dataTransfer?.files || []).filter((f) => f.type.startsWith('image/'));
633
+ if (files.length === 0) return;
634
+ e.preventDefault();
635
+ editorEl?.focus();
636
+ placeCaretAtPoint(e.clientX, e.clientY);
637
+ void insertImageFiles(files);
638
+ }
639
+
640
+ /** Move the caret under the pointer so a dropped image lands where it was dropped. */
641
+ function placeCaretAtPoint(x: number, y: number): void {
642
+ const doc = document as Document & {
643
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
644
+ caretRangeFromPoint?: (x: number, y: number) => Range | null;
645
+ };
646
+
647
+ let range: Range | null = null;
648
+ if (typeof doc.caretPositionFromPoint === 'function') {
649
+ const pos = doc.caretPositionFromPoint(x, y);
650
+ if (pos) {
651
+ range = document.createRange();
652
+ range.setStart(pos.offsetNode, pos.offset);
653
+ range.collapse(true);
654
+ }
655
+ } else if (typeof doc.caretRangeFromPoint === 'function') {
656
+ range = doc.caretRangeFromPoint(x, y);
657
+ }
658
+
659
+ if (!range || !editorEl?.contains(range.startContainer)) return;
660
+ const sel = window.getSelection();
661
+ sel?.removeAllRanges();
662
+ sel?.addRange(range);
663
+ savedSelection = range.cloneRange();
317
664
  }
318
665
 
319
666
  // ── Import RTF ──
@@ -328,6 +675,7 @@
328
675
  try {
329
676
  const html = await readRtfFile(file);
330
677
  if (editorEl) {
678
+ selectedImage = null;
331
679
  editorEl.innerHTML = html;
332
680
  updateCounts();
333
681
  fireChange();
@@ -335,8 +683,7 @@
335
683
  onimport?.({ html });
336
684
  }
337
685
  } catch (err) {
338
- importError = (err as Error).message || 'Failed to import RTF file';
339
- setTimeout(() => (importError = ''), 4000);
686
+ showError((err as Error).message || 'Failed to import RTF file');
340
687
  } finally {
341
688
  importing = false;
342
689
  if (fileInputEl) fileInputEl.value = '';
@@ -352,6 +699,22 @@
352
699
  if (mod && e.key === 'k') { e.preventDefault(); openLinkModal(); }
353
700
  if (mod && e.key === 's') { e.preventDefault(); autoSaveNow(); }
354
701
 
702
+ // Moving the caret leaves the picture behind, so drop its selection frame.
703
+ // Deliberately falls through: these keys still have their normal meaning,
704
+ // including Tab inside a <pre> below.
705
+ if (selectedImage && (e.key.startsWith('Arrow') || e.key === 'Enter' || e.key === 'Escape' ||
706
+ e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
707
+ selectedImage = null;
708
+ }
709
+
710
+ // Backspace/Delete removes a selected image — but not while the caret is
711
+ // in its description, where those keys must edit text as usual.
712
+ if (selectedImage && !selectionInCaption() && (e.key === 'Backspace' || e.key === 'Delete')) {
713
+ e.preventDefault();
714
+ removeSelectedImage();
715
+ return;
716
+ }
717
+
355
718
  if (e.key === 'Tab') {
356
719
  const sel = window.getSelection();
357
720
  if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
@@ -362,6 +725,18 @@
362
725
  }
363
726
 
364
727
  function handlePaste(e: ClipboardEvent): void {
728
+ // clipboardData is only valid during the event — pull the files out first.
729
+ const files = Array.from(e.clipboardData?.items || [])
730
+ .filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
731
+ .map((item) => item.getAsFile())
732
+ .filter((file): file is File => file !== null);
733
+
734
+ if (files.length > 0 && !readonly) {
735
+ e.preventDefault();
736
+ void insertImageFiles(files);
737
+ return;
738
+ }
739
+
365
740
  const sel = window.getSelection();
366
741
  if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
367
742
  e.preventDefault();
@@ -403,6 +778,16 @@
403
778
  onchange={handleFileImport}
404
779
  />
405
780
 
781
+ <!-- Hidden file input for image insertion (multiple images at once) -->
782
+ <input
783
+ type="file"
784
+ accept="image/*"
785
+ multiple
786
+ class="ink-hidden-input"
787
+ bind:this={imageInputEl}
788
+ onchange={handleImageFilesChosen}
789
+ />
790
+
406
791
  <div class="ink-editor" style="--ink-min-height: {minHeight};">
407
792
  {#if showToolbar && !readonly}
408
793
  <Toolbar
@@ -415,19 +800,36 @@
415
800
  />
416
801
  {/if}
417
802
 
418
- <!-- svelte-ignore a11y_no_static_element_interactions -->
419
- <div
420
- class="ink-content"
421
- contenteditable={!readonly}
422
- spellcheck="true"
423
- bind:this={editorEl}
424
- oninput={handleInput}
425
- onmouseup={updateToolbarState}
426
- onkeyup={updateToolbarState}
427
- onkeydown={handleKeydown}
428
- onpaste={handlePaste}
429
- data-placeholder={placeholder}
430
- ></div>
803
+ <div class="ink-content-wrap" bind:this={contentWrapEl}>
804
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
805
+ <div
806
+ class="ink-content"
807
+ contenteditable={!readonly}
808
+ spellcheck="true"
809
+ bind:this={editorEl}
810
+ oninput={handleInput}
811
+ onmouseup={updateToolbarState}
812
+ onkeyup={updateToolbarState}
813
+ onkeydown={handleKeydown}
814
+ onpaste={handlePaste}
815
+ onclick={handleContentClick}
816
+ ondragover={handleDragOver}
817
+ ondrop={handleDrop}
818
+ data-placeholder={placeholder}
819
+ ></div>
820
+
821
+ {#if !readonly}
822
+ <ImageOverlay
823
+ bind:this={overlayRef}
824
+ target={selectedImage}
825
+ bounds={contentWrapEl}
826
+ maxWidth={contentWidth}
827
+ onchange={handleImageEdited}
828
+ onremove={removeSelectedImage}
829
+ oncaption={focusCaption}
830
+ />
831
+ {/if}
832
+ </div>
431
833
 
432
834
  {#if showStatusBar}
433
835
  <div class="ink-status-bar">
@@ -456,16 +858,43 @@
456
858
 
457
859
  <!-- Image Modal -->
458
860
  <Modal title="Insert Image" open={imageModalOpen} onclose={() => (imageModalOpen = false)}>
459
- <input
460
- type="url"
461
- placeholder="https://example.com/image.jpg"
462
- bind:value={imageUrl}
463
- onkeydown={(e) => e.key === 'Enter' && applyImage()}
464
- />
465
- <input type="text" placeholder="Alt text (optional)" bind:value={imageAlt} />
861
+ <button class="ink-file-pick" onclick={chooseImageFiles}>
862
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3"/></svg>
863
+ {pendingImageFiles.length > 0
864
+ ? `${pendingImageFiles.length} image${pendingImageFiles.length === 1 ? '' : 's'} selected`
865
+ : 'Choose images from this device'}
866
+ </button>
867
+
868
+ {#if pendingImageFiles.length > 0}
869
+ <p class="ink-file-list">{pendingImageFiles.map((f) => f.name).join(', ')}</p>
870
+ {/if}
871
+
872
+ {#if pendingImageFiles.length === 0}
873
+ <div class="ink-modal-or"><span>or paste an address</span></div>
874
+ <input
875
+ type="url"
876
+ placeholder="https://example.com/image.jpg"
877
+ bind:value={imageUrl}
878
+ onkeydown={(e) => e.key === 'Enter' && applyImage()}
879
+ />
880
+ {/if}
881
+
882
+ {#if pendingImageFiles.length > 1}
883
+ <p class="ink-modal-hint">Add a description under each image after inserting.</p>
884
+ {:else}
885
+ <input type="text" placeholder="Description shown below the image (optional)" bind:value={imageCaption} />
886
+ {/if}
887
+ <input type="text" placeholder="Alt text for screen readers (optional)" bind:value={imageAlt} />
888
+
466
889
  {#snippet actions()}
467
890
  <button class="ink-btn-ghost" onclick={() => (imageModalOpen = false)}>Cancel</button>
468
- <button class="ink-btn-primary" onclick={applyImage}>Insert</button>
891
+ <button
892
+ class="ink-btn-primary"
893
+ disabled={insertingImages || (pendingImageFiles.length === 0 && !imageUrl.trim())}
894
+ onclick={applyImage}
895
+ >
896
+ {insertingImages ? 'Inserting…' : 'Insert'}
897
+ </button>
469
898
  {/snippet}
470
899
  </Modal>
471
900
 
@@ -486,6 +915,12 @@
486
915
  width: 100%;
487
916
  }
488
917
 
918
+ /* Anchors the image selection frame, which lives outside the
919
+ contenteditable area so it never lands in the exported HTML. */
920
+ .ink-content-wrap {
921
+ position: relative;
922
+ }
923
+
489
924
  /* ── Content Area ── */
490
925
  .ink-content {
491
926
  min-height: var(--ink-min-height, 40vh);
@@ -603,10 +1038,35 @@
603
1038
 
604
1039
  .ink-content :global(img) {
605
1040
  max-width: 100%;
1041
+ height: auto;
606
1042
  border-radius: var(--radius, 8px);
607
1043
  margin: 16px 0;
608
1044
  }
609
1045
 
1046
+ /* ── Figures (image + description) ── */
1047
+ .ink-content :global(figure) {
1048
+ margin: 20px 0;
1049
+ }
1050
+
1051
+ .ink-content :global(figure img) {
1052
+ margin: 0;
1053
+ vertical-align: bottom;
1054
+ cursor: pointer;
1055
+ }
1056
+
1057
+ .ink-content :global(figcaption) {
1058
+ margin-top: 8px;
1059
+ font-size: 13px;
1060
+ line-height: 1.5;
1061
+ font-style: italic;
1062
+ color: var(--text-muted, #8a7e72);
1063
+ }
1064
+
1065
+ .ink-content :global(figcaption:empty)::before {
1066
+ content: 'Add a description…';
1067
+ opacity: 0.55;
1068
+ }
1069
+
610
1070
  .ink-content :global(hr) {
611
1071
  border: none;
612
1072
  height: 1px;
@@ -647,6 +1107,69 @@
647
1107
  background: var(--accent-hover, #be5524);
648
1108
  }
649
1109
 
1110
+ :global(.ink-btn-primary:disabled) {
1111
+ opacity: 0.5;
1112
+ cursor: not-allowed;
1113
+ }
1114
+
1115
+ /* ── Image dialog ── */
1116
+ .ink-file-pick {
1117
+ display: flex;
1118
+ align-items: center;
1119
+ justify-content: center;
1120
+ gap: 8px;
1121
+ width: 100%;
1122
+ padding: 14px;
1123
+ margin-bottom: 12px;
1124
+ border: 1px dashed var(--border-active, #c4b5a0);
1125
+ border-radius: var(--radius-sm, 5px);
1126
+ background: var(--surface, #f2f0ec);
1127
+ font-family: 'DM Sans', sans-serif;
1128
+ font-size: 13px;
1129
+ font-weight: 500;
1130
+ color: var(--text, #2c2520);
1131
+ cursor: pointer;
1132
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
1133
+ }
1134
+
1135
+ .ink-file-pick:hover {
1136
+ border-color: var(--accent, #d4622b);
1137
+ color: var(--accent, #d4622b);
1138
+ }
1139
+
1140
+ .ink-file-list {
1141
+ margin: -6px 0 12px;
1142
+ font-size: 12px;
1143
+ line-height: 1.5;
1144
+ color: var(--text-muted, #8a7e72);
1145
+ word-break: break-word;
1146
+ }
1147
+
1148
+ .ink-modal-hint {
1149
+ margin: 0 0 12px;
1150
+ font-size: 12px;
1151
+ color: var(--text-muted, #8a7e72);
1152
+ }
1153
+
1154
+ .ink-modal-or {
1155
+ display: flex;
1156
+ align-items: center;
1157
+ gap: 10px;
1158
+ margin-bottom: 12px;
1159
+ font-size: 11px;
1160
+ text-transform: uppercase;
1161
+ letter-spacing: 0.5px;
1162
+ color: var(--text-muted, #8a7e72);
1163
+ }
1164
+
1165
+ .ink-modal-or::before,
1166
+ .ink-modal-or::after {
1167
+ content: '';
1168
+ flex: 1;
1169
+ height: 1px;
1170
+ background: var(--border, #e5e2dc);
1171
+ }
1172
+
650
1173
  :global(.ink-btn-ghost) {
651
1174
  background: none;
652
1175
  border: 1px solid var(--border, #e5e2dc);