svelte-rtf-editor 1.3.0 → 1.5.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,26 @@
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).
26
+ * fontFamily — Font for the document, both on screen and in the RTF.
27
+ * Default: 'Arial'.
28
+ * fontSize — Body text size in points, both on screen and in the RTF.
29
+ * Headings scale from it. Default: 12.
14
30
  *
15
31
  * Events (callback props):
16
- * onchange — Fires on content change with { html, text, wordCount, charCount }.
32
+ * onchange — Fires on content change with
33
+ * { html, text, wordCount, charCount, estimatedRtfBytes }.
17
34
  * onsave — Fires on auto-save or Ctrl+S with { html }.
18
35
  * onimport — Fires after a successful RTF import with { html }.
19
36
  *
@@ -23,6 +40,7 @@
23
40
  * setHTML(html) — Replaces editor content with the given HTML.
24
41
  * getMarkdown() — Returns Markdown conversion of current content.
25
42
  * getRTF() — Returns RTF conversion of current content.
43
+ * getRtfSize() — Returns the exact byte size of that RTF.
26
44
  * clear() — Clears the editor (no confirm dialog).
27
45
  * focus() — Focuses the editor.
28
46
  * exportFile(format) — Downloads as 'html', 'md', or 'rtf'.
@@ -32,15 +50,38 @@
32
50
  import { onMount } from 'svelte';
33
51
  import Toolbar from './Toolbar.svelte';
34
52
  import Modal from './Modal.svelte';
53
+ import ImageOverlay from './ImageOverlay.svelte';
35
54
  import { htmlToMarkdown, downloadFile } from '../utils.js';
36
55
  import { readRtfFile } from '../rtf-parser.js';
37
- import { htmlToRtf } from '../rtf-writer.js';
56
+ import {
57
+ htmlToRtf,
58
+ estimateRtfBytes,
59
+ cssFontFamily,
60
+ normalizeFontSize,
61
+ DEFAULT_FONT_FAMILY,
62
+ DEFAULT_FONT_SIZE
63
+ } from '../rtf-writer.js';
64
+ import {
65
+ fileToImageSrc,
66
+ urlToImageSrc,
67
+ isSafeImageUrl,
68
+ stripExtension,
69
+ initialDisplayWidth,
70
+ DEFAULT_MAX_IMAGE_EDGE,
71
+ DEFAULT_MAX_IMAGE_BYTES,
72
+ DEFAULT_MAX_IMAGE_DISPLAY_WIDTH
73
+ } from '../images.js';
38
74
 
39
75
  interface ChangePayload {
40
76
  html: string;
41
77
  text: string;
42
78
  wordCount: number;
43
79
  charCount: number;
80
+ /**
81
+ * Approximate size of this document as RTF. Pictures dominate it and are
82
+ * counted exactly; see getRtfSize() for the precise figure.
83
+ */
84
+ estimatedRtfBytes: number;
44
85
  }
45
86
 
46
87
  interface Props {
@@ -52,6 +93,11 @@
52
93
  showStatusBar?: boolean;
53
94
  minHeight?: string;
54
95
  readonly?: boolean;
96
+ maxImageEdge?: number;
97
+ maxImageBytes?: number;
98
+ maxImageDisplayWidth?: number;
99
+ fontFamily?: string;
100
+ fontSize?: number;
55
101
  onchange?: (payload: ChangePayload) => void;
56
102
  onsave?: (payload: { html: string }) => void;
57
103
  onimport?: (payload: { html: string }) => void;
@@ -67,13 +113,25 @@
67
113
  showStatusBar = true,
68
114
  minHeight = '40vh',
69
115
  readonly = false,
116
+ maxImageEdge = DEFAULT_MAX_IMAGE_EDGE,
117
+ maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
118
+ maxImageDisplayWidth = DEFAULT_MAX_IMAGE_DISPLAY_WIDTH,
119
+ fontFamily = DEFAULT_FONT_FAMILY,
120
+ fontSize = DEFAULT_FONT_SIZE,
70
121
  onchange,
71
122
  onsave,
72
123
  onimport
73
124
  }: Props = $props();
74
125
 
126
+ // The same font and size go to the screen and to the RTF, so the editor
127
+ // shows what the receiving system will show.
128
+ const rtfOptions = $derived({ fontFamily, fontSize });
129
+ const screenFontFamily = $derived(cssFontFamily(fontFamily));
130
+ const screenFontSize = $derived(normalizeFontSize(fontSize));
131
+
75
132
  // ── Internal state ──
