autumnnote 1.0.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
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",
@@ -60,8 +60,29 @@ export function underline() {
60
60
 
61
61
  /**
62
62
  * Strikethrough / removes strikethrough.
63
+ * Falls back to manual DOM manipulation inside nested formats where
64
+ * execCommand's state detection is unreliable (mirrors underline() logic).
63
65
  */
64
- export const strikethrough = () => execCommand('strikeThrough');
66
+ export function strikethrough() {
67
+ const sel = window.getSelection();
68
+ if (!sel || !sel.rangeCount) return;
69
+ // Use startContainer for consistent detection across collapsed and range
70
+ // selections — commonAncestorContainer can miss ancestor <s>/<strike> tags
71
+ // when the selection spans across nested inline elements.
72
+ let sc = sel.getRangeAt(0).startContainer;
73
+ if (sc.nodeType === 3) sc = sc.parentElement;
74
+ const sEl = sc && sc.closest && (sc.closest('s') || sc.closest('strike'));
75
+ const nativeState = document.queryCommandState('strikeThrough');
76
+ if (sEl && !nativeState) {
77
+ // Browser doesn’t recognise the strikethrough state (e.g. inside <code>
78
+ // or deeply nested inline formats). Manually unwrap the <s>/<strike>.
79
+ const parent = sEl.parentNode;
80
+ while (sEl.firstChild) parent.insertBefore(sEl.firstChild, sEl);
81
+ parent.removeChild(sEl);
82
+ return;
83
+ }
84
+ execCommand('strikeThrough');
85
+ }
65
86
 
66
87
  /**
67
88
  * Superscript toggle.
@@ -101,8 +122,35 @@ export function fontSize(size, editable = document) {
101
122
  const sel = window.getSelection();
102
123
  const wasCollapsed = !sel || !sel.rangeCount || sel.getRangeAt(0).collapsed;
103
124
 
104
- execCommand('fontSize', '7'); // placeholder
105
- // Replace font elements with spans, scoped to the active editable
125
+ // B-I-3/4: For a collapsed (caret) selection the browser's execCommand
126
+ // 'fontSize' leaves an internal "pending" state of size-7 (=48px) instead of
127
+ // creating a <font> element, so the very next typed character comes out at
128
+ // 48px. Fix: bypass execCommand entirely for collapsed selections and directly
129
+ // insert a span with the requested size, placing the cursor inside it.
130
+ // Only applies when there IS an active selection (sel.rangeCount > 0); when
131
+ // there is no selection at all (e.g. jsdom unit tests) fall through to the
132
+ // execCommand path so the font-replacement logic still runs.
133
+ if (wasCollapsed && sel && sel.rangeCount > 0) {
134
+ try {
135
+ const range = sel.getRangeAt(0);
136
+ const span = document.createElement('span');
137
+ span.style.fontSize = size;
138
+ const zwsNode = document.createTextNode('\u200B');
139
+ span.appendChild(zwsNode);
140
+ range.insertNode(span);
141
+ const nr = document.createRange();
142
+ nr.setStart(zwsNode, zwsNode.textContent.length);
143
+ nr.collapse(true);
144
+ sel.removeAllRanges();
145
+ sel.addRange(nr);
146
+ } catch (_) { /* ignore range errors on unusual DOM structures */ }
147
+ return;
148
+ }
149
+
150
+ // Non-collapsed selection (or no selection — handles jsdom test setup where
151
+ // <font size="7"> elements are injected directly without a live selection):
152
+ // use execCommand placeholder approach then replace <font> with <span>.
153
+ execCommand('fontSize', '7');
106
154
  const scope = editable instanceof HTMLElement ? editable : document;
107
155
  const newSpans = [];
108
156
  scope.querySelectorAll('font[size="7"]').forEach((el) => {
@@ -114,36 +162,20 @@ export function fontSize(size, editable = document) {
114
162
  newSpans.push(span);
115
163
  });
116
164
 
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) {
165
+ // Re-select all replaced content so toolbar getValue() reads the new size
166
+ // (B-I-1/2: without this re-selection the toolbar dropdown stays on the old
167
+ // value until the next selectionchange event).
168
+ if (!wasCollapsed && sel && newSpans.length > 0) {
123
169
  const first = newSpans[0];
124
170
  const last = newSpans[newSpans.length - 1];
125
171
  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
- }
172
+ const nr = document.createRange();
173
+ const startNode = first.firstChild || first;
174
+ const endNode = last.lastChild || last;
175
+ nr.setStart(startNode, 0);
176
+ nr.setEnd(endNode, endNode.nodeType === Node.TEXT_NODE ? endNode.textContent.length : endNode.childNodes.length);
177
+ sel.removeAllRanges();
178
+ sel.addRange(nr);
147
179
  } catch (_) { /* ignore range errors on unusual DOM structures */ }
