autumnnote 1.6.6 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.6.6",
3
+ "version": "1.7.0",
4
4
  "description": "WYSIWYG rich-text editor built with vanilla JavaScript — zero dependencies, no jQuery. Dark mode, @mention, markdown shortcuts, bubble toolbar. React and Vue 3 wrappers included.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
package/src/js/Context.js CHANGED
@@ -385,6 +385,22 @@ export class Context {
385
385
  this.invoke('editor.clearHistory');
386
386
  }
387
387
 
388
+ /**
389
+ * Returns the number of available undo steps.
390
+ * @returns {number}
391
+ */
392
+ getUndoCount() {
393
+ return this.invoke('editor.getUndoCount') ?? 0;
394
+ }
395
+
396
+ /**
397
+ * Returns the number of available redo steps.
398
+ * @returns {number}
399
+ */
400
+ getRedoCount() {
401
+ return this.invoke('editor.getRedoCount') ?? 0;
402
+ }
403
+
388
404
  /**
389
405
  * Returns true when the editor has no meaningful content.
390
406
  * @returns {boolean}
@@ -513,6 +529,22 @@ export class Context {
513
529
  });
514
530
  }
515
531
 
532
+ /**
533
+ * Returns an array of heading objects representing the table of contents.
534
+ * Each entry has: level (1-6), text (heading text), element (DOM element).
535
+ * @returns {{ level: number, text: string, element: HTMLElement }[]}
536
+ */
537
+ getTableOfContents() {
538
+ const headings = Array.from(
539
+ this.layoutInfo.editable.querySelectorAll('h1,h2,h3,h4,h5,h6')
540
+ );
541
+ return headings.map((el) => ({
542
+ level: parseInt(el.tagName[1], 10),
543
+ text: el.textContent?.trim() ?? '',
544
+ element: /** @type {HTMLElement} */ (el),
545
+ }));
546
+ }
547
+
516
548
  /**
517
549
  * Sets whether the editor is disabled (readonly).
518
550
  * @param {boolean} disabled
@@ -561,15 +593,19 @@ export class Context {
561
593
 
562
594
  const container = this.layoutInfo.container;
563
595
  const wasDark = container?.classList.contains('an-theme-dark');
596
+ const wasAuto = container?.classList.contains('an-theme-auto');
564
597
  if (container?.parentNode) {
565
598
  // Restore original element
566
599
  this.targetEl.style.display = '';
567
600
  container.remove();
568
601
  }
569
- // If this was a dark editor and no other dark containers remain, clean up body
602
+ // Clean up body theme classes if no other editors of that type remain
570
603
  if (wasDark && !document.querySelector('.an-container.an-theme-dark')) {
571
604
  document.body.classList.remove('an-theme-dark');
572
605
  }
606
+ if (wasAuto && !document.querySelector('.an-container.an-theme-auto')) {
607
+ document.body.classList.remove('an-theme-auto');
608
+ }
573
609
 
574
610
  if (typeof this.options.onDestroy === 'function') {
575
611
  this.options.onDestroy(this);
@@ -221,4 +221,14 @@ export class History {
221
221
  canRedo() {
222
222
  return this.stackOffset < this.stack.length - 1;
223
223
  }
224
+
225
+ /** @returns {number} */
226
+ getUndoCount() {
227
+ return Math.max(0, this.stackOffset);
228
+ }
229
+
230
+ /** @returns {number} */
231
+ getRedoCount() {
232
+ return Math.max(0, this.stack.length - 1 - this.stackOffset);
233
+ }
224
234
  }
package/src/js/i18n/de.js CHANGED
@@ -157,6 +157,7 @@ export const de = {
157
157
  replaceAriaLabel: 'Ersetzen durch',
158
158
  replaceBtn: 'Ersetzen',
159
159
  noResults: 'Keine Ergebnisse',
160
+ useRegex: 'Regulären Ausdruck verwenden',
160
161
  replaceAllBtn: 'Alle ersetzen',
161
162
  close: '×',
162
163
  },
@@ -275,6 +276,7 @@ export const de = {
275
276
  disableWordWrap: 'Zeilenumbruch deaktivieren',
276
277
  convertToParagraph: 'In Absatz umwandeln',
277
278
  deleteCodeBlock: 'Codeblock löschen',
279
+ lineNumbers: 'Zeilennummern umschalten',
278
280
  },
