autumnnote 1.0.5 → 1.0.7

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.
@@ -30,12 +30,31 @@ const ICONS = {
30
30
  removeFormat:`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>`,
31
31
  // Table
32
32
  table: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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"/></svg>`,
33
+ // Color
34
+ textColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/></svg>`,
35
+ highlightColor: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21v-4l9-9 4 4-9 9z"/><path d="M12 8l4 4"/><line x1="3" y1="21" x2="21" y2="21"/></svg>`,
36
+ noColor: `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="4" x2="20" y2="20"/><line x1="20" y1="4" x2="4" y2="20"/></svg>`,
37
+ back: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
33
38
  };
34
39
 
40
+ const COLOR_PRESETS = [
41
+ // Grayscale
42
+ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#efefef', '#ffffff',
43
+ // Saturated
44
+ '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff',
45
+ // Pastel
46
+ '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#d9d2e9', '#ead1dc',
47
+ ];
48
+
49
+ function makeColorSubItems(colorType) {
50
+ const label = colorType === 'foreColor' ? 'Text Color' : 'Highlight Color';
51
+ return () => [
52
+ { back: true, label, navigate: () => defaultItems },
53
+ { colorPalette: true, colorType },
54
+ ];
55
+ }
56
+
35
57
  const defaultItems = [
36
- { name: 'undo', label: 'Undo', icon: ICONS.undo, action: (ctx) => ctx.invoke('editor.undo') },
37
- { name: 'redo', label: 'Redo', icon: ICONS.redo, action: (ctx) => ctx.invoke('editor.redo') },
38
- { separator: true },
39
58
  { name: 'cut', label: 'Cut', icon: ICONS.cut, action: () => document.execCommand('cut') },
40
59
  { name: 'copy', label: 'Copy', icon: ICONS.copy, action: () => document.execCommand('copy') },
41
60
  { name: 'paste', label: 'Paste', icon: ICONS.paste, action: (ctx) => {
@@ -76,6 +95,9 @@ const defaultItems = [
76
95
  { name: 'italic', label: 'Italic', icon: ICONS.italic, action: (ctx) => ctx.invoke('editor.italic') },
77
96
  { name: 'underline', label: 'Underline', icon: ICONS.underline, action: (ctx) => ctx.invoke('editor.underline') },
78
97
  { separator: true },
98
+ { name: 'textColor', label: 'Text Color', icon: ICONS.textColor, colorStrip: 'foreColor', navigate: makeColorSubItems('foreColor') },
99
+ { name: 'highlightColor', label: 'Highlight Color', icon: ICONS.highlightColor, colorStrip: 'hiliteColor', navigate: makeColorSubItems('hiliteColor') },
100
+ { separator: true },
79
101
  { name: 'copyFormat', label: 'Copy Format', icon: ICONS.copyFormat, action: (ctx) => ctx.invoke('contextMenu.copyFormat') },
80
102
  { name: 'pasteFormat', label: 'Paste Format', icon: ICONS.pasteFormat, action: (ctx) => ctx.invoke('contextMenu.pasteFormat'), disabled: (ctx) => !ctx.invoke('contextMenu.hasCopiedFormat') },
81
103
  { name: 'removeFormat', label: 'Remove Format', icon: ICONS.removeFormat, action: (ctx) => ctx.invoke('contextMenu.removeFormat') },
@@ -152,8 +174,10 @@ export class ContextMenu {
152
174
  backBtn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || 'Back']));
153
175
  const off = on(backBtn, 'click', (e) => {
154
176
  e.stopPropagation();
177
+ const curLeft = parseFloat(this.el.style.left);
178
+ const curTop = parseFloat(this.el.style.top);
155
179
  this._renderItems(it.navigate());
156
- this._reposition();
180
+ this._reposition(curLeft, curTop);
157
181
  });
158
182
  this._menuDisposers.push(off);
159
183
  this.el.appendChild(backBtn);
@@ -164,9 +188,21 @@ export class ContextMenu {
164
188
  if (it.navigate) {
165
189
  const btn = createElement('button', { type: 'button', class: 'an-context-item an-context-submenu', 'data-name': it.name || '' });
166
190
  if (it.icon) {
167
- const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
168
- iconSpan.innerHTML = it.icon;
169
- btn.appendChild(iconSpan);
191
+ if (it.colorStrip) {
192
+ // Toolbar-style icon: SVG stacked above a colored strip
193
+ const iconWrap = createElement('span', { class: 'an-context-icon an-context-icon--color', 'aria-hidden': 'true' });
194
+ const svgSpan = createElement('span', { class: 'an-context-icon-svg' });
195
+ svgSpan.innerHTML = it.icon;
196
+ const strip = createElement('span', { class: 'an-context-color-strip' });
197
+ strip.style.background = this._getSelectionColor(it.colorStrip);
198
+ iconWrap.appendChild(svgSpan);
199
+ iconWrap.appendChild(strip);
200
+ btn.appendChild(iconWrap);
201
+ } else {
202
+ const iconSpan = createElement('span', { class: 'an-context-icon', 'aria-hidden': 'true' });
203
+ iconSpan.innerHTML = it.icon;
204
+ btn.appendChild(iconSpan);
205
+ }
170
206
  }
171
207
  btn.appendChild(createElement('span', { class: 'an-context-label' }, [it.label || it.name]));
172
208
  const chevron = createElement('span', { class: 'an-context-chevron', 'aria-hidden': 'true' });
@@ -174,14 +210,62 @@ export class ContextMenu {
174
210
  btn.appendChild(chevron);
175
211
  const off = on(btn, 'click', (e) => {
176
212
  e.stopPropagation();
213
+ const curLeft = parseFloat(this.el.style.left);
214
+ const curTop = parseFloat(this.el.style.top);
177
215
  this._renderItems(it.navigate());
178
- this._reposition();
216
+ this._reposition(curLeft, curTop);
179
217
  });
180
218
  this._menuDisposers.push(off);
181
219
  this.el.appendChild(btn);
182
220
  return;
183
221
  }
184
222
 
223
+ // Color palette item — renders inline color swatches
224
+ if (it.colorPalette) {
225
+ const palette = createElement('div', { class: 'an-context-color-palette' });
226
+ COLOR_PRESETS.forEach((color) => {
227
+ const sw = createElement('div', {
228
+ class: 'an-context-color-swatch',
229
+ title: color,
230
+ role: 'button',
231
+ 'aria-label': color,
232
+ });
233
+ sw.style.background = color;
234
+ const offSw = on(sw, 'click', (e) => { e.stopPropagation(); this._applyColor(it.colorType, color); });
235
+ this._menuDisposers.push(offSw);
236
+ palette.appendChild(sw);
237
+ });
238
+ if (it.colorType === 'hiliteColor') {
239
+ const noColor = createElement('div', {
240
+ class: 'an-context-color-swatch an-context-color-none',
241
+ title: 'No highlight',
242
+ role: 'button',
243
+ 'aria-label': 'No highlight',
244
+ });
245
+ noColor.innerHTML = ICONS.noColor;
246
+ const offNo = on(noColor, 'click', (e) => { e.stopPropagation(); this._applyColor('hiliteColor', 'transparent'); });
247
+ this._menuDisposers.push(offNo);
248
+ palette.appendChild(noColor);
249
+ }
250
+ this.el.appendChild(palette);
251
+
252
+ // Custom color row
253
+ const customRow = createElement('div', { class: 'an-context-color-custom' });
254
+ const colorInput = createElement('input', {
255
+ type: 'color',
256
+ value: it.colorType === 'foreColor' ? '#000000' : '#ffff00',
257
+ title: 'Custom color',
258
+ 'aria-label': 'Custom color',
259
+ });
260
+ const customLabel = createElement('span', {}, ['Custom…']);
261
+ const offCustom = on(colorInput, 'change', () => this._applyColor(it.colorType, colorInput.value));
262
+ this._menuDisposers.push(offCustom);
263
+ customRow.appendChild(colorInput);
264
+ customRow.appendChild(customLabel);
265
+ this.el.appendChild(customRow);
266
+ return;
267
+ }
268
+
185
269
  // Table grid picker item — expands an inline grid panel when clicked
186
270
  if (it.tableGrid) {
187
271
  const GRID_ROWS = 8, GRID_COLS = 8;
@@ -289,6 +373,7 @@ export class ContextMenu {
289
373
  const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
290
374
  if (!editable) return;
291
375
  if (!editable.contains(event.target)) return;
376
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
292
377
  event.preventDefault();
293
378
  this._lastX = event.clientX;
294
379
  this._lastY = event.clientY;
@@ -318,7 +403,29 @@ export class ContextMenu {
318
403
  let left = rx;
319
404
  let top = ry;
320
405
  if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
406
+ if (left < 8) left = 8;
321
407
  if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
408
+ 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
+
322
429
  this.el.style.left = `${left}px`;
323
430
  this.el.style.top = `${top}px`;
324
431
  }
@@ -333,6 +440,37 @@ export class ContextMenu {
333
440
  // Format operations (Copy Format / Paste Format / Remove Format)
334
441
  // ---------------------------------------------------------------------------
335
442
 
443
+ /** Read the current selection's text or highlight color for the strip.
444
+ * @param {'foreColor'|'hiliteColor'} type
445
+ * @returns {string} CSS color string
446
+ */
447
+ _getSelectionColor(type) {
448
+ const range = this._savedRange;
449
+ if (!range) return type === 'foreColor' ? '#000000' : 'transparent';
450
+ let node = range.startContainer;
451
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
452
+ if (!node) return type === 'foreColor' ? '#000000' : 'transparent';
453
+ const cs = window.getComputedStyle(node);
454
+ if (type === 'foreColor') {
455
+ return cs.color || '#000000';
456
+ }
457
+ const bg = cs.backgroundColor;
458
+ return (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? 'transparent' : bg;
459
+ }
460
+
461
+ /** Restore selection, apply a color command, then hide the menu. */
462
+ _applyColor(type, color) {
463
+ const editable = this.context.layoutInfo && this.context.layoutInfo.editable;
464
+ if (!editable || !this._savedRange) return;
465
+ editable.focus();
466
+ const sel = window.getSelection();
467
+ sel.removeAllRanges();
468
+ sel.addRange(this._savedRange.cloneRange());
469
+ document.execCommand(type, false, color);
470
+ this.context.invoke('editor.afterCommand');
471
+ this.hide();
472
+ }
473
+
336
474
  /** Returns true if a format has been copied — used to disable Paste Format. */
337
475
  hasCopiedFormat() { return !!this._copiedFormat; }
338
476
 
@@ -143,6 +143,8 @@ export class Editor {
143
143
  sel.addRange(nr);
144
144
  };
145
145
 
146
+ const isReadOnly = () => this.context.layoutInfo.container.classList.contains('an-disabled');
147
+
146
148
  this._disposers.push(
147
149
  on(editable, 'keydown', onKeydown),
148
150
  on(editable, 'beforeinput', onBeforeInput),
@@ -151,6 +153,48 @@ export class Editor {
151
153
  on(editable, 'click', onCheckboxClick),
152
154
  on(editable, 'mouseup', fixChecklistCursor),
153
155
  on(editable, 'keyup', fixChecklistCursor),
156
+ // Block drag-out and external drops in read-only mode
157
+ on(editable, 'dragstart', (e) => { if (isReadOnly()) e.preventDefault(); }),
158
+ on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
159
+ );
160
+
161
+ // B-V: Re-apply superscript / subscript after IME composition ends.
162
+ // Vietnamese and other IME-based inputs fire compositionstart/end around
163
+ // the inserted characters. During composition the browser may place the
164
+ // provisional text outside the current <sup>/<sub> element. When
165
+ // compositionend fires we detect whether the cursor escaped the sup/sub
166
+ // context and re-apply the command so the composed character stays inside.
167
+ /** @type {string|null} 'superscript' | 'subscript' | null */
168
+ let _compositionSupSub = null;
169
+ const onCompositionStart = () => {
170
+ const sel = window.getSelection();
171
+ if (!sel || !sel.rangeCount) { _compositionSupSub = null; return; }
172
+ let node = sel.getRangeAt(0).startContainer;
173
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
174
+ if (node && node.closest) {
175
+ if (node.closest('sup')) _compositionSupSub = 'superscript';
176
+ else if (node.closest('sub')) _compositionSupSub = 'subscript';
177
+ else _compositionSupSub = null;
178
+ }
179
+ };
180
+ const onCompositionEnd = () => {
181
+ const tag = _compositionSupSub;
182
+ _compositionSupSub = null;
183
+ if (!tag) return;
184
+ const sel = window.getSelection();
185
+ if (!sel || !sel.rangeCount) return;
186
+ let node = sel.getRangeAt(0).startContainer;
187
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
188
+ const inContext = node && node.closest &&
189
+ (tag === 'superscript' ? node.closest('sup') : node.closest('sub'));
190
+ if (!inContext) {
191
+ // The composed character escaped the sup/sub — re-apply the format.
192
+ document.execCommand(tag);
193
+ }
194
+ };
195
+ this._disposers.push(
196
+ on(editable, 'compositionstart', onCompositionStart),
197
+ on(editable, 'compositionend', onCompositionEnd),
154
198
  );
155
199
  }
156
200
 
@@ -256,6 +300,9 @@ export class Editor {
256
300
  // ---------------------------------------------------------------------------
257
301
 
258
302
  afterCommand() {
303
+ // C4: Remove figure.an-figure elements whose <img> has been deleted so
304
+ // orphaned figcaptions do not accumulate in the DOM.
305
+ this._cleanOrphanedFigures();
259
306
  // Immediate: keep toolbar and statusbar in sync on every mutation.
260
307
  this.context.invoke('toolbar.refresh');
261
308
  this.context.invoke('statusbar.update');
@@ -279,6 +326,20 @@ export class Editor {
279
326
  }, 400);
280
327
  }
281
328
 
329
+ /**
330
+ * C4: Removes figure.an-figure elements that no longer contain an <img>.
331
+ * This happens when a user selects only the image (not the whole figure)
332
+ * and deletes or replaces it, leaving a dangling figcaption.
333
+ */
334
+ _cleanOrphanedFigures() {
335
+ const editable = this.context.layoutInfo.editable;
336
+ editable.querySelectorAll('figure.an-figure').forEach((fig) => {
337
+ if (!fig.querySelector('img')) {
338
+ fig.parentNode.removeChild(fig);
339
+ }
340
+ });
341
+ }
342
+
282
343
  // ---------------------------------------------------------------------------
283
344
  // Focus management
284
345
  // ---------------------------------------------------------------------------
@@ -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;
@@ -66,7 +66,7 @@ function drawCropToCanvas(img, naturalRect, renderW, renderH) {
66
66
  if (
67
67
  img.src.startsWith('data:') ||
68
68
  img.src.startsWith('blob:') ||
69
- img.src.startsWith(location.origin)
69
+ img.src.startsWith(window.location.origin)
70
70
  ) {
71
71
  tryDraw(img);
72
72
  return;
@@ -489,7 +489,7 @@ export class ImageCropOverlay {
489
489
 
490
490
  if (!canvas) {
491
491
  // Cross-origin failure — inform user and abort
492
- alert(
492
+ window.alert(
493
493
  'Cannot crop this image: the image server does not allow cross-origin access.\n' +
494
494
  'Upload the image directly to use the crop tool.',
495
495
  );
@@ -121,9 +121,12 @@ export class ImageDialog {
121
121
  accept: 'image/*',
122
122
  });
123
123
  this._fileInput = fileInput;
124
+ // Hint line shown below the file input for format errors
125
+ const fileHint = createElement('p', { class: 'an-dialog-hint' });
126
+ this._fileHint = fileHint;
124
127
  const d = on(fileInput, 'change', () => this._onFileChange());
125
128
  this._disposers.push(d);
126
- box.append(fileLabel, fileInput);
129
+ box.append(fileLabel, fileInput, fileHint);
127
130
  }
128
131
 
129
132
  // Buttons
@@ -156,10 +159,22 @@ export class ImageDialog {
156
159
  const file = this._fileInput && this._fileInput.files && this._fileInput.files[0];
157
160
  if (!file || !file.type.startsWith('image/')) return;
158
161
 
162
+ // C2: Reject image formats browsers cannot display (TIFF, BMP, etc.).
163
+ const UNSUPPORTED = ['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp'];
164
+ if (UNSUPPORTED.includes(file.type)) {
165
+ const message = `Format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
166
+ if (this._fileHint) this._fileHint.textContent = message;
167
+ this.context.triggerEvent('imageError', { file, message });
168
+ this._fileInput.value = '';
169
+ return;
170
+ }
171
+ if (this._fileHint) this._fileHint.textContent = '';
172
+
159
173
  // Validate file size (max 5 MB by default)
160
174
  const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
161
175
  if (file.size > maxSize) {
162
176
  const message = `Image file is too large. Maximum allowed size is ${this.options.maxImageSize || 5} MB.`;
177
+ if (this._fileHint) this._fileHint.textContent = message;
163
178
  console.warn('[AutumnNote] ImageDialog:', message);
164
179
  this.context.triggerEvent('imageError', { file, message });
165
180
  this._fileInput.value = '';
@@ -168,7 +183,7 @@ export class ImageDialog {
168
183
 
169
184
  const reader = new FileReader();
170
185
  reader.onload = (e) => {
171
- this._urlInput.value = e.target.result;
186
+ this._urlInput.value = /** @type {string} */ (e.target.result);
172
187
  };
173
188
  reader.readAsDataURL(file);
174
189
  }
@@ -50,12 +50,13 @@ export class ImageResizer {
50
50
  on(editable, 'click', (e) => this._onEditorClick(e)),
51
51
  // Also select on right-click so the highlight shows before the context menu
52
52
  on(editable, 'contextmenu', (e) => {
53
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
53
54
  const img = e.target.closest('img');
54
55
  if (img) this._select(img);
55
56
  }),
56
57
  on(document, 'click', (e) => this._onDocClick(e)),
57
58
  on(window, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
58
- on(window, 'resize', onWindowResize),
59
+ on(window, 'resize', onWindowResize, { passive: true }),
59
60
  on(editable, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
60
61
  );
61
62
 
@@ -127,6 +128,7 @@ export class ImageResizer {
127
128
  }
128
129
 
129
130
  _onEditorClick(e) {
131
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
130
132
  const img = e.target.closest('img');
131
133
  if (img) {
132
134
  // Prevent browser from deselecting the current text selection / opening native resize
@@ -38,6 +38,7 @@ export class ImageTooltip {
38
38
 
39
39
  this._disposers.push(
40
40
  on(editable, 'mouseover', (e) => {
41
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
41
42
  const img = e.target.closest('img');
42
43
  // Skip when the image is inside a link — LinkTooltip takes priority there
43
44
  if (img && editable.contains(img) && !img.closest('a[href]')) {
@@ -173,7 +174,9 @@ export class ImageTooltip {
173
174
  _scheduleHide() {
174
175
  clearTimeout(this._showTimer);
175
176
  this._showTimer = null;
176
- if (this._hideTimer) return;
177
+ // Always reset the hide timer so rapid mouseout→mouseover sequences
178
+ // don't leave a stale timer that hides the tooltip prematurely.
179
+ clearTimeout(this._hideTimer);
177
180
  this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
178
181
  }
179
182
 
@@ -225,6 +228,8 @@ export class ImageTooltip {
225
228
  const target = img.closest('figure.an-figure') || img;
226
229
  target.style.float = value;
227
230
  target.style.display = '';
231
+ // Clear explicit width set for centering so the figure is free to float
232
+ if (target !== img) target.style.width = '';
228
233
  target.style.marginLeft = value === 'right' ? '12px' : '';
229
234
  target.style.marginRight = value === 'left' ? '12px' : '';
230
235
  this.context.invoke('editor.afterCommand');
@@ -238,6 +243,9 @@ export class ImageTooltip {
238
243
  const target = img.closest('figure.an-figure') || img;
239
244
  target.style.float = '';
240
245
  target.style.display = 'block';
246
+ // C1/C3: use fit-content so the block figure shrinks to the image width;
247
+ // without an explicit width, margin:auto has no effect on a 100%-wide block.
248
+ if (target !== img) target.style.width = 'fit-content';
241
249
  target.style.marginLeft = 'auto';
242
250
  target.style.marginRight = 'auto';
243
251
  this.context.invoke('editor.afterCommand');
@@ -333,7 +341,10 @@ export class ImageTooltip {
333
341
  img.style.marginLeft = '';
334
342
  img.style.marginRight = '';
335
343
  } else if (img.style.display === 'block' && img.style.marginLeft === 'auto') {
344
+ // C1/C3: use fit-content so the centered block figure tightly wraps the
345
+ // image, making margin:auto actually centre it regardless of image width.
336
346
  figure.style.display = 'block';
347
+ figure.style.width = 'fit-content';
337
348
  figure.style.marginLeft = 'auto';
338
349
  figure.style.marginRight = 'auto';
339
350
  img.style.display = '';
@@ -125,9 +125,13 @@ export class LinkTooltip {
125
125
  }
126
126
 
127
127
  _show(anchor) {
128
+ const isReadOnly = this.context.layoutInfo.container.classList.contains('an-disabled');
128
129
  const url = anchor.getAttribute('href') || '';
129
130
  this._urlLabel.textContent = this._truncateUrl(url);
130
131
  this._urlLabel.title = url;
132
+ // In read-only mode keep only open + copy; hide edit and unlink
133
+ this._editBtn.style.display = isReadOnly ? 'none' : '';
134
+ this._unlinkBtn.style.display = isReadOnly ? 'none' : '';
131
135
  this._el.style.display = 'flex';
132
136
  this._positionNear(anchor);
133
137
  }
@@ -39,7 +39,8 @@ export class Placeholder {
39
39
  _update() {
40
40
  const editable = this.context.layoutInfo.editable;
41
41
  const isFocused = document.activeElement === editable;
42
- const isEmpty = !editable.textContent.trim() && !editable.querySelector('img, table, hr');
42
+ const isEmpty = !editable.textContent.trim() &&
43
+ !editable.querySelector('img, table, hr, .an-video-wrapper');
43
44
  editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
44
45
  }
45
46
  }