autumnnote 1.0.8 → 1.1.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 (41) hide show
  1. package/README.md +401 -628
  2. package/dist/autumnnote.css +169 -0
  3. package/dist/autumnnote.es.js +3669 -179
  4. package/dist/autumnnote.es.js.map +1 -1
  5. package/dist/autumnnote.umd.js +3668 -178
  6. package/dist/autumnnote.umd.js.map +1 -1
  7. package/package.json +3 -2
  8. package/src/js/Context.js +18 -2
  9. package/src/js/core/func.js +5 -5
  10. package/src/js/i18n/de.js +320 -0
  11. package/src/js/i18n/en.js +328 -0
  12. package/src/js/i18n/es.js +320 -0
  13. package/src/js/i18n/fr.js +321 -0
  14. package/src/js/i18n/index.js +59 -0
  15. package/src/js/i18n/ja.js +321 -0
  16. package/src/js/i18n/ko.js +320 -0
  17. package/src/js/i18n/vi.js +321 -0
  18. package/src/js/i18n/zh.js +321 -0
  19. package/src/js/index.js +2 -1
  20. package/src/js/module/AutoSaveRestore.js +126 -0
  21. package/src/js/module/BubbleToolbar.js +243 -0
  22. package/src/js/module/CodeTooltip.js +12 -9
  23. package/src/js/module/ContextMenu.js +41 -37
  24. package/src/js/module/EmojiDialog.js +8 -7
  25. package/src/js/module/FindReplace.js +14 -13
  26. package/src/js/module/IconDialog.js +14 -13
  27. package/src/js/module/ImageDialog.js +17 -16
  28. package/src/js/module/ImageTooltip.js +13 -12
  29. package/src/js/module/LinkDialog.js +10 -9
  30. package/src/js/module/LinkTooltip.js +6 -5
  31. package/src/js/module/MarkdownShortcuts.js +253 -0
  32. package/src/js/module/Mention.js +337 -0
  33. package/src/js/module/ShortcutsDialog.js +5 -4
  34. package/src/js/module/Statusbar.js +6 -5
  35. package/src/js/module/TableTooltip.js +22 -19
  36. package/src/js/module/Toolbar.js +21 -15
  37. package/src/js/module/VideoDialog.js +10 -9
  38. package/src/js/module/VideoTooltip.js +12 -11
  39. package/src/js/settings.js +23 -0
  40. package/src/styles/autumnnote.scss +191 -0
  41. package/types/index.d.ts +114 -1