76
133
  let editorEl: HTMLDivElement | null = $state(null);
134
+ let contentWrapEl: HTMLDivElement | null = $state(null);
77
135
  let wordCount: number = $state(0);
78
136
  let charCount: number = $state(0);
79
137
  let lastSaved: string = $state('');
@@ -93,8 +151,16 @@
93
151
  let linkText: string = $state('');
94
152
  let imageUrl: string = $state('');
95
153
  let imageAlt: string = $state('');
154
+ let imageCaption: string = $state('');
96
155
  let savedSelection: Range | null = $state(null);
97
156
 
157
+ // Image state
158
+ let imageInputEl: HTMLInputElement | null = $state(null);
159
+ let pendingImageFiles: File[] = $state([]);
160
+ let selectedImage: HTMLImageElement | null = $state(null);
161
+ let overlayRef: { reposition: () => void } | null = $state(null);
162
+ let insertingImages: boolean = $state(false);
163
+
98
164
  // Import state
99
165
  let fileInputEl: HTMLInputElement | null = $state(null);
100
166
  let importing: boolean = $state(false);
@@ -111,6 +177,7 @@
111
177
 
112
178
  export function setHTML(html: string): void {
113
179
  if (editorEl) {
180
+ selectedImage = null;
114
181
  editorEl.innerHTML = html;
115
182
  updateCounts();
116
183
  if (autosave) scheduleAutoSave();
@@ -124,11 +191,24 @@
124
191
 
125
192
  export function getRTF(): string {
126
193
  if (!editorEl) return '';
127
- return htmlToRtf(editorEl);
194
+ return htmlToRtf(editorEl, rtfOptions);
195
+ }
196
+
197
+ /**
198
+ * Exact size of the RTF this document produces, in bytes. The output is
199
+ * ASCII, so this is also its character count — the figure a transport with a
200
+ * field-length limit cares about. Does the full conversion, including
201
+ * hex-encoding every picture, so call it when a number is needed rather than
202
+ * on every keystroke; the onchange payload carries an estimate for that.
203
+ */
204
+ export function getRtfSize(): number {
205
+ if (!editorEl) return 0;
206
+ return htmlToRtf(editorEl, rtfOptions).length;
128
207
  }
129
208
 
130
209
  export function clear(): void {
131
210
  if (editorEl) {
211
+ selectedImage = null;
132
212
  editorEl.innerHTML = '<p></p>';
133
213
  try { localStorage.removeItem(storageKey); } catch (e) {}
134
214
  updateCounts();
@@ -156,7 +236,7 @@
156
236
  break;
157
237
  }
158
238
  case 'rtf': {
159
- const rtf = htmlToRtf(editorEl);
239
+ const rtf = htmlToRtf(editorEl, rtfOptions);
160
240
  downloadFile('document.rtf', rtf, 'application/rtf');
161
241
  break;
162
242
  }
@@ -240,7 +320,8 @@
240
320
  html: editorEl?.innerHTML || '',
241
321
  text: editorEl?.innerText || '',
242
322
  wordCount,
243
- charCount
323
+ charCount,
324
+ estimatedRtfBytes: editorEl ? estimateRtfBytes(editorEl) : 0
244
325
  });
245
326
  }
246
327
 
@@ -252,7 +333,12 @@
252
333
  try {
253
334
  localStorage.setItem(storageKey, editorEl.innerHTML);
254
335
  lastSaved = `Saved ${new Date().toLocaleTimeString()}`;
255
- } catch (e) {}
336
+ } catch (e) {
337
+ // Embedded images can push a document past the localStorage quota. Say
338
+ // so instead of showing a "Saved" that did not happen — onsave still
339
+ // fires so the host app can persist the content itself.
340
+ lastSaved = 'Too large to auto-save in this browser';
341
+ }
256
342
  onsave?.({ html: editorEl.innerHTML });
257
343
  }
258
344
 
@@ -264,6 +350,10 @@
264
350
  // ── Input handler ──
265
351
  function handleInput(): void {
266
352
  updateToolbarState();
353
+ // Typing means the user is editing text, not the picture. Dropping the
354
+ // selection here keeps Backspace editing that text instead of deleting a
355
+ // still-selected image somewhere else in the document.
356
+ selectedImage = null;
267
357
  fireChange();
268
358
  if (autosave) scheduleAutoSave();
269
359
  }
