autumnnote 1.2.1 → 1.3.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.
@@ -44,11 +44,25 @@ const _ACTIONS = {
44
44
  strikethrough: (ctx) => ctx.invoke('editor.strikethrough'),
45
45
  link: (ctx) => ctx.invoke('linkDialog.show'),
46
46
  removeFormat: (ctx) => {
47
- // editor.removeFormat does not exist — call execCommand directly
48
47
  const editable = ctx.layoutInfo && ctx.layoutInfo.editable;
49
48
  if (!editable) return;
50
49
  editable.focus();
51
50
  document.execCommand('removeFormat');
51
+ // Also strip inline style attributes which execCommand('removeFormat') misses
52
+ const sel = window.getSelection();
53
+ if (sel && sel.rangeCount > 0 && !sel.getRangeAt(0).collapsed) {
54
+ const range = sel.getRangeAt(0);
55
+ const ancestor = range.commonAncestorContainer;
56
+ const root = ancestor.nodeType === 1 ? ancestor : ancestor.parentElement;
57
+ if (root) {
58
+ const candidates = [root, ...root.querySelectorAll('[style]')];
59
+ for (const el of candidates) {
60
+ if (el.hasAttribute('style') && range.intersectsNode(el)) {
61
+ el.removeAttribute('style');
62
+ }
63
+ }
64
+ }
65
+ }
52
66
  ctx.invoke('editor.afterCommand');
53
67
  },
54
68
  inlineCode: (ctx) => ctx.invoke('editor.inlineCode'),
@@ -172,6 +186,8 @@ export class BubbleToolbar {
172
186
 
173
187
  document.body.appendChild(el);
174
188
  this._el = el;
189
+ // Cache button references once — avoids querySelectorAll on every selectionchange
190
+ this._btnCache = Array.from(el.querySelectorAll('.an-bubble-btn'));
175
191
  }
176
192
 
177
193
  _buildColorPicker() {
@@ -291,9 +307,13 @@ export class BubbleToolbar {
291
307
  editable.focus();
292
308
  const sel = window.getSelection();
293
309
  sel.removeAllRanges();
294
- sel.addRange(this._savedRange.cloneRange());
310
+ try { sel.addRange(this._savedRange.cloneRange()); } catch (_) { return; }
295
311
 
296
- document.execCommand(type, false, color);
312
+ // Firefox does not support 'hiliteColor'; fall back to 'backColor'
313
+ const cmd = type === 'hiliteColor' ? 'hiliteColor' : type;
314
+ if (!document.execCommand(cmd, false, color) && cmd === 'hiliteColor') {
315
+ document.execCommand('backColor', false, color);
316
+ }
297
317
  this.context.invoke('editor.afterCommand');
298
318
 
299
319
  // Update the color strip on the corresponding button
@@ -349,10 +369,9 @@ export class BubbleToolbar {
349
369
  }
350
370
 
351
371
  _syncActive() {
352
- if (!this._el) return;
353
- this._el.querySelectorAll('.an-bubble-btn').forEach((btn) => {
354
- const name = btn.dataset.name;
355
- const activeFn = _ACTIVE[name];
372
+ if (!this._btnCache) return;
373
+ this._btnCache.forEach((btn) => {
374
+ const activeFn = _ACTIVE[btn.dataset.name];
356
375
  btn.classList.toggle('an-active', !!(activeFn && activeFn()));
357
376
  });
358
377
  }
@@ -239,7 +239,7 @@ export class Clipboard {
239
239
  const raw = clipboardData.getData('text/html');
240
240
  // Detect source type and apply appropriate pre-cleaner
241
241
  const isWordContent = /<[a-z]+:[a-z]/i.test(raw) || /class="Mso/i.test(raw) || /\bmso-/i.test(raw);
242
- const isSocialContent = /\bdata-testid\b/.test(raw) || /class="[^"]*\b(?:x[a-z0-9]{6,}|r-[a-z0-9]{3,})\b/.test(raw);
242
+ const isSocialContent = /class="[^"]*\b(?:x[a-z0-9]{6,}|r-[a-z0-9]{3,})\b/.test(raw);
243
243
  let html = raw;
244
244
  if (isWordContent) html = this._cleanWordHtml(html);
245
245
  else if (isSocialContent) html = this._cleanSocialHtml(html);
@@ -351,7 +351,7 @@ export class Clipboard {
351
351
  */
352
352
  _dataUrlToBlob(dataUrl) {
353
353
  const [header, b64] = dataUrl.split(',');
354
- const mime = header.match(/:(.*?);/)[1];
354
+ const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png';
355
355
  const binary = atob(b64);
356
356
  const arr = new Uint8Array(binary.length);
357
357
  for (let i = 0; i < binary.length; i++) arr[i] = binary.charCodeAt(i);
@@ -307,7 +307,7 @@ export class ContextMenu {
307
307
  cells.forEach((cell) => {
308
308
  cell.classList.toggle('active', +cell.dataset.row <= rows && +cell.dataset.col <= cols);
309
309
  });
310
- labelEl.textContent = (rows && cols) ? `${cols} × ${rows}` : (this.context.locale.contextMenu.table || 'Insert Table');
310
+ labelEl.textContent = (rows && cols) ? `${rows} × ${cols}` : (this.context.locale.contextMenu.table || 'Insert Table');
311
311
  };
312
312
 
313
313
  panel.appendChild(gridEl);
@@ -271,8 +271,6 @@ export class FindReplace {
271
271
  const rawMatches = this._findRawMatches(query, editable);
272
272
  if (rawMatches.length === 0) return;
273
273
 
274
- this._currentIndex = 0;
275
-
276
274
  // Wrap matches in reverse order so earlier text offsets stay valid when
277
275
  // later sections of the same text node are split by surroundContents().
278
276
  // Use push() instead of unshift() to avoid O(n²) shifting on every insert;
@@ -297,6 +295,7 @@ export class FindReplace {
297
295
 
298
296
  // Drop entries where wrapping failed so the counter and navigation are accurate
299
297
  this._matches = this._matches.filter((m) => m.mark);
298
+ this._currentIndex = 0;
300
299
  if (this._matches.length === 0) return;
301
300
 
302
301
  // Highlight the first (current) match
@@ -324,12 +323,14 @@ export class FindReplace {
324
323
  }
325
324
  const re = this._queryRegex;
326
325
 
326
+ // Cap results to prevent blocking the main thread on very large documents
327
+ const MAX_RESULTS = 500;
327
328
  const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
328
329
  let node;
329
- while ((node = walker.nextNode())) {
330
+ while ((node = walker.nextNode()) && results.length < MAX_RESULTS) {
330
331
  re.lastIndex = 0;
331
332
  let m;
332
- while ((m = re.exec(node.textContent)) !== null) {
333
+ while ((m = re.exec(node.textContent)) !== null && results.length < MAX_RESULTS) {
333
334
  results.push({ node, start: m.index, end: m.index + m[0].length });
334
335
  }
335
336
  }
@@ -447,7 +448,7 @@ export class FindReplace {
447
448
  const total = this._matches.length;
448
449
  if (total === 0) {
449
450
  const query = this._findInput ? this._findInput.value : '';
450
- this._counterEl.textContent = query ? 'No results' : '';
451
+ this._counterEl.textContent = query ? this.context.locale.findReplace.noResults : '';
451
452
  } else {
452
453
  this._counterEl.textContent = `${this._currentIndex + 1} / ${total}`;
453
454
  }
@@ -227,6 +227,11 @@ export class ImageCropOverlay {
227
227
  e.stopPropagation();
228
228
  this._startHandleDrag(e, id);
229
229
  }),
230
+ on(h, 'touchstart', (e) => {
231
+ e.preventDefault();
232
+ e.stopPropagation();
233
+ this._startHandleDrag({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }, id);
234
+ }, { passive: false }),
230
235
  );
231
236
  this._handles[id] = h;
232
237
  cropBox.appendChild(h);
@@ -235,16 +240,19 @@ export class ImageCropOverlay {
235
240
  /* ---- crop move drag ---- */
236
241
  this._disposers.push(
237
242
  on(cropBox, 'mousedown', (e) => {
238
- // Only directly on the box surface (not on a handle)
239
- if (e.target !== cropBox && e.target !== grid && !(e.target.tagName === 'DIV' && !e.target.className.includes('handle'))) {
240
- // Let handle mousedown handle it
241
- return;
242
- }
243
- if (e.target.className && e.target.className.includes('an-crop-handle')) return;
243
+ if (e.target.classList.contains('an-crop-handle')) return;
244
+ if (e.target !== cropBox && e.target !== grid) return;
244
245
  e.preventDefault();
245
246
  e.stopPropagation();
246
247
  this._startBoxMove(e);
247
248
  }),
249
+ on(cropBox, 'touchstart', (e) => {
250
+ if (e.target.classList.contains('an-crop-handle')) return;
251
+ if (e.target !== cropBox && e.target !== grid) return;
252
+ e.preventDefault();
253
+ e.stopPropagation();
254
+ this._startBoxMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
255
+ }, { passive: false }),
248
256
  );
249
257
 
250
258
  /* ---- info label ---- */
@@ -434,15 +442,23 @@ export class ImageCropOverlay {
434
442
  * @param {(e: MouseEvent) => void} onMove
435
443
  */
436
444
  _attachDocDrag(onMove) {
445
+ const onTouchMove = (e) => {
446
+ e.preventDefault();
447
+ onMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
448
+ };
437
449
  const cleanup = () => {
438
450
  document.removeEventListener('mousemove', onMove);
439
451
  document.removeEventListener('mouseup', cleanup);
452
+ document.removeEventListener('touchmove', onTouchMove);
453
+ document.removeEventListener('touchend', cleanup);
440
454
  document.body.style.userSelect = '';
441
455
  document.body.style.cursor = '';
442
456
  };
443
457
  document.body.style.userSelect = 'none';
444
458
  document.addEventListener('mousemove', onMove);
445
459
  document.addEventListener('mouseup', cleanup, { once: true });
460
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
461
+ document.addEventListener('touchend', cleanup, { once: true });
446
462
  }
447
463
 
448
464
  // ---------------------------------------------------------------------------
@@ -489,8 +505,8 @@ export class ImageCropOverlay {
489
505
 
490
506
  if (!canvas) {
491
507
  // Cross-origin failure — inform user and abort
492
- window.alert(
493
- 'Cannot crop this image: the image server does not allow cross-origin access.\n' +
508
+ this._showCropError(
509
+ 'Cannot crop this image: the image server does not allow cross-origin access. ' +
494
510
  'Upload the image directly to use the crop tool.',
495
511
  );
496
512
  this._close(false);
@@ -516,6 +532,28 @@ export class ImageCropOverlay {
516
532
  this.context.invoke('imageResizer.updateOverlay');
517
533
  }
518
534
 
535
+ /**
536
+ * Show a non-blocking inline error banner appended to document.body.
537
+ * Auto-dismisses after 4 seconds.
538
+ * @param {string} msg
539
+ */
540
+ _showCropError(msg) {
541
+ const banner = document.createElement('div');
542
+ banner.setAttribute('role', 'alert');
543
+ banner.style.cssText = [
544
+ 'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
545
+ 'z-index:10200', 'max-width:420px', 'width:max-content',
546
+ 'background:#7f1d1d', 'color:#fecaca', 'border:1px solid #b91c1c',
547
+ 'border-radius:8px', 'padding:12px 18px',
548
+ 'font:13px/1.5 system-ui,sans-serif',
549
+ 'box-shadow:0 4px 16px rgba(0,0,0,.4)',
550
+ 'pointer-events:auto',
551
+ ].join(';');
552
+ banner.textContent = msg;
553
+ document.body.appendChild(banner);
554
+ setTimeout(() => { if (banner.parentNode) banner.parentNode.removeChild(banner); }, 4000);
555
+ }
556
+
519
557
  /**
520
558
  * Remove all overlay DOM elements and reset state.
521
559
  * @param {boolean} _committed - reserved for future use
@@ -151,6 +151,7 @@ export class ImageResizer {
151
151
  this._activeImg.classList.remove('an-image-selected');
152
152
  }
153
153
  this._activeImg = img;
154
+ this._lastOverlayPos = null; // invalidate position cache on new selection
154
155
  img.classList.add('an-image-selected');
155
156
  this._updateOverlayPosition();
156
157
  this._overlay.style.display = 'block';
@@ -177,6 +178,12 @@ export class ImageResizer {
177
178
  const offsetParent = this._overlay.offsetParent || this._container;
178
179
  const containerRect = offsetParent.getBoundingClientRect();
179
180
  const rect = this._activeImg.getBoundingClientRect();
181
+
182
+ // Skip DOM writes when position hasn't changed (common during non-scroll rAF ticks)
183
+ const p = this._lastOverlayPos;
184
+ if (p && p.l === rect.left && p.t === rect.top && p.w === rect.width && p.h === rect.height) return;
185
+ this._lastOverlayPos = { l: rect.left, t: rect.top, w: rect.width, h: rect.height };
186
+
180
187
  const left = rect.left - containerRect.left + offsetParent.scrollLeft;
181
188
  const top = rect.top - containerRect.top + offsetParent.scrollTop;
182
189
  this._overlay.style.left = `${left}px`;
@@ -92,16 +92,29 @@ export class Mention {
92
92
  const el = document.createElement('div');
93
93
  el.className = 'an-mention-dropdown';
94
94
  el.setAttribute('role', 'listbox');
95
+
96
+ // Event delegation: single listeners on the container instead of per-item
97
+ el.addEventListener('mousedown', (e) => e.preventDefault());
98
+ el.addEventListener('click', (e) => {
99
+ const item = e.target.closest('.an-mention-item');
100
+ if (item) this._select(+item.dataset.index);
101
+ });
102
+ el.addEventListener('mousemove', (e) => {
103
+ const item = e.target.closest('.an-mention-item');
104
+ if (item) this._highlightItem(+item.dataset.index);
105
+ });
106
+
95
107
  document.body.appendChild(el);
96
108
  this._dropdown = el;
97
109
  }
98
110
 
99
111
  _renderItems(items) {
100
112
  const dd = this._dropdown;
101
- dd.innerHTML = '';
102
113
  this._items = items.slice(0, this._cfg.maxResults);
103
114
  this._activeIndex = this._items.length > 0 ? 0 : -1;
104
115
 
116
+ // Build all items in a DocumentFragment — one batch DOM insertion
117
+ const frag = document.createDocumentFragment();
105
118
  this._items.forEach((item, i) => {
106
119
  const li = document.createElement('div');
107
120
  li.className = 'an-mention-item';
@@ -117,11 +130,12 @@ export class Mention {
117
130
  const label = document.createElement('span');
118
131
  label.textContent = item.label;
119
132
  li.appendChild(label);
120
- li.addEventListener('mousedown', (e) => e.preventDefault());
121
- li.addEventListener('click', () => this._select(i));
122
- dd.appendChild(li);
133
+ frag.appendChild(li);
123
134
  });
124
135
 
136
+ dd.innerHTML = ''; // one clear
137
+ dd.appendChild(frag); // one batch insert
138
+
125
139
  this._highlightItem(this._activeIndex);
126
140
  }
127
141
 
@@ -214,7 +214,8 @@ export class Statusbar {
214
214
  update() {
215
215
  if (!this._wordCountEl || !this._charCountEl) return;
216
216
  const editable = this.context.layoutInfo.editable;
217
- const text = editable.innerText || '';
217
+ // textContent is faster than innerText (no layout flush, no CSS visibility check)
218
+ const text = editable.textContent || '';
218
219
  const words = _countWords(text);
219
220
  const chars = text.replace(/\n/g, '').length;
220
221
  const maxWords = this.options.maxWords || 0;
@@ -115,6 +115,8 @@ export class Toolbar {
115
115
  this._disposers = [];
116
116
  /** @type {Array<() => void>} closers for all open color picker popups */
117
117
  this._colorPickerClosers = [];
118
+ /** @type {number|null} rAF handle for debounced refresh */
119
+ this._refreshRaf = null;
118
120
  }
119
121
 
120
122
  // ---------------------------------------------------------------------------
@@ -132,6 +134,8 @@ export class Toolbar {
132
134
  }
133
135
 
134
136
  destroy() {
137
+ if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
138
+ this._refreshRaf = null;
135
139
  this._disposers.forEach((d) => d());
136
140
  this._disposers = [];
137
141
  if (this.el && this.el.parentNode) {
@@ -653,6 +657,16 @@ export class Toolbar {
653
657
  // ---------------------------------------------------------------------------
654
658
 
655
659
  refresh() {
660
+ // Debounce via rAF — multiple rapid calls (e.g. afterCommand + button click)
661
+ // collapse into a single update per animation frame.
662
+ if (this._refreshRaf) cancelAnimationFrame(this._refreshRaf);
663
+ this._refreshRaf = requestAnimationFrame(() => {
664
+ this._refreshRaf = null;
665
+ this._doRefresh();
666
+ });
667
+ }
668
+
669
+ _doRefresh() {
656
670
  if (!this.el) return;
657
671
  const btnMap = this._btnMap || new Map();
658
672