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.
- package/README.md +72 -5
- package/dist/autumnnote.cjs +20 -20
- package/dist/autumnnote.css +1 -1
- package/dist/autumnnote.es.js +661 -798
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +20 -20
- package/dist/autumnnote.umd.js +20 -20
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +13 -4
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -25
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -231
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -312
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1521
- package/src/js/module/Toolbar.js +0 -750
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- 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*( )?\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('&', '&')
|
|
574
|
-
.replaceAll('<', '<')
|
|
575
|
-
.replaceAll('>', '>')
|
|
576
|
-
.replaceAll('"', '"')
|
|
577
|
-
.replaceAll("'", ''');
|
|
578
|
-
}
|
|
579
|
-
}
|