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.
- package/README.md +107 -6
- package/dist/RtfViewer.svelte +41 -3
- package/dist/RtfViewer.svelte.d.ts +2 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +598 -54
- package/dist/components/InkEditor.svelte.d.ts +11 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +254 -37
- package/dist/rtf-writer.d.ts +56 -4
- package/dist/rtf-writer.js +384 -35
- package/dist/utils.js +13 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 {
|
|
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
|
-
// ──
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
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:
|
|
499
|
-
font-size:
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
567
|
-
font-
|
|
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: '
|
|
575
|
-
font-size:
|
|
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>
|