autumnnote 1.0.1 → 1.0.4

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.1",
3
+ "version": "1.0.4",
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",
@@ -19,7 +19,7 @@
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "vitest run",
21
21
  "test:watch": "vitest",
22
- "lint": "eslint src --ext .js"
22
+ "lint": "eslint src"
23
23
  },
24
24
  "keywords": [
25
25
  "wysiwyg",
@@ -11,6 +11,16 @@ const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form',
11
11
  /** Attributes whose values must be sanitised as URLs. */
12
12
  const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
13
13
 
14
+ /** Trusted hosts for iframe embeds when allowIframes is enabled. */
15
+ const TRUSTED_IFRAME_HOSTS = new Set([
16
+ 'www.youtube.com',
17
+ 'youtube.com',
18
+ 'm.youtube.com',
19
+ 'www.youtube-nocookie.com',
20
+ 'youtube-nocookie.com',
21
+ 'player.vimeo.com',
22
+ ]);
23
+
14
24
  /**
15
25
  * Sanitises an HTML string by removing dangerous elements and attributes.
16
26
  * Uses DOMParser so the sanitisation follows normal browser parsing rules —
@@ -54,12 +64,45 @@ export function sanitiseHTML(html, { allowIframes = false } = {}) {
54
64
  el.removeAttribute(attr.name);
55
65
  }
56
66
  }
67
+
68
+ // Strip iframe HTML-injection vectors and limit iframe src to trusted hosts.
69
+ if (el.tagName === 'IFRAME') {
70
+ if (attr.name === 'srcdoc') {
71
+ el.removeAttribute(attr.name);
72
+ return;
73
+ }
74
+ if (attr.name === 'src') {
75
+ if (!isTrustedIframeSrc(attr.value)) {
76
+ el.removeAttribute(attr.name);
77
+ }
78
+ return;
79
+ }
80
+ }
57
81
  });
58
82
  });
59
83
 
60
84
  return doc.body.innerHTML;
61
85
  }
62
86
 
87
+ /**
88
+ * Returns true if iframe src points to an approved video host.
89
+ * Relative, protocol-relative and invalid URLs are rejected.
90
+ * @param {string} src
91
+ * @returns {boolean}
92
+ */
93
+ function isTrustedIframeSrc(src) {
94
+ const trimmed = (src || '').trim();
95
+ if (!trimmed) return false;
96
+ if (trimmed.startsWith('//') || trimmed.startsWith('/')) return false;
97
+ try {
98
+ const url = new URL(trimmed);
99
+ if (url.protocol !== 'https:') return false;
100
+ return TRUSTED_IFRAME_HOSTS.has(url.hostname.toLowerCase());
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
63
106
  /**
64
107
  * Sanitises a URL string, rejecting dangerous protocols.
65
108
  *
@@ -56,7 +56,7 @@ export class History {
56
56
  if (cur === node) return count + offset;
57
57
  count += cur.length;
58
58
  }
59
- return count;
59
+ return 0;
60
60
  }
61
61
 
62
62
  /**
@@ -83,7 +83,15 @@ export class History {
83
83
  }
84
84
  count += len;
85
85
  }
86
- if (!startNode) return;
86
+ if (!startNode) {
87
+ // Offset exceeds content (e.g. undo to a shorter state): place at end
88
+ const lastWalker = document.createTreeWalker(this.editable, NodeFilter.SHOW_TEXT, null);
89
+ let lastNode = null;
90
+ while ((lastNode = lastWalker.nextNode())) { startNode = lastNode; }
91
+ startOff = startNode ? startNode.length : 0;
92
+ endNode = startNode;
93
+ endOff = startOff;
94
+ }
87
95
  if (!endNode) { endNode = startNode; endOff = startOff; }
88
96
  try {
89
97
  const range = document.createRange();
@@ -36,8 +36,27 @@ export const italic = () => execCommand('italic');
36
36
 
37
37
  /**
38
38
  * Underlines / un-underlines the selection.
39
+ * Falls back to manual DOM manipulation when inside <code> where
40
+ * execCommand's state detection is unreliable.
39
41
  */
40
- export const underline = () => execCommand('underline');
42
+ export function underline() {
43
+ const sel = window.getSelection();
44
+ if (!sel || !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 = container && container.closest && 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
+ parent.removeChild(uEl);
56
+ return;
57
+ }
58
+ execCommand('underline');
59
+ }
41
60
 
42
61
  /**
43
62
  * Strikethrough / removes strikethrough.
@@ -254,12 +273,22 @@ export function toggleInlineCode(editable) {
254
273
  try {
255
274
  const code = document.createElement('code');
256
275
  range.surroundContents(code);
276
+ // Re-select wrapped content so subsequent format toggles work
277
+ const newRange = document.createRange();
278
+ newRange.selectNodeContents(code);
279
+ sel.removeAllRanges();
280
+ sel.addRange(newRange);
257
281
  } catch {
258
282
  // surroundContents fails across element boundaries — extract and rewrap
259
283
  const frag = range.extractContents();
260
284
  const code = document.createElement('code');
261
285
  code.appendChild(frag);
262
286
  range.insertNode(code);
287
+ // Re-select wrapped content
288
+ const newRange = document.createRange();
289
+ newRange.selectNodeContents(code);
290
+ sel.removeAllRanges();
291
+ sel.addRange(newRange);
263
292
  }
264
293
  }
265
294
  }
@@ -306,13 +335,45 @@ export function toggleChecklist() {
306
335
  ul.removeChild(li);
307
336
  if (ul.children.length === 0) ul.remove();
308
337
  const nr = document.createRange();
309
- nr.setStart(p, 0);
338
+ // Point into the text node (or first child) rather than the element node
339
+ // so the cursor is at a well-defined text position, not element-offset 0.
340
+ const startNode = p.firstChild || p;
341
+ nr.setStart(startNode, 0);
310
342
  nr.collapse(true);
311
343
  sel.removeAllRanges();
312
344
  sel.addRange(nr);
313
345
  } else {
314
- const cb = '<input type="checkbox" contenteditable="false">';
315
- execCommand('insertHTML', `<ul class="an-checklist"><li>${cb}\u00a0</li></ul>`);
346
+ // Use a temporary marker attribute so the new <li> can be found reliably
347
+ // even when execCommand('insertHTML') places the cursor outside the list.
348
+ // Chrome often moves the caret to a generated <p> after the <ul> rather
349
+ // than staying inside the <li>, making a selection-based lookup fail.
350
+ const MARKER = 'data-an-new-cli';
351
+ execCommand('insertHTML',
352
+ `<ul class="an-checklist"><li ${MARKER}><input type="checkbox" contenteditable="false"></li></ul>`);
353
+ const newLi = document.querySelector(`[${MARKER}]`);
354
+ if (newLi) {
355
+ newLi.removeAttribute(MARKER);
356
+ const cbEl = newLi.querySelector('input[type="checkbox"]');
357
+ if (cbEl) {
358
+ // Use \u200B (zero-width space) instead of an empty string.
359
+ // Chrome does not reliably honour a Selection pointing into an empty
360
+ // text node and may silently normalise it to an element-level offset,
361
+ // rendering the caret before the absolutely-positioned checkbox.
362
+ // \u200B is invisible/zero-width and is stripped by getHTML().
363
+ let textNode = cbEl.nextSibling;
364
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
365
+ textNode = document.createTextNode('\u200B');
366
+ newLi.appendChild(textNode);
367
+ } else if (!textNode.textContent) {
368
+ textNode.textContent = '\u200B';
369
+ }
370
+ const nr = document.createRange();
371
+ nr.setStart(textNode, 0);
372
+ nr.collapse(true);
373
+ sel.removeAllRanges();
374
+ sel.addRange(nr);
375
+ }
376
+ }
316
377
  }
317
378
  }
318
379
 
@@ -16,6 +16,153 @@ import { currentRange } from '../core/range.js';
16
16
  * @returns {boolean} true if the event was consumed
17
17
  */
18
18
  export function handleKeydown(event, editable, options = {}) {
19
+ const isFAIcon = (n) => !!(n && n.nodeName === 'I' && /\bfa-/.test(n.className || ''));
20
+ const isZwsAnchor = (n) => !!(n && n.nodeType === Node.TEXT_NODE && (n.textContent === '\u200B' || n.textContent === ''));
21
+ const moveCaret = (setFn) => {
22
+ const sel = window.getSelection();
23
+ if (!sel) return false;
24
+ const nr = document.createRange();
25
+ setFn(nr);
26
+ nr.collapse(true);
27
+ sel.removeAllRanges();
28
+ sel.addRange(nr);
29
+ return true;
30
+ };
31
+
32
+ // -------------------------------------------------------------------------
33
+ // Backspace key — one-press deletion of a preceding FA icon (<i> element)
34
+ // -------------------------------------------------------------------------
35
+ if (isKey(event, key.BACKSPACE)) {
36
+ const sel = window.getSelection();
37
+ if (sel && sel.rangeCount > 0) {
38
+ const r = sel.getRangeAt(0);
39
+ if (r.collapsed && r.startContainer.nodeType === Node.TEXT_NODE) {
40
+ const textNode = r.startContainer;
41
+ // Case A: cursor at offset 0, preceding sibling is an FA icon
42
+ if (r.startOffset === 0 && isFAIcon(textNode.previousSibling)) {
43
+ event.preventDefault();
44
+ textNode.previousSibling.remove();
45
+ return true;
46
+ }
47
+
48
+ // Case B: cursor at offset 1 of a ZWS-only text node whose preceding
49
+ // sibling is an FA icon. The ZWS is the invisible caret anchor inserted
50
+ // by IconDialog; treat the whole Backspace as "delete icon + its anchor".
51
+ if (r.startOffset === 1 && textNode.textContent === '\u200B' &&
52
+ isFAIcon(textNode.previousSibling)) {
53
+ event.preventDefault();
54
+ textNode.previousSibling.remove();
55
+ textNode.remove();
56
+ return true;
57
+ }
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // -------------------------------------------------------------------------
64
+ // ArrowLeft / ArrowRight — one-press navigation across FA icon nodes
65
+ // -------------------------------------------------------------------------
66
+ if (isKey(event, key.LEFT) || isKey(event, key.RIGHT)) {
67
+ const sel = window.getSelection();
68
+ if (!sel || sel.rangeCount === 0) return false;
69
+
70
+ const r = sel.getRangeAt(0);
71
+ if (!r.collapsed) return false;
72
+
73
+ const sc = r.startContainer;
74
+ const movingLeft = isKey(event, key.LEFT);
75
+
76
+ if (sc.nodeType === Node.TEXT_NODE) {
77
+ const textNode = sc;
78
+
79
+ if (movingLeft &&
80
+ r.startOffset === 1 &&
81
+ textNode.textContent === '\u200B' &&
82
+ isFAIcon(textNode.previousSibling)) {
83
+ event.preventDefault();
84
+ return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling));
85
+ }
86
+
87
+ if (movingLeft && r.startOffset === 0 && isFAIcon(textNode.previousSibling)) {
88
+ event.preventDefault();
89
+ return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling));
90
+ }
91
+
92
+ if (movingLeft &&
93
+ r.startOffset === 0 &&
94
+ isZwsAnchor(textNode.previousSibling) &&
95
+ isFAIcon(textNode.previousSibling.previousSibling)) {
96
+ event.preventDefault();
97
+ return moveCaret((nr) => nr.setStartBefore(textNode.previousSibling.previousSibling));
98
+ }
99
+
100
+ if (!movingLeft &&
101
+ r.startOffset === textNode.textContent.length &&
102
+ isFAIcon(textNode.nextSibling)) {
103
+ const icon = textNode.nextSibling;
104
+ const after = icon.nextSibling;
105
+ event.preventDefault();
106
+ if (after && after.nodeType === Node.TEXT_NODE) {
107
+ const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
108
+ return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
109
+ }
110
+ return moveCaret((nr) => nr.setStartAfter(icon));
111
+ }
112
+
113
+ if (!movingLeft &&
114
+ r.startOffset === textNode.textContent.length &&
115
+ isZwsAnchor(textNode.nextSibling) &&
116
+ isFAIcon(textNode.nextSibling.nextSibling)) {
117
+ const icon = textNode.nextSibling.nextSibling;
118
+ const after = icon.nextSibling;
119
+ event.preventDefault();
120
+ if (after && after.nodeType === Node.TEXT_NODE) {
121
+ const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
122
+ return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
123
+ }
124
+ return moveCaret((nr) => nr.setStartAfter(icon));
125
+ }
126
+ }
127
+
128
+ if (sc.nodeType === Node.ELEMENT_NODE) {
129
+ const el = sc;
130
+ if (movingLeft && r.startOffset > 0) {
131
+ const prev = el.childNodes[r.startOffset - 1];
132
+ if (isFAIcon(prev)) {
133
+ event.preventDefault();
134
+ return moveCaret((nr) => nr.setStartBefore(prev));
135
+ }
136
+ if (isZwsAnchor(prev) && isFAIcon(prev.previousSibling)) {
137
+ event.preventDefault();
138
+ return moveCaret((nr) => nr.setStartBefore(prev.previousSibling));
139
+ }
140
+ }
141
+ if (!movingLeft && r.startOffset < el.childNodes.length) {
142
+ const next = el.childNodes[r.startOffset];
143
+ if (isFAIcon(next)) {
144
+ const after = next.nextSibling;
145
+ event.preventDefault();
146
+ if (after && after.nodeType === Node.TEXT_NODE) {
147
+ const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
148
+ return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
149
+ }
150
+ return moveCaret((nr) => nr.setStartAfter(next));
151
+ }
152
+ if (isZwsAnchor(next) && isFAIcon(next.nextSibling)) {
153
+ const icon = next.nextSibling;
154
+ const after = icon.nextSibling;
155
+ event.preventDefault();
156
+ if (after && after.nodeType === Node.TEXT_NODE) {
157
+ const offset = ((after.textContent || '').startsWith('\u200B')) ? 1 : 0;
158
+ return moveCaret((nr) => nr.setStart(after, Math.min(offset, after.textContent.length)));
159
+ }
160
+ return moveCaret((nr) => nr.setStartAfter(icon));
161
+ }
162
+ }
163
+ }
164
+ }
165
+
19
166
  // -------------------------------------------------------------------------
20
167
  // Tab key — indent / outdent list items, or insert soft tab in code blocks
21
168
  // -------------------------------------------------------------------------
@@ -36,6 +183,7 @@ export function handleKeydown(event, editable, options = {}) {
36
183
 
37
184
  // In a pre/code block, insert spaces
38
185
  if (para && para.nodeName.toUpperCase() === 'PRE') {
186
+ if (event.shiftKey) return false;
39
187
  event.preventDefault();
40
188
  execCommand('insertText', ' ');
41
189
  return true;
@@ -43,6 +191,7 @@ export function handleKeydown(event, editable, options = {}) {
43
191
 
44
192
  // Default: insert &nbsp; * tabSize
45
193
  if (options.tabSize) {
194
+ if (event.shiftKey) return false;
46
195
  event.preventDefault();
47
196
  execCommand('insertText', ' '.repeat(options.tabSize));
48
197
  return true;
@@ -65,62 +214,119 @@ export function handleKeydown(event, editable, options = {}) {
65
214
  const range = currentRange(editable);
66
215
  if (!range) return false;
67
216
 
68
- // Checklist Enter creates new item; empty item exits the list
217
+ // Hoist sc/el once so all guards below can reuse them.
69
218
  const sc = range.sc;
70
219
  const el = sc.nodeType === 3 ? sc.parentElement : sc;
220
+
221
+ // Guard: if the cursor is inside a <i> FA icon element (zero text children,
222
+ // rendered entirely by CSS ::before), pressing Enter would split the block
223
+ // and leave an orphan <i> in the new paragraph — visually an "auto-created
224
+ // icon". Push the cursor to just after the <i> first, then fall through so
225
+ // the browser fires its default Enter at a safe text boundary.
226
+ if (el && el.nodeName === 'I' && /\bfa-/.test(el.className || '')) {
227
+ const nr = document.createRange();
228
+ nr.setStartAfter(el);
229
+ nr.collapse(true);
230
+ const selI = window.getSelection();
231
+ if (selI) { selI.removeAllRanges(); selI.addRange(nr); }
232
+ return false; // cursor is now outside <i> — let browser default handle Enter
233
+ }
234
+
235
+ // Video wrapper — Enter should create a new paragraph after the wrapper,
236
+ // not split the wrapper's container and produce an empty video clone.
237
+ const videoWrapper = el && el.closest && el.closest('.an-video-wrapper');
238
+ if (videoWrapper) {
239
+ event.preventDefault();
240
+ const p = document.createElement('p');
241
+ p.innerHTML = '\u00a0';
242
+ videoWrapper.parentNode.insertBefore(p, videoWrapper.nextSibling);
243
+ const nr = document.createRange();
244
+ nr.setStart(p, 0);
245
+ nr.collapse(true);
246
+ const sel = window.getSelection();
247
+ sel.removeAllRanges();
248
+ sel.addRange(nr);
249
+ return true;
250
+ }
251
+
252
+ // Checklist — Enter creates new item; empty item exits the list
71
253
  const checkLi = el && el.closest && el.closest('.an-checklist li');
72
254
  if (checkLi) {
73
255
  event.preventDefault();
74
256
  const ul = checkLi.closest('.an-checklist');
75
257
  const sel = window.getSelection();
76
- const nativeRange = sel.getRangeAt(0);
77
-
78
- // Extract content from cursor to end of li into a fragment
79
- const afterRange = document.createRange();
80
- afterRange.setStart(nativeRange.endContainer, nativeRange.endOffset);
81
- afterRange.setEnd(checkLi, checkLi.childNodes.length);
82
- const afterFrag = afterRange.extractContents();
258
+ let nativeRange = sel.getRangeAt(0);
83
259
 
84
- // Check if text remaining before cursor (excl checkbox) is empty
85
- const textBefore = Array.from(checkLi.childNodes)
86
- .filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
87
- .map((n) => n.textContent).join('').replace(/\u00a0/g, ' ').trim();
260
+ // Helper: get trimmed text content of a li, excluding the checkbox INPUT.
261
+ // Strip both \u00a0 (placeholder nbsp) and \u200B (ZWS cursor anchors).
262
+ const liText = (li) =>
263
+ Array.from(li.childNodes)
264
+ .filter((n) => !(n.nodeType === 1 && n.tagName === 'INPUT'))
265
+ .map((n) => n.textContent).join('').replace(/[\u00a0\u200B]/g, ' ').trim();
88
266
 
89
- if (!textBefore) {
267
+ // 1. Check if the ENTIRE item is empty BEFORE any DOM mutation.
268
+ // (Do NOT check only the "before-cursor" part — that check incorrectly
269
+ // exits the list when cursor is at the start of a non-empty item.)
270
+ if (!liText(checkLi)) {
90
271
  // Empty item — exit checklist, insert <p> after list
91
272
  const p = document.createElement('p');
92
- const afterText = afterFrag.textContent.replace(/\u00a0/g, ' ').trim();
93
- p.textContent = afterText || '\u00a0';
273
+ p.innerHTML = '\u00a0';
94
274
  ul.parentNode.insertBefore(p, ul.nextSibling);
95
275
  checkLi.remove();
96
276
  if (ul.children.length === 0) ul.remove();
97
277
  const nr = document.createRange();
98
- nr.setStart(p, 0);
99
- nr.collapse(true);
100
- sel.removeAllRanges();
101
- sel.addRange(nr);
102
- } else {
103
- // Create new checklist item; move after-cursor content into it
104
- const newLi = document.createElement('li');
105
- const cb = document.createElement('input');
106
- cb.type = 'checkbox';
107
- cb.setAttribute('contenteditable', 'false');
108
- newLi.appendChild(cb);
109
- let cursorNode;
110
- if (afterFrag.textContent.length > 0) {
111
- newLi.appendChild(afterFrag);
112
- cursorNode = newLi.childNodes[1]; // first node after checkbox
113
- } else {
114
- cursorNode = document.createTextNode('\u00a0');
115
- newLi.appendChild(cursorNode);
116
- }
117
- checkLi.insertAdjacentElement('afterend', newLi);
118
- const nr = document.createRange();
119
- nr.setStart(cursorNode, 0);
278
+ nr.setStart(p.firstChild, 0);
120
279
  nr.collapse(true);
121
280
  sel.removeAllRanges();
122
281
  sel.addRange(nr);
282
+ return true;
123
283
  }
284
+
285
+ // 2. If selection is not collapsed, delete the selected content first —
286
+ // mirrors browser-default Enter behaviour (delete selection, then split).
287
+ if (!nativeRange.collapsed) {
288
+ nativeRange.deleteContents();
289
+ // nativeRange is now collapsed at the deletion point; re-read it
290
+ nativeRange = sel.getRangeAt(0);
291
+ }
292
+
293
+ // 3. Extract everything from cursor to end of li into afterFrag.
294
+ // Use startContainer/startOffset (cursor position after potential delete),
295
+ // NOT endContainer/endOffset which is wrong for non-collapsed ranges.
296
+ const afterRange = document.createRange();
297
+ afterRange.setStart(nativeRange.startContainer, nativeRange.startOffset);
298
+ afterRange.setEnd(checkLi, checkLi.childNodes.length);
299
+ const afterFrag = afterRange.extractContents();
300
+
301
+ // 4. Build the new checklist item with the extracted "after" content.
302
+ const newLi = document.createElement('li');
303
+ const cb = document.createElement('input');
304
+ cb.type = 'checkbox';
305
+ cb.setAttribute('contenteditable', 'false');
306
+ newLi.appendChild(cb);
307
+
308
+ // Append extracted "after" content (if any) then insert the new item.
309
+ if (afterFrag.textContent.replace(/[\u00a0\u200B]/g, '').length > 0) {
310
+ newLi.appendChild(afterFrag);
311
+ }
312
+
313
+ // Always ensure a text node exists so the cursor has a text-level
314
+ // anchor. Use \u200B (zero-width space) instead of an empty string:
315
+ // Chrome does not reliably honour a Selection in an empty text node and
316
+ // may normalise it to element-level, placing the caret before the
317
+ // absolutely-positioned checkbox. \u200B is stripped by getHTML().
318
+ let cursorNode = newLi.childNodes[1]; // first child after checkbox
319
+ if (!cursorNode || cursorNode.nodeType !== Node.TEXT_NODE) {
320
+ cursorNode = document.createTextNode('\u200B');
321
+ newLi.appendChild(cursorNode);
322
+ }
323
+ checkLi.insertAdjacentElement('afterend', newLi);
324
+
325
+ const nr = document.createRange();
326
+ nr.setStart(cursorNode, 0);
327
+ nr.collapse(true);
328
+ sel.removeAllRanges();
329
+ sel.addRange(nr);
124
330
  return true;
125
331
  }
126
332
 
@@ -1,12 +1,12 @@
1
- /**
2
- * index.umd.js — UMD entry point for AutumnNote
3
- *
4
- * Re-exports only the default export so the UMD global is the factory object
5
- * directly, enabling the script-tag usage documented in the README:
6
- *
7
- * <script src="dist/autumnnote.umd.js"></script>
8
- * <script>
9
- * const editor = AutumnNote.create('#my-editor');
10
- * </script>
11
- */
12
- export { default } from './index.js';
1
+ /**
2
+ * index.umd.js — UMD entry point for AutumnNote
3
+ *
4
+ * Re-exports only the default export so the UMD global is the factory object
5
+ * directly, enabling the script-tag usage documented in the README:
6
+ *
7
+ * <script src="dist/autumnnote.umd.js"></script>
8
+ * <script>
9
+ * const editor = AutumnNote.create('#my-editor');
10
+ * </script>
11
+ */
12
+ export { default } from './index.js';
@@ -57,7 +57,16 @@ function btn(name, icon, tooltip, action, isActive, isDisabled) {
57
57
 
58
58
  export const boldBtn = btn('bold', 'bold', 'Bold (Ctrl+B)', () => Style.bold(), () => document.queryCommandState('bold'));
59
59
  export const italicBtn = btn('italic', 'italic', 'Italic (Ctrl+I)', () => Style.italic(), () => document.queryCommandState('italic'));
60
- export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => document.queryCommandState('underline'));
60
+ export const underlineBtn = btn('underline', 'underline', 'Underline (Ctrl+U)', () => Style.underline(), () => {
61
+ // queryCommandState('underline') is unreliable inside <code> elements;
62
+ // also check for a <u> ancestor in the DOM.
63
+ if (document.queryCommandState('underline')) return true;
64
+ const sel = window.getSelection();
65
+ 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'));
69
+ });
61
70
  export const strikeBtn = btn('strikethrough', 'strikethrough', 'Strikethrough', () => Style.strikethrough(), () => document.queryCommandState('strikeThrough'));
62
71
  export const superscriptBtn = btn('superscript', 'superscript', 'Superscript', () => Style.superscript(), () => document.queryCommandState('superscript'));
63
72
  export const subscriptBtn = btn('subscript', 'subscript', 'Subscript', () => Style.subscript(), () => document.queryCommandState('subscript'));
@@ -134,8 +143,8 @@ export const fontSizeBtn = {
134
143
  if (sel && sel.rangeCount) {
135
144
  let el = sel.getRangeAt(0).startContainer;
136
145
  if (el.nodeType === 3) el = el.parentElement;
137
- while (el && !el.style) el = el.parentElement;
138
- const size = el ? (el.style.fontSize || '') : '';
146
+ while (el && el.nodeType === 1 && !el.style.fontSize) el = el.parentElement;
147
+ const size = (el && el.style && el.style.fontSize) ? el.style.fontSize : '';
139
148
  if (size) return size;
140
149
  }
141
150
  // Fallback: read the base font size from the editable element itself
@@ -237,7 +246,8 @@ export const lineHeightBtn = {
237
246
  let el = sel.getRangeAt(0).startContainer;
238
247
  if (el.nodeType === 3) el = el.parentElement;
239
248
  while (el && !BLOCKS.has(el.tagName)) el = el.parentElement;
240
- return el ? (el.style.lineHeight || '') : '';
249
+ if (!el) return '';
250
+ return el.style.lineHeight || getComputedStyle(el).lineHeight || '';
241
251
  } catch { return ''; }
242
252
  },
243
253
  };
@@ -248,7 +258,7 @@ export const lineHeightBtn = {
248
258
 
249
259
  export const codeviewBtn = btn('codeview', 'code', 'HTML Code View', (ctx) => ctx.invoke('codeview.toggle'), (ctx) => ctx.invoke('codeview.isActive'));
250
260
  export const fullscreenBtn = btn('fullscreen', 'expand', 'Fullscreen', (ctx) => ctx.invoke('fullscreen.toggle'), (ctx) => ctx.invoke('fullscreen.isActive'));
251
- export const shortcutsBtn = btn('shortcuts', 'keyboard', 'Keyboard Shortcuts (Shift+?)', (ctx) => ctx.invoke('shortcutsDialog.show'));
261
+ export const shortcutsBtn = btn('shortcuts', 'keyboard', 'Keyboard Shortcuts (Ctrl+Shift+/)', (ctx) => ctx.invoke('shortcutsDialog.show'));
252
262
  export const findBtn = btn('find', 'search', 'Find (Ctrl+F)', (ctx) => ctx.invoke('findReplace.show', 'find'));
253
263
  export const findReplaceBtn = btn('findReplace', 'find-replace', 'Find & Replace (Ctrl+H)', (ctx) => ctx.invoke('findReplace.show', 'replace'));
254
264
  export const inlineCodeBtn = btn('inlineCode', 'inline-code', 'Inline Code (Ctrl+`)', (ctx) => ctx.invoke('editor.inlineCode'), () => Style.isInlineCode());
@@ -221,8 +221,9 @@ export class CodeTooltip {
221
221
  if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
222
222
  if (left < margin) left = margin;
223
223
 
224
- this._el.style.top = `${top + window.scrollY}px`;
225
- this._el.style.left = `${left + window.scrollX}px`;
224
+ // Tooltip uses position:fixed, so viewport coordinates are used directly.
225
+ this._el.style.top = `${top}px`;
226
+ this._el.style.left = `${left}px`;
226
227
  }
227
228
 
228
229
  // ---------------------------------------------------------------------------
@@ -356,15 +357,25 @@ export class CodeTooltip {
356
357
  if (!this.context.options.codeHighlight || window.Prism) return;
357
358
  const cdn = this.context.options.codeHighlightCDN
358
359
  || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
360
+ const themeHref = `${cdn}/themes/prism-tomorrow.min.css`;
361
+ const scriptSrc = `${cdn}/prism.min.js`;
362
+
363
+ if (!document.querySelector(`link[href="${themeHref}"]`)) {
364
+ const link = document.createElement('link');
365
+ link.rel = 'stylesheet';
366
+ link.href = themeHref;
367
+ document.head.appendChild(link);
368
+ }
359
369
 
360
- const link = document.createElement('link');
361
- link.rel = 'stylesheet';
362
- link.href = `${cdn}/themes/prism-tomorrow.min.css`;
363
- document.head.appendChild(link);
370
+ const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
371
+ if (existingScript) {
372
+ this._prismScript = window.Prism ? null : existingScript;
373
+ return;
374
+ }
364
375
 
365
376
  const script = document.createElement('script');
366
377
  script.dataset.manual = ''; // prevent auto-highlight on load
367
- script.src = `${cdn}/prism.min.js`;
378
+ script.src = scriptSrc;
368
379
  this._prismScript = script;
369
380
  script.addEventListener('load', () => { this._prismScript = null; }, { once: true });
370
381
  document.head.appendChild(script);
@@ -98,7 +98,12 @@ export class Codeview {
98
98
  const INLINE_RE = /^<(a|abbr|b|bdo|br|button|cite|code|dfn|em|i|img|input|kbd|label|output|q|samp|select|small|span|strong|sub|sup|textarea|time|tt|u|var)([\s>/])/i;
99
99
  let indent = 0;
100
100
  return html
101
- .replace(/>\s*</g, '>\n<')
101
+ .replace(/>\s*</g, (match, offset, str) => {
102
+ // Check what comes after the '>' — if it starts an inline tag, keep on same line
103
+ const remaining = str.slice(offset + 1);
104
+ if (INLINE_RE.test(remaining.trimStart())) return '><';
105
+ return '>\n<';
106
+ })
102
107
  .split('\n')
103
108
  .map((line) => {
104
109
  const stripped = line.trim();