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.
- package/README.md +75 -2
- package/dist/RtfViewer.svelte +18 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +556 -33
- package/dist/components/InkEditor.svelte.d.ts +9 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +188 -23
- package/dist/rtf-writer.d.ts +16 -2
- package/dist/rtf-writer.js +302 -27
- package/dist/utils.js +13 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
// ──
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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);
|