autumnnote 1.0.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/LICENSE +21 -0
- package/README.md +874 -0
- package/dist/autumnnote.css +1 -0
- package/dist/autumnnote.es.js +5888 -0
- package/dist/autumnnote.es.js.map +1 -0
- package/dist/autumnnote.umd.js +74 -0
- package/dist/autumnnote.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/js/Context.js +497 -0
- package/src/js/core/dom.js +315 -0
- package/src/js/core/env.js +25 -0
- package/src/js/core/func.js +153 -0
- package/src/js/core/key.js +66 -0
- package/src/js/core/lists.js +121 -0
- package/src/js/core/markdown.js +294 -0
- package/src/js/core/range.js +194 -0
- package/src/js/core/sanitise.js +78 -0
- package/src/js/editing/History.js +205 -0
- package/src/js/editing/Style.js +329 -0
- package/src/js/editing/Table.js +59 -0
- package/src/js/editing/Typing.js +142 -0
- package/src/js/index.js +126 -0
- package/src/js/module/Buttons.js +300 -0
- package/src/js/module/Clipboard.js +460 -0
- package/src/js/module/CodeTooltip.js +428 -0
- package/src/js/module/Codeview.js +122 -0
- package/src/js/module/ContextMenu.js +470 -0
- package/src/js/module/Editor.js +528 -0
- package/src/js/module/EmojiDialog.js +726 -0
- package/src/js/module/FindReplace.js +440 -0
- package/src/js/module/Fullscreen.js +80 -0
- package/src/js/module/IconDialog.js +620 -0
- package/src/js/module/ImageDialog.js +208 -0
- package/src/js/module/ImageResizer.js +216 -0
- package/src/js/module/ImageTooltip.js +286 -0
- package/src/js/module/LinkDialog.js +204 -0
- package/src/js/module/LinkTooltip.js +242 -0
- package/src/js/module/Placeholder.js +44 -0
- package/src/js/module/ShortcutsDialog.js +141 -0
- package/src/js/module/Statusbar.js +238 -0
- package/src/js/module/TableTooltip.js +568 -0
- package/src/js/module/Toolbar.js +562 -0
- package/src/js/module/VideoDialog.js +263 -0
- package/src/js/module/VideoResizer.js +227 -0
- package/src/js/module/VideoTooltip.js +252 -0
- package/src/js/renderer.js +107 -0
- package/src/js/settings.js +134 -0
- package/src/styles/_variables.scss +48 -0
- package/src/styles/autumnnote.scss +1740 -0
- package/types/index.d.ts +324 -0
|
@@ -0,0 +1,460 @@
|
|
|
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 || !this._blobRegistry.size) return;
|
|
68
|
+
const imgs = [];
|
|
69
|
+
if (node.nodeName === 'IMG') {
|
|
70
|
+
imgs.push(node);
|
|
71
|
+
} else if (node.querySelectorAll) {
|
|
72
|
+
imgs.push(...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*( )?\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
|
+
const UNWRAP_TAGS = new Set(['span', 'div']);
|
|
129
|
+
let changed = true;
|
|
130
|
+
// Iteratively unwrap until stable (handles deeply nested span soup)
|
|
131
|
+
while (changed) {
|
|
132
|
+
changed = false;
|
|
133
|
+
doc.querySelectorAll('span, div').forEach((el) => {
|
|
134
|
+
if (!UNWRAP_TAGS.has(el.tagName.toLowerCase())) return;
|
|
135
|
+
// Keep if it has a meaningful role (link, heading, list item are handled by parent)
|
|
136
|
+
if (el.querySelector('a, strong, em, b, i, ul, ol, li, table, img, blockquote, pre, code, h1, h2, h3, h4, h5, h6')) return;
|
|
137
|
+
// Unwrap — replace el with its children
|
|
138
|
+
const parent = el.parentNode;
|
|
139
|
+
if (!parent) return;
|
|
140
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
141
|
+
parent.removeChild(el);
|
|
142
|
+
changed = true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Strip class and all data-* attributes from every remaining element
|
|
146
|
+
doc.querySelectorAll('*').forEach((el) => {
|
|
147
|
+
el.removeAttribute('class');
|
|
148
|
+
el.removeAttribute('id');
|
|
149
|
+
Array.from(el.attributes)
|
|
150
|
+
.filter((a) => a.name.startsWith('data-') || a.name.startsWith('aria-'))
|
|
151
|
+
.forEach((a) => el.removeAttribute(a.name));
|
|
152
|
+
});
|
|
153
|
+
return doc.body.innerHTML;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Strips presentational attributes (class, style, data-*, id) from all elements,
|
|
158
|
+
* keeping only semantic structure and URL attributes.
|
|
159
|
+
* Used when `pasteStripAttributes` option is true.
|
|
160
|
+
* @param {string} html
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
_stripAttributes(html) {
|
|
164
|
+
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
165
|
+
const KEEP_ATTRS = new Set(['href', 'src', 'alt', 'target', 'rel', 'colspan', 'rowspan', 'type']);
|
|
166
|
+
doc.querySelectorAll('*').forEach((el) => {
|
|
167
|
+
Array.from(el.attributes)
|
|
168
|
+
.filter((a) => !KEEP_ATTRS.has(a.name))
|
|
169
|
+
.forEach((a) => el.removeAttribute(a.name));
|
|
170
|
+
});
|
|
171
|
+
return doc.body.innerHTML;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Forces the next paste operation to strip all HTML formatting.
|
|
176
|
+
* Called by Editor when Ctrl+Shift+V is pressed.
|
|
177
|
+
* @param {boolean} val
|
|
178
|
+
*/
|
|
179
|
+
setForcePlain(val) {
|
|
180
|
+
this._forcePlain = !!val;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_onPaste(event) {
|
|
184
|
+
const clipboardData = event.clipboardData || window.clipboardData;
|
|
185
|
+
if (!clipboardData) return;
|
|
186
|
+
|
|
187
|
+
// Consume and reset the one-shot plain-paste flag
|
|
188
|
+
const forcePlain = this._forcePlain;
|
|
189
|
+
this._forcePlain = false;
|
|
190
|
+
|
|
191
|
+
// 1. Image file in clipboard (screenshot, copy-image-from-browser, etc.)
|
|
192
|
+
if (clipboardData.items) {
|
|
193
|
+
const imageItems = Array.from(clipboardData.items).filter(
|
|
194
|
+
(item) => item.kind === 'file' && item.type.startsWith('image/'),
|
|
195
|
+
);
|
|
196
|
+
if (imageItems.length > 0) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
const files = imageItems.map((item) => item.getAsFile()).filter(Boolean);
|
|
199
|
+
this._insertImageFiles(files);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Fire onPaste hook so consumers can observe / intercept
|
|
205
|
+
if (typeof this.options.onPaste === 'function') {
|
|
206
|
+
this.options.onPaste({
|
|
207
|
+
text: clipboardData.getData('text/plain') || '',
|
|
208
|
+
html: clipboardData.types.includes('text/html') ? clipboardData.getData('text/html') : null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Force plain-text only — strip all formatting
|
|
213
|
+
if (forcePlain || this.options.pasteAsPlainText) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
const text = clipboardData.getData('text/plain');
|
|
216
|
+
const html = text
|
|
217
|
+
.split(/\r?\n/)
|
|
218
|
+
.map((line) => `<p>${this._escapeHTML(line) || '<br>'}</p>`)
|
|
219
|
+
.join('');
|
|
220
|
+
execCommand('insertHTML', html);
|
|
221
|
+
this.context.invoke('editor.afterCommand');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 3. Markdown paste — only when no HTML is on the clipboard (pure text source)
|
|
226
|
+
if (this.options.markdownPaste !== false && !clipboardData.types.includes('text/html')) {
|
|
227
|
+
const text = clipboardData.getData('text/plain');
|
|
228
|
+
if (text && isMarkdown(text)) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
const html = sanitiseHTML(markdownToHTML(text));
|
|
231
|
+
execCommand('insertHTML', html);
|
|
232
|
+
this.context.invoke('editor.afterCommand');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 4. Sanitise HTML on paste when pasteCleanHTML is true (default)
|
|
238
|
+
if (this.options.pasteCleanHTML !== false && clipboardData.types.includes('text/html')) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const raw = clipboardData.getData('text/html');
|
|
241
|
+
// Detect source type and apply appropriate pre-cleaner
|
|
242
|
+
const isWordContent = /<[a-z]+:[a-z]/i.test(raw) || /class="Mso/i.test(raw) || /\bmso-/i.test(raw);
|
|
243
|
+
const isSocialContent = /\bdata-testid\b/.test(raw) || /class="[^"]*\b(?:x[a-z0-9]{6,}|r-[a-z0-9]{3,})\b/.test(raw);
|
|
244
|
+
let html = raw;
|
|
245
|
+
if (isWordContent) html = this._cleanWordHtml(html);
|
|
246
|
+
else if (isSocialContent) html = this._cleanSocialHtml(html);
|
|
247
|
+
html = sanitiseHTML(html);
|
|
248
|
+
if (this.options.pasteStripAttributes) html = this._stripAttributes(html);
|
|
249
|
+
execCommand('insertHTML', html);
|
|
250
|
+
this.context.invoke('editor.afterCommand');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Otherwise let the browser handle paste natively
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Drag & drop handlers
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
_onDragover(event) {
|
|
262
|
+
if (!event.dataTransfer) return;
|
|
263
|
+
const types = Array.from(event.dataTransfer.types || []);
|
|
264
|
+
if (types.includes('Files')) {
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_onDrop(event) {
|
|
271
|
+
const dt = event.dataTransfer;
|
|
272
|
+
if (!dt || !dt.files || dt.files.length === 0) return;
|
|
273
|
+
|
|
274
|
+
const imageFiles = Array.from(dt.files).filter((f) => f.type.startsWith('image/'));
|
|
275
|
+
if (imageFiles.length === 0) return;
|
|
276
|
+
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
event.stopPropagation();
|
|
279
|
+
|
|
280
|
+
// Place the caret at the drop coordinates before inserting
|
|
281
|
+
this._placeCaretAtPoint(event.clientX, event.clientY);
|
|
282
|
+
this._insertImageFiles(imageFiles);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Image file processing — shared by paste and drop
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Inserts one or more image Files into the editor.
|
|
291
|
+
* Delegates to `options.onImageUpload` when provided; otherwise compresses
|
|
292
|
+
* and embeds as base64.
|
|
293
|
+
* @param {File[]} files
|
|
294
|
+
*/
|
|
295
|
+
_insertImageFiles(files) {
|
|
296
|
+
if (!files || files.length === 0) return;
|
|
297
|
+
|
|
298
|
+
if (typeof this.options.onImageUpload === 'function') {
|
|
299
|
+
this.options.onImageUpload(files);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
|
|
304
|
+
files.forEach((file) => {
|
|
305
|
+
if (!file || !file.type.startsWith('image/')) return;
|
|
306
|
+
if (file.size > maxBytes) {
|
|
307
|
+
const message = `Image "${file.name}" exceeds the ${this.options.maxImageSize || 5} MB size limit.`;
|
|
308
|
+
this.context.triggerEvent('imageError', { file, message });
|
|
309
|
+
console.warn(`[AutumnNote] ${message}`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const alt = file.name.replace(/\.[^.]+$/, '');
|
|
314
|
+
this._compressImage(file).then((dataUrl) => {
|
|
315
|
+
// Keep the large data URL in a JS Map; insert a lightweight blob: URL
|
|
316
|
+
// into the DOM so editable.innerHTML never contains the big base64 string.
|
|
317
|
+
const blob = this._dataUrlToBlob(dataUrl);
|
|
318
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
319
|
+
this._blobRegistry.set(blobUrl, dataUrl);
|
|
320
|
+
this.context.invoke('editor.insertImage', blobUrl, alt);
|
|
321
|
+
}).catch((err) => {
|
|
322
|
+
const message = `Image "${file.name}" could not be processed.`;
|
|
323
|
+
this.context.triggerEvent('imageError', { file, message, error: err });
|
|
324
|
+
if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
|
|
325
|
+
console.warn('[AutumnNote]', message, err);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Replaces any blob: URLs created by this module with their original data URLs.
|
|
333
|
+
* Called by Editor.getHTML() so the returned HTML is fully self-contained.
|
|
334
|
+
* @param {string} html
|
|
335
|
+
* @returns {string}
|
|
336
|
+
*/
|
|
337
|
+
resolveImages(html) {
|
|
338
|
+
if (!this._blobRegistry || !this._blobRegistry.size) return html;
|
|
339
|
+
return html.replace(/blob:[^"'> \t\n\r]*/g, (url) => this._blobRegistry.get(url) || url);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Converts a data URL to a Blob (no FileReader — synchronous).
|
|
344
|
+
* @param {string} dataUrl
|
|
345
|
+
* @returns {Blob}
|
|
346
|
+
*/
|
|
347
|
+
_dataUrlToBlob(dataUrl) {
|
|
348
|
+
const [header, b64] = dataUrl.split(',');
|
|
349
|
+
const mime = header.match(/:(.*?);/)[1];
|
|
350
|
+
const binary = atob(b64);
|
|
351
|
+
const arr = new Uint8Array(binary.length);
|
|
352
|
+
for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
|
|
353
|
+
return new Blob([arr], { type: mime });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Compresses an image File using a Canvas.
|
|
358
|
+
* - Resizes so the longest edge is at most MAX_DIM pixels.
|
|
359
|
+
* - Encodes as WebP (if supported) or JPEG at quality 0.85.
|
|
360
|
+
* Falls back to plain FileReader if canvas is unavailable.
|
|
361
|
+
* @param {File} file
|
|
362
|
+
* @returns {Promise<string>} data URL
|
|
363
|
+
*/
|
|
364
|
+
_compressImage(file) {
|
|
365
|
+
const MAX_DIM = 1920;
|
|
366
|
+
const QUALITY = 0.85;
|
|
367
|
+
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
const objectUrl = URL.createObjectURL(file);
|
|
370
|
+
const img = new Image();
|
|
371
|
+
|
|
372
|
+
img.onload = () => {
|
|
373
|
+
URL.revokeObjectURL(objectUrl);
|
|
374
|
+
|
|
375
|
+
let { width, height } = img;
|
|
376
|
+
if (width > MAX_DIM || height > MAX_DIM) {
|
|
377
|
+
if (width >= height) {
|
|
378
|
+
height = Math.round((height * MAX_DIM) / width);
|
|
379
|
+
width = MAX_DIM;
|
|
380
|
+
} else {
|
|
381
|
+
width = Math.round((width * MAX_DIM) / height);
|
|
382
|
+
height = MAX_DIM;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const canvas = document.createElement('canvas');
|
|
387
|
+
canvas.width = width;
|
|
388
|
+
canvas.height = height;
|
|
389
|
+
const ctx = canvas.getContext('2d');
|
|
390
|
+
if (!ctx) {
|
|
391
|
+
// Canvas context unavailable (e.g. device memory limit) — fall back to
|
|
392
|
+
// embedding the original file without compression.
|
|
393
|
+
const reader = new FileReader();
|
|
394
|
+
reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
|
|
395
|
+
reader.readAsDataURL(file);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
399
|
+
|
|
400
|
+
// Prefer WebP for better compression; fall back to JPEG
|
|
401
|
+
const webp = canvas.toDataURL('image/webp', QUALITY);
|
|
402
|
+
resolve(webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', QUALITY));
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
img.onerror = () => {
|
|
406
|
+
URL.revokeObjectURL(objectUrl);
|
|
407
|
+
// Fallback: embed original without compression
|
|
408
|
+
const reader = new FileReader();
|
|
409
|
+
reader.onload = (e) => resolve(/** @type {string} */ (e.target.result));
|
|
410
|
+
reader.readAsDataURL(file);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
img.src = objectUrl;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Positions the caret at the given viewport coordinates.
|
|
419
|
+
* Supports both Chrome (caretRangeFromPoint) and Firefox (caretPositionFromPoint).
|
|
420
|
+
* @param {number} x
|
|
421
|
+
* @param {number} y
|
|
422
|
+
*/
|
|
423
|
+
_placeCaretAtPoint(x, y) {
|
|
424
|
+
let range;
|
|
425
|
+
if (document.caretRangeFromPoint) {
|
|
426
|
+
range = document.caretRangeFromPoint(x, y);
|
|
427
|
+
} else if (document.caretPositionFromPoint) {
|
|
428
|
+
const pos = document.caretPositionFromPoint(x, y);
|
|
429
|
+
if (pos) {
|
|
430
|
+
range = document.createRange();
|
|
431
|
+
range.setStart(pos.offsetNode, pos.offset);
|
|
432
|
+
range.collapse(true);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!range) return;
|
|
436
|
+
const sel = window.getSelection();
|
|
437
|
+
if (sel) {
|
|
438
|
+
sel.removeAllRanges();
|
|
439
|
+
sel.addRange(range);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Helpers
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Escapes HTML special characters.
|
|
449
|
+
* @param {string} str
|
|
450
|
+
* @returns {string}
|
|
451
|
+
*/
|
|
452
|
+
_escapeHTML(str) {
|
|
453
|
+
return str
|
|
454
|
+
.replace(/&/g, '&')
|
|
455
|
+
.replace(/</g, '<')
|
|
456
|
+
.replace(/>/g, '>')
|
|
457
|
+
.replace(/"/g, '"')
|
|
458
|
+
.replace(/'/g, ''');
|
|
459
|
+
}
|
|
460
|
+
}
|