autumnnote 1.0.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
@@ -45,9 +45,10 @@
45
45
  "license": "MIT",
46
46
  "devDependencies": {
47
47
  "@vitest/browser": "^4.1.2",
48
+ "@vitest/coverage-v8": "^4.1.2",
48
49
  "cross-env": "^10.1.0",
49
50
  "eslint": "^10.2.0",
50
- "jsdom": "^29.0.1",
51
+ "jsdom": "^25.0.1",
51
52
  "rollup-plugin-visualizer": "^7.0.1",
52
53
  "sass": "^1.99.0",
53
54
  "typescript": "^6.0.2",
package/src/js/Context.js CHANGED
@@ -444,9 +444,15 @@ export class Context {
444
444
  if (disabled) {
445
445
  editable.setAttribute('contenteditable', 'false');
446
446
  this.layoutInfo.container.classList.add('an-disabled');
447
+ editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
448
+ cb.setAttribute('disabled', '');
449
+ });
447
450
  } else {
448
451
  editable.setAttribute('contenteditable', 'true');
449
452
  this.layoutInfo.container.classList.remove('an-disabled');
453
+ editable.querySelectorAll('ul.an-checklist input[type="checkbox"]').forEach((cb) => {
454
+ cb.removeAttribute('disabled');
455
+ });
450
456
  }
451
457
  }
452
458
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  /** Tags that are unconditionally removed from editor content. */
9
- const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'];
9
+ const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'button'];
10
10
 
11
11
  /** Attributes whose values must be sanitised as URLs. */
12
12
  const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
@@ -26,7 +26,8 @@ const TRUSTED_IFRAME_HOSTS = new Set([
26
26
  * Uses DOMParser so the sanitisation follows normal browser parsing rules —
27
27
  * no regex shortcuts that can be bypassed by encoding tricks.
28
28
  *
29
- * - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, input, button)
29
+ * - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, button)
30
+ * - Allows input[type="checkbox"] only inside ul.an-checklist li; removes all other <input>
30
31
  * - Removes all on* event-handler attributes
31
32
  * - Rejects javascript: and vbscript: URLs in URL attributes
32
33
  * - Rejects data: URIs everywhere except img[src] (base64 uploads)
@@ -81,6 +82,23 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
81
82
  });
82
83
  });
83
84
 
85
+ // Allow only input[type="checkbox"] inside ul.an-checklist li; strip everything else.
86
+ // This preserves checklist state while blocking arbitrary <input> injection.
87
+ doc.querySelectorAll('input').forEach((el) => {
88
+ const inChecklist = el.closest('ul.an-checklist') !== null &&
89
+ el.closest('li') !== null;
90
+ if (!inChecklist || el.getAttribute('type') !== 'checkbox') {
91
+ el.remove();
92
+ } else {
93
+ // Harden: keep only safe attributes on checklist checkboxes
94
+ Array.from(el.attributes).forEach((attr) => {
95
+ if (!['type', 'checked', 'contenteditable'].includes(attr.name)) {
96
+ el.removeAttribute(attr.name);
97
+ }
98
+ });
99
+ }
100
+ });
101
+
84
102
  return doc.body.innerHTML;
85
103
  }
86
104
 
@@ -98,15 +98,54 @@ export const fontName = (name) => execCommand('fontName', name);
98
98
  * @param {HTMLElement} [editable] - scoping element to avoid touching nodes outside this editor
99
99
  */
