autumnnote 2.0.0 → 2.2.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.
Files changed (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
@@ -1,579 +0,0 @@
1
- /**
2
- * Clipboard.js - Handles paste events to strip unwanted formatting,
3
- * and paste/drop of image files.
4
- * Inspired by Summernote's Clipboard module
5
- */
6
-
7
- import { on } from '../core/dom.js';
8
- import { execCommand } from '../editing/Style.js';
9
- import { sanitiseHTML } from '../core/sanitise.js';
10
- import { isMarkdown, markdownToHTML } from '../core/markdown.js';
11
-
12
- export class Clipboard {
13
- /**
14
- * @param {import('../Context.js').Context} context
15
- */
16
- constructor(context) {
17
- this.context = context;
18
- this.options = context.options;
19
- this._disposers = [];
20
- }
21
-
22
- initialize() {
23
- /** @type {Map<string, string>} Maps blob: URL (in DOM) → data: URL (serialisable) */
24
- this._blobRegistry = new Map();
25
- /** @type {boolean} Set to true by Ctrl+Shift+V shortcut to force one-shot plain paste */
26
- this._forcePlain = false;
27
- const editable = this.context.layoutInfo.editable;
28
- this._disposers.push(
29
- on(editable, 'paste', (e) => this._onPaste(e)),
30
- on(editable, 'dragover', (e) => this._onDragover(e)),
31
- on(editable, 'drop', (e) => this._onDrop(e)),
32
- );
33
-
34
- // Watch for removed images so their blob: URLs are revoked immediately,
35
- // preventing memory leaks during long editing sessions.
36
- this._mutationObserver = new MutationObserver((mutations) => {
37
- for (const mutation of mutations) {
38
- for (const node of mutation.removedNodes) {
39
- this._revokeRemovedBlobs(node);
40
- }
41
- }
42
- });
43
- this._mutationObserver.observe(editable, { childList: true, subtree: true });
44
-
45
- return this;
46
- }
47
-
48
- destroy() {
49
- this._disposers.forEach((d) => d());
50
- this._disposers = [];
51
- if (this._mutationObserver) {
52
- this._mutationObserver.disconnect();
53
- this._mutationObserver = null;
54
- }
55
- // Release any remaining object URLs
56
- if (this._blobRegistry) {
57
- this._blobRegistry.forEach((_, blobUrl) => URL.revokeObjectURL(blobUrl));
58
- this._blobRegistry.clear();
59
- }
60
- }
61
-
62
- /**
63
- * Revokes blob URLs for any <img> elements removed from the DOM.
64
- * @param {Node} node
65
- */
66
- _revokeRemovedBlobs(node) {
67
- if (!this._blobRegistry?.size) return;
68
- const imgs = /** @type {Element[]} */ ([]);
69
- if (node.nodeName === 'IMG') {
70
- imgs.push(/** @type {Element} */ (node));
71
- } else if (/** @type {Element} */ (node).querySelectorAll) {
72
- imgs.push(.../** @type {Element} */ (node).querySelectorAll('img'));
73
- }
74
- imgs.forEach((img) => {
75
- const src = img.getAttribute('src') || '';
76
- if (src.startsWith('blob:') && this._blobRegistry.has(src)) {
77
- URL.revokeObjectURL(src);
78
- this._blobRegistry.delete(src);
79
- }
80
- });
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Paste handler
85
- // ---------------------------------------------------------------------------
86
-
87
- /**
88
- * Strips Microsoft Word / Office HTML artefacts from a pasted HTML string.
89
- * Removes conditional comments, Office namespace elements, MsoXxx classes,
90
- * mso-* inline style rules, and empty paragraphs left behind by Word.
91
- * @param {string} html
92
- * @returns {string}
93
- */
94
- _cleanWordHtml(html) {
95
- return html
96
- // Conditional comments <!--[if ...]>...<![endif]-->
97
- .replace(/<!--\[if[\s\S]*?\[endif\]-->/gi, '')
98
- // XML data blobs <xml>...</xml>
99
- .replace(/<xml[\s\S]*?<\/xml>/gi, '')
100
- // XML processing instructions <?xml ... ?>
101
- .replace(/<\?xml[\s\S]*?\?>/gi, '')
102
- // Office namespace elements: <o:p>, <w:sDt>, <m:oMath>, <v:shape> …
103
- .replace(/<\/?(o|w|m|v|st1):[a-z][^>]*>/gi, '')
104
- // MsoNormal, MsoBodyText, etc. class attributes
105
- .replace(/\s+class="Mso[^"]*"/gi, '')
106
- // mso-* properties inside inline style attributes
107
- .replace(/\s+style="([^"]*)"/gi, (_m, style) => {
108
- const cleaned = style.split(';')
109
- .map((s) => s.trim())
110
- .filter((s) => s && !/^mso-/i.test(s) && !/^(tab-stops|margin-[a-z]+-alt)/i.test(s))
111
- .join('; ');
112
- return cleaned ? ` style="${cleaned}"` : '';
113
- })
114
- // Empty paragraphs Word sprinkles everywhere
115
- .replace(/<p[^>]*>\s*(&nbsp;)?\s*<\/p>/gi, '');
116
- }
117
-
118
- /**
119
- * Detects and strips noise from social media sites (Facebook, X/Twitter, LinkedIn, etc.).
120
- * These React-based pages produce HTML with utility class names like `x1n2onr6` / `r-bcqeeo`,
121
- * `data-testid`, `data-lexical-*`, etc. We keep the semantic structure but remove all the noise.
122
- * @param {string} html
123
- * @returns {string}
124
- */
125
- _cleanSocialHtml(html) {
126
- const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
127
- // Unwrap purely presentational wrapper spans/divs with no semantic meaning.
128
- // Single-pass reverse traversal: querySelectorAll returns elements in document
129
- // order, so iterating backwards processes innermost elements first — once a
130
- // child is unwrapped its parent may become unwrappable in the same pass.
131
- // This replaces the previous O(n²) while-loop that re-queried the whole tree
132
- // on every iteration.
133
- const candidates = Array.from(doc.querySelectorAll('span, div'));
134
- for (let i = candidates.length - 1; i >= 0; i--) {
135
- const el = candidates[i];
136
- if (!el.parentNode) continue; // already detached by an earlier iteration
137
- // Keep if it contains any semantic child element
138
- if (el.querySelector('a, strong, em, b, i, ul, ol, li, table, img, blockquote, pre, code, h1, h2, h3, h4, h5, h6')) continue;
139
- // Unwrap — replace el with its children
140
- const parent = el.parentNode;
141
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
142
- el.remove();
143
- }
144
- // Strip class and all data-* attributes from every remaining element
145
- doc.querySelectorAll('*').forEach((el) => {
146
- el.removeAttribute('class');
147
- el.removeAttribute('id');
148
- Array.from(el.attributes)
149
- .filter((a) => a.name.startsWith('data-') || a.name.startsWith('aria-'))
150
- .forEach((a) => el.removeAttribute(a.name));
151
- });
152
- return doc.body.innerHTML;
153
- }
154
-
155
- /**
156
- * Strips presentational attributes (class, style, data-*, id) from all elements,
157
- * keeping only semantic structure and URL attributes.
158
- * Used when `pasteStripAttributes` option is true.
159
- * @param {string} html
160
- * @returns {string}
161
- */
162
- _stripAttributes(html) {
163
- const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
164
- const KEEP_ATTRS = new Set(['href', 'src', 'alt', 'target', 'rel', 'colspan', 'rowspan', 'type']);
165
- doc.querySelectorAll('*').forEach((el) => {
166
- Array.from(el.attributes)
167
- .filter((a) => !KEEP_ATTRS.has(a.name))
168
- .forEach((a) => el.removeAttribute(a.name));
169
- });
170
- return doc.body.innerHTML;
171
- }
172
-
173
- /**
174
- * Normalizes task lists from external sources (GitHub, GitLab, etc.) so they
175
- * pass the sanitiser's `ul.an-checklist` guard. Runs before sanitiseHTML().
176
- * @param {string} html
177
- * @returns {string}
178
- */
179
- _normalizeExternalTaskLists(html) {
180
- const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
181
- for (const cb of doc.querySelectorAll('input[type="checkbox"]')) {
182
- const li = cb.closest('li');
183
- const ul = li?.closest('ul');
184
- if (!li || !ul || ul.classList.contains('an-checklist')) continue;
185
- ul.classList.add('an-checklist');
186
- cb.removeAttribute('disabled');
187
- cb.setAttribute('contenteditable', 'false');
188
- for (const attr of Array.from(cb.attributes)) {
189
- if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
190
- cb.removeAttribute(attr.name);
191
- }
192
- }
193
- }
194
- return doc.body.innerHTML;
195
- }
196
-
197
- /**
198
- * Checks whether an HTML payload has no semantic markup beyond plain
199
- * wrapper elements (e.g. a bare <div>/<p>). Used to decide whether a
200
- * markdown-shaped plain-text paste should win over an accompanying HTML
201
- * payload that isn't actually carrying any real rich-text formatting.
202
- * @param {string} html
203
- * @returns {boolean}
204
- */
205
- _isTriviallyPlainHtml(html) {
206
- const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
207
- const SIGNIFICANT = 'a,img,table,ul,ol,li,blockquote,pre,code,h1,h2,h3,h4,h5,h6,strong,b,em,i,u,s,del,strike,hr,br';
208
- return !doc.body.querySelector(SIGNIFICANT);
209
- }
210
-
211
- /**
212
- * Forces the next paste operation to strip all HTML formatting.
213
- * Called by Editor when Ctrl+Shift+V is pressed.
214
- * @param {boolean} val
215
- */
216
- setForcePlain(val) {
217
- this._forcePlain = !!val;
218
- }
219
-
220
- _onPaste(event) {
221
- const clipboardData = event.clipboardData || /** @type {any} */ (globalThis).clipboardData;
222
- if (!clipboardData) return;
223
-
224
- // Consume and reset the one-shot plain-paste flag
225
- const forcePlain = this._forcePlain;
226
- this._forcePlain = false;
227
-
228
- // Enforce maxPasteSize limit (default 5 MB)
229
- const maxBytes = (this.options.maxPasteSize ?? 5) * 1024 * 1024;
230
- if (maxBytes > 0) {
231
- const text = clipboardData.getData('text/plain') || '';
232
- const html = clipboardData.getData('text/html') || '';
233
- const size = Math.max(text.length, html.length);
234
- if (size > maxBytes) {
235
- event.preventDefault();
236
- const message = `Pasted content (${size} bytes) exceeds the ${this.options.maxPasteSize ?? 5} MB paste size limit.`;
237
- this.context.triggerEvent('pasteError', { size, maxBytes, message });
238
- console.warn(`[AutumnNote] ${message}`);
239
- return;
240
- }
241
- }
242
-
243
- // 1. Image file in clipboard (screenshot, copy-image-from-browser, etc.)
244
- if (clipboardData.items) {
245
- const imageItems = Array.from(clipboardData.items).filter(
246
- (item) => item.kind === 'file' && item.type.startsWith('image/'),
247
- );
248
- if (imageItems.length > 0) {
249
- event.preventDefault();
250
- const files = imageItems.map((item) => item.getAsFile()).filter(Boolean);
251
- this._insertImageFiles(files);
252
- return;
253
- }
254
- }
255
-
256
- // Fire onPaste hook so consumers can observe / intercept
257
- if (typeof this.options.onPaste === 'function') {
258
- this.options.onPaste({
259
- text: clipboardData.getData('text/plain') || '',
260
- html: clipboardData.types.includes('text/html') ? clipboardData.getData('text/html') : null,
261
- });
262
- }
263
-
264
- // 2. Force plain-text only — strip all formatting
265
- if (forcePlain || this.options.pasteAsPlainText) {
266
- event.preventDefault();
267
- const text = clipboardData.getData('text/plain');
268
- const html = text
269
- .split(/\r?\n/)
270
- .map((line) => `<p>${this._escapeHTML(line) || '<br>'}</p>`)
271
- .join('');
272
- execCommand('insertHTML', html);
273
- this.context.invoke('editor.afterCommand');
274
- return;
275
- }
276
-
277
- // 3. Markdown paste — when there's no HTML on the clipboard, or the
278
- // accompanying HTML has no semantic markup (e.g. some terminal/clipboard
279
- // tools put both a markdown-shaped text/plain and a trivial <div>-wrapped
280
- // text/html on the clipboard). Real rich-text sources (Word, Docs, etc.)
281
- // always have semantic tags after cleaning, so this is unaffected.
282
- if (this.options.markdownPaste !== false) {
283
- const hasHtml = clipboardData.types.includes('text/html');
284
- const html = hasHtml ? clipboardData.getData('text/html') : '';
285
- const htmlTriviallyPlain = !hasHtml || this._isTriviallyPlainHtml(html);
286
- const text = clipboardData.getData('text/plain');
287
- if (text && htmlTriviallyPlain && isMarkdown(text)) {
288
- event.preventDefault();
289
- const converted = sanitiseHTML(markdownToHTML(text));
290
- execCommand('insertHTML', converted);
291
- this.context.invoke('editor.afterCommand');
292
- return;
293
- }
294
- }
295
-
296
- // 4. Sanitise HTML on paste when pasteCleanHTML is true (default)
297
- if (this.options.pasteCleanHTML !== false && clipboardData.types.includes('text/html')) {
298
- event.preventDefault();
299
- const raw = clipboardData.getData('text/html');
300
- // Detect source type and apply appropriate pre-cleaner
301
- const isWordContent = /<[a-z]+:[a-z]/i.test(raw) || /class="Mso/i.test(raw) || /\bmso-/i.test(raw);
302
- const isSocialContent = /class="[^"]*\b(?:x[a-z0-9]{6,}|r-[a-z0-9]{3,})\b/.test(raw);
303
- let html = raw;
304
- if (isWordContent) html = this._cleanWordHtml(html);
305
- else if (isSocialContent) html = this._cleanSocialHtml(html);
306
- html = this._normalizeExternalTaskLists(html);
307
- html = sanitiseHTML(html);
308
- if (this.options.pasteStripAttributes) html = this._stripAttributes(html);
309
- execCommand('insertHTML', html);
310
- this.context.invoke('editor.afterCommand');
311
- }
312
-
313
- // Otherwise let the browser handle paste natively
314
- }
315
-
316
- // ---------------------------------------------------------------------------
317
- // Drag & drop handlers
318
- // ---------------------------------------------------------------------------
319
-
320
- _onDragover(event) {
321
- if (!event.dataTransfer) return;
322
- const types = Array.from(event.dataTransfer.types || []);
323
- if (types.includes('Files')) {
324
- event.preventDefault();
325
- event.dataTransfer.dropEffect = 'copy';
326
- }
327
- }
328
-
329
- _onDrop(event) {
330
- const dt = event.dataTransfer;
331
- if (!dt?.files?.length) return;
332
-
333
- const imageFiles = Array.from(dt.files).filter((f) => f.type.startsWith('image/'));
334
- if (imageFiles.length > 0) {
335
- event.preventDefault();
336
- event.stopPropagation();
337
- // Place the caret at the drop coordinates before inserting
338
- this._placeCaretAtPoint(event.clientX, event.clientY);
339
- this._insertImageFiles(imageFiles);
340
- return;
341
- }
342
-
343
- if (this.options.markdownPaste !== false) {
344
- const mdFile = Array.from(dt.files).find((f) => /\.md$/i.test(f.name) || f.type === 'text/markdown');
345
- if (mdFile) {
346
- event.preventDefault();
347
- event.stopPropagation();
348
- this._placeCaretAtPoint(event.clientX, event.clientY);
349
- this._insertMarkdownFile(mdFile);
350
- }
351
- }
352
- }
353
-
354
- /**
355
- * Reads a dropped `.md` File and inserts it converted to HTML at the
356
- * current caret. Skips the isMarkdown() heuristic — an explicit `.md`
357
- * extension/MIME type is an unambiguous signal, unlike pasted plain text.
358
- * @param {File} file
359
- */
360
- _insertMarkdownFile(file) {
361
- const maxBytes = (this.options.maxPasteSize ?? 5) * 1024 * 1024;
362
- if (maxBytes > 0 && file.size > maxBytes) {
363
- const message = `Dropped file "${file.name}" (${file.size} bytes) exceeds the ${this.options.maxPasteSize ?? 5} MB paste size limit.`;
364
- this.context.triggerEvent('pasteError', { size: file.size, maxBytes, message });
365
- console.warn(`[AutumnNote] ${message}`);
366
- return;
367
- }
368
- const reader = new FileReader();
369
- reader.onload = (e) => {
370
- const html = sanitiseHTML(markdownToHTML(/** @type {string} */ (e.target.result) || ''));
371
- execCommand('insertHTML', html);
372
- this.context.invoke('editor.afterCommand');
373
- };
374
- reader.onerror = () => {
375
- const message = `Failed to read dropped markdown file "${file.name}".`;
376
- console.warn(`[AutumnNote] ${message}`);
377
- this.context.triggerEvent('pasteError', { message });
378
- };
379
- reader.readAsText(file);
380
- }
381
-
382
- // ---------------------------------------------------------------------------
383
- // Image file processing — shared by paste and drop
384
- // ---------------------------------------------------------------------------
385
-
386
- /**
387
- * Inserts one or more image Files into the editor.
388
- * Delegates to `options.onImageUpload` when provided; otherwise compresses
389
- * and embeds as base64.
390
- * @param {File[]} files
391
- */
392
- _insertImageFiles(files) {
393
- if (!files || files.length === 0) return;
394
-
395
- if (typeof this.options.onImageUpload === 'function') {
396
- this.options.onImageUpload(files);
397
- return;
398
- }
399
-
400
- // C2: Reject image formats that browsers cannot decode/display.
401
- const UNSUPPORTED = new Set(['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp']);
402
- const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
403
- files.forEach((file) => {
404
- if (!file?.type?.startsWith('image/')) return;
405
- if (UNSUPPORTED.has(file.type)) {
406
- const message = `Image format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
407
- this.context.triggerEvent('imageError', { file, message });
408
- console.warn('[AutumnNote]', message);
409
- return;
410
- }
411
- if (file.size > maxBytes) {
412
- const message = `Image "${file.name}" exceeds the ${this.options.maxImageSize || 5} MB size limit.`;
413
- this.context.triggerEvent('imageError', { file, message });
414
- console.warn(`[AutumnNote] ${message}`);
415
- return;
416
- }
417
-
418
- const alt = file.name.replace(/\.[^.]+$/, '');
419
- this.compressAndRegister(file).then((blobUrl) => {
420
- this.context.invoke('editor.insertImage', blobUrl, alt);
421
- }).catch((err) => {
422
- const message = `Image "${file.name}" could not be processed.`;
423
- this.context.triggerEvent('imageError', { file, message, error: err });
424
- console.warn('[AutumnNote]', message, err);
425
- });
426
- });
427
- }
428
-
429
- /**
430
- * Compresses an image File via canvas and registers the result behind a
431
- * lightweight blob: URL (see `resolveImages`), so callers never have to hold
432
- * the full base64 string in the DOM. Shared by paste/drop and ImageDialog's
433
- * file picker so every image-insertion path gets the same compression.
434
- * @param {File} file
435
- * @returns {Promise<string>} blob: URL usable as an <img src>
436
- */
437
- async compressAndRegister(file) {
438
- const processor = this.options.imageProcessor;
439
- const dataUrl = typeof processor === 'function'
440
- ? await processor(file, { context: this.context })
441
- : await this._compressImage(file);
442
- const blob = this._dataUrlToBlob(dataUrl);
443
- const blobUrl = URL.createObjectURL(blob);
444
- this._blobRegistry.set(blobUrl, dataUrl);
445
- return blobUrl;
446
- }
447
-
448
- /**
449
- * Replaces any blob: URLs created by this module with their original data URLs.
450
- * Called by Editor.getHTML() so the returned HTML is fully self-contained.
451
- * @param {string} html
452
- * @returns {string}
453
- */
454
- resolveImages(html) {
455
- if (!this._blobRegistry?.size) return html;
456
- return html.replace(/blob:[^"'> \t\n\r]*/g, (url) => this._blobRegistry.get(url) || url);
457
- }
458
-
459
- /**
460
- * Converts a data URL to a Blob (no FileReader — synchronous).
461
- * @param {string} dataUrl
462
- * @returns {Blob}
463
- */
464
- _dataUrlToBlob(dataUrl) {
465
- const [header, b64] = dataUrl.split(',');
466
- const mime = /:(.*?);/.exec(header)?.[1] ?? 'image/png';
467
- const binary = atob(b64);
468
- const arr = new Uint8Array(binary.length);
469
- for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
470
- return new Blob([arr], { type: mime });
471
- }
472
-
473
- /**
474
- * Compresses an image File using a Canvas.
475
- * - Resizes so the longest edge is at most MAX_DIM pixels.
476
- * - Encodes as WebP (if supported) or JPEG at quality 0.85.
477
- * Falls back to plain FileReader if canvas is unavailable.
478
- * @param {File} file
479
- * @returns {Promise<string>} data URL
480
- */
481
- _compressImage(file) {
482
- const MAX_DIM = 1920;
483
- const QUALITY = 0.85;
484
-
485
- return new Promise((resolve, reject) => {
486
- const objectUrl = URL.createObjectURL(file);
487
- const img = new Image();
488
-
489
- img.onload = () => {
490
- URL.revokeObjectURL(objectUrl);
491
-
492
- let { width, height } = img;
493
- if (width > MAX_DIM || height > MAX_DIM) {
494
- if (width >= height) {
495
- height = Math.round((height * MAX_DIM) / width);
496
- width = MAX_DIM;
497
- } else {
498
- width = Math.round((width * MAX_DIM) / height);
499
- height = MAX_DIM;
500
- }
501
- }
502
-
503
- const canvas = document.createElement('canvas');
504
- canvas.width = width;
505
- canvas.height = height;
506
- const ctx = canvas.getContext('2d');
507
- if (!ctx) {
508
- // Canvas context unavailable (e.g. device memory limit) — fall back to
509
- // embedding the original file without compression.
510
- const reader = new FileReader();
511
- reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
512
- reader.onerror = () => reject(new Error('FileReader failed'));
513
- reader.readAsDataURL(file);
514
- return;
515
- }
516
- ctx.drawImage(img, 0, 0, width, height);
517
-
518
- // Prefer WebP for better compression; fall back to JPEG
519
- const webp = canvas.toDataURL('image/webp', QUALITY);
520
- resolve(webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', QUALITY));
521
- };
522
-
523
- img.onerror = () => {
524
- URL.revokeObjectURL(objectUrl);
525
- // Fallback: embed original without compression
526
- const reader = new FileReader();
527
- reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
528
- reader.onerror = () => reject(new Error('FileReader failed'));
529
- reader.readAsDataURL(file);
530
- };
531
-
532
- img.src = objectUrl;
533
- });
534
- }
535
-
536
- /**
537
- * Positions the caret at the given viewport coordinates.
538
- * Supports both Chrome (caretRangeFromPoint) and Firefox (caretPositionFromPoint).
539
- * @param {number} x
540
- * @param {number} y
541
- */
542
- _placeCaretAtPoint(x, y) {
543
- let range;
544
- if (document.caretRangeFromPoint) {
545
- range = document.caretRangeFromPoint(x, y);
546
- } else if (document.caretPositionFromPoint) {
547
- const pos = document.caretPositionFromPoint(x, y);
548
- if (pos) {
549
- range = document.createRange();
550
- range.setStart(pos.offsetNode, pos.offset);
551
- range.collapse(true);
552
- }
553
- }
554
- if (!range) return;
555
- const sel = globalThis.getSelection();
556
- if (sel) {
557
- sel.removeAllRanges();
558
- sel.addRange(range);
559
- }
560
- }
561
-
562
- // ---------------------------------------------------------------------------
563
- // Helpers
564
- // ---------------------------------------------------------------------------
565
-
566
- /**
567
- * Escapes HTML special characters.
568
- * @param {string} str
569
- * @returns {string}
570
- */
571
- _escapeHTML(str) {
572
- return str
573
- .replaceAll('&', '&amp;')
574
- .replaceAll('<', '&lt;')
575
- .replaceAll('>', '&gt;')
576
- .replaceAll('"', '&quot;')
577
- .replaceAll("'", '&#039;');
578
- }
579
- }