autumnnote 1.0.6 → 1.0.8

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,59 @@ 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
+ // D-1: Also block dragging of iframes and .an-video-wrapper elements in
158
+ // edit mode — a user can inadvertently drag the iframe out of its wrapper
159
+ // (making it playable/removable from contenteditable protection) by holding
160
+ // the mouse and moving outside the wrapper before releasing.
161
+ on(editable, 'dragstart', (e) => {
162
+ if (isReadOnly()) { e.preventDefault(); return; }
163
+ const target = e.target;
164
+ if (target && (target.nodeName === 'IFRAME' ||
165
+ (target.closest && target.closest('.an-video-wrapper')))) {
166
+ e.preventDefault();
167
+ }
168
+ }),
169
+ on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
170
+ );
171
+
172
+ // B-V: Re-apply superscript / subscript after IME composition ends.
173
+ // Vietnamese and other IME-based inputs fire compositionstart/end around
174
+ // the inserted characters. During composition the browser may place the
175
+ // provisional text outside the current <sup>/<sub> element. When
176
+ // compositionend fires we detect whether the cursor escaped the sup/sub
177
+ // context and re-apply the command so the composed character stays inside.
178
+ /** @type {string|null} 'superscript' | 'subscript' | null */
179
+ let _compositionSupSub = null;
180
+ const onCompositionStart = () => {
181
+ const sel = window.getSelection();
182
+ if (!sel || !sel.rangeCount) { _compositionSupSub = null; return; }
183
+ let node = sel.getRangeAt(0).startContainer;
184
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
185
+ if (node && node.closest) {
186
+ if (node.closest('sup')) _compositionSupSub = 'superscript';
187
+ else if (node.closest('sub')) _compositionSupSub = 'subscript';
188
+ else _compositionSupSub = null;
189
+ }
190
+ };
191
+ const onCompositionEnd = () => {
192
+ const tag = _compositionSupSub;
193
+ _compositionSupSub = null;
194
+ if (!tag) return;
195
+ const sel = window.getSelection();
196
+ if (!sel || !sel.rangeCount) return;
197
+ let node = sel.getRangeAt(0).startContainer;
198
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
199
+ const inContext = node && node.closest &&
200
+ (tag === 'superscript' ? node.closest('sup') : node.closest('sub'));
201
+ if (!inContext) {
202
+ // The composed character escaped the sup/sub — re-apply the format.
203
+ document.execCommand(tag);
204
+ }
205
+ };
206
+ this._disposers.push(
207
+ on(editable, 'compositionstart', onCompositionStart),
208
+ on(editable, 'compositionend', onCompositionEnd),
154
209
  );
155
210
  }
156
211
 
@@ -256,6 +311,9 @@ export class Editor {
256
311
  // ---------------------------------------------------------------------------
257
312
 
258
313
  afterCommand() {
314
+ // C4: Remove figure.an-figure elements whose <img> has been deleted so
315
+ // orphaned figcaptions do not accumulate in the DOM.
316
+ this._cleanOrphanedFigures();
259
317
  // Immediate: keep toolbar and statusbar in sync on every mutation.
260
318
  this.context.invoke('toolbar.refresh');
261
319
  this.context.invoke('statusbar.update');
@@ -279,6 +337,20 @@ export class Editor {
279
337
  }, 400);
280
338
  }
281
339
 
340
+ /**
341
+ * C4: Removes figure.an-figure elements that no longer contain an <img>.
342
+ * This happens when a user selects only the image (not the whole figure)
343
+ * and deletes or replaces it, leaving a dangling figcaption.
344
+ */
345
+ _cleanOrphanedFigures() {
346
+ const editable = this.context.layoutInfo.editable;
347
+ editable.querySelectorAll('figure.an-figure').forEach((fig) => {
348
+ if (!fig.querySelector('img')) {
349
+ fig.parentNode.removeChild(fig);
350
+ }
351
+ });
352
+ }
353
+
282
354
  // ---------------------------------------------------------------------------
283
355
  // Focus management
284
356
  // ---------------------------------------------------------------------------
@@ -309,7 +381,7 @@ export class Editor {
309
381
  * @param {string} html - HTML string (will be sanitised)
310
382
  */
311
383
  setHTML(html) {
312
- this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html);
384
+ this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html, { allowIframes: true });
313
385
  if (this._history) this._history.reset();
314
386
  this.afterCommand();
315
387
  }