100
100
  export function fontSize(size, editable = document) {
101
+ const sel = window.getSelection();
102
+ const wasCollapsed = !sel || !sel.rangeCount || sel.getRangeAt(0).collapsed;
103
+
101
104
  execCommand('fontSize', '7'); // placeholder
102
105
  // Replace font elements with spans, scoped to the active editable
103
- editable.querySelectorAll('font[size="7"]').forEach((el) => {
106
+ const scope = editable instanceof HTMLElement ? editable : document;
107
+ const newSpans = [];
108
+ scope.querySelectorAll('font[size="7"]').forEach((el) => {
104
109
  const span = document.createElement('span');
105
110
  span.style.fontSize = size;
106
111
  el.parentNode.insertBefore(span, el);
107
112
  while (el.firstChild) span.appendChild(el.firstChild);
108
113
  el.parentNode.removeChild(el);
114
+ newSpans.push(span);
109
115
  });
116
+
117
+ // Restore the selection inside the new span(s) so:
118
+ // 1. The toolbar getValue() correctly reflects the new font size.
119
+ // 2. For a collapsed (caret) selection, subsequent typing inherits the
120
+ // chosen size rather than the browser's stale execCommand state (which
121
+ // would produce size 7 = 48 px instead of the requested value).
122
+ if (sel && newSpans.length > 0) {
123
+ const first = newSpans[0];
124
+ const last = newSpans[newSpans.length - 1];
125
+ try {
126
+ if (wasCollapsed) {
127
+ // Ensure the span has a text anchor so the cursor can live inside it.
128
+ if (!first.firstChild) {
129
+ first.appendChild(document.createTextNode('\u200B'));
130
+ }
131
+ const nr = document.createRange();
132
+ const anchor = first.firstChild;
133
+ nr.setStart(anchor, anchor.textContent.length);
134
+ nr.collapse(true);
135
+ sel.removeAllRanges();
136
+ sel.addRange(nr);
137
+ } else {
138
+ // Re-select all replaced content so toolbar refresh reads the new size.
139
+ const nr = document.createRange();
140
+ const startNode = first.firstChild || first;
141
+ const endNode = last.lastChild || last;
142
+ nr.setStart(startNode, 0);
143
+ nr.setEnd(endNode, endNode.nodeType === Node.TEXT_NODE ? endNode.textContent.length : endNode.childNodes.length);
144
+ sel.removeAllRanges();
145
+ sel.addRange(nr);
146
+ }
147
+ } catch (_) { /* ignore range errors on unusual DOM structures */ }
148
+ }
110
149
  }
111
150
 
112
151
  // ---------------------------------------------------------------------------
@@ -263,11 +302,36 @@ export function toggleInlineCode(editable) {
263
302
  if (container.nodeType === 3) container = container.parentElement;
264
303
  const codeEl = container && container.closest ? container.closest('code') : null;
265
304
  if (codeEl && !codeEl.closest('pre')) {
266
- // Unwrap
305
+ // Unwrap — save range endpoints relative to surrounding text so we can
306
+ // restore the selection after normalize() merges adjacent text nodes.
267
307
  const parent = codeEl.parentNode;
308
+ // Note the sibling before the code element so we can re-anchor later.
309
+ const prevSibling = codeEl.previousSibling;
310
+ const movedChildren = Array.from(codeEl.childNodes);
268
311
  while (codeEl.firstChild) parent.insertBefore(codeEl.firstChild, codeEl);
269
312
  parent.removeChild(codeEl);
270
- if (editable) editable.normalize();
313
+ // Normalize only the immediate parent to merge adjacent text nodes without
314
+ // invalidating distant selection anchors (full editable.normalize() can
315
+ // cause selection offsets to shift, making subsequent format toggles miss).
316
+ if (parent && parent.normalize) parent.normalize();
317
+ // Restore selection to the text that was inside the unwrapped <code>.
318
+ if (movedChildren.length > 0) {
319
+ try {
320
+ // After normalize, find the merged text node that contains the content.
321
+ const firstMoved = movedChildren[0];
322
+ const lastMoved = movedChildren[movedChildren.length - 1];
323
+ const nr = document.createRange();
324
+ // Use the (possibly merged) live node if still in the DOM.
325
+ const anchorNode = (firstMoved.parentNode === parent) ? firstMoved : (prevSibling ? prevSibling.nextSibling : parent.firstChild);
326
+ if (anchorNode) {
327
+ nr.setStart(anchorNode, 0);
328
+ const endAnchor = (lastMoved.parentNode === parent) ? lastMoved : anchorNode;
329
+ nr.setEnd(endAnchor, endAnchor.nodeType === Node.TEXT_NODE ? endAnchor.textContent.length : endAnchor.childNodes.length);
330
+ sel.removeAllRanges();
331
+ sel.addRange(nr);
332
+ }
333
+ } catch (_) { /* ignore */ }
334
+ }
271
335
  } else {
272
336
  if (range.collapsed) return;
273
337
  try {
@@ -296,14 +360,17 @@ export function toggleInlineCode(editable) {
296
360
  /**
297
361
  * Returns true when the cursor / selection is inside an inline <code>
298
362
  * (not nested in a <pre>).
363
+ * Uses startContainer for reliable cross-browser detection regardless of
364
+ * whether the selection is collapsed or a range (commonAncestorContainer
365
+ * can behave inconsistently for range selections on some browsers).
299
366
  * @returns {boolean}
300
367
  */
301
368
  export function isInlineCode() {
302
369
  const sel = window.getSelection();
303
370
  if (!sel || !sel.rangeCount) return false;
304
- let container = sel.getRangeAt(0).commonAncestorContainer;
305
- if (container.nodeType === 3) container = container.parentElement;
306
- const code = container && container.closest ? container.closest('code') : null;
371
+ let sc = sel.getRangeAt(0).startContainer;
372
+ if (sc.nodeType === 3) sc = sc.parentElement;
373
+ const code = sc && sc.closest ? sc.closest('code') : null;
307
374
  return !!(code && !code.closest('pre'));
308
375
  }
309
376
 
@@ -357,7 +424,53 @@ export function toggleChecklist() {
357
424
  }
358
425
  }
359
426
 
360
- // Otherwise: insert new checklist from selected text
427
+ // Otherwise: insert new checklist from selected text (or current block when collapsed).
428
+ const isCollapsed = range.collapsed;
429
+ if (isCollapsed) {
430
+ // Find the nearest block-level ancestor (p, div, li, h1-h6, blockquote, etc.)
431
+ // and convert it into a single checklist item.
432
+ const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI']);
433
+ let block = container;
434
+ while (block && block.parentNode && !BLOCK_TAGS.has(block.tagName)) {
435
+ block = block.parentNode;
436
+ }
437
+ // Fallback: if no block element found (e.g. cursor directly in editable root), use the
438
+ // insertion approach with a zero-width-space item so the cursor ends up inside.
439
+ const itemText = (block && BLOCK_TAGS.has(block.tagName))
440
+ ? Array.from(block.childNodes)
441
+ .map((n) => n.textContent)
442
+ .join('')
443
+ .replace(/\u00a0/g, ' ')
444
+ : '';
445
+
446
+ const ul = document.createElement('ul');
447
+ ul.className = 'an-checklist';
448
+ const li = document.createElement('li');
449
+ const checkbox = document.createElement('input');
450
+ checkbox.type = 'checkbox';
451
+ checkbox.contentEditable = 'false';
452
+ li.appendChild(checkbox);
453
+ li.appendChild(document.createTextNode(itemText || '\u200B'));
454
+ ul.appendChild(li);
455
+
456
+ if (block && BLOCK_TAGS.has(block.tagName)) {
457
+ block.parentNode.replaceChild(ul, block);
458
+ } else {
459
+ document.execCommand('insertHTML', false, ul.outerHTML);
460
+ return;
461
+ }
462
+
463
+ // Move caret to the text node inside the new <li>
464
+ const textNode = li.lastChild;
465
+ const nr = document.createRange();
466
+ const offset = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent.length : 0;
467
+ nr.setStart(textNode, offset);
468
+ nr.collapse(true);
469
+ sel.removeAllRanges();
470
+ sel.addRange(nr);
471
+ return;
472
+ }
473
+
361
474
  const text = sel.toString();
362
475
  const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
363
476
  if (lines.length === 0) return;
@@ -59,13 +59,14 @@ export const boldBtn = btn('bold', 'bold', 'Bold (Ctrl+B)', () => Style.bold(),
59
59
  export const italicBtn = btn('italic', 'italic', 'Italic (Ctrl+I)', () => Style.italic(), () => document.queryCommandState('italic'));
60
60
  export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => {
61
61
  // queryCommandState('underline') is unreliable inside <code> elements;
62
- // also check for a <u> ancestor in the DOM.
62
+ // also check for a <u> ancestor in the DOM using startContainer for
63
+ // consistent behaviour across both collapsed and range selections.
63
64
  if (document.queryCommandState('underline')) return true;
64
65
  const sel = window.getSelection();
65
66
  if (!sel || !sel.rangeCount) return false;
66
- let container = sel.getRangeAt(0).commonAncestorContainer;
67
- if (container.nodeType === 3) container = container.parentElement;
68
- return !!(container && container.closest && container.closest('u'));
67
+ let sc = sel.getRangeAt(0).startContainer;
68
+ if (sc.nodeType === 3) sc = sc.parentElement;
69
+ return !!(sc && sc.closest && sc.closest('u'));
69
70
  });
70
71
  export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
71
72
  export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
@@ -299,9 +299,17 @@ export class Clipboard {
299
299
  return;
300
300
  }
301
301
 
302
+ // C2: Reject image formats that browsers cannot decode/display.
303
+ const UNSUPPORTED = ['image/tiff', 'image/x-tiff', 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp'];
302
304
  const maxBytes = (this.options.maxImageSize || 5) * 1024 * 1024;
303
305
  files.forEach((file) => {
304
306
  if (!file || !file.type.startsWith('image/')) return;
307
+ if (UNSUPPORTED.includes(file.type)) {
308
+ const message = `Image format "${file.type}" is not supported for display in web browsers. Please convert to PNG, JPEG, or WebP first.`;
309
+ this.context.triggerEvent('imageError', { file, message });
310
+ console.warn('[AutumnNote]', message);
311
+ return;
312
+ }
305
313
  if (file.size > maxBytes) {
306
314
  const message = `Image "${file.name}" exceeds the ${this.options.maxImageSize || 5} MB size limit.`;
307
315
  this.context.triggerEvent('imageError', { file, message });
@@ -39,6 +39,7 @@ export class CodeTooltip {
39
39
 
40
40
  this._disposers.push(
41
41
  on(editable, 'mouseover', (e) => {
42
+ if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
42
43
  const pre = e.target.closest('pre');
43
44
  if (pre && editable.contains(pre)) {
44
45
  this._scheduleShow(pre);
@@ -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
  // ---------------------------------------------------------------------------
@@ -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
  );