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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. 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*(&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
+ 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, '&amp;')
455
+ .replace(/</g, '&lt;')
456
+ .replace(/>/g, '&gt;')
457
+ .replace(/"/g, '&quot;')
458
+ .replace(/'/g, '&#039;');
459
+ }
460
+ }