148
180
  }
149
181
  }
@@ -185,8 +217,69 @@ export const indent = () => execCommand('indent');
185
217
 
186
218
  /**
187
219
  * Outdents the list or block.
220
+ * G.5: When cursor is inside a checklist item, "outdent" means converting
221
+ * that item back to a regular <p> element rather than calling execCommand
222
+ * (which would destroy the ul > li checklist structure).
223
+ */
224
+ export function outdent() {
225
+ const sel = window.getSelection();
226
+ if (sel && sel.rangeCount) {
227
+ let container = sel.getRangeAt(0).commonAncestorContainer;
228
+ if (container.nodeType === 3) container = container.parentElement;
229
+ const checkLi = container && container.closest && container.closest('.an-checklist li');
230
+ if (checkLi) {
231
+ _checklistItemToP(checkLi);
232
+ return;
233
+ }
234
+ }
235
+ execCommand('outdent');
236
+ }
237
+
238
+ /**
239
+ * G.5 helper: splits a checklist at checkLi, converts it to a <p>,
240
+ * and keeps items before/after as separate checklists.
241
+ * @param {HTMLElement} checkLi
188
242
  */
189
- export const outdent = () => execCommand('outdent');
243
+ function _checklistItemToP(checkLi) {
244
+ const checkUl = checkLi.closest('.an-checklist');
245
+ if (!checkUl) return;
246
+
247
+ const allLis = Array.from(checkUl.children);
248
+ const liIndex = allLis.indexOf(checkLi);
249
+ const afterLis = allLis.slice(liIndex + 1);
250
+
251
+ // Build <p> from the item's text (skip the checkbox INPUT)
252
+ const p = document.createElement('p');
253
+ const text = Array.from(checkLi.childNodes)
254
+ .filter(n => !(n.nodeType === 1 && n.tagName === 'INPUT'))
255
+ .map(n => n.textContent).join('').replace(/\u200B/g, '').trim();
256
+ p.textContent = text || '\u00a0';
257
+
258
+ // Move items after the current li into a new checklist
259
+ if (afterLis.length > 0) {
260
+ const newUl = document.createElement('ul');
261
+ newUl.className = 'an-checklist';
262
+ afterLis.forEach(li => newUl.appendChild(li));
263
+ checkUl.parentNode.insertBefore(newUl, checkUl.nextSibling);
264
+ }
265
+
266
+ // Insert <p> after checkUl (before any newUl)
267
+ checkUl.parentNode.insertBefore(p, checkUl.nextSibling);
268
+
269
+ // Remove current li from checkUl; delete checkUl if now empty
270
+ checkUl.removeChild(checkLi);
271
+ if (checkUl.children.length === 0) checkUl.parentNode.removeChild(checkUl);
272
+
273
+ // Place caret at start of the new <p>
274
+ try {
275
+ const nr = document.createRange();
276
+ const firstChild = p.firstChild;
277
+ nr.setStart(firstChild && firstChild.nodeType === 3 ? firstChild : p, 0);
278
+ nr.collapse(true);
279
+ const s = window.getSelection();
280
+ if (s) { s.removeAllRanges(); s.addRange(nr); }
281
+ } catch {}
282
+ }
190
283
 
