autumnnote 1.8.0 → 1.8.2

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.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "WYSIWYG rich-text editor built with vanilla JavaScript — zero dependencies, no jQuery. Dark mode, @mention, markdown shortcuts, bubble toolbar. React and Vue 3 wrappers included.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
@@ -78,7 +78,7 @@
78
78
  "rtl",
79
79
  "autosave"
80
80
  ],
81
- "homepage": "https://cmm-cmm.github.io/Autumn-Note/",
81
+ "homepage": "https://autumn.konexforge.com/",
82
82
  "repository": {
83
83
  "type": "git",
84
84
  "url": "git+https://github.com/cmm-cmm/Autumn-Note.git"
@@ -89,7 +89,16 @@ function _domToMd(node, depth = 0) {
89
89
  const items = Array.from(el.querySelectorAll(':scope > li'));
90
90
  if (!items.length) return inner();
91
91
  const indent = ' '.repeat(depth);
92
- const lines = items.map((li) => `${indent}- ${_domToMd(li, depth + 1).trim()}`).join('\n');
92
+ const isChecklist = el.classList.contains('an-checklist');
93
+ const lines = items.map((li) => {
94
+ let prefix = '- ';
95
+ if (isChecklist) {
96
+ const cb = /** @type {HTMLInputElement | null} */ (li.querySelector('input[type="checkbox"]'));
97
+ const checked = cb ? cb.checked : false;
98
+ prefix = checked ? '- [x] ' : '- [ ] ';
99
+ }
100
+ return `${indent}${prefix}${_domToMd(li, depth + 1).trim()}`;
101
+ }).join('\n');
93
102
  return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
94
103
  }
95
104
  case 'ol': {
@@ -130,7 +139,7 @@ function _domToMd(node, depth = 0) {
130
139
  * @returns {boolean} `true` if any Markdown-like pattern is present, `false` otherwise.
131
140
  */
132
141
  export function isMarkdown(text) {
133
- return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S|^```|^\*{2}.+?\*{2}/m.test(text);
142
+ return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> [^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text);
134
143
  }
135
144
 
136
145
  /**
@@ -189,14 +198,31 @@ export function markdownToHTML(text) {
189
198
  continue;
190
199
  }
191
200
 
192
- // ---- Unordered list - / * / + item ------------------------------------
201
+ // ---- Checklist or Unordered list - / * / + item ----------------------
193
202
  if (/^[-*+] /.test(line)) {
194
203
  const items = [];
204
+ const isChecklist = /^[-*+]\s+\[[ xX]\]\s+/.test(line);
205
+ const listTag = isChecklist ? 'ul class="an-checklist"' : 'ul';
195
206
  while (i < lines.length && /^[-*+] /.test(lines[i])) {
196
- items.push(`<li>${_inline(lines[i].slice(2))}</li>`);
207
+ const nextLineIsChecklist = /^[-*+]\s+\[[ xX]\]\s+/.test(lines[i]);
208
+ if (nextLineIsChecklist !== isChecklist) {
209
+ break;
210
+ }
211
+ const itemLine = lines[i];
212
+ const content = itemLine.slice(2);
213
+ if (isChecklist) {
214
+ const cbMatch = /^\[([ xX])\][ \t]+/.exec(content);
215
+ const checked = cbMatch?.[1]?.toLowerCase() === 'x';
216
+ const checkedAttr = checked ? ' checked' : '';
217
+ const cbHtml = `<input type="checkbox" contenteditable="false"${checkedAttr}>`;
218
+ const textContent = cbMatch ? content.slice(cbMatch[0].length) : content;
219
+ items.push(`<li>${cbHtml}${_inline(textContent)}</li>`);
220
+ } else {
221
+ items.push(`<li>${_inline(content)}</li>`);
222
+ }
197
223
  i++;
198
224
  }
199
- out.push(`<ul>${items.join('')}</ul>`);
225
+ out.push(`<${listTag}>${items.join('')}</${listTag.split(' ')[0]}>`);
200
226
  continue;
201
227
  }
202
228
 
@@ -290,7 +316,7 @@ function _inline(text) {
290
316
  text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${_esc(c)}</em>`);
291
317
  text = text.replace(/_([^_\n]+?)_/g, (_, c) => `<em>${_esc(c)}</em>`);
292
318
  // Strikethrough ~~text~~
293
- text = text.replace(/~~([^\n]+?)~~/g, (_, c) => `<del>${_esc(c)}</del>`);
319
+ text = text.replace(/~~([^~\n]+?)~~/g, (_, c) => `<del>${_esc(c)}</del>`);
294
320
  // Inline code `code`
295
321
  text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${_esc(c)}</code>`);
296
322
  return text;
@@ -294,14 +294,99 @@ function _checklistItemToP(checkLi) {
294
294
  }
295
295
 
296
296
  /**
297
- * Inserts an unordered list or converts selection.
297
+ * Inserts an unordered (bulleted) list, or converts the current list to `<ul>`.
298
+ *
299
+ * When the cursor is already inside a list, direct DOM manipulation is used to
300
+ * transition between list types — `execCommand` alone cannot handle checklist →
301
+ * UL/OL conversions because it has no awareness of the `an-checklist` class or
302
+ * the checkbox `<input>` elements.
303
+ *
304
+ * Transition paths:
305
+ * - **Checklist → UL**: strips `an-checklist` class and all checkbox inputs;
306
+ * converts `<ol>` container to `<ul>` via `changeTagName()` if needed.
307
+ * - **OL → UL**: swaps the container tag via `changeTagName()`.
308
+ * - **UL → paragraphs**: falls back to `execCommand('insertUnorderedList')`
309
+ * which toggles the list off (browser-native behaviour).
310
+ * - **No list → UL**: falls back to `execCommand('insertUnorderedList')`.
311
+ */
312
+ /**
313
+ * Helper to get the closest ul/ol element containing the current selection.
314
+ * @returns {Element|null}
315
+ */
316
+ function getSelectedList() {
317
+ const sel = globalThis.getSelection();
318
+ if (!sel?.rangeCount) return null;
319
+ let container = sel.getRangeAt(0).commonAncestorContainer;
320
+ if (container.nodeType === 3) container = container.parentElement;
321
+ return /** @type {Element|null} */ (container)?.closest('ul, ol') || null;
322
+ }
323
+
324
+ /**
325
+ * Strips the checklist class and checkbox inputs from a list element.
326
+ * @param {Element} listEl
298
327
  */
299
- export const insertUnorderedList = () => execCommand('insertUnorderedList');
328
+ function stripChecklist(listEl) {
329
+ listEl.classList.remove('an-checklist');
330
+ listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.remove());
331
+ }
332
+
333
+ export function insertUnorderedList() {
334
+ const listEl = getSelectedList();
335
+ if (listEl) {
336
+ if (listEl.classList.contains('an-checklist')) {
337
+ // Checklist → UL: strip checkboxes and class, swap tag if needed
338
+ stripChecklist(listEl);
339
+ if (listEl.tagName === 'OL') {
340
+ changeTagName(listEl, 'ul');
341
+ }
342
+ } else if (listEl.tagName === 'OL') {
343
+ // OL → UL: swap container tag
344
+ changeTagName(listEl, 'ul');
345
+ } else {
346
+ // Already UL → toggle off via execCommand
347
+ execCommand('insertUnorderedList');
348
+ }
349
+ } else {
350
+ // Not in a list → create new UL via execCommand
351
+ execCommand('insertUnorderedList');
352
+ }
353
+ }
300
354
 
301
355
  /**
302
- * Inserts an ordered list or converts selection.
356
+ * Inserts an ordered (numbered) list, or converts the current list to `<ol>`.
357
+ *
358
+ * When the cursor is already inside a list, direct DOM manipulation is used to
359
+ * transition between list types — `execCommand` alone cannot handle checklist →
360
+ * UL/OL conversions because it has no awareness of the `an-checklist` class or
361
+ * the checkbox `<input>` elements.
362
+ *
363
+ * Transition paths:
364
+ * - **Checklist → OL**: strips `an-checklist` class and all checkbox inputs;
365
+ * converts container to `<ol>` via `changeTagName()`.
366
+ * - **UL → OL**: swaps the container tag via `changeTagName()`.
367
+ * - **OL → paragraphs**: falls back to `execCommand('insertOrderedList')`
368
+ * which toggles the list off (browser-native behaviour).
369
+ * - **No list → OL**: falls back to `execCommand('insertOrderedList')`.
303
370
  */
304
- export const insertOrderedList = () => execCommand('insertOrderedList');
371
+ export function insertOrderedList() {
372
+ const listEl = getSelectedList();
373
+ if (listEl) {
374
+ if (listEl.classList.contains('an-checklist')) {
375
+ // Checklist → OL: strip checkboxes and class, swap to <ol>
376
+ stripChecklist(listEl);
377
+ changeTagName(listEl, 'ol');
378
+ } else if (listEl.tagName === 'UL') {
379
+ // UL → OL: swap container tag
380
+ changeTagName(listEl, 'ol');
381
+ } else {
382
+ // Already OL → toggle off via execCommand
383
+ execCommand('insertOrderedList');
384
+ }
385
+ } else {
386
+ // Not in a list → create new OL via execCommand
387
+ execCommand('insertOrderedList');
388
+ }
389
+ }
305
390
 
306
391
  // ---------------------------------------------------------------------------
307
392
  // Line-height helper
@@ -490,19 +575,42 @@ export function isInlineCode() {
490
575
  // Checklist (task list)
491
576
  // ---------------------------------------------------------------------------
492
577
 
578
+ /**
579
+ * Changes the tag name of an element in the DOM while preserving attributes and children.
580
+ * @param {Element} el
581
+ * @param {string} newTagName
582
+ * @returns {HTMLElement}
583
+ */
584
+ function changeTagName(el, newTagName) {
585
+ const newEl = document.createElement(newTagName);
586
+ for (const attr of el.attributes) {
587
+ newEl.setAttribute(attr.name, attr.value);
588
+ }
589
+ while (el.firstChild) {
590
+ newEl.appendChild(el.firstChild);
591
+ }
592
+ el.parentNode.replaceChild(newEl, el);
593
+ return newEl;
594
+ }
595
+
596
+ /**
597
+ * Ensures all list items under the list element have a checkbox.
598
+ * @param {Element} listEl
599
+ */
600
+ function ensureCheckboxes(listEl) {
601
+ listEl.querySelectorAll('li').forEach(li => {
602
+ const existingCb = li.querySelector('input[type="checkbox"]');
603
+ if (!existingCb) {
604
+ const cb = document.createElement('input');
605
+ cb.type = 'checkbox';
606
+ cb.contentEditable = 'false';
607
+ li.insertBefore(cb, li.firstChild);
608
+ }
609
+ });
610
+ }
611
+
493
612
  /**
494
613
  * Toggle a checklist at the current selection or caret.
495
- *
496
- * When the selection is inside an existing checklist `<ul class="an-checklist">`,
497
- * converts the selected `<li>` items back into `<p>` paragraphs and places the caret
498
- * at the start of the first converted paragraph. Otherwise creates a checklist:
499
- * - If the selection is collapsed, converts the nearest block-level ancestor (or inserts
500
- * a single checklist item at the editable root) into a checklist with one item containing
501
- * that block's text and places the caret inside the new item.
502
- * - If the selection is a range, converts each intersecting block element into one checklist
503
- * item (preserving textual content) and places the caret at the end of the last item.
504
- *
505
- * Empty or whitespace-only selections do not create a checklist.
506
614
  */
507
615
  export function toggleChecklist() {
508
616
  const sel = globalThis.getSelection();
@@ -511,162 +619,183 @@ export function toggleChecklist() {
511
619
  let container = range.commonAncestorContainer;
512
620
  if (container.nodeType === 3) container = container.parentElement;
513
621
 
514
- const ul = /** @type {Element|null} */ (container)?.closest('.an-checklist');
515
- if (ul) {
516
- // If selection covers multiple <li>, convert them all
517
- const selectedLis = Array.from(ul.querySelectorAll('li')).filter((li) =>
518
- sel.containsNode(li, true),
519
- );
520
- if (selectedLis.length > 0) {
521
- /** @type {HTMLElement|null} */ let firstP = null;
522
- selectedLis.forEach((li) => {
523
- const p = document.createElement('p');
524
- for (const child of li.childNodes) {
525
- if (child.nodeType === 1 && /** @type {Element} */ (child).tagName === 'INPUT') continue;
526
- p.appendChild(child.cloneNode(true));
527
- }
528
- p.innerHTML = p.innerHTML.replaceAll('\u200b', '');
529
- if (!p.hasChildNodes() || !p.textContent.trim()) {
530
- p.innerHTML = '';
531
- p.appendChild(document.createTextNode('\u00a0'));
622
+ const listEl = /** @type {Element|null} */ (container)?.closest('ul, ol');
623
+ if (listEl) {
624
+ if (listEl.classList.contains('an-checklist')) {
625
+ // Transition from Checklist to Paragraphs (Toggle off checklist entirely)
626
+ const parent = listEl.parentNode;
627
+ if (parent) {
628
+ const lis = Array.from(listEl.children);
629
+ let /** @type {HTMLParagraphElement|null} */ firstP = null;
630
+ lis.forEach(li => {
631
+ const p = document.createElement('p');
632
+ for (const child of li.childNodes) {
633
+ if (child.nodeType === 1 && /** @type {Element} */ (child).tagName === 'INPUT') continue;
634
+ p.appendChild(child.cloneNode(true));
635
+ }
636
+ p.innerHTML = p.innerHTML.replaceAll('\u200b', '').replaceAll('\u200B', '');
637
+ if (!p.hasChildNodes() || !p.textContent.trim()) {
638
+ p.innerHTML = '';
639
+ p.appendChild(document.createTextNode('\u00a0'));
640
+ }
641
+ listEl.before(p);
642
+ if (!firstP) firstP = p;
643
+ });
644
+ listEl.remove();
645
+
646
+ if (firstP) {
647
+ const nr = document.createRange();
648
+ nr.setStart(firstP.firstChild || firstP, 0);
649
+ nr.collapse(true);
650
+ sel.removeAllRanges();
651
+ sel.addRange(nr);
532
652
  }
533
- ul.parentNode.insertBefore(p, ul);
534
- if (!firstP) firstP = p;
535
- li.remove();
536
- });
537
- if (ul.children.length === 0) ul.remove();
538
- // Move caret to first converted paragraph
539
- if (firstP) {
653
+ }
654
+ } else {
655
+ // Transition from standard UL/OL to Checklist
656
+ const targetUl = changeTagName(listEl, 'ul');
657
+ targetUl.classList.add('an-checklist');
658
+ ensureCheckboxes(targetUl);
659
+
660
+ // Place caret inside the first LI
661
+ const firstLi = targetUl.querySelector('li');
662
+ if (firstLi) {
540
663
  const nr = document.createRange();
541
- nr.setStart(firstP.firstChild || firstP, 0);
542
- nr.collapse(true);
664
+ nr.selectNodeContents(firstLi);
665
+ nr.collapse(false);
543
666
  sel.removeAllRanges();
544
667
  sel.addRange(nr);
545
668
  }
546
- return;
547
669
  }
548
- }
670
+ } else {
671
+ // Selection is not in a list: build the checklist directly via DOM
672
+ // manipulation. execCommand('insertUnorderedList') is intentionally
673
+ // avoided here — its behaviour on collapsed/empty selections and
674
+ // non-standard blocks (e.g. <section>) is too inconsistent across
675
+ // browsers (and a no-op in jsdom), which left toggleChecklist() as a
676
+ // silent no-op in those cases.
677
+ // The editable root itself is a <div> and must never be treated as a
678
+ // "block" to convert/replace/remove — otherwise selections that include
679
+ // raw text nodes sitting directly inside it (e.g. the first line typed
680
+ // into an empty editor) would destroy the .an-editable element.
681
+ const editableRoot = /** @type {Element|null} */ (container)?.closest('[contenteditable="true"]');
682
+ const isCollapsed = range.collapsed;
683
+ if (isCollapsed) {
684
+ // Find the nearest block-level ancestor (p, div, li, h1-h6, blockquote,
685
+ // etc.) and convert it into a single checklist item.
686
+ const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI']);
687
+ let block = /** @type {Element|null} */ (container);
688
+ while (block?.parentNode && block !== editableRoot && !BLOCK_TAGS.has(block.tagName)) {
689
+ block = /** @type {Element|null} */ (block.parentNode);
690
+ }
691
+ if (block === editableRoot) block = null;
692
+ // Fallback: if no block element found (e.g. cursor directly in editable
693
+ // root), insert a fresh item with a zero-width-space so the cursor ends
694
+ // up inside it.
695
+ const itemText = (block && BLOCK_TAGS.has(block.tagName))
696
+ ? Array.from(block.childNodes)
697
+ .map((n) => n.textContent)
698
+ .join('')
699
+ .replaceAll('\u00a0', ' ')
700
+ : '';
701
+
702
+ const newUl = document.createElement('ul');
703
+ newUl.className = 'an-checklist';
704
+ const li = document.createElement('li');
705
+ const checkbox = document.createElement('input');
706
+ checkbox.type = 'checkbox';
707
+ checkbox.contentEditable = 'false';
708
+ li.appendChild(checkbox);
709
+ li.appendChild(document.createTextNode(itemText || '\u200B'));
710
+ newUl.appendChild(li);
711
+
712
+ if (block && BLOCK_TAGS.has(block.tagName)) {
713
+ block.parentNode.replaceChild(newUl, block);
714
+ } else {
715
+ // Cursor directly in editable root — insert via Range API.
716
+ const nativeRange = sel.getRangeAt(0);
717
+ nativeRange.deleteContents();
718
+ nativeRange.insertNode(newUl);
719
+ }
549
720
 
550
- // Otherwise: insert new checklist from selected text (or current block when collapsed).
551
- const isCollapsed = range.collapsed;
552
- if (isCollapsed) {
553
- // Find the nearest block-level ancestor (p, div, li, h1-h6, blockquote, etc.)
554
- // and convert it into a single checklist item.
555
- const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI']);
556
- let block = /** @type {Element|null} */ (container);
557
- while (block?.parentNode && !BLOCK_TAGS.has(block.tagName)) {
558
- block = /** @type {Element|null} */ (block.parentNode);
559
- }
560
- // Fallback: if no block element found (e.g. cursor directly in editable root), use the
561
- // insertion approach with a zero-width-space item so the cursor ends up inside.
562
- const itemText = (block && BLOCK_TAGS.has(block.tagName))
563
- ? Array.from(block.childNodes)
564
- .map((n) => n.textContent)
565
- .join('')
566
- .replaceAll('\u00a0', ' ')
567
- : '';
568
-
569
- const ul = document.createElement('ul');
570
- ul.className = 'an-checklist';
571
- const li = document.createElement('li');
572
- const checkbox = document.createElement('input');
573
- checkbox.type = 'checkbox';
574
- checkbox.contentEditable = 'false';
575
- li.appendChild(checkbox);
576
- li.appendChild(document.createTextNode(itemText || '\u200B'));
577
- ul.appendChild(li);
578
-
579
- if (block && BLOCK_TAGS.has(block.tagName)) {
580
- block.parentNode.replaceChild(ul, block);
581
- } else {
582
- // Cursor directly in editable root — insert via Range API
583
- const nativeRange = sel.getRangeAt(0);
584
- nativeRange.deleteContents();
585
- nativeRange.insertNode(ul);
721
+ // Move caret to the text node inside the new <li>.
722
+ const textNode = li.lastChild;
723
+ const nr = document.createRange();
724
+ const offset = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent.length : 0;
725
+ nr.setStart(textNode, offset);
726
+ nr.collapse(true);
727
+ sel.removeAllRanges();
728
+ sel.addRange(nr);
729
+ return;
586
730
  }
587
731
 
588
- // Move caret to the text node inside the new <li>
589
- const textNode = li.lastChild;
590
- const nr = document.createRange();
591
- const offset = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent.length : 0;
592
- nr.setStart(textNode, offset);
593
- nr.collapse(true);
594
- sel.removeAllRanges();
595
- sel.addRange(nr);
596
- return;
597
- }
598
-
599
- // G-4: Non-collapsed, non-checklist selection — convert each intersected
600
- // block element into a checklist item using direct DOM manipulation.
601
- // execCommand('insertHTML') is avoided here because in modern browsers it
602
- // deletes the selection but may silently fail to insert when the selection
603
- // spans multiple block elements, causing text to disappear.
604
-
605
- // Guard: if the raw selection is entirely whitespace, do nothing (mirrors
606
- // the old line-filter behaviour that prevented empty checklist creation).
607
- const rawSelText = sel.toString().replace(/[\u00a0\u200B]/g, ' ').trim();
608
- if (!rawSelText) return;
609
-
610
- const BLOCK_TAGS_MULTI = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'LI']);
611
-
612
- // Collect block-level ancestors of every node in the selection, in order.
613
- const blocks = [];
614
- const seenBlocks = new Set();
615
- const commonAncestor = range.commonAncestorContainer;
616
- const iter = document.createNodeIterator(
617
- commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentNode : commonAncestor,
618
- NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
619
- null,
620
- );
621
- let node;
622
- while ((node = iter.nextNode())) {
623
- if (!range.intersectsNode(node)) continue;
624
- let block = /** @type {Element|null} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node);
625
- while (block && !BLOCK_TAGS_MULTI.has(block.tagName)) {
626
- block = block.parentElement;
627
- }
628
- if (block && !seenBlocks.has(block)) {
629
- seenBlocks.add(block);
630
- blocks.push(block);
732
+ // Non-collapsed selection convert each intersected block element into
733
+ // a checklist item using direct DOM manipulation.
734
+ const rawSelText = sel.toString().replace(/[\u00a0\u200B]/g, ' ').trim();
735
+ if (!rawSelText) return;
736
+
737
+ const BLOCK_TAGS_MULTI = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'LI']);
738
+
739
+ // Collect block-level ancestors of every node in the selection, in order.
740
+ const blocks = [];
741
+ const seenBlocks = new Set();
742
+ const commonAncestor = range.commonAncestorContainer;
743
+ const iter = document.createNodeIterator(
744
+ commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentNode : commonAncestor,
745
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
746
+ null,
747
+ );
748
+ let node;
749
+ while ((node = iter.nextNode())) {
750
+ if (!range.intersectsNode(node)) continue;
751
+ let blockEl = /** @type {Element|null} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node);
752
+ while (blockEl && blockEl !== editableRoot && !BLOCK_TAGS_MULTI.has(blockEl.tagName)) {
753
+ blockEl = blockEl.parentElement;
754
+ }
755
+ if (blockEl === editableRoot) blockEl = null;
756
+ if (blockEl && !seenBlocks.has(blockEl)) {
757
+ seenBlocks.add(blockEl);
758
+ blocks.push(blockEl);
759
+ }
631
760
  }
632
- }
633
-
634
- if (blocks.length === 0) return;
635
-
636
- // Build checklist and replace collected blocks.
637
- const newUl = document.createElement('ul');
638
- newUl.className = 'an-checklist';
639
- /** @type {Text|null} */ let lastTextNode = null;
640
- blocks.forEach((block) => {
641
- const li = document.createElement('li');
642
- const cb = document.createElement('input');
643
- cb.type = 'checkbox';
644
- cb.setAttribute('contenteditable', 'false');
645
- li.appendChild(cb);
646
- // Preserve plain text content; ZWS/NBSP are stripped for display.
647
- const blockText = Array.from(block.childNodes)
648
- .map((n) => n.textContent)
649
- .join('')
650
- .replace(/[\u00a0\u200B]/g, ' ')
651
- .trim();
652
- const tn = document.createTextNode(blockText || '\u200B');
653
- li.appendChild(tn);
654
- newUl.appendChild(li);
655
- lastTextNode = tn;
656
- });
657
761
 
658
- // Insert the new list before the first block, then remove all source blocks.
659
- const firstBlock = blocks[0];
660
- firstBlock.parentNode.insertBefore(newUl, firstBlock);
661
- blocks.forEach((block) => block.remove());
762
+ if (blocks.length === 0) return;
662
763
 
663
- // Move caret to end of the last checklist item.
664
- if (lastTextNode) {
665
- const nr = document.createRange();
666
- nr.setStart(lastTextNode, lastTextNode.textContent.length);
667
- nr.collapse(true);
668
- sel.removeAllRanges();
669
- sel.addRange(nr);
764
+ // Build checklist and replace collected blocks.
765
+ const newUl = document.createElement('ul');
766
+ newUl.className = 'an-checklist';
767
+ /** @type {Text|null} */ let lastTextNode = null;
768
+ blocks.forEach((block) => {
769
+ const li = document.createElement('li');
770
+ const cb = document.createElement('input');
771
+ cb.type = 'checkbox';
772
+ cb.contentEditable = 'false';
773
+ li.appendChild(cb);
774
+ // Preserve plain text content; ZWS/NBSP are stripped for display.
775
+ const blockText = Array.from(block.childNodes)
776
+ .map((n) => n.textContent)
777
+ .join('')
778
+ .replace(/[\u00a0\u200B]/g, ' ')
779
+ .trim();
780
+ const tn = document.createTextNode(blockText || '\u200B');
781
+ li.appendChild(tn);
782
+ newUl.appendChild(li);
783
+ lastTextNode = tn;
784
+ });
785
+
786
+ // Insert the new list before the first block, then remove all source blocks.
787
+ const firstBlock = blocks[0];
788
+ firstBlock.parentNode.insertBefore(newUl, firstBlock);
789
+ blocks.forEach((block) => block.remove());
790
+
791
+ // Move caret to end of the last checklist item.
792
+ if (lastTextNode) {
793
+ const nr = document.createRange();
794
+ nr.setStart(lastTextNode, lastTextNode.textContent.length);
795
+ nr.collapse(true);
796
+ sel.removeAllRanges();
797
+ sel.addRange(nr);
798
+ }
670
799
  }
671
800
  }
672
801
 
@@ -59,7 +59,11 @@ export function insertTable(cols, rows, opts = {}) {
59
59
  const sel = globalThis.getSelection();
60
60
  if (!sel || sel.rangeCount === 0) return;
61
61
  const range = sel.getRangeAt(0);
62
- range.deleteContents();
62
+ try {
63
+ range.deleteContents();
64
+ } catch (_) {
65
+ return;
66
+ }
63
67
 
64
68
  // Walk up to find the nearest block-level ancestor to insert after
65
69
  const BLOCK = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'LI', 'PRE']);
@@ -82,7 +86,11 @@ export function insertTable(cols, rows, opts = {}) {
82
86
  anchor.remove();
83
87
  }
84
88
  } else {
85
- range.insertNode(table);
89
+ try {
90
+ range.insertNode(table);
91
+ } catch (_) {
92
+ return;
93
+ }
86
94
  }
87
95
 
88
96
  // Place cursor in the first cell
@@ -17,6 +17,25 @@ const _FA_PATTERN = /\bfa-/;
17
17
  const isFAIcon = (n) => !!(n?.nodeName === 'I' && _FA_PATTERN.test(n.className || ''));
18
18
  const isZwsAnchor = (n) => !!(n?.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
19
19
 
20
+ /**
21
+ * Extracts the content from `startContainer:startOffset` to the end of `li`.
22
+ * Returns an empty fragment if the range is invalid (e.g. detached node).
23
+ * @param {Range} nativeRange
24
+ * @param {Element} li
25
+ * @returns {DocumentFragment}
26
+ */
27
+ function extractAfterContent(nativeRange, li) {
28
+ try {
29
+ const r = document.createRange();
30
+ r.setStart(nativeRange.startContainer, nativeRange.startOffset);
31
+ r.setEnd(li, li.childNodes.length);
32
+ return r.extractContents();
33
+ } catch (_) {
34
+ void _;
35
+ return document.createDocumentFragment();
36
+ }
37
+ }
38
+
20
39
  /**
21
40
  * Handles special keydown behaviour inside the editor.
22
41
  * @param {KeyboardEvent} event
@@ -312,16 +331,14 @@ export function handleKeydown(event, editable, options = {}) {
312
331
  if (!nativeRange.collapsed) {
313
332
  nativeRange.deleteContents();
314
333
  // nativeRange is now collapsed at the deletion point; re-read it
334
+ if (sel.rangeCount === 0 || !checkLi.isConnected) return true;
315
335
  nativeRange = sel.getRangeAt(0);
316
336
  }
317
337
 
318
338
  // 3. Extract everything from cursor to end of li into afterFrag.
319
339
  // Use startContainer/startOffset (cursor position after potential delete),
320
340
  // NOT endContainer/endOffset which is wrong for non-collapsed ranges.
321
- const afterRange = document.createRange();
322
- afterRange.setStart(nativeRange.startContainer, nativeRange.startOffset);
323
- afterRange.setEnd(checkLi, checkLi.childNodes.length);
324
- const afterFrag = afterRange.extractContents();
341
+ const afterFrag = extractAfterContent(nativeRange, checkLi);
325
342
 
326
343
  // 4. Build the new checklist item with the extracted "after" content.
327
344
  const newLi = document.createElement('li');