@@ -688,7 +688,18 @@ export class EmojiDialog {
688
688
 
689
689
  // 2. Insert emoji as a plain text node — no ZWS or execCommand needed.
690
690
  // Text nodes are natively navigable so the caret lands cleanly after.
691
+ //
692
+ // E-1: When the range covers the entire content of a <td>/<th>,
693
+ // deleteContents() can drift the range endpoint outside the cell in some
694
+ // browsers. Save the cell reference first and re-anchor after deletion.
695
+ const _sc = range.startContainer;
696
+ const _tdAnchor = (_sc.nodeType === 1 ? _sc : _sc.parentElement)
697
+ ?.closest?.('td, th');
691
698
  range.deleteContents();
699
+ if (_tdAnchor && _tdAnchor.isConnected && !_tdAnchor.contains(range.startContainer)) {
700
+ range.setStart(_tdAnchor, 0);
701
+ range.collapse(true);
702
+ }
692
703
  const textNode = document.createTextNode(char);
693
704
  range.insertNode(textNode);
694
705
 
@@ -575,7 +575,18 @@ export class IconDialog {
575
575
 
576
576
  // 3. Insert the <i> node directly — never via execCommand/insertHTML
577
577
  // (execCommand leaves the caret inside the inserted element)
578
+ //
579
+ // E-1: When the range covers the entire content of a <td>/<th>,
580
+ // deleteContents() can drift the range endpoint outside the cell in some
581
+ // browsers. Save the cell reference first and re-anchor after deletion.
582
+ const _sc = range.startContainer;
583
+ const _tdAnchor = (_sc.nodeType === 1 ? _sc : _sc.parentElement)
584
+ ?.closest?.('td, th');
578
585
  range.deleteContents();
586
+ if (_tdAnchor && _tdAnchor.isConnected && !_tdAnchor.contains(range.startContainer)) {
587
+ range.setStart(_tdAnchor, 0);
588
+ range.collapse(true);
589
+ }
579
590
  range.insertNode(iconEl);
580
591
 
581
592
  // 4. Place cursor after the icon.
@@ -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
  );
@@ -118,12 +118,15 @@ export class ImageDialog {
118
118
  const fileInput = createElement('input', {
119
119
  type: 'file',
120
120
  class: 'an-input',
121
- accept: 'image/*',
121
+ accept: 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif',
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,26 @@ 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: Only allow web-displayable formats. TIFF, BMP, RAW and similar
163
+ // formats are not rendered by browsers — reject them with a clear message.
164
+ const SUPPORTED = new Set([
165
+ 'image/jpeg', 'image/png', 'image/gif',
166
+ 'image/webp', 'image/svg+xml', 'image/avif',
167
+ ]);
168
+ if (!SUPPORTED.has(file.type)) {
169
+ const message = `Format "${file.type}" is not supported for display in web browsers. Please convert to JPEG, PNG, or WebP first.`;
170
+ if (this._fileHint) this._fileHint.textContent = message;
171
+ this.context.triggerEvent('imageError', { file, message });
172
+ this._fileInput.value = '';
173
+ return;
174
+ }
175
+ if (this._fileHint) this._fileHint.textContent = '';
176
+
159
177
  // Validate file size (max 5 MB by default)
160
178
  const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
161
179
  if (file.size > maxSize) {
162
180
  const message = `Image file is too large. Maximum allowed size is ${this.options.maxImageSize || 5} MB.`;
181
+ if (this._fileHint) this._fileHint.textContent = message;
163
182
  console.warn('[AutumnNote] ImageDialog:', message);
164
183
  this.context.triggerEvent('imageError', { file, message });
165
184
  this._fileInput.value = '';
@@ -168,7 +187,7 @@ export class ImageDialog {
168
187
 
169
188
  const reader = new FileReader();
170
189
  reader.onload = (e) => {
171
- this._urlInput.value = e.target.result;
190
+ this._urlInput.value = /** @type {string} */ (e.target.result);
172
191
  };
173
192
  reader.readAsDataURL(file);
174
193
  }
@@ -50,6 +50,7 @@ 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
  }),
@@ -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]')) {
@@ -227,6 +228,8 @@ export class ImageTooltip {
227
228
  const target = img.closest('figure.an-figure') || img;
228
229
  target.style.float = value;
229
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 = '';
230
233
  target.style.marginLeft = value === 'right' ? '12px' : '';
231
234
  target.style.marginRight = value === 'left' ? '12px' : '';
232
235
  this.context.invoke('editor.afterCommand');
@@ -240,6 +243,9 @@ export class ImageTooltip {
240
243
  const target = img.closest('figure.an-figure') || img;
241
244
  target.style.float = '';
242
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';
243
249
  target.style.marginLeft = 'auto';
244
250
  target.style.marginRight = 'auto';
245
251
  this.context.invoke('editor.afterCommand');
@@ -335,7 +341,10 @@ export class ImageTooltip {
335
341
  img.style.marginLeft = '';
336
342
  img.style.marginRight = '';
337
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.
338
346
  figure.style.display = 'block';
347
+ figure.style.width = 'fit-content';
339
348
  figure.style.marginLeft = 'auto';
340
349
  figure.style.marginRight = 'auto';
341
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,13 @@ 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
+ // Strip ZWS (\u200B) cursor anchors used by checklist/icon insertion in
43
+ // addition to regular whitespace before deciding if the editor is empty.
44
+ // Without this, a freshly-created checklist item or icon leaves a ZWS in
45
+ // the DOM that causes the placeholder to overlap real content (A-1).
46
+ const hasText = editable.textContent.replace(/\u200B/g, '').trim().length > 0;
47
+ const isEmpty = !hasText &&
48
+ !editable.querySelector('img, table, hr, .an-video-wrapper');
43
49
  editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
44
50
  }
45
51
  }