191
284
  /**
192
285
  * Inserts an unordered list or converts selection.
@@ -471,20 +564,78 @@ export function toggleChecklist() {
471
564
  return;
472
565
  }
473
566
 
474
- const text = sel.toString();
475
- const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
476
- if (lines.length === 0) return;
477
- const items = lines
478
- .map(
479
- (l) =>
480
- `<li><input type="checkbox" contenteditable="false">${l || '\u200B'}</li>`,
481
- )
482
- .join('');
483
- document.execCommand(
484
- 'insertHTML',
485
- false,
486
- `<ul class="an-checklist">${items}</ul>`,
567
+ // G-4: Non-collapsed, non-checklist selection — convert each intersected
568
+ // block element into a checklist item using direct DOM manipulation.
569
+ // execCommand('insertHTML') is avoided here because in modern browsers it
570
+ // deletes the selection but may silently fail to insert when the selection
571
+ // spans multiple block elements, causing text to disappear.
572
+
573
+ // Guard: if the raw selection is entirely whitespace, do nothing (mirrors
574
+ // the old line-filter behaviour that prevented empty checklist creation).
575
+ const rawSelText = sel.toString().replace(/[\u00a0\u200B]/g, ' ').trim();
576
+ if (!rawSelText) return;
577
+
578
+ const BLOCK_TAGS_MULTI = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'LI']);
579
+
580
+ // Collect block-level ancestors of every node in the selection, in order.
581
+ const blocks = [];
582
+ const seenBlocks = new Set();
583
+ const commonAncestor = range.commonAncestorContainer;
584
+ const iter = document.createNodeIterator(
585
+ commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentNode : commonAncestor,
586
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
587
+ null,
487
588
  );
589
+ let node;
590
+ while ((node = iter.nextNode())) {
591
+ if (!range.intersectsNode(node)) continue;
592
+ let block = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
593
+ while (block && !BLOCK_TAGS_MULTI.has(block.tagName)) {
594
+ block = block.parentElement;
595
+ }
596
+ if (block && !seenBlocks.has(block)) {
597
+ seenBlocks.add(block);
598
+ blocks.push(block);
599
+ }
600
+ }
601
+
602
+ if (blocks.length === 0) return;
603
+
604
+ // Build checklist and replace collected blocks.
605
+ const newUl = document.createElement('ul');
606
+ newUl.className = 'an-checklist';
607
+ let lastTextNode = null;
608
+ blocks.forEach((block) => {
609
+ const li = document.createElement('li');
610
+ const cb = document.createElement('input');
611
+ cb.type = 'checkbox';
612
+ cb.setAttribute('contenteditable', 'false');
613
+ li.appendChild(cb);
614
+ // Preserve plain text content; ZWS/NBSP are stripped for display.
615
+ const blockText = Array.from(block.childNodes)
616
+ .map((n) => n.textContent)
617
+ .join('')
618
+ .replace(/[\u00a0\u200B]/g, ' ')
619
+ .trim();
620
+ const tn = document.createTextNode(blockText || '\u200B');
621
+ li.appendChild(tn);
622
+ newUl.appendChild(li);
623
+ lastTextNode = tn;
624
+ });
625
+
626
+ // Insert the new list before the first block, then remove all source blocks.
627
+ const firstBlock = blocks[0];
628
+ firstBlock.parentNode.insertBefore(newUl, firstBlock);
629
+ blocks.forEach((block) => block.parentNode && block.parentNode.removeChild(block));
630
+
631
+ // Move caret to end of the last checklist item.
632
+ if (lastTextNode) {
633
+ const nr = document.createRange();
634
+ nr.setStart(lastTextNode, lastTextNode.textContent.length);
635
+ nr.collapse(true);
636
+ sel.removeAllRanges();
637
+ sel.addRange(nr);
638
+ }
488
639
  }
489
640
 
490
641
  /**
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { key, isKey } from '../core/key.js';
7
7
  import { closestPara, isLi } from '../core/dom.js';
8
- import { execCommand } from './Style.js';
8
+ import { execCommand, outdent } from './Style.js';
9
9
  import { currentRange } from '../core/range.js';
10
10
 
11
11
  // ---------------------------------------------------------------------------
@@ -199,7 +199,7 @@ export function handleKeydown(event, editable, options = {}) {
199
199
  if (para && isLi(para)) {
200
200
  event.preventDefault();
201
201
  if (event.shiftKey) {
202
- execCommand('outdent');
202
+ outdent();
203
203
  } else {
204
204
  execCommand('indent');
205
205
  }
@@ -153,8 +153,19 @@ export class Editor {
153
153
  on(editable, 'click', onCheckboxClick),
154
154
  on(editable, 'mouseup', fixChecklistCursor),
155
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(); }),
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
+ }),
158
169
  on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
159
170
  );
160
171
 
@@ -370,7 +381,7 @@ export class Editor {
370
381
  * @param {string} html - HTML string (will be sanitised)
371
382
  */
372
383
  setHTML(html) {
373
- this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html);
384
+ this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html, { allowIframes: true });
374
385
  if (this._history) this._history.reset();
375
386
  this.afterCommand();
376
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.
@@ -118,7 +118,7 @@ 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
124
  // Hint line shown below the file input for format errors
@@ -159,10 +159,14 @@ export class ImageDialog {
159
159
  const file = this._fileInput && this._fileInput.files && this._fileInput.files[0];
160
160
  if (!file || !file.type.startsWith('image/')) return;
161
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.`;
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.`;
166
170
  if (this._fileHint) this._fileHint.textContent = message;
167
171
  this.context.triggerEvent('imageError', { file, message });
168
172
  this._fileInput.value = '';
@@ -39,7 +39,12 @@ 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() &&
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 &&
43
48
  !editable.querySelector('img, table, hr, .an-video-wrapper');
44
49
  editable.classList.toggle('an-placeholder', isEmpty && !isFocused);
45
50
  }