autumnnote 2.0.0 → 2.2.0

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.
Files changed (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
@@ -1,812 +0,0 @@
1
- /**
2
- * Style.js - Inline / block style detection and application utilities
3
- * Rewritten from Summernote's approach using vanilla JS + execCommand fallback
4
- */
5
-
6
- import { closest, isElement, isPara } from '../core/dom.js';
7
- import { currentRange } from '../core/range.js';
8
-
9
- // ---------------------------------------------------------------------------
10
- // execCommand wrappers (still the most compatible way in contenteditable)
11
- // ---------------------------------------------------------------------------
12
-
13
- /**
14
- * Applies a document execCommand.
15
- * @param {string} cmd
16
- * @param {string} [value]
17
- * @returns {boolean}
18
- */
19
- export function execCommand(cmd, value = null) {
20
- return document.execCommand(cmd, false, value);
21
- }
22
-
23
- // ---------------------------------------------------------------------------
24
- // Inline style helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- /**
28
- * Bolds / unbolds the selection.
29
- */
30
- export const bold = () => execCommand('bold');
31
-
32
- /**
33
- * Italicises / un-italicises the selection.
34
- */
35
- export const italic = () => execCommand('italic');
36
-
37
- /**
38
- * Underlines / un-underlines the selection.
39
- * Falls back to manual DOM manipulation when inside <code> where
40
- * execCommand's state detection is unreliable.
41
- */
42
- export function underline() {
43
- const sel = globalThis.getSelection();
44
- if (!sel?.rangeCount) return;
45
- let container = sel.getRangeAt(0).commonAncestorContainer;
46
- if (container.nodeType === 3) container = container.parentElement;
47
- // Check if we're inside a <u> (DOM truth), to guard against unreliable queryCommandState
48
- const uEl = /** @type {Element|null} */ (container)?.closest('u');
49
- const nativeState = document.queryCommandState('underline');
50
- if (uEl && !nativeState) {
51
- // Browser doesn't recognise the underline state (e.g. inside <code>).
52
- // Manually unwrap the <u> element.
53
- const parent = uEl.parentNode;
54
- while (uEl.firstChild) parent.insertBefore(uEl.firstChild, uEl);
55
- uEl.remove();
56
- return;
57
- }
58
- execCommand('underline');
59
- }
60
-
61
- /**
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).
65
- */
66
- export function strikethrough() {
67
- const sel = globalThis.getSelection();
68
- if (!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 = /** @type {Element|null} */ (sc)?.closest('s') || /** @type {Element|null} */ (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
- sEl.remove();
82
- return;
83
- }
84
- execCommand('strikeThrough');
85
- }
86
-
87
- /**
88
- * Superscript toggle.
89
- */
90
- export const superscript = () => execCommand('superscript');
91
-
92
- /**
93
- * Subscript toggle.
94
- */
95
- export const subscript = () => execCommand('subscript');
96
-
97
- /**
98
- * Sets the foreground colour of the selected text.
99
- * @param {string} color - CSS colour string
100
- */
101
- export const foreColor = (color) => execCommand('foreColor', color);
102
-
103
- /**
104
- * Sets the background (highlight) colour of the selected text.
105
- * @param {string} color - CSS colour string
106
- */
107
- export const backColor = (color) => execCommand('hiliteColor', color);
108
-
109
- /**
110
- * Sets the font name for the selection.
111
- * @param {string} name
112
- */
113
- export const fontName = (name) => execCommand('fontName', name);
114
-
115
- /**
116
- * Sets the font size (in pt or with unit) for the selection.
117
- * Uses a span-based approach to set px sizes precisely.
118
- * @param {string} size - e.g. '14px'
119
- * @param {HTMLElement|Document} [editable] - scoping element to avoid touching nodes outside this editor
120
- */
121
- export function fontSize(size, editable = document) {
122
- const sel = globalThis.getSelection();
123
- const wasCollapsed = !sel?.rangeCount || sel.getRangeAt(0).collapsed;
124
-
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?.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 (_) { void _; /* 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');
154
- const scope = editable instanceof HTMLElement ? editable : document;
155
- const newSpans = [];
156
- scope.querySelectorAll('font[size="7"]').forEach((el) => {
157
- const span = document.createElement('span');
158
- span.style.fontSize = size;
159
- el.parentNode.insertBefore(span, el);
160
- while (el.firstChild) span.appendChild(el.firstChild);
161
- el.remove();
162
- newSpans.push(span);
163
- });
164
-
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) {
169
- const first = newSpans[0];
170
- const last = newSpans.at(-1);
171
- try {
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);
179
- } catch (_) { void _; /* ignore range errors on unusual DOM structures */ }
180
- }
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Block style helpers
185
- // ---------------------------------------------------------------------------
186
-
187
- /**
188
- * Wraps the selection in the given block tag (p, h1-h6, blockquote, pre).
189
- * @param {string} tagName
190
- */
191
- export const formatBlock = (tagName) => execCommand('formatBlock', `<${tagName}>`);
192
-
193
- /**
194
- * Left-aligns the current block.
195
- */
196
- export const justifyLeft = () => execCommand('justifyLeft');
197
-
198
- /**
199
- * Center-aligns the current block.
200
- */
201
- export const justifyCenter = () => execCommand('justifyCenter');
202
-
203
- /**
204
- * Right-aligns the current block.
205
- */
206
- export const justifyRight = () => execCommand('justifyRight');
207
-
208
- /**
209
- * Fully justifies the current block.
210
- */
211
- export const justifyFull = () => execCommand('justifyFull');
212
-
213
- /**
214
- * Indents the list or block.
215
- */
216
- export const indent = () => execCommand('indent');
217
-
218
- /**
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 = globalThis.getSelection();
226
- if (sel?.rangeCount) {
227
- let container = sel.getRangeAt(0).commonAncestorContainer;
228
- if (container.nodeType === 3) container = container.parentElement;
229
- const checkLi = /** @type {Element|null} */ (container)?.closest('.an-checklist li');
230
- if (checkLi) {
231
- _checklistItemToP(/** @type {HTMLElement} */ (checkLi));
232
- return;
233
- }
234
- }
235
- execCommand('outdent');
236
- }
237
-
238
- /**
239
- * Convert a checklist <li> into a paragraph and move any following items into a new checklist.
240
- *
241
- * Preserves inline markup from the converted item, strips zero-width space anchors,
242
- * and replaces empty content with a non‑breaking space. If there are list items
243
- * after the converted item they are moved into a new <ul class="an-checklist">
244
- * inserted immediately after the original list. The original <li> is removed and
245
- * the original list is removed if it becomes empty. Attempts to place the caret
246
- * at the start of the newly created <p>.
247
- * @param {HTMLElement} checkLi - The checklist `<li>` element to convert to a `<p>`.
248
- */
249
- function _checklistItemToP(checkLi) {
250
- const checkUl = checkLi.closest('.an-checklist');
251
- if (!checkUl) return;
252
-
253
- const allLis = Array.from(checkUl.children);
254
- const liIndex = allLis.indexOf(checkLi);
255
- const afterLis = allLis.slice(liIndex + 1);
256
-
257
- // Build <p> preserving inline formatting (bold/italic/links) from the item's content
258
- const p = document.createElement('p');
259
- for (const child of checkLi.childNodes) {
260
- if (child.nodeType === 1 && /** @type {Element} */ (child).tagName === 'INPUT') continue;
261
- p.appendChild(child.cloneNode(true));
262
- }
263
- // Strip ZWS anchors left over from checklist markup
264
- p.innerHTML = p.innerHTML.replaceAll('\u200B', '');
265
- if (!p.hasChildNodes() || !p.textContent.trim()) {
266
- p.innerHTML = '';
267
- p.appendChild(document.createTextNode('\u00a0'));
268
- }
269
-
270
- // Move items after the current li into a new checklist
271
- if (afterLis.length > 0) {
272
- const newUl = document.createElement('ul');
273
- newUl.className = 'an-checklist';
274
- afterLis.forEach(li => newUl.appendChild(li));
275
- checkUl.parentNode.insertBefore(newUl, checkUl.nextSibling);
276
- }
277
-
278
- // Insert <p> after checkUl (before any newUl)
279
- checkUl.parentNode.insertBefore(p, checkUl.nextSibling);
280
-
281
- // Remove current li from checkUl; delete checkUl if now empty
282
- checkLi.remove();
283
- if (checkUl.children.length === 0) checkUl.remove();
284
-
285
- // Place caret at start of the new <p>
286
- try {
287
- const nr = document.createRange();
288
- const firstChild = p.firstChild;
289
- nr.setStart(firstChild?.nodeType === 3 ? firstChild : p, 0);
290
- nr.collapse(true);
291
- const s = globalThis.getSelection();
292
- if (s) { s.removeAllRanges(); s.addRange(nr); }
293
- } catch {}
294
- }
295
-
296
- /**
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
327
- */
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
- }
354
-
355
- /**
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')`.
370
- */
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
- }
390
-
391
- // ---------------------------------------------------------------------------
392
- // Line-height helper
393
- // ---------------------------------------------------------------------------
394
-
395
- /**
396
- * Set the line-height on every block-level element that intersects the current selection.
397
- *
398
- * If the selection is collapsed, the nearest enclosing block element receives the style.
399
- * For a non-collapsed selection, all unique block ancestors of text nodes that intersect the range are updated;
400
- * if none are found, the nearest block ancestor of the range's common ancestor is updated.
401
- * @param {string} value - Line-height value to apply; typically a unitless multiplier (for example, "1.5").
402
- */
403
- export function lineHeight(value) {
404
- const sel = globalThis.getSelection();
405
- if (!sel || sel.rangeCount === 0) return;
406
-
407
- const range = sel.getRangeAt(0);
408
- const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'BLOCKQUOTE', 'PRE', 'TD', 'TH']);
409
-
410
- const nearestBlock = (node) => {
411
- let el = node instanceof Element ? node : node.parentElement;
412
- while (el) {
413
- if (BLOCK_TAGS.has(el.tagName)) return el;
414
- el = el.parentElement;
415
- }
416
- return null;
417
- };
418
-
419
- if (range.collapsed) {
420
- const block = nearestBlock(range.startContainer);
421
- if (block) block.style.lineHeight = value;
422
- return;
423
- }
424
-
425
- // For a range selection, collect all unique block ancestors of text nodes
426
- const blocks = new Set();
427
- const iter = document.createTreeWalker(
428
- range.commonAncestorContainer,
429
- NodeFilter.SHOW_TEXT,
430
- { acceptNode: (node) => range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP },
431
- );
432
- let textNode;
433
- while ((textNode = iter.nextNode())) {
434
- const block = nearestBlock(textNode);
435
- if (block) blocks.add(block);
436
- }
437
- if (blocks.size === 0) {
438
- const block = nearestBlock(range.commonAncestorContainer);
439
- if (block) blocks.add(block);
440
- }
441
- blocks.forEach((block) => { block.style.lineHeight = value; });
442
- }
443
-
444
- // ---------------------------------------------------------------------------
445
- // Style query helpers
446
- // ---------------------------------------------------------------------------
447
-
448
- /**
449
- * Returns the computed styles relevant to the current cursor position.
450
- * @param {HTMLElement} editable
451
- * @returns {object} styleMap
452
- */
453
- export function currentStyle(editable) {
454
- const range = currentRange(editable);
455
- if (!range) return {};
456
-
457
- const container = range.isCollapsed()
458
- ? range.sc
459
- : range.commonAncestor();
460
-
461
- const el = /** @type {Element|null} */ (isElement(container) ? container : container.parentElement);
462
- if (!el) return {};
463
-
464
- const computed = globalThis.getComputedStyle(el);
465
-
466
- return {
467
- bold: document.queryCommandState('bold'),
468
- italic: document.queryCommandState('italic'),
469
- underline: document.queryCommandState('underline'),
470
- strikethrough: document.queryCommandState('strikeThrough'),
471
- superscript: document.queryCommandState('superscript'),
472
- subscript: document.queryCommandState('subscript'),
473
- fontSize: computed.fontSize,
474
- fontFamily: computed.fontFamily,
475
- color: computed.color,
476
- backgroundColor: computed.backgroundColor,
477
- textAlign: computed.textAlign,
478
- lineHeight: computed.lineHeight,
479
- formatBlock: (closest(el, isPara, editable) || { nodeName: 'p' }).nodeName.toLowerCase(),
480
- };
481
- }
482
-
483
- // ---------------------------------------------------------------------------
484
- // Inline code toggle
485
- // ---------------------------------------------------------------------------
486
-
487
- /**
488
- * Wraps the selection in an inline <code> element, or unwraps it if the
489
- * cursor is already inside a <code> that is not inside a <pre>.
490
- * @param {HTMLElement} [_editable]
491
- */
492
- export function toggleInlineCode(_editable) {
493
- const sel = globalThis.getSelection();
494
- if (!sel?.rangeCount) return;
495
- const range = sel.getRangeAt(0);
496
- let container = range.commonAncestorContainer;
497
- if (container.nodeType === 3) container = container.parentElement;
498
- const codeEl = /** @type {Element|null} */ (container)?.closest('code');
499
- if (codeEl && !codeEl.closest('pre')) {
500
- // Unwrap — save range endpoints relative to surrounding text so we can
501
- // restore the selection after normalize() merges adjacent text nodes.
502
- const parent = codeEl.parentNode;
503
- // Note the sibling before the code element so we can re-anchor later.
504
- const prevSibling = codeEl.previousSibling;
505
- const movedChildren = Array.from(codeEl.childNodes);
506
- while (codeEl.firstChild) parent.insertBefore(codeEl.firstChild, codeEl);
507
- codeEl.remove();
508
- // Normalize only the immediate parent to merge adjacent text nodes without
509
- // invalidating distant selection anchors (full editable.normalize() can
510
- // cause selection offsets to shift, making subsequent format toggles miss).
511
- parent?.normalize();
512
- // Restore selection to the text that was inside the unwrapped <code>.
513
- if (movedChildren.length > 0) {
514
- try {
515
- // After normalize, find the merged text node that contains the content.
516
- const firstMoved = movedChildren[0];
517
- const lastMoved = movedChildren.at(-1);
518
- const nr = document.createRange();
519
- // Use the (possibly merged) live node if still in the DOM.
520
- const anchorNode = firstMoved.parentNode === parent
521
- ? firstMoved
522
- : (prevSibling ? prevSibling.nextSibling : parent.firstChild);
523
- if (anchorNode) {
524
- nr.setStart(anchorNode, 0);
525
- const endAnchor = (lastMoved.parentNode === parent) ? lastMoved : anchorNode;
526
- nr.setEnd(endAnchor, endAnchor.nodeType === Node.TEXT_NODE ? endAnchor.textContent.length : endAnchor.childNodes.length);
527
- sel.removeAllRanges();
528
- sel.addRange(nr);
529
- }
530
- } catch (_) { void _; /* ignore */ }
531
- }
532
- } else {
533
- if (range.collapsed) return;
534
- try {
535
- const code = document.createElement('code');
536
- range.surroundContents(code);
537
- // Re-select wrapped content so subsequent format toggles work
538
- const newRange = document.createRange();
539
- newRange.selectNodeContents(code);
540
- sel.removeAllRanges();
541
- sel.addRange(newRange);
542
- } catch {
543
- // surroundContents fails across element boundaries — extract and rewrap
544
- const frag = range.extractContents();
545
- const code = document.createElement('code');
546
- code.appendChild(frag);
547
- range.insertNode(code);
548
- // Re-select wrapped content
549
- const newRange = document.createRange();
550
- newRange.selectNodeContents(code);
551
- sel.removeAllRanges();
552
- sel.addRange(newRange);
553
- }
554
- }
555
- }
556
-
557
- /**
558
- * Returns true when the cursor / selection is inside an inline <code>
559
- * (not nested in a <pre>).
560
- * Uses startContainer for reliable cross-browser detection regardless of
561
- * whether the selection is collapsed or a range (commonAncestorContainer
562
- * can behave inconsistently for range selections on some browsers).
563
- * @returns {boolean}
564
- */
565
- export function isInlineCode() {
566
- const sel = globalThis.getSelection();
567
- if (!sel?.rangeCount) return false;
568
- let sc = sel.getRangeAt(0).startContainer;
569
- if (sc.nodeType === 3) sc = sc.parentElement;
570
- const code = /** @type {Element|null} */ (sc)?.closest('code');
571
- return !!(code && !code.closest('pre'));
572
- }
573
-
574
- // ---------------------------------------------------------------------------
575
- // Checklist (task list)
576
- // ---------------------------------------------------------------------------
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
-
612
- /**
613
- * Toggle a checklist at the current selection or caret.
614
- */
615
- export function toggleChecklist() {
616
- const sel = globalThis.getSelection();
617
- if (!sel?.rangeCount) return;
618
- const range = sel.getRangeAt(0);
619
- let container = range.commonAncestorContainer;
620
- if (container.nodeType === 3) container = container.parentElement;
621
-
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);
652
- }
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) {
663
- const nr = document.createRange();
664
- nr.selectNodeContents(firstLi);
665
- nr.collapse(false);
666
- sel.removeAllRanges();
667
- sel.addRange(nr);
668
- }
669
- }
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
- }
720
-
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;
730
- }
731
-
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
- }
760
- }
761
-
762
- if (blocks.length === 0) return;
763
-
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
- }
799
- }
800
- }
801
-
802
- /**
803
- * Returns true when the cursor is inside a checklist item.
804
- * @returns {boolean}
805
- */
806
- export function isInChecklist() {
807
- const sel = globalThis.getSelection();
808
- if (!sel?.rangeCount) return false;
809
- let container = sel.getRangeAt(0).commonAncestorContainer;
810
- if (container.nodeType === 3) container = container.parentElement;
811
- return !!(/** @type {Element|null} */ (container)?.closest('.an-checklist li'));
812
- }