@@ -300,20 +390,298 @@
300
390
  linkModalOpen = false;
301
391
  }
302
392
 
303
- // ── Image modal ──
393
+ // ── Images ──
304
394
  function openImageModal(): void {
305
395
  saveSelection();
306
396
  imageUrl = '';
307
397
  imageAlt = '';
398
+ imageCaption = '';
399
+ pendingImageFiles = [];
308
400
  imageModalOpen = true;
309
401
  }
310
402
 
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 || ''}" />`);
403
+ function chooseImageFiles(): void {
404
+ imageInputEl?.click();
405
+ }
406
+
407
+ function handleImageFilesChosen(e: Event): void {
408
+ const input = e.target as HTMLInputElement;
409
+ pendingImageFiles = Array.from(input.files || []).filter((f) => f.type.startsWith('image/'));
410
+ input.value = '';
411
+ }
412
+
413
+ async function applyImage(): Promise<void> {
414
+ const files = pendingImageFiles;
415
+ const url = imageUrl.trim();
416
+ const caption = imageCaption.trim();
417
+ const alt = imageAlt.trim();
418
+
316
419
  imageModalOpen = false;
420
+ pendingImageFiles = [];
421
+
422
+ if (files.length > 0) {
423
+ await insertImageFiles(files, caption, alt);
424
+ return;
425
+ }
426
+ if (!url) return;
427
+ if (!isSafeImageUrl(url)) {
428
+ showError('That image address is not supported');
429
+ return;
430
+ }
431
+
432
+ insertingImages = true;
433
+ try {
434
+ const { src, embedded } = await urlToImageSrc(url, imageLimits());
435
+ restoreSelection();
436
+ editorEl?.focus();
437
+ finishInsert(insertFigure(src, alt || caption, caption));
438
+ // It displays either way, but only embedded bytes reach the RTF.
439
+ if (!embedded) {
440
+ showError('That image could not be embedded — it will export as a placeholder');
441
+ }
442
+ } finally {
443
+ insertingImages = false;
444
+ }
445
+ }
446
+
447
+ /** Caps applied to a picture as it is inserted. */
448
+ function imageLimits(): { maxEdge: number; maxBytes: number } {
449
+ return { maxEdge: maxImageEdge, maxBytes: maxImageBytes };
450
+ }
451
+
452
+ /** Width in px available to an image — the editor's content box. */
453
+ function contentWidth(): number {
454
+ if (!editorEl) return 0;
455
+ const styles = getComputedStyle(editorEl);
456
+ const padding = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0');
457
+ return Math.max(0, editorEl.clientWidth - padding);
458
+ }
459
+
460
+ /** The direct child of the editor that contains the caret, if any. */
461
+ function caretBlock(): HTMLElement | null {
462
+ const sel = window.getSelection();
463
+ if (!sel || sel.rangeCount === 0 || !editorEl) return null;
464
+ let node: Node | null = sel.getRangeAt(0).startContainer;
465
+ if (!editorEl.contains(node)) return null;
466
+ while (node && node.parentNode !== editorEl) node = node.parentNode;
467
+ return node instanceof HTMLElement ? node : null;
468
+ }
469
+
470
+ function isEmptyBlock(el: HTMLElement): boolean {
471
+ if (el.querySelector('img, table, hr')) return false;
472
+ return (el.textContent || '').trim().length === 0;
473
+ }
474
+
475
+ function placeCaret(el: HTMLElement, atStart = true): void {
476
+ const range = document.createRange();
477
+ range.selectNodeContents(el);
478
+ range.collapse(atStart);
479
+ const sel = window.getSelection();
480
+ sel?.removeAllRanges();
481
+ sel?.addRange(range);
482
+ savedSelection = range.cloneRange();
483
+ }
484
+
485
+ /**
486
+ * Insert a <figure><img><figcaption> block at the caret. Figures are block
487
+ * level, so they are placed between top-level blocks rather than inside the
488
+ * paragraph the caret happens to be in.
489
+ */
490
+ function insertFigure(
491
+ src: string,
492
+ alt: string,
493
+ caption: string
494
+ ): { img: HTMLImageElement; after: HTMLElement } | null {
495
+ if (!editorEl) return null;
496
+
497
+ const figure = document.createElement('figure');
498
+ const img = document.createElement('img');
499
+ img.src = src;
500
+ if (alt) img.alt = alt;
501
+ const figcaption = document.createElement('figcaption');
502
+ if (caption) figcaption.textContent = caption;
503
+ figure.append(img, figcaption);
504
+
505
+ const block = caretBlock();
506
+ if (block && isEmptyBlock(block)) block.replaceWith(figure);
507
+ else if (block) block.after(figure);
508
+ else editorEl.appendChild(figure);
509
+
510
+ // Guarantee somewhere to keep typing after the image.
511
+ let after = figure.nextElementSibling as HTMLElement | null;
512
+ if (!after || after.tagName === 'FIGURE') {
513
+ const p = document.createElement('p');
514
+ p.appendChild(document.createElement('br'));
515
+ figure.after(p);
516
+ after = p;
517
+ }
518
+
519
+ fitImageWhenLoaded(img);
520
+ return { img, after };
521
+ }
522
+
523
+ /**
524
+ * Size a freshly inserted image: the smallest of its natural width, the
525
+ * editor column and the page cap, never larger. The page cap is what keeps
526
+ * \picwgoal inside the width of the page the document lands on — a browser
527
+ * -wide picture would be written as 12in and overflow it. The user can still
528
+ * enlarge it deliberately with the resize handles.
529
+ */
530
+ function fitImage(img: HTMLImageElement): void {
531
+ const width = initialDisplayWidth(img.naturalWidth, contentWidth(), maxImageDisplayWidth);
532
+ if (!width) return;
533
+ img.style.width = `${width}px`;
534
+ img.style.height = 'auto';
535
+ overlayRef?.reposition();
536
+ }
537
+
538
+ function fitImageWhenLoaded(img: HTMLImageElement): void {
539
+ if (img.complete && img.naturalWidth) {
540
+ fitImage(img);
541
+ return;
542
+ }
543
+ img.addEventListener(
544
+ 'load',
545
+ () => {
546
+ fitImage(img);
547
+ updateCounts();
548
+ fireChange();
549
+ if (autosave) scheduleAutoSave();
550
+ },
551
+ { once: true }
552
+ );
553
+ }
554
+
555
+ function finishInsert(inserted: { img: HTMLImageElement; after: HTMLElement } | null): void {
556
+ if (!inserted) return;
557
+ placeCaret(inserted.after);
558
+ selectedImage = inserted.img;
559
+ updateCounts();
560
+ fireChange();
561
+ if (autosave) scheduleAutoSave();
562
+ }
563
+
564
+ /** Insert one figure per file, in the order they were given. */
565
+ async function insertImageFiles(files: File[], caption = '', alt = ''): Promise<void> {
566
+ if (files.length === 0) return;
567
+ insertingImages = true;
568
+ let inserted: { img: HTMLImageElement; after: HTMLElement } | null = null;
569
+
570
+ try {
571
+ for (const file of files) {
572
+ try {
573
+ const src = await fileToImageSrc(file, imageLimits());
574
+ restoreSelection();
575
+ editorEl?.focus();
576
+ // A single image takes the description typed in the dialog; a batch
577
+ // gets empty captions the user fills in under each picture.
578
+ const isBatch = files.length > 1;
579
+ inserted = insertFigure(
580
+ src,
581
+ alt || caption || stripExtension(file.name),
582
+ isBatch ? '' : caption
583
+ );
584
+ if (inserted) placeCaret(inserted.after);
585
+ } catch (err) {
586
+ showError((err as Error).message || 'Could not insert the image');
587
+ }
588
+ }
589
+ } finally {
590
+ insertingImages = false;
591
+ }
592
+
593
+ finishInsert(inserted);
594
+ }
595
+
596
+ function showError(message: string): void {
597
+ importError = message;
598
+ setTimeout(() => (importError = ''), 4000);
599
+ }
600
+
601
+ // ── Image selection / editing ──
602
+ function handleContentClick(e: MouseEvent): void {
603
+ if (readonly) return;
604
+ const target = e.target as HTMLElement | null;
605
+ selectedImage = target?.tagName === 'IMG' ? (target as HTMLImageElement) : null;
606
+ }
607
+
608
+ function selectionInCaption(): boolean {
609
+ const node = window.getSelection()?.anchorNode;
610
+ if (!node) return false;
611
+ const el = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
612
+ return !!el?.closest('figcaption');
613
+ }
614
+
615
+ function removeSelectedImage(): void {
616
+ if (!selectedImage) return;
617
+ const figure = selectedImage.closest('figure');
618
+ const next = (figure ?? selectedImage).nextElementSibling as HTMLElement | null;
619
+ (figure ?? selectedImage).remove();
620
+ selectedImage = null;
621
+ if (next) placeCaret(next);
622
+ updateCounts();
623
+ fireChange();
624
+ if (autosave) scheduleAutoSave();
625
+ }
626
+
627
+ /** Put the caret in the image's description line, creating it if needed. */
628
+ function focusCaption(img: HTMLImageElement): void {
629
+ const figure = img.closest('figure');
630
+ if (!figure) return;
631
+ let caption = figure.querySelector('figcaption');
632
+ if (!caption) {
633
+ caption = document.createElement('figcaption');
634
+ figure.appendChild(caption);
635
+ }
636
+ editorEl?.focus();
637
+ placeCaret(caption as HTMLElement, false);
638
+ }
639
+
640
+ function handleImageEdited(): void {
641
+ updateCounts();
642
+ fireChange();
643
+ if (autosave) scheduleAutoSave();
644
+ }
645
+
646
+ function handleDragOver(e: DragEvent): void {
647
+ if (readonly) return;
648
+ if (Array.from(e.dataTransfer?.types || []).includes('Files')) e.preventDefault();
649
+ }
650
+
651
+ function handleDrop(e: DragEvent): void {
652
+ if (readonly) return;
653
+ const files = Array.from(e.dataTransfer?.files || []).filter((f) => f.type.startsWith('image/'));
654
+ if (files.length === 0) return;
655
+ e.preventDefault();
656
+ editorEl?.focus();
657
+ placeCaretAtPoint(e.clientX, e.clientY);
658
+ void insertImageFiles(files);
659
+ }
660
+
661
+ /** Move the caret under the pointer so a dropped image lands where it was dropped. */
662
+ function placeCaretAtPoint(x: number, y: number): void {
663
+ const doc = document as Document & {
664
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
665
+ caretRangeFromPoint?: (x: number, y: number) => Range | null;
666
+ };
667
+
668
+ let range: Range | null = null;
669
+ if (typeof doc.caretPositionFromPoint === 'function') {
670
+ const pos = doc.caretPositionFromPoint(x, y);
671
+ if (pos) {
672
+ range = document.createRange();
673
+ range.setStart(pos.offsetNode, pos.offset);
674
+ range.collapse(true);
675
+ }
676
+ } else if (typeof doc.caretRangeFromPoint === 'function') {
677
+ range = doc.caretRangeFromPoint(x, y);
678
+ }
679
+
680
+ if (!range || !editorEl?.contains(range.startContainer)) return;
681
+ const sel = window.getSelection();
682
+ sel?.removeAllRanges();
683
+ sel?.addRange(range);
684
+ savedSelection = range.cloneRange();
317
685
  }