@@ -0,0 +1,243 @@
1
+ /**
2
+ * BubbleToolbar.js — A mini floating toolbar that appears above the current
3
+ * text selection, allowing quick access to common formatting actions without
4
+ * reaching for the main toolbar.
5
+ *
6
+ * Activated when `bubbleToolbar: true`. The set of visible buttons is
7
+ * controlled by `bubbleToolbarItems` (array of button name strings).
8
+ *
9
+ * Built-in names: 'bold', 'italic', 'underline', 'strikethrough',
10
+ * 'link', 'foreColor', 'removeFormat', 'inlineCode'.
11
+ * Any name not found in the built-in map is silently ignored.
12
+ */
13
+
14
+ import { on } from '../core/dom.js';
15
+
16
+ // Minimal SVG icon set — only what the bubble toolbar needs.
17
+ const _S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
18
+ const _svg = (p) =>
19
+ `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" ${_S} style="display:block">${p}</svg>`;
20
+
21
+ const _ICONS = {
22
+ bold: _svg('<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/>'),
23
+ italic: _svg('<line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/>'),
24
+ underline: _svg('<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/>'),
25
+ strikethrough: _svg('<path d="M17.3 12H6.7"/><path d="M10 6.5C10 5.1 11.1 4 12.5 4c1.4 0 2.5 1.1 2.5 2.5 0 .8-.4 1.5-1 2"/><path d="M14 17.5C14 19 12.9 20 11.5 20 10.1 20 9 18.9 9 17.5c0-.8.4-1.5 1-2"/>'),
26
+ link: _svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
27
+ foreColor: _svg('<path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/>'),
28
+ removeFormat: _svg('<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/>'),
29
+ inlineCode: _svg('<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5c0 1.1.9 2 2 2a2 2 0 0 1-2 2v5a2 2 0 0 1-2 2h-1"/>'),
30
+ };
31
+
32
+ const _ACTIONS = {
33
+ bold: (ctx) => ctx.invoke('editor.bold'),
34
+ italic: (ctx) => ctx.invoke('editor.italic'),
35
+ underline: (ctx) => ctx.invoke('editor.underline'),
36
+ strikethrough: (ctx) => ctx.invoke('editor.strikethrough'),
37
+ link: (ctx) => ctx.invoke('linkDialog.show'),
38
+ foreColor: (ctx) => {
39
+ const color = window.prompt('Text color (hex or name):', '#000000');
40
+ if (color) ctx.invoke('editor.foreColor', color);
41
+ },
42
+ removeFormat: (ctx) => ctx.invoke('editor.removeFormat'),
43
+ inlineCode: (ctx) => ctx.invoke('editor.inlineCode'),
44
+ };
45
+
46
+ const _ACTIVE = {
47
+ bold: () => document.queryCommandState('bold'),
48
+ italic: () => document.queryCommandState('italic'),
49
+ underline: () => document.queryCommandState('underline'),
50
+ strikethrough: () => document.queryCommandState('strikeThrough'),
51
+ };
52
+
53
+ export class BubbleToolbar {
54
+ /** @param {import('../Context.js').Context} context */
55
+ constructor(context) {
56
+ this.context = context;
57
+ this.options = context.options;
58
+
59
+ /** @type {HTMLElement|null} */
60
+ this._el = null;
61
+ this._visible = false;
62
+ this._rafId = null;
63
+ this._contextMenuOpen = false;
64
+ this._disposers = [];
65
+
66
+ this._onSelectionChange = this._onSelectionChange.bind(this);
67
+ this._onMousedown = this._onMousedown.bind(this);
68
+ this._onKeydown = this._onKeydown.bind(this);
69
+ this._onContextMenu = this._onContextMenu.bind(this);
70
+ }
71
+
72
+ initialize() {
73
+ if (!this.options.bubbleToolbar) return this;
74
+ this._build();
75
+ const d1 = on(document, 'selectionchange', this._onSelectionChange);
76
+ const d2 = on(document, 'mousedown', this._onMousedown);
77
+ const d3 = on(document, 'keydown', this._onKeydown);
78
+ const d4 = on(document, 'contextmenu', this._onContextMenu);
79
+ const d5 = this.context.on('contextMenu:show', () => {
80
+ this._contextMenuOpen = true;
81
+ this._hide();
82
+ });
83
+ const d6 = this.context.on('contextMenu:hide', () => {
84
+ this._contextMenuOpen = false;
85
+ });
86
+ this._disposers.push(d1, d2, d3, d4, d5, d6);
87
+ return this;
88
+ }
89
+
90
+ destroy() {
91
+ if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
92
+ this._el = null;
93
+ this._disposers.forEach((d) => d());
94
+ this._disposers = [];
95
+ cancelAnimationFrame(this._rafId);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Build
100
+ // ---------------------------------------------------------------------------
101
+
102
+ _build() {
103
+ const el = document.createElement('div');
104
+ el.className = 'an-bubble-toolbar';
105
+ el.setAttribute('role', 'toolbar');
106
+ el.setAttribute('aria-label', 'Formatting');
107
+
108
+ const items = this.options.bubbleToolbarItems || ['bold', 'italic', 'underline', 'link', 'foreColor', 'removeFormat'];
109
+ for (const name of items) {
110
+ if (!_ICONS[name] || !_ACTIONS[name]) continue;
111
+ const btn = document.createElement('button');
112
+ btn.type = 'button';
113
+ btn.className = 'an-bubble-btn';
114
+ btn.dataset.name = name;
115
+ btn.setAttribute('aria-label', name);
116
+ btn.innerHTML = _ICONS[name];
117
+ btn.addEventListener('mousedown', (e) => {
118
+ // Prevent mousedown from collapsing selection before click fires
119
+ e.preventDefault();
120
+ });
121
+ btn.addEventListener('click', (e) => {
122
+ e.preventDefault();
123
+ e.stopPropagation();
124
+ this.context.invoke('editor.focus');
125
+ _ACTIONS[name](this.context);
126
+ this.context.invoke('editor.afterCommand');
127
+ this._syncActive();
128
+ });
129
+ el.appendChild(btn);
130
+ }
131
+
132
+ document.body.appendChild(el);
133
+ this._el = el;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Show / hide
138
+ // ---------------------------------------------------------------------------
139
+
140
+ _show(rect) {
141
+ if (!this._el) return;
142
+ const el = this._el;
143
+
144
+ // Measure while invisible — position:fixed so no scroll offset needed
145
+ el.style.visibility = 'hidden';
146
+ el.style.display = 'flex';
147
+
148
+ const bw = el.offsetWidth;
149
+ const bh = el.offsetHeight;
150
+ const gap = 8;
151
+
152
+ // Center horizontally above the selection; flip below if no room above
153
+ let left = rect.left + rect.width / 2 - bw / 2;
154
+ let top = rect.top - bh - gap;
155
+
156
+ left = Math.max(8, Math.min(left, window.innerWidth - bw - 8));
157
+
158
+ if (top < 8) {
159
+ top = rect.bottom + gap;
160
+ }
161
+
162
+ el.style.top = `${top}px`;
163
+ el.style.left = `${left}px`;
164
+ el.style.visibility = '';
165
+
166
+ this._syncActive();
167
+ this._visible = true;
168
+ }
169
+
170
+ _hide() {
171
+ if (!this._el) return;
172
+ this._el.style.display = 'none';
173
+ this._visible = false;
174
+ }
175
+
176
+ _syncActive() {
177
+ if (!this._el) return;
178
+ this._el.querySelectorAll('.an-bubble-btn').forEach((btn) => {
179
+ const name = btn.dataset.name;
180
+ const activeFn = _ACTIVE[name];
181
+ btn.classList.toggle('an-active', !!(activeFn && activeFn()));
182
+ });
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Events
187
+ // ---------------------------------------------------------------------------
188
+
189
+ _onSelectionChange() {
190
+ cancelAnimationFrame(this._rafId);
191
+ this._rafId = requestAnimationFrame(() => {
192
+ if (this._contextMenuOpen) return;
193
+
194
+ const sel = window.getSelection();
195
+ if (!sel || sel.isCollapsed || !sel.rangeCount) {
196
+ this._hide();
197
+ return;
198
+ }
199
+
200
+ const editable = this.context.layoutInfo.editable;
201
+ if (!editable.contains(sel.anchorNode)) {
202
+ this._hide();
203
+ return;
204
+ }
205
+
206
+ // Don't show inside code view or read-only
207
+ if (this.context.options.readOnly) {
208
+ this._hide();
209
+ return;
210
+ }
211
+
212
+ const range = sel.getRangeAt(0);
213
+ const rect = range.getBoundingClientRect();
214
+ if (!rect || rect.width === 0) {
215
+ this._hide();
216
+ return;
217
+ }
218
+
219
+ this._show(rect);
220
+ });
221
+ }
222
+
223
+ _onMousedown(e) {
224
+ // Hide when clicking outside both the editable and the bubble toolbar
225
+ if (!this._visible) return;
226
+ if (this._el && this._el.contains(e.target)) return;
227
+ const editable = this.context.layoutInfo.editable;
228
+ if (editable.contains(e.target)) return;
229
+ this._hide();
230
+ }
231
+
232
+ _onKeydown(e) {
233
+ if (e.key === 'Escape' && this._visible) {
234
+ this._hide();
235
+ }
236
+ }
237
+
238
+ _onContextMenu() {
239
+ // Hide immediately on right-click — contextMenu:show event will also set the flag,
240
+ // but firing here prevents a one-frame flicker before the event propagates.
241
+ this._hide();
242
+ }
243
+ }
@@ -76,16 +76,17 @@ export class CodeTooltip {
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
78
  _buildTooltip() {
79
+ const L = this.context.locale.tooltips.code;
79
80
  const el = createElement('div', {
80
81
  class: 'an-link-tooltip an-code-tooltip',
81
82
  role: 'toolbar',
82
- 'aria-label': 'Code block actions',
83
+ 'aria-label': L.ariaLabel,
83
84
  });
84
85
  el.style.display = 'none';
85
86
 
86
87
  // Label
87
88
  this._label = createElement('span', { class: 'an-link-tooltip-url' });
88
- this._label.textContent = 'Code';
89
+ this._label.textContent = L.label;
89
90
  el.appendChild(this._label);
90
91
 
91
92
  el.appendChild(this._sep());
@@ -93,8 +94,8 @@ export class CodeTooltip {
93
94
  // Language selector
94
95
  this._langSelect = createElement('select', {
95
96
  class: 'an-code-lang-select',
96
- title: 'Syntax Language',
97
- 'aria-label': 'Syntax language',
97
+ title: L.syntaxLanguage,
98
+ 'aria-label': L.syntaxAriaLabel,
98
99
  });
99
100
  const LANGUAGES = [
100
101
  ['', 'Plain text'], ['javascript', 'JavaScript'], ['typescript', 'TypeScript'],
@@ -115,25 +116,25 @@ export class CodeTooltip {
115
116
  el.appendChild(this._sep());
116
117
 
117
118
  // Copy code
118
- this._copyBtn = this._makeBtn(ICONS.copy, 'Copy Code', () => this._copyCode());
119
+ this._copyBtn = this._makeBtn(ICONS.copy, L.copyCode, () => this._copyCode());
119
120
 
120
121
  el.appendChild(this._copyBtn);
121
122
 
122
123
  el.appendChild(this._sep());
123
124
 
124
125
  // Toggle word-wrap
125
- this._wrapBtn = this._makeBtn(ICONS.wrapOn, 'Toggle Word Wrap', () => this._toggleWrap());
126
+ this._wrapBtn = this._makeBtn(ICONS.wrapOn, L.toggleWordWrap, () => this._toggleWrap());
126
127
  el.appendChild(this._wrapBtn);
127
128
 
128
129
  el.appendChild(this._sep());
129
130
 
130
131
  // Convert to normal paragraph
131
- el.appendChild(this._makeBtn(ICONS.toParagraph, 'Convert to Paragraph', () => this._toParagraph()));
132
+ el.appendChild(this._makeBtn(ICONS.toParagraph, L.convertToParagraph, () => this._toParagraph()));
132
133
 
133
134
  el.appendChild(this._sep());
134
135
 
135
136
  // Delete block
136
- el.appendChild(this._makeBtn(ICONS.deleteCode, 'Delete Code Block', () => this._delete(), true));
137
+ el.appendChild(this._makeBtn(ICONS.deleteCode, L.deleteCodeBlock, () => this._delete(), true));
137
138
 
138
139
  // Keep tooltip alive while hovering it
139
140
  this._disposers.push(
@@ -241,7 +242,9 @@ export class CodeTooltip {
241
242
  const wrapped = (this._activePre.style.whiteSpace || '').includes('pre-wrap')
242
243
  || window.getComputedStyle(this._activePre).whiteSpace === 'pre-wrap';
243
244
  this._wrapBtn.classList.toggle('active', wrapped);
244
- this._wrapBtn.title = wrapped ? 'Disable Word Wrap' : 'Enable Word Wrap';
245
+ this._wrapBtn.title = wrapped
246
+ ? this.context.locale.tooltips.code.disableWordWrap
247
+ : this.context.locale.tooltips.code.enableWordWrap;
245
248
  }
246
249
 
247
250
  _syncLangSelect() {
@@ -48,8 +48,9 @@ const COLOR_PRESETS = [
48
48
 
49
49
  function makeColorSubItems(colorType) {
50
50
  const label = colorType === 'foreColor' ? 'Text Color' : 'Highlight Color';
51
+ const localeKey = colorType === 'foreColor' ? 'textColor' : 'highlightColor';
51
52
  return () => [
52
- { back: true, label, navigate: () => defaultItems },
53
+ { back: true, label, localeKey, navigate: () => defaultItems },
53
54
  { colorPalette: true, colorType },
54
55
  ];
55
56
  }
@@ -171,7 +172,8 @@ export class ContextMenu {
171
172
  const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
172
173
  iconSpan.innerHTML = ICONS.back;
173
174
  backBtn.appendChild(iconSpan);
174
- backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Back']));
175
+ const backLabel = (it.localeKey && this.context.locale.contextMenu[it.localeKey]) || it.label || this.context.locale.contextMenu.back || 'Back';
176
+ backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [backLabel]));
175
177
  const off = on(backBtn, 'click', (e) => {
176
178
  e.stopPropagation();
177
179
  const curLeft = parseFloat(this.el.style.left);
@@ -204,7 +206,7 @@ export class ContextMenu {
204
206
  btn.appendChild(iconSpan);
205
207
  }
206
208
  }
207
- btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
209
+ btn.appendChild(createElement('span', { class: 'an-context-label' }, [(this.context.locale.contextMenu[it.name] || it.label || it.name)]));
208
210
  const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
209
211
  chevron.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
210
212
  btn.appendChild(chevron);
@@ -238,9 +240,9 @@ export class ContextMenu {
238
240
  if (it.colorType === 'hiliteColor') {
239
241
  const noColor = createElement('div', {
240
242
  class: 'an-context-color-swatch an-context-color-none',
241
- title: 'No highlight',
243
+ title: this.context.locale.contextMenu.noHighlight || 'No highlight',
242
244
  role: 'button',
243
- 'aria-label': 'No highlight',
245
+ 'aria-label': this.context.locale.contextMenu.noHighlight || 'No highlight',
244
246
  });
245
247
  noColor.innerHTML = ICONS.noColor;
246
248
  const offNo = on(noColor, 'click', (e) => { e.stopPropagation(); this._applyColor('hiliteColor', 'transparent'); });
@@ -254,10 +256,10 @@ export class ContextMenu {
254
256
  const colorInput = createElement('input', {
255
257
  type: 'color',
256
258
  value: it.colorType === 'foreColor' ? '#000000' : '#ffff00',
257
- title: 'Custom color',
258
- 'aria-label': 'Custom color',
259
+ title: this.context.locale.contextMenu.customColor || 'Custom color',
260
+ 'aria-label': this.context.locale.contextMenu.customColor || 'Custom color',
259
261
  });
260
- const customLabel = createElement('span', {}, ['Custom…']);
262
+ const customLabel = createElement('span', {}, [this.context.locale.contextMenu.customColorLabel || 'Custom…']);
261
263
  const offCustom = on(colorInput, 'change', () => this._applyColor(it.colorType, colorInput.value));
262
264
  this._menuDisposers.push(offCustom);
263
265
  customRow.appendChild(colorInput);
@@ -277,7 +279,7 @@ export class ContextMenu {
277
279
  iconSpan.innerHTML = it.icon;
278
280
  headerBtn.appendChild(iconSpan);
279
281
  }
280
- headerBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Insert Table']));
282
+ headerBtn.appendChild(createElement('span', { class: 'an-context-label' }, [this.context.locale.contextMenu.table || it.label || 'Insert Table']));
281
283
  const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
282
284
  chevron.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
283
285
  headerBtn.appendChild(chevron);
@@ -305,7 +307,7 @@ export class ContextMenu {
305
307
  cells.forEach((cell) => {
306
308
  cell.classList.toggle('active', +cell.dataset.row <= rows && +cell.dataset.col <= cols);
307
309
  });
308
- labelEl.textContent = (rows && cols) ? `${cols} × ${rows}` : 'Insert Table';
310
+ labelEl.textContent = (rows && cols) ? `${cols} × ${rows}` : (this.context.locale.contextMenu.table || 'Insert Table');
309
311
  };
310
312
 
311
313
  panel.appendChild(gridEl);
@@ -358,7 +360,7 @@ export class ContextMenu {
358
360
  iconSpan.innerHTML = it.icon;
359
361
  btn.appendChild(iconSpan);
360
362
  }
361
- btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
363
+ btn.appendChild(createElement('span', { class: 'an-context-label' }, [(this.context.locale.contextMenu[it.name] || it.label || it.name)]));
362
364
  const off = on(btn, 'click', (e) => {
363
365
  e.stopPropagation();
364
366
  this.hide();
@@ -375,12 +377,30 @@ export class ContextMenu {
375
377
  if (!editable.contains(event.target)) return;
376
378
  if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
377
379
  event.preventDefault();
378
- this._lastX = event.clientX;
379
- this._lastY = event.clientY;
380
+
380
381
  const winSel = window.getSelection();
381
382
  this._savedRange = (winSel && winSel.rangeCount > 0) ? winSel.getRangeAt(0).cloneRange() : null;
382
383
  this._renderItems(this._items);
383
- this.showAt(event.clientX, event.clientY);
384
+
385
+ // Open below the selected text so the selection stays visible.
386
+ // Only apply when there is a real (non-collapsed) selection — a collapsed range
387
+ // (cursor only) also has height > 0, which would misplace the menu relative
388
+ // to the actual click point.
389
+ let openX = event.clientX;
390
+ let openY = event.clientY;
391
+ if (this._savedRange && !this._savedRange.collapsed) {
392
+ try {
393
+ const selRect = this._savedRange.getBoundingClientRect();
394
+ if (selRect.width > 0 && selRect.height > 0) {
395
+ // Keep X at click position (feels natural); Y just below the selection.
396
+ openY = selRect.bottom + 4;
397
+ }
398
+ } catch (_) {}
399
+ }
400
+
401
+ this._lastX = openX;
402
+ this._lastY = openY;
403
+ this.showAt(openX, openY);
384
404
  }
385
405
 
386
406
  _maybeHide(event) {
@@ -393,39 +413,22 @@ export class ContextMenu {
393
413
  this.el.style.display = 'block';
394
414
  this._reposition(x, y);
395
415
  this.el.setAttribute('aria-hidden', 'false');
416
+ this.context.triggerEvent('contextMenu:show');
396
417
  }
397
418
 
398
419
  _reposition(x, y) {
399
420
  if (!this.el) return;
400
421
  const rx = x !== undefined ? x : this._lastX;
401
422
  const ry = y !== undefined ? y : this._lastY;
402
- const rect = this.el.getBoundingClientRect();
423
+ const w = this.el.offsetWidth;
424
+ const h = this.el.offsetHeight;
403
425
  let left = rx;
404
426
  let top = ry;
405
- if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
427
+ // Clamp to viewport so the menu never overflows the screen edge
428
+ if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;
406
429
  if (left < 8) left = 8;
407
- if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
430
+ if (top + h > window.innerHeight - 8) top = window.innerHeight - h - 8;
408
431
  if (top < 8) top = 8;
409
-
410
- // Avoid covering the saved selection range
411
- if (this._savedRange) {
412
- try {
413
- const sel = this._savedRange.getBoundingClientRect();
414
- if (sel.width > 0 || sel.height > 0) {
415
- const overlaps = top < sel.bottom && (top + rect.height) > sel.top &&
416
- left < sel.right && (left + rect.width) > sel.left;
417
- if (overlaps) {
418
- const belowTop = sel.bottom + 6;
419
- if (belowTop + rect.height <= window.innerHeight - 8) {
420
- top = belowTop;
421
- } else {
422
- top = Math.max(8, sel.top - rect.height - 6);
423
- }
424
- }
425
- }
426
- } catch (_) { /* stale range — ignore */ }
427
- }
428
-
429
432
  this.el.style.left = `${left}px`;
430
433
  this.el.style.top = `${top}px`;
431
434
  }
@@ -434,6 +437,7 @@ export class ContextMenu {
434
437
  if (!this.el) return;
435
438
  this.el.style.display = 'none';
436
439
  this.el.setAttribute('aria-hidden', 'true');
440
+ this.context.triggerEvent('contextMenu:hide');
437
441
  }
438
442
 
439
443
  // ---------------------------------------------------------------------------
@@ -555,19 +555,20 @@ export class EmojiDialog {
555
555
  // ---------------------------------------------------------------------------
556
556
 
557
557
  _buildDialog() {
558
+ const L = this.context.locale.emojiDialog;
558
559
  const overlay = createElement('div', {
559
560
  class: 'an-dialog-overlay',
560
561
  role: 'dialog',
561
562
  'aria-modal': 'true',
562
- 'aria-label': 'Insert emoji',
563
+ 'aria-label': L.ariaLabel,
563
564
  });
564
565
  const box = createElement('div', { class: 'an-dialog-box an-emoji-box' });
565
566
 
566
567
  // Title row
567
568
  const titleRow = createElement('div', { class: 'an-icon-title-row' });
568
569
  const title = createElement('h3', { class: 'an-dialog-title' });
569
- title.textContent = 'Insert Emoji';
570
- const closeBtn = createElement('button', { type: 'button', class: 'an-icon-close', 'aria-label': 'Close' });
570
+ title.textContent = L.title;
571
+ const closeBtn = createElement('button', { type: 'button', class: 'an-icon-close', 'aria-label': L.close });
571
572
  closeBtn.innerHTML = '&times;';
572
573
  titleRow.append(title, closeBtn);
573
574
 
@@ -575,7 +576,7 @@ export class EmojiDialog {
575
576
  const searchInput = createElement('input', {
576
577
  type: 'search',
577
578
  class: 'an-input an-icon-search',
578
- placeholder: 'Search emojis…',
579
+ placeholder: L.searchPlaceholder,
579
580
  autocomplete: 'off',
580
581
  });
581
582
  this._searchInput = searchInput;
@@ -583,11 +584,11 @@ export class EmojiDialog {
583
584
  // Category tabs
584
585
  const catBar = createElement('div', { class: 'an-icon-cats' });
585
586
  const allTab = createElement('button', { type: 'button', class: 'an-icon-cat active', 'data-cat': 'all' });
586
- allTab.textContent = 'All';
587
+ allTab.textContent = L.all;
587
588
  catBar.appendChild(allTab);
588
589
  EMOJI_CATS.forEach(({ id, label }) => {
589
590
  const tab = createElement('button', { type: 'button', class: 'an-icon-cat', 'data-cat': id });
590
- tab.textContent = label;
591
+ tab.textContent = (L.categories && L.categories[id]) || label;
591
592
  catBar.appendChild(tab);
592
593
  });
593
594
  this._catBar = catBar;
@@ -611,7 +612,7 @@ export class EmojiDialog {
611
612
  // Cancel only — clicking an emoji inserts immediately
612
613
  const btnRow = createElement('div', { class: 'an-dialog-actions' });
613
614
  const cancelBtn = createElement('button', { type: 'button', class: 'an-btn' });
614
- cancelBtn.textContent = 'Cancel';
615
+ cancelBtn.textContent = L.cancelBtn;
615
616
  btnRow.appendChild(cancelBtn);
616
617
 
617
618
  box.append(titleRow, searchInput, catBar, grid, btnRow);
@@ -122,7 +122,7 @@ export class FindReplace {
122
122
  const isReplace = this._mode === 'replace';
123
123
  if (replaceRow) replaceRow.style.display = isReplace ? '' : 'none';
124
124
  if (replaceActions) replaceActions.style.display = isReplace ? '' : 'none';
125
- if (title) title.textContent = isReplace ? 'Find & Replace' : 'Find';
125
+ if (title) title.textContent = isReplace ? this.context.locale.findReplace.findReplaceTitle : this.context.locale.findReplace.findTitle;
126
126
  }
127
127
 
128
128
  // ---------------------------------------------------------------------------
@@ -130,24 +130,25 @@ export class FindReplace {
130
130
  // ---------------------------------------------------------------------------
131
131
 
132
132
  _buildDialog() {
133
+ const L = this.context.locale.findReplace;
133
134
  const overlay = createElement('div', {
134
135
  class: 'an-dialog-overlay an-fr-dialog',
135
136
  role: 'dialog',
136
137
  'aria-modal': 'true',
137
- 'aria-label': 'Find and Replace',
138
+ 'aria-label': L.findReplaceTitle,
138
139
  });
139
140
  const box = createElement('div', { class: 'an-dialog-box' });
140
141
 
141
142
  // ---- Title row ----
142
143
  const titleRow = createElement('div', { class: 'an-icon-title-row' });
143
144
  const title = createElement('h3', { class: 'an-dialog-title' });
144
- title.textContent = 'Find';
145
+ title.textContent = L.findTitle;
145
146
  const closeBtn = createElement('button', {
146
147
  type: 'button',
147
148
  class: 'an-icon-close',
148
149
  'aria-label': 'Close',
149
150
  });
150
- closeBtn.textContent = '×';
151
+ closeBtn.textContent = L.close;
151
152
  this._closeBtn = closeBtn;
152
153
  titleRow.append(title, closeBtn);
153
154
  box.appendChild(titleRow);
@@ -157,8 +158,8 @@ export class FindReplace {
157
158
  const findInput = createElement('input', {
158
159
  type: 'text',
159
160
  class: 'an-input',
160
- placeholder: 'Find…',
161
- 'aria-label': 'Search text',
161
+ placeholder: L.findPlaceholder,
162
+ 'aria-label': L.searchAriaLabel,
162
163
  });
163
164
  this._findInput = findInput;
164
165
  findRow.appendChild(findInput);
@@ -172,7 +173,7 @@ export class FindReplace {
172
173
  'aria-label': 'Case sensitive',
173
174
  });
174
175
  this._caseCheckbox = caseCheckbox;
175
- caseLabel.append(caseCheckbox, document.createTextNode('\u00a0Case sensitive'));
176
+ caseLabel.append(caseCheckbox, document.createTextNode(L.caseSensitive));
176
177
  const counter = createElement('span', { class: 'an-fr-counter' });
177
178
  this._counterEl = counter;
178
179
  optRow.append(caseLabel, counter);
@@ -181,9 +182,9 @@ export class FindReplace {
181
182
  // ---- Find actions ----
182
183
  const findActions = createElement('div', { class: 'an-dialog-actions an-fr-find-actions' });
183
184
  const prevBtn = createElement('button', { type: 'button', class: 'an-btn' });
184
- prevBtn.textContent = '\u2190 Prev';
185
+ prevBtn.textContent = L.prevBtn;
185
186
  const nextBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
186
- nextBtn.textContent = 'Next \u2192';
187
+ nextBtn.textContent = L.nextBtn;
187
188
  findActions.append(prevBtn, nextBtn);
188
189
  box.appendChild(findActions);
189
190
 
@@ -193,8 +194,8 @@ export class FindReplace {
193
194
  const replaceInput = createElement('input', {
194
195
  type: 'text',
195
196
  class: 'an-input',
196
- placeholder: 'Replace with\u2026',
197
- 'aria-label': 'Replace with',
197
+ placeholder: L.replacePlaceholder,
198
+ 'aria-label': L.replaceAriaLabel,
198
199
  });
199
200
  this._replaceInput = replaceInput;
200
201
  replaceRow.appendChild(replaceInput);
@@ -204,9 +205,9 @@ export class FindReplace {
204
205
  const replaceActions = createElement('div', { class: 'an-dialog-actions an-fr-replace-actions' });
205
206
  replaceActions.style.display = 'none';
206
207
  const replaceBtn = createElement('button', { type: 'button', class: 'an-btn' });
207
- replaceBtn.textContent = 'Replace';
208
+ replaceBtn.textContent = L.replaceBtn;
208
209
  const replaceAllBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
209
- replaceAllBtn.textContent = 'Replace All';
210
+ replaceAllBtn.textContent = L.replaceAllBtn;
210
211
  replaceActions.append(replaceBtn, replaceAllBtn);
211
212
  box.appendChild(replaceActions);
212
213