279
281
  table: {
280
282
  ariaLabel: 'Tabellen-Aktionen',
@@ -297,6 +299,11 @@ export const de = {
297
299
  tableBorderWidthPx: 'Tabellenrahmenbreite (px)',
298
300
  cancelBtn: 'Abbrechen',
299
301
  applyBtn: 'Anwenden',
302
+ sortAsc: 'Aufsteigend sortieren',
303
+ sortDesc: 'Absteigend sortieren',
304
+ exportCSV: 'Als CSV exportieren',
305
+ cellPadding: 'Zellauffüllung',
306
+ cellPaddingPx: 'Zellauffüllung (px)',
300
307
  },
301
308
  video: {
302
309
  ariaLabel: 'Video-Aktionen',
package/src/js/i18n/en.js CHANGED
@@ -160,6 +160,7 @@ export const en = {
160
160
  replaceBtn: 'Replace',
161
161
  replaceAllBtn: 'Replace All',
162
162
  noResults: 'No results',
163
+ useRegex: 'Use Regular Expression',
163
164
  close: '\u00d7',
164
165
  },
165
166
 
@@ -281,6 +282,7 @@ export const en = {
281
282
  disableWordWrap: 'Disable Word Wrap',
282
283
  convertToParagraph: 'Convert to Paragraph',
283
284
  deleteCodeBlock: 'Delete Code Block',
285
+ lineNumbers: 'Toggle Line Numbers',
284
286
  },
285
287
  table: {
286
288
  ariaLabel: 'Table actions',
@@ -297,14 +299,26 @@ export const en = {
297
299
  columnWidth: 'Column Width',
298
300
  rowHeight: 'Row Height',
299
301
  tableBorderWidth: 'Table Border Width',
302
+ tableBorderColor: 'Table Border Color',
300
303
  deleteTable: 'Delete Table',
304
+ cellAlignLeft: 'Align Left',
305
+ cellAlignCenter: 'Align Center',
306
+ cellAlignRight: 'Align Right',
307
+ cellAlignJustify: 'Align Justify',
308
+ toggleHeaderRow: 'Toggle Header Row',
301
309
  cellBackground: 'Cell Background',
302
310
  noShading: 'No Shading',
311
+ noBorderColor: 'No Border Color',
303
312
  columnWidthPx: 'Column Width (px)',
304
313
  rowHeightPx: 'Row Height (px)',
305
314
  tableBorderWidthPx: 'Table Border Width (px)',
306
315
  cancelBtn: 'Cancel',
307
316
  applyBtn: 'Apply',
317
+ sortAsc: 'Sort Ascending',
318
+ sortDesc: 'Sort Descending',
319
+ exportCSV: 'Export as CSV',
320
+ cellPadding: 'Cell Padding',
321
+ cellPaddingPx: 'Cell Padding (px)',
308
322
  },
309
323
  video: {
310
324
  ariaLabel: 'Video actions',
package/src/js/i18n/es.js CHANGED
@@ -157,6 +157,7 @@ export const es = {
157
157
  replaceAriaLabel: 'Reemplazar con',
158
158
  replaceBtn: 'Reemplazar',
159
159
  noResults: 'Sin resultados',
160
+ useRegex: 'Usar expresión regular',
160
161
  replaceAllBtn: 'Reemplazar todo',
161
162
  close: '×',
162
163
  },
@@ -275,6 +276,7 @@ export const es = {
275
276
  disableWordWrap: 'Desactivar ajuste de línea',
276
277
  convertToParagraph: 'Convertir en párrafo',
277
278
  deleteCodeBlock: 'Eliminar bloque de código',
279
+ lineNumbers: 'Alternar números de línea',
278
280
  },
279
281
  table: {
280
282
  ariaLabel: 'Acciones de tabla',
@@ -297,6 +299,11 @@ export const es = {
297
299
  tableBorderWidthPx: 'Grosor del borde (px)',
298
300
  cancelBtn: 'Cancelar',
299
301
  applyBtn: 'Aplicar',
302
+ sortAsc: 'Ordenar ascendente',
303
+ sortDesc: 'Ordenar descendente',
304
+ exportCSV: 'Exportar como CSV',
305
+ cellPadding: 'Relleno de celda',
306
+ cellPaddingPx: 'Relleno de celda (px)',
300
307
  },
301
308
  video: {
302
309
  ariaLabel: 'Acciones de vídeo',
package/src/js/i18n/fr.js CHANGED
@@ -158,6 +158,7 @@ export const fr = {
158
158
  replaceAriaLabel: 'Remplacer par',
159
159
  replaceBtn: 'Remplacer',
160
160
  noResults: 'Aucun résultat',
161
+ useRegex: 'Utiliser une expression régulière',
161
162
  replaceAllBtn: 'Tout remplacer',
162
163
  close: '\u00d7',
163
164
  },
@@ -276,6 +277,7 @@ export const fr = {
276
277
  disableWordWrap: 'Désactiver le retour à la ligne',
277
278
  convertToParagraph: 'Convertir en paragraphe',
278
279
  deleteCodeBlock: 'Supprimer le bloc de code',
280
+ lineNumbers: 'Activer/désactiver les numéros de ligne',
279
281
  },
280
282
  table: {
281
283
  ariaLabel: 'Actions du tableau',
@@ -298,6 +300,11 @@ export const fr = {
298
300
  tableBorderWidthPx: 'Épaisseur des bordures (px)',
299
301
  cancelBtn: 'Annuler',
300
302
  applyBtn: 'Appliquer',
303
+ sortAsc: 'Trier par ordre croissant',
304
+ sortDesc: 'Trier par ordre décroissant',
305
+ exportCSV: 'Exporter en CSV',
306
+ cellPadding: 'Rembourrage des cellules',
307
+ cellPaddingPx: 'Rembourrage des cellules (px)',
301
308
  },
302
309
  video: {
303
310
  ariaLabel: 'Actions de la vidéo',
package/src/js/i18n/ja.js CHANGED
@@ -158,6 +158,7 @@ export const ja = {
158
158
  replaceAriaLabel: '置換後のテキスト',
159
159
  replaceBtn: '置換',
160
160
  noResults: '結果なし',
161
+ useRegex: '正規表現を使用',
161
162
  replaceAllBtn: 'すべて置換',
162
163
  close: '\u00d7',
163
164
  },
@@ -276,6 +277,7 @@ export const ja = {
276
277
  disableWordWrap: '折り返しを無効にする',
277
278
  convertToParagraph: '段落に変換',
278
279
  deleteCodeBlock: 'コードブロックを削除',
280
+ lineNumbers: '行番号を切り替え',
279
281
  },
280
282
  table: {
281
283
  ariaLabel: 'テーブル操作',
@@ -298,6 +300,11 @@ export const ja = {
298
300
  tableBorderWidthPx: 'テーブルの枠幅 (px)',
299
301
  cancelBtn: 'キャンセル',
300
302
  applyBtn: '適用',
303
+ sortAsc: '昇順で並べ替え',
304
+ sortDesc: '降順で並べ替え',
305
+ exportCSV: 'CSVとしてエクスポート',
306
+ cellPadding: 'セルの余白',
307
+ cellPaddingPx: 'セルの余白 (px)',
301
308
  },
302
309
  video: {
303
310
  ariaLabel: '動画操作',
package/src/js/i18n/ko.js CHANGED
@@ -157,6 +157,7 @@ export const ko = {
157
157
  replaceAriaLabel: '바꿀 내용',
158
158
  replaceBtn: '바꾸기',
159
159
  noResults: '결과 없음',
160
+ useRegex: '정규식 사용',
160
161
  replaceAllBtn: '모두 바꾸기',
161
162
  close: '×',
162
163
  },
@@ -275,6 +276,7 @@ export const ko = {
275
276
  disableWordWrap: '줄 바꿈 해제',
276
277
  convertToParagraph: '단락으로 변환',
277
278
  deleteCodeBlock: '코드 블록 삭제',
279
+ lineNumbers: '줄 번호 전환',
278
280
  },
279
281
  table: {
280
282
  ariaLabel: '표 작업',
@@ -297,6 +299,11 @@ export const ko = {
297
299
  tableBorderWidthPx: '표 테두리 너비 (px)',
298
300
  cancelBtn: '취소',
299
301
  applyBtn: '적용',
302
+ sortAsc: '오름차순 정렬',
303
+ sortDesc: '내림차순 정렬',
304
+ exportCSV: 'CSV로 내보내기',
305
+ cellPadding: '셀 패딩',
306
+ cellPaddingPx: '셀 패딩 (px)',
300
307
  },
301
308
  video: {
302
309
  ariaLabel: '동영상 작업',
package/src/js/i18n/vi.js CHANGED
@@ -159,6 +159,7 @@ export const vi = {
159
159
  replaceBtn: 'Thay thế',
160
160
  replaceAllBtn: 'Thay thế tất cả',
161
161
  noResults: 'Không có kết quả',
162
+ useRegex: 'Dùng biểu thức chính quy',
162
163
  close: '\u00d7',
163
164
  },
164
165
 
@@ -276,6 +277,7 @@ export const vi = {
276
277
  disableWordWrap: 'Tắt xuống dòng',
277
278
  convertToParagraph: 'Chuyển thành đoạn văn',
278
279
  deleteCodeBlock: 'Xóa khối mã',
280
+ lineNumbers: 'Bật/Tắt số dòng',
279
281
  },
280
282
  table: {
281
283
  ariaLabel: 'Hành động bảng',
@@ -300,6 +302,11 @@ export const vi = {
300
302
  tableBorderWidthPx: 'Độ rộng viền bảng (px)',
301
303
  cancelBtn: 'Hủy',
302
304
  applyBtn: 'Áp dụng',
305
+ sortAsc: 'Sắp xếp tăng dần',
306
+ sortDesc: 'Sắp xếp giảm dần',
307
+ exportCSV: 'Xuất CSV',
308
+ cellPadding: 'Khoảng đệm ô',
309
+ cellPaddingPx: 'Khoảng đệm ô (px)',
303
310
  },
304
311
  video: {
305
312
  ariaLabel: 'Hành động video',
package/src/js/i18n/zh.js CHANGED
@@ -158,6 +158,7 @@ export const zh = {
158
158
  replaceAriaLabel: '替换为',
159
159
  replaceBtn: '替换',
160
160
  noResults: '没有结果',
161
+ useRegex: '使用正则表达式',
161
162
  replaceAllBtn: '全部替换',
162
163
  close: '\u00d7',
163
164
  },
@@ -276,6 +277,7 @@ export const zh = {
276
277
  disableWordWrap: '禁用自动换行',
277
278
  convertToParagraph: '转换为段落',
278
279
  deleteCodeBlock: '删除代码块',
280
+ lineNumbers: '切换行号',
279
281
  },
280
282
  table: {
281
283
  ariaLabel: '表格操作',
@@ -298,6 +300,11 @@ export const zh = {
298
300
  tableBorderWidthPx: '表格边框宽度 (px)',
299
301
  cancelBtn: '取消',
300
302
  applyBtn: '应用',
303
+ sortAsc: '升序排列',
304
+ sortDesc: '降序排列',
305
+ exportCSV: '导出为 CSV',
306
+ cellPadding: '单元格内边距',
307
+ cellPaddingPx: '单元格内边距 (px)',
301
308
  },
302
309
  video: {
303
310
  ariaLabel: '视频操作',
package/src/js/index.js CHANGED
@@ -141,7 +141,7 @@ const AutumnNote = {
141
141
  registerButton(btnDef) { registerButton(btnDef); return this; },
142
142
 
143
143
  /** Library version */
144
- version: '1.6.6',
144
+ version: '1.7.0',
145
145
  };
146
146
 
147
147
  // ---------------------------------------------------------------------------
@@ -19,6 +19,13 @@ export class BaseDialog {
19
19
  this._removeTrap = null;
20
20
  }
21
21
 
22
+ // ---------------------------------------------------------------------------
23
+ // Abstract — subclasses must override
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** @returns {HTMLElement} */
27
+ _buildDialog() { throw new Error('_buildDialog() must be implemented by subclass'); }
28
+
22
29
  // ---------------------------------------------------------------------------
23
30
  // Lifecycle (shared)
24
31
  // ---------------------------------------------------------------------------
@@ -14,6 +14,7 @@ const ICONS = {
14
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>`,
15
15
  toParagraph:`<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"><path d="M13 4v16"/><path d="M17 4v16"/><path d="M9 4h8a4 4 0 0 1 0 8H9V4z"/></svg>`,
16
16
  deleteCode: `<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"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
17
+ lineNumbers: `<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="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-2-2-2"/></svg>`,
17
18
  };
18
19
 
19
20
  export class CodeTooltip {
@@ -26,6 +27,8 @@ export class CodeTooltip {
26
27
  this._hideTimer = null;
27
28
  this._disposers = [];
28
29
  this._copyBtn = null;
30
+ this._wrapBtn = null;
31
+ this._lineNumbersBtn = null;
29
32
  this._langSelect = null;
30
33
  this._prismScript = null; // script element while Prism is loading
31
34
  }
@@ -127,6 +130,10 @@ export class CodeTooltip {
127
130
  this._wrapBtn = this._makeBtn(ICONS.wrapOn, L.toggleWordWrap, () => this._toggleWrap());
128
131
  el.appendChild(this._wrapBtn);
129
132
 
133
+ // Toggle line numbers
134
+ this._lineNumbersBtn = this._makeBtn(ICONS.lineNumbers, L.lineNumbers, () => this._toggleLineNumbers());
135
+ el.appendChild(this._lineNumbersBtn);
136
+
130
137
  el.appendChild(this._sep());
131
138
 
132
139
  // Convert to normal paragraph
@@ -183,6 +190,7 @@ export class CodeTooltip {
183
190
  this._showTimer = setTimeout(() => {
184
191
  this._activePre = pre;
185
192
  this._syncWrapBtn();
193
+ this._syncLineNumbersBtn();
186
194
  this._syncLangSelect();
187
195
  this._show(pre);
188
196
  }, SHOW_DELAY);
@@ -248,6 +256,13 @@ export class CodeTooltip {
248
256
  : this.context.locale.tooltips.code.enableWordWrap;
249
257
  }
250
258
 
259
+ _syncLineNumbersBtn() {
260
+ if (!this._activePre || !this._lineNumbersBtn) return;
261
+ const on = this._activePre.classList.contains('an-code-line-numbers');
262
+ this._lineNumbersBtn.classList.toggle('active', on);
263
+ this._lineNumbersBtn.title = this.context.locale.tooltips.code.lineNumbers;
264
+ }
265
+
251
266
  _syncLangSelect() {
252
267
  if (!this._activePre || !this._langSelect) return;
253
268
  const codeEl = this._activePre.querySelector('code');
@@ -301,6 +316,14 @@ export class CodeTooltip {
301
316
  this._positionNear(pre);
302
317
  }
303
318
 
319
+ _toggleLineNumbers() {
320
+ const pre = this._activePre;
321
+ if (!pre) return;
322
+ pre.classList.toggle('an-code-line-numbers');
323
+ this._syncLineNumbersBtn();
324
+ this.context.invoke('editor.afterCommand');
325
+ }
326
+
304
327
  /**
305
328
  * Applies a language to a given <pre> element: sets classes, data-language,
306
329
  * and triggers Prism highlighting. Called by the auto-detect flow.
@@ -525,6 +525,14 @@ export class Editor {
525
525
  return this._history ? this._history.canRedo() : false;
526
526
  }
527
527
 
528
+ getUndoCount() {
529
+ return this._history ? this._history.getUndoCount() : 0;
530
+ }
531
+
532
+ getRedoCount() {
533
+ return this._history ? this._history.getRedoCount() : 0;
534
+ }
535
+
528
536
  // ---------------------------------------------------------------------------
529
537
  // Style commands (delegated to Style module)
530
538
  // ---------------------------------------------------------------------------
@@ -30,6 +30,7 @@ export class FindReplace extends BaseDialog {
30
30
  this._matches = [];
31
31
  this._currentIndex = -1;
32
32
  this._caseSensitive = false;
33
+ this._useRegex = false;
33
34
  /** @type {'find'|'replace'} */
34
35
  this._mode = 'find';
35
36
 
@@ -37,6 +38,7 @@ export class FindReplace extends BaseDialog {
37
38
  this._queryRegex = null;
38
39
  this._lastQuery = null;
39
40
  this._lastCaseSensitive = null;
41
+ this._lastUseRegex = null;
40
42
 
41
43
  this._focusTimer = null;
42
44
  }
@@ -169,6 +171,14 @@ export class FindReplace extends BaseDialog {
169
171
  });
170
172
  caseBtn.textContent = 'Aa';
171
173
 
174
+ const regexBtn = createElement('button', {
175
+ type: 'button',
176
+ class: 'an-fr-icon-btn',
177
+ title: L.useRegex,
178
+ 'aria-label': L.useRegex,
179
+ });
180
+ regexBtn.textContent = '.*';
181
+
172
182
  const prevBtn = createElement('button', {
173
183
  type: 'button',
174
184
  class: 'an-fr-icon-btn',
@@ -188,7 +198,7 @@ export class FindReplace extends BaseDialog {
188
198
  const counter = createElement('span', { class: 'an-fr-counter' });
189
199
  this._counterEl = counter;
190
200
 
191
- searchBar.append(findInput, caseCheckbox, caseBtn, prevBtn, nextBtn, counter);
201
+ searchBar.append(findInput, caseCheckbox, caseBtn, regexBtn, prevBtn, nextBtn, counter);
192
202
  box.appendChild(searchBar);
193
203
 
194
204
  // ---- Replace row: [input] [Replace] [All] (hidden by default) ----
@@ -249,7 +259,14 @@ export class FindReplace extends BaseDialog {
249
259
  this._replace();
250
260
  }
251
261
  });
252
- this._disposers.push(d1, d2, d3, d4, d5, d6, d7, d8, d9, d10, d11);
262
+ const dRegex = on(regexBtn, 'click', () => {
263
+ this._useRegex = !this._useRegex;
264
+ regexBtn.classList.toggle('an-fr-icon-btn--active', this._useRegex);
265
+ this._queryRegex = null; // force recompile
266
+ this._lastQuery = null;
267
+ this._onSearch();
268
+ });
269
+ this._disposers.push(d1, d2, d3, d4, d5, d6, d7, d8, d9, d10, d11, dRegex);
253
270
 
254
271
  return overlay;
255
272
  }
@@ -324,14 +341,22 @@ export class FindReplace extends BaseDialog {
324
341
  */
325
342
  _findRawMatches(query, root) {
326
343
  const results = [];
327
- // Reuse compiled regex when query and case-sensitivity haven't changed
328
- if (this._lastQuery !== query || this._lastCaseSensitive !== this._caseSensitive) {
344
+ // Reuse compiled regex when query, case-sensitivity, and regex mode haven't changed
345
+ if (this._lastQuery !== query || this._lastCaseSensitive !== this._caseSensitive || this._lastUseRegex !== this._useRegex) {
329
346
  const flags = this._caseSensitive ? 'g' : 'gi';
330
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
331
- this._queryRegex = new RegExp(escaped, flags);
347
+ try {
348
+ const pattern = this._useRegex
349
+ ? query
350
+ : query.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
351
+ this._queryRegex = new RegExp(pattern, flags);
352
+ } catch (_) {
353
+ this._queryRegex = null;
354
+ }
332
355
  this._lastQuery = query;
333
356
  this._lastCaseSensitive = this._caseSensitive;
357
+ this._lastUseRegex = this._useRegex;
334
358
  }
359
+ if (!this._queryRegex) return results;
335
360
  const re = this._queryRegex;
336
361
 
337
362
  // Cap results to prevent blocking the main thread on very large documents
@@ -269,14 +269,18 @@ export class Mention {
269
269
  this._query = query;
270
270
  clearTimeout(this._debounceTimer);
271
271
  this._debounceTimer = setTimeout(() => {
272
- this._cfg.onSearch(this._query, (items) => {
272
+ const cb = (items) => {
273
273
  if (!Array.isArray(items) || items.length === 0) {
274
274
  this._hideDropdown();
275
275
  return;
276
276
  }
277
277
  this._renderItems(items);
278
278
  this._showDropdown();
279
- });
279
+ };
280
+ const result = this._cfg.onSearch(this._query, cb);
281
+ if (result && typeof result.then === 'function') {
282
+ result.then(cb).catch(() => this._hideDropdown());
283
+ }
280
284
  }, this._cfg.debounce);
281
285
  }
282
286
 
@@ -90,9 +90,9 @@ export class Statusbar {
90
90
  }
91
91
 
92
92
  // Counters
93
- this._wordCountEl = createElement('span', { class: 'an-word-count' });
94
- this._charCountEl = createElement('span', { class: 'an-char-count' });
95
- const info = createElement('div', { class: 'an-status-info' });
93
+ this._wordCountEl = createElement('span', { class: 'an-word-count', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' });
94
+ this._charCountEl = createElement('span', { class: 'an-char-count', 'aria-live': 'polite', 'aria-atomic': 'true' });
95
+ const info = createElement('div', { class: 'an-status-info', 'aria-label': 'Editor statistics' });
96
96
  info.appendChild(this._wordCountEl);
97
97
  info.appendChild(this._charCountEl);
98
98
  this.el.appendChild(info);