autumnnote 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
@@ -19,7 +19,11 @@
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "vitest run",
21
21
  "test:watch": "vitest",
22
- "lint": "eslint src"
22
+ "lint": "eslint src",
23
+ "typecheck": "tsc --noEmit",
24
+ "build:cdn": "vite build --config vite.cdn.config.js",
25
+ "analyze": "cross-env ANALYZE=1 vite build",
26
+ "bench": "vitest bench"
23
27
  },
24
28
  "keywords": [
25
29
  "wysiwyg",
@@ -40,12 +44,15 @@
40
44
  "author": "Minh Pham",
41
45
  "license": "MIT",
42
46
  "devDependencies": {
43
- "@vitest/browser": "^1.0.0",
44
- "eslint": "^8.57.0",
47
+ "@vitest/browser": "^4.1.2",
48
+ "cross-env": "^10.1.0",
49
+ "eslint": "^10.2.0",
45
50
  "jsdom": "^29.0.1",
46
- "sass": "^1.77.0",
47
- "vite": "^5.2.0",
48
- "vitest": "^1.6.0"
51
+ "rollup-plugin-visualizer": "^7.0.1",
52
+ "sass": "^1.99.0",
53
+ "typescript": "^6.0.2",
54
+ "vite": "^8.0.3",
55
+ "vitest": "^4.1.2"
49
56
  },
50
57
  "browserslist": [
51
58
  "last 2 versions",
package/src/js/Context.js CHANGED
@@ -32,6 +32,7 @@ import { IconDialog } from './module/IconDialog.js';
32
32
  import { ContextMenu } from './module/ContextMenu.js';
33
33
  import { ShortcutsDialog } from './module/ShortcutsDialog.js';
34
34
  import { FindReplace } from './module/FindReplace.js';
35
+ import { ImageCropOverlay } from './module/ImageCropOverlay.js';
35
36
 
36
37
  /** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
37
38
  export const _customModules = new Map();
@@ -107,8 +108,8 @@ export class Context {
107
108
  _registerModules() {
108
109
  const register = (name, ModuleClass) => {
109
110
  const instance = new ModuleClass(this);
110
- instance.initialize();
111
111
  this._modules.set(name, instance);
112
+ instance.initialize();
112
113
  };
113
114
 
114
115
  register('editor', Editor);
@@ -133,6 +134,7 @@ export class Context {
133
134
  register('iconDialog', IconDialog);
134
135
  register('shortcutsDialog', ShortcutsDialog);
135
136
  register('findReplace', FindReplace);
137
+ register('imageCropOverlay', ImageCropOverlay);
136
138
 
137
139
  // Custom modules registered via AutumnNote.registerModule()
138
140
  for (const [name, ModuleClass] of _customModules) {
@@ -155,6 +157,9 @@ export class Context {
155
157
  }
156
158
 
157
159
  _bindEditorEvents(editable) {
160
+ // Keep the original textarea/input value in sync immediately on every input.
161
+ // This guarantees form.submit() sees fresh data even before debounced change.
162
+ const d0 = on(editable, 'input', () => this._syncToTarget());
158
163
  const d1 = on(editable, 'focus', () => {
159
164
  this.layoutInfo.container.classList.add('an-focused');
160
165
  if (typeof this.options.onFocus === 'function') {
@@ -170,7 +175,7 @@ export class Context {
170
175
  });
171
176
  // Sync textarea/input value on every change so form.submit() always gets fresh content
172
177
  const d3 = this.on('change', () => this._syncToTarget());
173
- this._disposers.push(d1, d2, d3);
178
+ this._disposers.push(d0, d1, d2, d3);
174
179
 
175
180
  // Auto-save to localStorage on every change
176
181
  if (this.options.autoSave && this.options.autoSaveKey) {
@@ -196,15 +201,11 @@ export class Context {
196
201
  const [moduleName, methodName] = path.split('.');
197
202
  const module = this._modules.get(moduleName);
198
203
  if (!module) {
199
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
200
- console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
201
- }
204
+ console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
202
205
  return undefined;
203
206
  }
204
207
  if (typeof module[methodName] !== 'function') {
205
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
206
- console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
207
- }
208
+ console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
208
209
  return undefined;
209
210
  }
210
211
  return module[methodName](...args);
@@ -313,8 +313,8 @@ export function isInlineCode() {
313
313
 
314
314
  /**
315
315
  * Toggles a task-list at the cursor.
316
- * If inside a checklist <li>, converts it back to a <p>.
317
- * Otherwise inserts a new <ul class="an-checklist"> with one item.
316
+ * If inside a checklist <li>, converts it (and any other selected items) back to <p> elements.
317
+ * Otherwise inserts a new <ul class="an-checklist"> with one item per selected line.
318
318
  */
319
319
  export function toggleChecklist() {
320
320
  const sel = window.getSelection();
@@ -322,59 +322,56 @@ export function toggleChecklist() {
322
322
  const range = sel.getRangeAt(0);
323
323
  let container = range.commonAncestorContainer;
324
324
  if (container.nodeType === 3) container = container.parentElement;
325
- const li = container && container.closest ? container.closest('.an-checklist li') : null;
326
- if (li) {
327
- // Exit checklist — convert item to a <p>
328
- const ul = li.closest('.an-checklist');
329
- const text = Array.from(li.childNodes)
330
- .filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
331
- .map((n) => n.textContent).join('').replace(/\u00a0/g, ' ').trim();
332
- const p = document.createElement('p');
333
- p.textContent = text || '\u00a0';
334
- ul.parentNode.insertBefore(p, ul.nextSibling);
335
- ul.removeChild(li);
336
- if (ul.children.length === 0) ul.remove();
337
- const nr = document.createRange();
338
- // Point into the text node (or first child) rather than the element node
339
- // so the cursor is at a well-defined text position, not element-offset 0.
340
- const startNode = p.firstChild || p;
341
- nr.setStart(startNode, 0);
342
- nr.collapse(true);
343
- sel.removeAllRanges();
344
- sel.addRange(nr);
345
- } else {
346
- // Use a temporary marker attribute so the new <li> can be found reliably
347
- // even when execCommand('insertHTML') places the cursor outside the list.
348
- // Chrome often moves the caret to a generated <p> after the <ul> rather
349
- // than staying inside the <li>, making a selection-based lookup fail.
350
- const MARKER = 'data-an-new-cli';
351
- execCommand('insertHTML',
352
- `<ul class="an-checklist"><li ${MARKER}><input type="checkbox" contenteditable="false"></li></ul>`);
353
- const newLi = document.querySelector(`[${MARKER}]`);
354
- if (newLi) {
355
- newLi.removeAttribute(MARKER);
356
- const cbEl = newLi.querySelector('input[type="checkbox"]');
357
- if (cbEl) {
358
- // Use \u200B (zero-width space) instead of an empty string.
359
- // Chrome does not reliably honour a Selection pointing into an empty
360
- // text node and may silently normalise it to an element-level offset,
361
- // rendering the caret before the absolutely-positioned checkbox.
362
- // \u200B is invisible/zero-width and is stripped by getHTML().
363
- let textNode = cbEl.nextSibling;
364
- if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
365
- textNode = document.createTextNode('\u200B');
366
- newLi.appendChild(textNode);
367
- } else if (!textNode.textContent) {
368
- textNode.textContent = '\u200B';
369
- }
325
+
326
+ const ul = container.closest && container.closest('.an-checklist');
327
+ if (ul) {
328
+ // If selection covers multiple <li>, convert them all
329
+ const selectedLis = Array.from(ul.querySelectorAll('li')).filter((li) =>
330
+ sel.containsNode(li, true),
331
+ );
332
+ if (selectedLis.length > 0) {
333
+ let firstP = null;
334
+ selectedLis.forEach((li) => {
335
+ const text = Array.from(li.childNodes)
336
+ .filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
337
+ .map((n) => n.textContent)
338
+ .join('')
339
+ .replace(/\u00a0/g, ' ')
340
+ .trim();
341
+ const p = document.createElement('p');
342
+ p.textContent = text || '\u00a0';
343
+ ul.parentNode.insertBefore(p, ul);
344
+ if (!firstP) firstP = p;
345
+ ul.removeChild(li);
346
+ });
347
+ if (ul.children.length === 0) ul.remove();
348
+ // Move caret to first converted paragraph
349
+ if (firstP) {
370
350
  const nr = document.createRange();
371
- nr.setStart(textNode, 0);
351
+ nr.setStart(firstP.firstChild || firstP, 0);
372
352
  nr.collapse(true);
373
353
  sel.removeAllRanges();
374
354
  sel.addRange(nr);
375
355
  }
356
+ return;
376
357
  }
377
358
  }
359
+
360
+ // Otherwise: insert new checklist from selected text
361
+ const text = sel.toString();
362
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
363
+ if (lines.length === 0) return;
364
+ const items = lines
365
+ .map(
366
+ (l) =>
367
+ `<li><input type="checkbox" contenteditable="false">${l || '\u200B'}</li>`,
368
+ )
369
+ .join('');
370
+ document.execCommand(
371
+ 'insertHTML',
372
+ false,
373
+ `<ul class="an-checklist">${items}</ul>`,
374
+ );
378
375
  }
379
376
 
380
377
  /**
@@ -8,6 +8,15 @@ import { closestPara, isLi } from '../core/dom.js';
8
8
  import { execCommand } from './Style.js';
9
9
  import { currentRange } from '../core/range.js';
10
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // Module-level predicates — defined once, not re-created on every keypress.
13
+ // Previously these were arrow functions inside handleKeydown() which fires
14
+ // at ~120+ events/sec during normal typing.
15
+ // ---------------------------------------------------------------------------
16
+ const _FA_PATTERN = /\bfa-/;
17
+ const isFAIcon = (n) => !!(n && n.nodeName === 'I' && _FA_PATTERN.test(n.className || ''));
18
+ const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
19
+
11
20
  /**
12
21
  * Handles special keydown behaviour inside the editor.
13
22
  * @param {KeyboardEvent} event
@@ -16,8 +25,6 @@ import { currentRange } from '../core/range.js';
16
25
  * @returns {boolean} true if the event was consumed
17
26
  */
18
27
  export function handleKeydown(event, editable, options = {}) {
19
- const isFAIcon = (n) => !!(n && n.nodeName === 'I' && /\bfa-/.test(n.className || ''));
20
- const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
21
28
  const moveCaret = (setFn) => {
22
29
  const sel = window.getSelection();
23
30
  if (!sel) return false;
@@ -51,8 +58,26 @@ export function handleKeydown(event, editable, options = {}) {
51
58
  if (r.startOffset === 1 && textNode.textContent === '\u200B' &&
52
59
  isFAIcon(textNode.previousSibling)) {
53
60
  event.preventDefault();
54
- textNode.previousSibling.remove();
61
+ const parent = textNode.parentNode;
62
+ const icon = textNode.previousSibling;
63
+ const prevNode = icon.previousSibling; // node before the icon (e.g. ZWS of prior icon)
64
+ icon.remove();
55
65
  textNode.remove();
66
+ // Explicitly restore the cursor to the node preceding the deleted icon.
67
+ // Without this, the browser collapses the selection to the parent element
68
+ // (not a text node), causing the next Backspace to miss Cases A/B and
69
+ // requiring an extra keypress when two icons are adjacent.
70
+ const nr = document.createRange();
71
+ if (prevNode && prevNode.nodeType === Node.TEXT_NODE) {
72
+ nr.setStart(prevNode, prevNode.textContent.length);
73
+ } else if (prevNode) {
74
+ nr.setStartAfter(prevNode);
75
+ } else if (parent) {
76
+ nr.setStart(parent, 0);
77
+ }
78
+ nr.collapse(true);
79
+ sel.removeAllRanges();
80
+ sel.addRange(nr);
56
81
  return true;
57
82
  }
58
83
  }
package/src/js/index.js CHANGED
@@ -99,7 +99,7 @@ const AutumnNote = {
99
99
  registerModule(name, ModuleClass) { _customModules.set(name, ModuleClass); },
100
100
 
101
101
  /** Library version */
102
- version: '1.0.1',
102
+ version: '1.0.4',
103
103
  };
104
104
 
105
105
  // ---------------------------------------------------------------------------
@@ -124,23 +124,22 @@ export class Clipboard {
124
124
  */
125
125
  _cleanSocialHtml(html) {
126
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
- });
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() 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
+ parent.removeChild(el);
144
143
  }
145
144
  // Strip class and all data-* attributes from every remaining element
146
145
  doc.querySelectorAll('*').forEach((el) => {
@@ -321,9 +320,7 @@ export class Clipboard {
321
320
  }).catch((err) => {
322
321
  const message = `Image "${file.name}" could not be processed.`;
323
322
  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
- }
323
+ console.warn('[AutumnNote]', message, err);
327
324
  });
328
325
  });
329
326
  }
@@ -6,6 +6,9 @@ import { createElement, on } from '../core/dom.js';
6
6
  const SHOW_DELAY = 100;
7
7
  const HIDE_DELAY = 180;
8
8
 
9
+ // Cached regex for extracting language class — defined once at module level.
10
+ const _LANG_CLASS_RE = /language-(\S+)/;
11
+
9
12
  const ICONS = {
10
13
  copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
11
14
  wrapOn: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6H3"/><polyline points="6 15 3 18 6 21"/></svg>`,
@@ -185,7 +188,9 @@ export class CodeTooltip {
185
188
  _scheduleHide() {
186
189
  clearTimeout(this._showTimer);
187
190
  this._showTimer = null;
188
- if (this._hideTimer) return;
191
+ // Always reset the hide timer so rapid mouseout→mouseover sequences
192
+ // don't leave a stale timer that hides the tooltip prematurely.
193
+ clearTimeout(this._hideTimer);
189
194
  this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
190
195
  }
191
196
 
@@ -242,7 +247,7 @@ export class CodeTooltip {
242
247
  if (!this._activePre || !this._langSelect) return;
243
248
  const codeEl = this._activePre.querySelector('code');
244
249
  const fromAttr = this._activePre.getAttribute('data-language') || '';
245
- const fromClass = codeEl ? (codeEl.className.match(/language-(\S+)/) || [])[1] || '' : '';
250
+ const fromClass = codeEl ? (_LANG_CLASS_RE.exec(codeEl.className) || [])[1] || '' : '';
246
251
  this._langSelect.value = fromAttr || fromClass || '';
247
252
  }
248
253
 
@@ -355,8 +360,7 @@ export class CodeTooltip {
355
360
  */
356
361
  _ensurePrism() {
357
362
  if (!this.context.options.codeHighlight || window.Prism) return;
358
- const cdn = this.context.options.codeHighlightCDN
359
- || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
363
+ const cdn = this.context.options.codeHighlightCDN;
360
364
  const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
361
365
  const scriptSrc = `${cdn}/prism.min.js`;
362
366
 
@@ -389,8 +393,7 @@ export class CodeTooltip {
389
393
  * @param {Function} cb – called once the grammar is ready
390
394
  */
391
395
  _loadPrismComponent(lang, cb) {
392
- const cdn = this.context.options.codeHighlightCDN
393
- || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
396
+ const cdn = this.context.options.codeHighlightCDN;
394
397
  const src = `${cdn}/components/prism-${lang}.min.js`;
395
398
  // Avoid loading the same component twice
396
399
  if (document.querySelector(`script[src="${src}"]`)) {
@@ -34,6 +34,11 @@ export class FindReplace {
34
34
  /** @type {'find'|'replace'} */
35
35
  this._mode = 'find';
36
36
 
37
+ /** Cached compiled regex — reused when query and case-sensitivity are unchanged */
38
+ this._queryRegex = null;
39
+ this._lastQuery = null;
40
+ this._lastCaseSensitive = null;
41
+
37
42
  this._disposers = [];
38
43
  this._removeTrap = null;
39
44
  this._focusTimer = null;
@@ -267,7 +272,10 @@ export class FindReplace {
267
272
 
268
273
  this._currentIndex = 0;
269
274
 
270
- // Wrap in reverse order so earlier offsets in the same text node stay valid
275
+ // Wrap matches in reverse order so earlier text offsets stay valid when
276
+ // later sections of the same text node are split by surroundContents().
277
+ // Use push() instead of unshift() to avoid O(n²) shifting on every insert;
278
+ // reverse() at the end restores forward document order in O(n).
271
279
  for (let i = rawMatches.length - 1; i >= 0; i--) {
272
280
  const { node, start, end } = rawMatches[i];
273
281
  try {
@@ -277,13 +285,14 @@ export class FindReplace {
277
285
  const mark = document.createElement('mark');
278
286
  mark.className = 'an-highlight';
279
287
  range.surroundContents(mark);
280
- this._matches.unshift({ mark });
288
+ this._matches.push({ mark });
281
289
  } catch (_) {
282
290
  // surroundContents fails when the range crosses element boundaries.
283
291
  // This can happen with <br> inside matched text — skip safely.
284
- this._matches.unshift({ mark: null });
292
+ this._matches.push({ mark: null });
285
293
  }
286
294
  }
295
+ this._matches.reverse(); // O(n) — restore forward document order
287
296
 
288
297
  // Drop entries where wrapping failed so the counter and navigation are accurate
289
298
  this._matches = this._matches.filter((m) => m.mark);
@@ -304,10 +313,15 @@ export class FindReplace {
304
313
  */
305
314
  _findRawMatches(query, root) {
306
315
  const results = [];
307
- const flags = this._caseSensitive ? 'g' : 'gi';
308
- // Escape regex special characters in the literal query string
309
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
310
- const re = new RegExp(escaped, flags);
316
+ // Reuse compiled regex when query and case-sensitivity haven't changed
317
+ if (this._lastQuery !== query || this._lastCaseSensitive !== this._caseSensitive) {
318
+ const flags = this._caseSensitive ? 'g' : 'gi';
319
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
320
+ this._queryRegex = new RegExp(escaped, flags);
321
+ this._lastQuery = query;
322
+ this._lastCaseSensitive = this._caseSensitive;
323
+ }
324
+ const re = this._queryRegex;
311
325
 
312
326
  const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
313
327
  let node;