autumnnote 1.0.5 → 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.5",
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
@@ -201,15 +201,11 @@ export class Context {
201
201
  const [moduleName, methodName] = path.split('.');
202
202
  const module = this._modules.get(moduleName);
203
203
  if (!module) {
204
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
205
- console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
206
- }
204
+ console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
207
205
  return undefined;
208
206
  }
209
207
  if (typeof module[methodName] !== 'function') {
210
- if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
211
- console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
212
- }
208
+ console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
213
209
  return undefined;
214
210
  }
215
211
  return module[methodName](...args);
@@ -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
  }
@@ -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;
@@ -55,7 +55,7 @@ export class ImageResizer {
55
55
  }),
56
56
  on(document, 'click', (e) => this._onDocClick(e)),
57
57
  on(window, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
58
- on(window, 'resize', onWindowResize),
58
+ on(window, 'resize', onWindowResize, { passive: true }),
59
59
  on(editable, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
60
60
  );
61
61
 
@@ -173,7 +173,9 @@ export class ImageTooltip {
173
173
  _scheduleHide() {
174
174
  clearTimeout(this._showTimer);
175
175
  this._showTimer = null;
176
- if (this._hideTimer) return;
176
+ // Always reset the hide timer so rapid mouseout→mouseover sequences
177
+ // don't leave a stale timer that hides the tooltip prematurely.
178
+ clearTimeout(this._hideTimer);
177
179
  this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
178
180
  }
179
181
 
@@ -74,6 +74,7 @@ const ICONS = {
74
74
  mergeCells: `<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="2" y="7" width="8" height="10" rx="1"/><rect x="14" y="7" width="8" height="10" rx="1"/><path d="M10 12h4"/><path d="M12 10l2 2-2 2"/></svg>`,
75
75
  colWidth: `<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="7" y1="4" x2="7" y2="20"/><line x1="17" y1="4" x2="17" y2="20"/><line x1="7" y1="12" x2="17" y2="12"/><path d="M10 9l-3 3 3 3"/><path d="M14 9l3 3-3 3"/></svg>`,
76
76
  rowHeight: `<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="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/><line x1="12" y1="7" x2="12" y2="17"/><path d="M9 10l3-3 3 3"/><path d="M9 14l3 3 3-3"/></svg>`,
77
+ tableBorder: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6" stroke-width="1"/><line x1="3" y1="13" x2="21" y2="13" stroke-width="2"/><line x1="3" y1="20" x2="21" y2="20" stroke-width="3"/></svg>`,
77
78
  deleteTable: `<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="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/><line x1="16" y1="16" x2="22" y2="22" stroke="#ef4444"/><line x1="22" y1="16" x2="16" y2="22" stroke="#ef4444"/></svg>`,
78
79
  };
79
80
 
@@ -320,8 +321,9 @@ export class TableTooltip {
320
321
  el.appendChild(this._sep());
321
322
 
322
323
  // Resize
323
- el.appendChild(this._makeBtn(ICONS.colWidth, 'Column Width', () => this._openSizePopover('col')));
324
- el.appendChild(this._makeBtn(ICONS.rowHeight, 'Row Height', () => this._openSizePopover('row')));
324
+ el.appendChild(this._makeBtn(ICONS.colWidth, 'Column Width', () => this._openSizePopover('col')));
325
+ el.appendChild(this._makeBtn(ICONS.rowHeight, 'Row Height', () => this._openSizePopover('row')));
326
+ el.appendChild(this._makeBtn(ICONS.tableBorder,'Table Border Width',() => this._openSizePopover('border')));
325
327
 
326
328
  el.appendChild(this._sep());
327
329
 
@@ -641,30 +643,59 @@ export class TableTooltip {
641
643
  _openSizePopover(type) {
642
644
  const cell = this._getCell();
643
645
  if (!cell || !this._sizePopover) return;
644
- const isCol = type === 'col';
645
- this._sizeTitleEl.textContent = isCol ? 'Column Width (px)' : 'Row Height (px)';
646
- this._sizeInputEl.value = isCol
647
- ? (cell.offsetWidth || 120)
648
- : (cell.closest('tr') ? (cell.closest('tr').offsetHeight || 40) : 40);
649
-
650
- this._sizeApply = (val) => {
651
- if (isCol) {
652
- const table = cell.closest('table');
653
- const visualColIdx = getVisualColIndex(cell);
654
- const rows = Array.from(table.querySelectorAll('tr'));
655
- // Batch reads, then writes
656
- const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
657
- cells.forEach(c => {
658
- if (c) { c.style.width = `${val}px`; c.style.minWidth = `${val}px`; }
659
- });
660
- } else {
661
- const row = cell.closest('tr');
662
- if (row) {
663
- for (const c of row.cells) { c.style.height = `${val}px`; c.style.minHeight = `${val}px`; }
646
+
647
+ if (type === 'border') {
648
+ const table = cell.closest('table');
649
+ if (!table) return;
650
+ // Read current inline border-width, fall back to computed value, then default 1px
651
+ const firstCell = table.querySelector('td, th');
652
+ const currentPx = firstCell
653
+ ? (parseInt(firstCell.style.borderWidth, 10) ||
654
+ parseInt(window.getComputedStyle(firstCell).borderWidth, 10) || 1)
655
+ : 1;
656
+ this._sizeTitleEl.textContent = 'Table Border Width (px)';
657
+ this._sizeInputEl.min = '0';
658
+ this._sizeInputEl.max = '10';
659
+ this._sizeInputEl.value = currentPx;
660
+ this._sizeApply = (val) => {
661
+ // Apply border-width to every cell; with border-collapse:collapse this
662
+ // controls all grid lines including the outer table border.
663
+ // Setting only borderWidth preserves the existing border-color from CSS.
664
+ const cells = Array.from(table.querySelectorAll('td, th'));
665
+ if (val === 0) {
666
+ cells.forEach(c => { c.style.borderWidth = '0'; c.style.borderStyle = 'none'; });
667
+ } else {
668
+ cells.forEach(c => { c.style.borderWidth = `${val}px`; c.style.borderStyle = 'solid'; });
664
669
  }
665
- }
666
- this.context.invoke('editor.afterCommand');
667
- };
670
+ this.context.invoke('editor.afterCommand');
671
+ };
672
+ } else {
673
+ const isCol = type === 'col';
674
+ this._sizeTitleEl.textContent = isCol ? 'Column Width (px)' : 'Row Height (px)';
675
+ this._sizeInputEl.min = '1';
676
+ this._sizeInputEl.max = '2000';
677
+ this._sizeInputEl.value = isCol
678
+ ? (cell.offsetWidth || 120)
679
+ : (cell.closest('tr') ? (cell.closest('tr').offsetHeight || 40) : 40);
680
+ this._sizeApply = (val) => {
681
+ if (isCol) {
682
+ const table = cell.closest('table');
683
+ const visualColIdx = getVisualColIndex(cell);
684
+ const rows = Array.from(table.querySelectorAll('tr'));
685
+ // Batch reads, then writes
686
+ const cells = rows.map(r => getCellAtVisualCol(r, visualColIdx));
687
+ cells.forEach(c => {
688
+ if (c) { c.style.width = `${val}px`; c.style.minWidth = `${val}px`; }
689
+ });
690
+ } else {
691
+ const row = cell.closest('tr');
692
+ if (row) {
693
+ for (const c of row.cells) { c.style.height = `${val}px`; c.style.minHeight = `${val}px`; }
694
+ }
695
+ }
696
+ this.context.invoke('editor.afterCommand');
697
+ };
698
+ }
668
699
 
669
700
  this._sizePopover.style.display = 'block';
670
701
  requestAnimationFrame(() => {