318
686
 
319
687
  // ── Import RTF ──
@@ -328,6 +696,7 @@
328
696
  try {
329
697
  const html = await readRtfFile(file);
330
698
  if (editorEl) {
699
+ selectedImage = null;
331
700
  editorEl.innerHTML = html;
332
701
  updateCounts();
333
702
  fireChange();
@@ -335,8 +704,7 @@
335
704
  onimport?.({ html });
336
705
  }
337
706
  } catch (err) {
338
- importError = (err as Error).message || 'Failed to import RTF file';
339
- setTimeout(() => (importError = ''), 4000);
707
+ showError((err as Error).message || 'Failed to import RTF file');
340
708
  } finally {
341
709
  importing = false;
342
710
  if (fileInputEl) fileInputEl.value = '';
@@ -352,6 +720,22 @@
352
720
  if (mod && e.key === 'k') { e.preventDefault(); openLinkModal(); }
353
721
  if (mod && e.key === 's') { e.preventDefault(); autoSaveNow(); }
354
722
 
723
+ // Moving the caret leaves the picture behind, so drop its selection frame.
724
+ // Deliberately falls through: these keys still have their normal meaning,
725
+ // including Tab inside a <pre> below.
726
+ if (selectedImage && (e.key.startsWith('Arrow') || e.key === 'Enter' || e.key === 'Escape' ||
727
+ e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
728
+ selectedImage = null;
729
+ }
730
+
731
+ // Backspace/Delete removes a selected image — but not while the caret is
732
+ // in its description, where those keys must edit text as usual.
733
+ if (selectedImage && !selectionInCaption() && (e.key === 'Backspace' || e.key === 'Delete')) {
734
+ e.preventDefault();
735
+ removeSelectedImage();
736
+ return;
737
+ }
738
+
355
739
  if (e.key === 'Tab') {
356
740
  const sel = window.getSelection();
357
741
  if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
@@ -362,6 +746,18 @@
362
746
  }
363
747
 
364
748
  function handlePaste(e: ClipboardEvent): void {
749
+ // clipboardData is only valid during the event — pull the files out first.
750
+ const files = Array.from(e.clipboardData?.items || [])
751
+ .filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
752
+ .map((item) => item.getAsFile())
753
+ .filter((file): file is File => file !== null);
754
+
755
+ if (files.length > 0 && !readonly) {
756
+ e.preventDefault();
757
+ void insertImageFiles(files);
758
+ return;
759
+ }
760
+
365
761
  const sel = window.getSelection();
366
762
  if (sel?.anchorNode?.parentElement?.closest?.('pre')) {
367
763
  e.preventDefault();
@@ -403,7 +799,22 @@
403
799
  onchange={handleFileImport}
404
800
  />
405
801
 
406
- <div class="ink-editor" style="--ink-min-height: {minHeight};">
802
+ <!-- Hidden file input for image insertion (multiple images at once) -->
803
+ <input
804
+ type="file"
805
+ accept="image/*"
806
+ multiple
807
+ class="ink-hidden-input"
808
+ bind:this={imageInputEl}
809
+ onchange={handleImageFilesChosen}
810
+ />
811
+
812
+ <div
813
+ class="ink-editor"
814
+ style:--ink-min-height={minHeight}
815
+ style:--ink-font-family={screenFontFamily}
816
+ style:--ink-font-size="{screenFontSize}pt"
817
+ >
407
818
  {#if showToolbar && !readonly}
408
819
  <Toolbar
409
820
  {formatState}
@@ -415,19 +826,36 @@
415
826
  />
416
827
  {/if}
417
828
 
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>
829
+ <div class="ink-content-wrap" bind:this={contentWrapEl}>
830
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
831
+ <div
832
+ class="ink-content"
833
+ contenteditable={!readonly}
834
+ spellcheck="true"
835
+ bind:this={editorEl}
836
+ oninput={handleInput}
837
+ onmouseup={updateToolbarState}
838
+ onkeyup={updateToolbarState}
839
+ onkeydown={handleKeydown}
840
+ onpaste={handlePaste}
841
+ onclick={handleContentClick}
842
+ ondragover={handleDragOver}
843
+ ondrop={handleDrop}
844
+ data-placeholder={placeholder}
845
+ ></div>
846
+
847
+ {#if !readonly}
848
+ <ImageOverlay
849
+ bind:this={overlayRef}
850
+ target={selectedImage}
851
+ bounds={contentWrapEl}
852
+ maxWidth={contentWidth}
853
+ onchange={handleImageEdited}
854
+ onremove={removeSelectedImage}
855
+ oncaption={focusCaption}
856
+ />
857
+ {/if}
858
+ </div>
431
859
 
432
860
  {#if showStatusBar}
433
861
  <div class="ink-status-bar">
@@ -456,16 +884,43 @@
456
884
 
457
885
  <!-- Image Modal -->
458
886
  <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} />
887
+ <button class="ink-file-pick" onclick={chooseImageFiles}>
888
+ <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>
889
+ {pendingImageFiles.length > 0
890
+ ? `${pendingImageFiles.length} image${pendingImageFiles.length === 1 ? '' : 's'} selected`
891
+ : 'Choose images from this device'}
892
+ </button>
893
+
894
+ {#if pendingImageFiles.length > 0}
895
+ <p class="ink-file-list">{pendingImageFiles.map((f) => f.name).join(', ')}</p>
896
+ {/if}
897
+
898
+ {#if pendingImageFiles.length === 0}
899
+ <div class="ink-modal-or"><span>or paste an address</span></div>
900
+ <input
901
+ type="url"
902
+ placeholder="https://example.com/image.jpg"
903
+ bind:value={imageUrl}
904
+ onkeydown={(e) => e.key === 'Enter' && applyImage()}
905
+ />
906
+ {/if}
907
+
908
+ {#if pendingImageFiles.length > 1}
909
+ <p class="ink-modal-hint">Add a description under each image after inserting.</p>
910
+ {:else}
911
+ <input type="text" placeholder="Description shown below the image (optional)" bind:value={imageCaption} />
912
+ {/if}
913
+ <input type="text" placeholder="Alt text for screen readers (optional)" bind:value={imageAlt} />
914
+
466
915
  {#snippet actions()}
467
916
  <button class="ink-btn-ghost" onclick={() => (imageModalOpen = false)}>Cancel</button>
468
- <button class="ink-btn-primary" onclick={applyImage}>Insert</button>
917
+ <button
918
+ class="ink-btn-primary"
919
+ disabled={insertingImages || (pendingImageFiles.length === 0 && !imageUrl.trim())}
920
+ onclick={applyImage}
921
+ >
922
+ {insertingImages ? 'Inserting…' : 'Insert'}
923
+ </button>
469
924
  {/snippet}
470
925
  </Modal>
471
926
 
@@ -486,6 +941,12 @@
486
941
  width: 100%;
487
942
  }
488
943
 
944
+ /* Anchors the image selection frame, which lives outside the
945
+ contenteditable area so it never lands in the exported HTML. */
946
+ .ink-content-wrap {
947
+ position: relative;
948
+ }
949
+
489
950
  /* ── Content Area ── */
490
951
  .ink-content {
491
952
  min-height: var(--ink-min-height, 40vh);
@@ -495,8 +956,8 @@
495
956
  border-radius: var(--radius, 8px);
496
957
  box-shadow: var(--shadow-md, 0 4px 12px rgba(44, 37, 32, 0.08));
497
958
  outline: none;
498
- font-family: 'DM Sans', sans-serif;
499
- font-size: 16px;
959
+ font-family: var(--ink-font-family, Arial, sans-serif);
960
+ font-size: var(--ink-font-size, 12pt);
500
961
  line-height: 1.75;
501
962
  color: var(--text, #2c2520);
502
963
  caret-color: var(--accent, #d4622b);
@@ -516,9 +977,10 @@
516
977
  }
517
978
 
518
979
  /* ── Typography inside editor ── */
980
+ /* Headings use the document font and keep the same ratio to the body text
981
+ as the RTF writer gives them, so the screen matches the RTF. */
519
982
  .ink-content :global(h1) {
520
- font-family: 'Instrument Serif', serif;
521
- font-size: 38px;
983
+ font-size: 2em;
522
984
  line-height: 1.2;
523
985
  color: var(--ink, #1a1714);
524
986
  margin-bottom: 16px;
@@ -526,8 +988,7 @@
526
988
  }
527
989
 
528
990
  .ink-content :global(h2) {
529
- font-family: 'Instrument Serif', serif;
530
- font-size: 28px;
991
+ font-size: 1.5em;
531
992
  line-height: 1.3;
532
993
  color: var(--ink, #1a1714);
533
994
  margin-bottom: 12px;
@@ -535,8 +996,7 @@
535
996
  }
536
997
 
537
998
  .ink-content :global(h3) {
538
- font-family: 'DM Sans', sans-serif;
539
- font-size: 20px;
999
+ font-size: calc(7em / 6);
540
1000
  font-weight: 600;
541
1001
  line-height: 1.4;
542
1002
  color: var(--ink, #1a1714);
@@ -563,16 +1023,17 @@
563
1023
  color: #e8e2da;
564
1024
  padding: 20px 24px;
565
1025
  border-radius: var(--radius, 8px);
566
- font-family: 'JetBrains Mono', monospace;
567
- font-size: 14px;
1026
+ /* Same font and ratio to the body as the RTF writes code blocks in. */
1027
+ font-family: 'Courier New', monospace;
1028
+ font-size: calc(5em / 6);
568
1029
  line-height: 1.6;
569
1030
  margin: 16px 0;
570
1031
  overflow-x: auto;
571
1032
  }
572
1033
 
573
1034
  .ink-content :global(code) {
574
- font-family: 'JetBrains Mono', monospace;
575
- font-size: 14px;
1035
+ font-family: 'Courier New', monospace;
1036
+ font-size: 1em;
576
1037
  background: var(--surface, #f2f0ec);
577
1038
  padding: 2px 6px;
578
1039
  border-radius: 3px;
@@ -603,10 +1064,36 @@
603
1064
 
604
1065
  .ink-content :global(img) {
605
1066
  max-width: 100%;
1067
+ height: auto;
606
1068
  border-radius: var(--radius, 8px);
607
1069
  margin: 16px 0;
608
1070
  }
609
1071
 
1072
+ /* ── Figures (image + description) ── */
1073
+ .ink-content :global(figure) {
1074
+ margin: 20px 0;
1075
+ }
1076
+
1077
+ .ink-content :global(figure img) {
1078
+ margin: 0;
1079
+ vertical-align: bottom;
1080
+ cursor: pointer;
1081
+ }
1082
+
1083
+ .ink-content :global(figcaption) {
1084
+ margin-top: 8px;
1085
+ /* Body size, as the RTF writes captions. */
1086
+ font-size: 1em;
1087
+ line-height: 1.5;
1088
+ font-style: italic;
1089
+ color: var(--text-muted, #8a7e72);
1090
+ }
1091
+
1092
+ .ink-content :global(figcaption:empty)::before {
1093
+ content: 'Add a description…';
1094
+ opacity: 0.55;
1095
+ }
1096
+
610
1097
  .ink-content :global(hr) {
611
1098
  border: none;
612
1099
  height: 1px;
@@ -647,6 +1134,69 @@
647
1134
  background: var(--accent-hover, #be5524);
648
1135
  }
649
1136
 
1137
+ :global(.ink-btn-primary:disabled) {
1138
+ opacity: 0.5;
1139
+ cursor: not-allowed;
1140
+ }
1141
+
1142
+ /* ── Image dialog ── */
1143
+ .ink-file-pick {
1144
+ display: flex;
1145
+ align-items: center;
1146
+ justify-content: center;
1147
+ gap: 8px;
1148
+ width: 100%;
1149
+ padding: 14px;
1150
+ margin-bottom: 12px;
1151
+ border: 1px dashed var(--border-active, #c4b5a0);
1152
+ border-radius: var(--radius-sm, 5px);
1153
+ background: var(--surface, #f2f0ec);
1154
+ font-family: 'DM Sans', sans-serif;
1155
+ font-size: 13px;
1156
+ font-weight: 500;
1157
+ color: var(--text, #2c2520);
1158
+ cursor: pointer;
1159
+ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
1160
+ }
1161
+
1162
+ .ink-file-pick:hover {
1163
+ border-color: var(--accent, #d4622b);
1164
+ color: var(--accent, #d4622b);
1165
+ }
1166
+
1167
+ .ink-file-list {
1168
+ margin: -6px 0 12px;
1169
+ font-size: 12px;
1170
+ line-height: 1.5;
1171
+ color: var(--text-muted, #8a7e72);
1172
+ word-break: break-word;
1173
+ }
1174
+
1175
+ .ink-modal-hint {
1176
+ margin: 0 0 12px;
1177
+ font-size: 12px;
1178
+ color: var(--text-muted, #8a7e72);
1179
+ }
1180
+
1181
+ .ink-modal-or {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ gap: 10px;
1185
+ margin-bottom: 12px;
1186
+ font-size: 11px;
1187
+ text-transform: uppercase;
1188
+ letter-spacing: 0.5px;
1189
+ color: var(--text-muted, #8a7e72);
1190
+ }
1191
+
1192
+ .ink-modal-or::before,
1193
+ .ink-modal-or::after {
1194
+ content: '';
1195
+ flex: 1;
1196
+ height: 1px;
1197
+ background: var(--border, #e5e2dc);
1198
+ }
1199
+
650
1200
  :global(.ink-btn-ghost) {
651
1201
  background: none;
652
1202
  border: 1px solid var(--border, #e5e2dc);
@@ -700,11 +1250,5 @@
700
1250
  .ink-content {
701
1251
  padding: 28px 24px;
702
1252
  }
703
- .ink-content :global(h1) {
704
- font-size: 28px;
705
- }
706
- .ink-content :global(h2) {
707
- font-size: 22px;
708
- }
709
1253
  }
710
1254
  </style>