autumnnote 1.10.0 → 1.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
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",
@@ -31,6 +31,18 @@ export function htmlToMarkdown(html) {
31
31
  * @param {number} [depth=0] - Current nesting depth used to indent nested list items.
32
32
  * @returns {string} The Markdown representation of the node subtree.
33
33
  */
34
+ /**
35
+ * Direct child elements matching a tag name. Used instead of the CSS
36
+ * `:scope > tag` combinator, which this project's jsdom version resolves
37
+ * incorrectly (matches descendants at any depth, not just direct children).
38
+ * @param {Element} el
39
+ * @param {string} tagName
40
+ * @returns {Element[]}
41
+ */
42
+ function _directChildren(el, tagName) {
43
+ return Array.from(el.children).filter((c) => c.tagName === tagName.toUpperCase());
44
+ }
45
+
34
46
  function _domToMd(node, depth = 0) {
35
47
  if (node.nodeType === 3) {
36
48
  return node.textContent.replace(/\s+/g, ' ');
@@ -60,6 +72,18 @@ function _domToMd(node, depth = 0) {
60
72
  case 'strike': return `~~${inner()}~~`;
61
73
  case 'sup': return `^${inner()}^`;
62
74
  case 'sub': return `~${inner()}~`;
75
+ case 'u': return `<u>${inner()}</u>`;
76
+ case 'span': {
77
+ // Markdown has no native underline/color/size syntax; pass through as
78
+ // raw inline HTML for the specific styles the editor's own toolbar
79
+ // creates (foreColor/backColor/fontSize) — other noise spans (e.g. from
80
+ // pasted content) are unwrapped to plain text as before.
81
+ const style = el.getAttribute('style') || '';
82
+ if (/\b(color|background-color|font-size)\s*:/.test(style)) {
83
+ return `<span style="${_escAttr(style)}">${inner()}</span>`;
84
+ }
85
+ return inner();
86
+ }
63
87
  case 'code': {
64
88
  // Inside <pre> we emit raw text; outside we wrap in backticks
65
89
  if (el.closest('pre')) return inner();
@@ -73,8 +97,10 @@ function _domToMd(node, depth = 0) {
73
97
  return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
74
98
  }
75
99
  case 'blockquote': {
76
- const lines = inner().trim().split('\n');
77
- return `\n\n${lines.map((l) => `> ${l}`).join('\n')}\n\n`;
100
+ const rawLines = inner().trim().split('\n');
101
+ // Collapse consecutive blank lines (from adjacent <p> blocks) into one.
102
+ const lines = rawLines.filter((l, idx) => l.trim() !== '' || (rawLines[idx - 1] ?? '').trim() !== '');
103
+ return `\n\n${lines.map((l) => (l.trim() === '' ? '>' : `> ${l}`)).join('\n')}\n\n`;
78
104
  }
79
105
  case 'a': {
80
106
  const href = el.getAttribute('href') || '';
@@ -86,14 +112,16 @@ function _domToMd(node, depth = 0) {
86
112
  return `![${alt}](${src})`;
87
113
  }
88
114
  case 'ul': {
89
- const items = Array.from(el.querySelectorAll(':scope > li'));
115
+ const items = _directChildren(el, 'li');
90
116
  if (!items.length) return inner();
91
117
  const indent = ' '.repeat(depth);
92
118
  const isChecklist = el.classList.contains('an-checklist');
93
119
  const lines = items.map((li) => {
120
+ const cb = /** @type {HTMLInputElement | undefined} */ (
121
+ _directChildren(li, 'input').find((c) => c.getAttribute('type') === 'checkbox')
122
+ );
94
123
  let prefix = '- ';
95
- if (isChecklist) {
96
- const cb = /** @type {HTMLInputElement | null} */ (li.querySelector('input[type="checkbox"]'));
124
+ if (isChecklist || cb) {
97
125
  const checked = cb ? cb.checked : false;
98
126
  prefix = checked ? '- [x] ' : '- [ ] ';
99
127
  }
@@ -102,7 +130,7 @@ function _domToMd(node, depth = 0) {
102
130
  return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
103
131
  }
104
132
  case 'ol': {
105
- const items = Array.from(el.querySelectorAll(':scope > li'));
133
+ const items = _directChildren(el, 'li');
106
134
  if (!items.length) return inner();
107
135
  const indent = ' '.repeat(depth);
108
136
  const lines = items.map((li, i) => `${indent}${i + 1}. ${_domToMd(li, depth + 1).trim()}`).join('\n');
@@ -111,17 +139,24 @@ function _domToMd(node, depth = 0) {
111
139
  case 'li': return inner();
112
140
  case 'hr': return '\n\n---\n\n';
113
141
  case 'table': {
114
- const rows = Array.from(el.querySelectorAll('tr'));
115
- if (!rows.length) return inner();
116
- const cellTexts = rows.map((tr) =>
142
+ const allRows = Array.from(el.querySelectorAll('tr'));
143
+ if (!allRows.length) return inner();
144
+ const theadEl = _directChildren(el, 'thead')[0];
145
+ const firstRowIsHeader = !!theadEl || (
146
+ allRows[0].children.length > 0 &&
147
+ Array.from(allRows[0].children).every((c) => c.tagName === 'TH')
148
+ );
149
+ const cellTexts = allRows.map((tr) =>
117
150
  Array.from(tr.querySelectorAll('th, td')).map((c) => c.textContent.trim().replaceAll('|', String.raw`\|`)),
118
151
  );
119
152
  const cols = Math.max(...cellTexts.map((r) => r.length));
120
153
  const padRow = (row) => { const r = [...row]; while (r.length < cols) r.push(''); return r; };
154
+ const bodyStart = firstRowIsHeader ? 1 : 0;
155
+ const headerCells = firstRowIsHeader ? padRow(cellTexts[0]) : new Array(cols).fill('');
121
156
  let md = '\n\n';
122
- md += `| ${padRow(cellTexts[0]).join(' | ')} |\n`;
157
+ md += `| ${headerCells.join(' | ')} |\n`;
123
158
  md += `| ${new Array(cols).fill('---').join(' | ')} |\n`;
124
- for (let r = 1; r < cellTexts.length; r++) {
159
+ for (let r = bodyStart; r < cellTexts.length; r++) {
125
160
  md += `| ${padRow(cellTexts[r]).join(' | ')} |\n`;
126
161
  }
127
162
  return md + '\n';
@@ -139,13 +174,22 @@ function _domToMd(node, depth = 0) {
139
174
  * @returns {boolean} `true` if any Markdown-like pattern is present, `false` otherwise.
140
175
  */
141
176
  export function isMarkdown(text) {
142
- return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> [^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text)
177
+ return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> ?[^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text)
143
178
  || /^.+\n=+\s*$/m.test(text)
144
179
  || /^.+\n-{2,}\s*$/m.test(text)
145
180
  || /^---\s*\n(?:[\s\S]*?\n)?(?:---|\.\.\.)\s*(?:\n|$)/.test(text)
146
181
  || /^\|.+\|[ \t]*\n\|[ \t:|-]+\|/m.test(text);
147
182
  }
148
183
 
184
+ // Blockquote line: optional up-to-3 leading spaces, '>', optional single space, rest of line.
185
+ const BQ_RE = /^ {0,3}>( ?)(.*)$/;
186
+ // Horizontal rule: 3+ of the same character (-, * or _), optionally space-separated.
187
+ const HR_RE = /^ {0,3}([-*_])( *\1){2,}\s*$/;
188
+ // Hard-break marker — placed between paragraph lines that end in a
189
+ // CommonMark hard-break (trailing 2+ spaces or a trailing backslash),
190
+ // restored to <br> after _inline() runs. Distinct from _inline()'s own MARK.
191
+ const HARD_BREAK = String.fromCharCode(1);
192
+
149
193
  /**
150
194
  * Converts a Markdown string to an HTML string.
151
195
  * @param {string} text
@@ -158,6 +202,17 @@ export function markdownToHTML(text) {
158
202
  lines = refs.clean;
159
203
  _linkDefs = refs.linkDefs;
160
204
  _footnoteIds = refs.footnoteIds;
205
+ return _parseBlocks(lines);
206
+ }
207
+
208
+ /**
209
+ * Parses a line array into block-level HTML. Called recursively for content
210
+ * nested inside a blockquote so nested quotes and block content (lists,
211
+ * headings, etc.) inside `>` are parsed the same as top-level content.
212
+ * @param {string[]} lines
213
+ * @returns {string}
214
+ */
215
+ function _parseBlocks(lines) {
161
216
  const out = [];
162
217
  let i = 0;
163
218
 
@@ -181,7 +236,7 @@ export function markdownToHTML(text) {
181
236
  }
182
237
 
183
238
  // ---- Setext headings (Title\n=== or Title\n---) -------------------------
184
- if (line.trim() && !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line) && !/^#{1,6} /.test(line) && i + 1 < lines.length) {
239
+ if (line.trim() && !HR_RE.test(line) && !/^#{1,6} /.test(line) && i + 1 < lines.length) {
185
240
  if (/^=+\s*$/.test(lines[i + 1])) {
186
241
  out.push(`<h1>${_inline(line.trim())}</h1>`);
187
242
  i += 2;
@@ -194,8 +249,8 @@ export function markdownToHTML(text) {
194
249
  }
195
250
  }
196
251
 
197
- // ---- Horizontal rule --- / *** / _________________________________________
198
- if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
252
+ // ---- Horizontal rule --- / *** / ___ / - - - / * * * ----------------------
253
+ if (HR_RE.test(line)) {
199
254
  out.push('<hr>');
200
255
  i++;
201
256
  continue;
@@ -205,19 +260,22 @@ export function markdownToHTML(text) {
205
260
  const hMatch = /^(#{1,6})\s+(.+)$/.exec(line);
206
261
  if (hMatch) {
207
262
  const level = hMatch[1].length;
208
- out.push(`<h${level}>${_inline(hMatch[2])}</h${level}>`);
263
+ // Strip an optional closing sequence of #'s (e.g. "## Heading ##"),
264
+ // only when preceded by whitespace — "Heading#" (no space) is untouched.
265
+ const content = hMatch[2].replace(/(?:^|\s)#+\s*$/, '');
266
+ out.push(`<h${level}>${_inline(content)}</h${level}>`);
209
267
  i++;
210
268
  continue;
211
269
  }
212
270
 
213
271
  // ---- Blockquote > text --------------------------------------------------
214
- if (line.startsWith('> ')) {
272
+ if (BQ_RE.test(line)) {
215
273
  const bqLines = [];
216
- while (i < lines.length && lines[i].startsWith('> ')) {
217
- bqLines.push(lines[i].slice(2));
274
+ while (i < lines.length && BQ_RE.test(lines[i])) {
275
+ bqLines.push(BQ_RE.exec(lines[i])[2]);
218
276
  i++;
219
277
  }
220
- out.push(`<blockquote>${bqLines.map(_inline).join('<br>')}</blockquote>`);
278
+ out.push(`<blockquote>${_parseBlocks(bqLines)}</blockquote>`);
221
279
  continue;
222
280
  }
223
281
 
@@ -273,7 +331,9 @@ export function markdownToHTML(text) {
273
331
  while (
274
332
  i < lines.length &&
275
333
  lines[i].trim() !== '' &&
276
- !/^(#{1,6} |> |[-*+] |\d+\. |```|---\s*$|\*{3}\s*$|_{3}\s*$)/.test(lines[i]) &&
334
+ !/^(#{1,6} |[-*+] |\d+\. |```)/.test(lines[i]) &&
335
+ !BQ_RE.test(lines[i]) &&
336
+ !HR_RE.test(lines[i]) &&
277
337
  !/^\|.+\|/.test(lines[i]) &&
278
338
  !(i + 1 < lines.length && /^=+\s*$/.test(lines[i + 1])) &&
279
339
  !(i + 1 < lines.length && /^-{2,}\s*$/.test(lines[i + 1]))
@@ -282,7 +342,7 @@ export function markdownToHTML(text) {
282
342
  i++;
283
343
  }
284
344
  if (paraLines.length) {
285
- out.push(`<p>${_inline(paraLines.join(' '))}</p>`);
345
+ out.push(`<p>${_inline(_joinParagraphLines(paraLines)).replaceAll(HARD_BREAK, '<br>')}</p>`);
286
346
  }
287
347
  }
288
348
 
@@ -356,17 +416,42 @@ function _extractReferenceDefinitions(lines) {
356
416
  }
357
417
 
358
418
  /**
359
- * Splits a GFM table row string into trimmed cell strings.
360
- * '| a | b | c |' → ['a', 'b', 'c']
419
+ * Joins a paragraph's source lines into one string, converting CommonMark
420
+ * hard-break markers (a trailing backslash, or 2+ trailing spaces) on all
421
+ * but the last line into a HARD_BREAK placeholder instead of a plain space.
422
+ * @param {string[]} paraLines
423
+ * @returns {string}
424
+ */
425
+ function _joinParagraphLines(paraLines) {
426
+ let joined = '';
427
+ for (let idx = 0; idx < paraLines.length; idx++) {
428
+ const isLast = idx === paraLines.length - 1;
429
+ const ln = paraLines[idx];
430
+ if (!isLast && /\\$/.test(ln)) { joined += ln.replace(/\\$/, '') + HARD_BREAK; continue; }
431
+ if (!isLast && / {2,}$/.test(ln)) { joined += ln.replace(/ {2,}$/, '') + HARD_BREAK; continue; }
432
+ joined += ln + (isLast ? '' : ' ');
433
+ }
434
+ return joined;
435
+ }
436
+
437
+ /**
438
+ * Splits a GFM table row string into trimmed cell strings, treating an
439
+ * escaped pipe (`\|`) as a literal character rather than a cell separator.
440
+ * '| a | b | c |' → ['a', 'b', 'c']; '| a\|b | c |' → ['a|b', 'c']
361
441
  * @param {string} row
362
442
  * @returns {string[]}
363
443
  */
364
444
  function _parseTableRow(row) {
365
- return row
366
- .replace(/^\|/, '')
367
- .replace(/\|$/, '')
368
- .split('|')
369
- .map((c) => c.trim());
445
+ const trimmed = row.replace(/^\|/, '').replace(/\|$/, '');
446
+ const cells = [];
447
+ let cur = '';
448
+ for (let i = 0; i < trimmed.length; i++) {
449
+ if (trimmed[i] === '\\' && trimmed[i + 1] === '|') { cur += '|'; i++; continue; }
450
+ if (trimmed[i] === '|') { cells.push(cur); cur = ''; continue; }
451
+ cur += trimmed[i];
452
+ }
453
+ cells.push(cur);
454
+ return cells.map((c) => c.trim());
370
455
  }
371
456
 
372
457
  function _parseListBlock(lines, startIdx) {
@@ -374,11 +459,31 @@ function _parseListBlock(lines, startIdx) {
374
459
  const isOL = /^\s*\d+\. /.test(lines[startIdx]);
375
460
  const items = [];
376
461
  let firstIsCB = null;
462
+ let loose = false;
463
+ let pendingBlank = false;
377
464
  let i = startIdx;
378
465
 
379
466
  while (i < lines.length) {
380
467
  const line = lines[i];
381
- if (line.trim() === '') break;
468
+
469
+ if (line.trim() === '') {
470
+ // A blank line only ends the list if what follows isn't a continuation
471
+ // of it (another item at the same marker/indent, or indented text
472
+ // belonging to the current item) — otherwise it marks a "loose" list.
473
+ const next = lines[i + 1];
474
+ const nextIndent = next !== undefined ? (next.match(/^(\s*)/)[1]).length : -1;
475
+ const nextIsSameItem = next !== undefined &&
476
+ /^\s*(?:[-*+]|\d+\.) /.test(next) &&
477
+ (/^\s*\d+\. /.test(next) === isOL) &&
478
+ nextIndent === baseIndent;
479
+ const nextIsContinuation = next !== undefined && next.trim() !== '' && nextIndent > baseIndent;
480
+ if (!items.length || (!nextIsSameItem && !nextIsContinuation)) break;
481
+ loose = true;
482
+ pendingBlank = true;
483
+ i++;
484
+ continue;
485
+ }
486
+
382
487
  const indent = (line.match(/^(\s*)/)[1]).length;
383
488
  if (indent < baseIndent) break;
384
489
 
@@ -386,12 +491,18 @@ function _parseListBlock(lines, startIdx) {
386
491
  if (!/^\s*(?:[-*+]|\d+\.) /.test(line)) break;
387
492
  if (/^\s*\d+\. /.test(line) !== isOL) break;
388
493
  const raw = isOL ? line.replace(/^\s*\d+\. /, '') : line.replace(/^\s*[-*+] /, '');
494
+ // Checklists are intentionally UL-only: sanitise.js's checkbox guard,
495
+ // the injected checklist CSS, and every checklist-toggle command are
496
+ // all hardcoded to `ul.an-checklist` with no `ol` equivalent, so an
497
+ // ordered-list checkbox would be stripped by the sanitiser and get no
498
+ // styling even if parsed here — "1. [ ] item" intentionally stays plain.
389
499
  const isCB = !isOL && /^\[[ xX]\]\s+/.test(raw);
390
500
  if (firstIsCB === null) firstIsCB = isCB;
391
501
  if (isCB !== firstIsCB) break;
392
502
  const checked = isCB && raw[1].toLowerCase() === 'x';
393
503
  const text = isCB ? raw.replace(/^\[[ xX]\]\s+/, '') : raw;
394
- items.push({ text, isCB, checked, sub: '' });
504
+ items.push({ paras: [text], isCB, checked, sub: '' });
505
+ pendingBlank = false;
395
506
  i++;
396
507
  } else {
397
508
  if (!items.length) { i++; continue; }
@@ -399,64 +510,164 @@ function _parseListBlock(lines, startIdx) {
399
510
  const nested = _parseListBlock(lines, i);
400
511
  items[items.length - 1].sub += nested.html;
401
512
  i = nested.endIdx;
513
+ pendingBlank = false;
514
+ } else if (pendingBlank) {
515
+ items[items.length - 1].paras.push(line.trim());
516
+ pendingBlank = false;
517
+ i++;
402
518
  } else {
403
- items[items.length - 1].text += ' ' + line.trim();
519
+ const paras = items[items.length - 1].paras;
520
+ paras[paras.length - 1] += ' ' + line.trim();
404
521
  i++;
405
522
  }
406
523
  }
407
524
  }
408
525
 
409
526
  const hasCB = !isOL && (firstIsCB === true);
410
- const open = isOL ? '<ol>' : hasCB ? '<ul class="an-checklist">' : '<ul>';
527
+ const startMatch = isOL ? /^\s*(\d+)\. /.exec(lines[startIdx]) : null;
528
+ const startNum = startMatch ? Number.parseInt(startMatch[1], 10) : 1;
529
+ const open = isOL
530
+ ? (startNum !== 1 ? `<ol start="${startNum}">` : '<ol>')
531
+ : (hasCB ? '<ul class="an-checklist">' : '<ul>');
411
532
  const close = isOL ? '</ol>' : '</ul>';
412
- const liHTML = items.map(({ text, isCB, checked, sub }) => {
533
+ const liHTML = items.map(({ paras, isCB, checked, sub }) => {
413
534
  const cbHTML = isCB
414
535
  ? `<input type="checkbox" contenteditable="false"${checked ? ' checked' : ''}>`
415
536
  : '';
416
- return `<li>${cbHTML}${_inline(text)}${sub}</li>`;
537
+ const body = loose
538
+ ? paras.map((p, idx) => `<p>${idx === 0 ? cbHTML : ''}${_inline(p)}</p>`).join('')
539
+ : `${cbHTML}${_inline(paras[0])}`;
540
+ return `<li>${body}${sub}</li>`;
417
541
  }).join('');
418
542
  return { html: `${open}${liHTML}${close}`, endIdx: i };
419
543
  }
420
544
 
421
- function _inline(text) {
422
- // Images before links (they share [] syntax)
545
+ // Backslash-escapable inline punctuation (CommonMark-ish, narrowed to the
546
+ // syntax characters this converter actually uses).
547
+ const ESCAPABLE_RE = /\\([*_`#[\]()>\\~|])/g;
548
+ // Placeholder marker for escaped literals — a NUL character can't appear in
549
+ // real markdown text, so it's safe as a delimiter. Built at runtime (not
550
+ // written as a literal escape) to avoid embedding a raw NUL byte in this file.
551
+ const MARK = String.fromCharCode(0);
552
+
553
+ /**
554
+ * Step 0 of _inline(): replaces backslash-escaped punctuation with inert
555
+ * placeholders so later syntax regexes can't match them.
556
+ * @param {string} text
557
+ * @returns {{ text: string, literals: string[] }}
558
+ */
559
+ function _extractBackslashEscapes(text) {
560
+ const literals = [];
561
+ const replaced = text.replace(ESCAPABLE_RE, (_, ch) => {
562
+ literals.push(ch);
563
+ return `${MARK}${literals.length - 1}${MARK}`;
564
+ });
565
+ return { text: replaced, literals };
566
+ }
567
+
568
+ /**
569
+ * Restores placeholders from _extractBackslashEscapes(), HTML-escaping each
570
+ * literal since it's inserted directly into the output.
571
+ * @param {string} text
572
+ * @param {string[]} literals
573
+ * @returns {string}
574
+ */
575
+ function _restoreBackslashEscapes(text, literals) {
576
+ return text.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_, idx) => _esc(literals[Number(idx)]));
577
+ }
578
+
579
+ /**
580
+ * Resolves images, inline links, GFM reference-style links (explicit,
581
+ * shortcut, and bare/implicit forms), and footnote markers. Must run on text
582
+ * already passed through _esc() — see _inline()'s Step 1 comment.
583
+ * @param {string} text
584
+ * @returns {string}
585
+ */
586
+ function _resolveLinksAndFootnotes(text) {
423
587
  text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
424
- `<img src="${_escAttr(src)}" alt="${_escAttr(alt)}" class="an-image">`);
425
- // Links
588
+ `<img src="${_escAttrQuotes(src)}" alt="${_escAttrQuotes(alt)}" class="an-image">`);
426
589
  text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
427
- `<a href="${_escAttr(href)}">${_esc(label)}</a>`);
428
- // Reference-style links [text][ref] and shortcut [text][]
590
+ `<a href="${_escAttrQuotes(href)}">${label}</a>`);
429
591
  text = text.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (m, label, ref) => {
430
- const def = _linkDefs.get((ref || label).trim().toLowerCase());
592
+ const def = _linkDefs.get(_unescAmpLtGt(ref || label).trim().toLowerCase());
431
593
  if (!def) return m;
432
594
  const titleAttr = def.title ? ` title="${_escAttr(def.title)}"` : '';
433
- return `<a href="${_escAttr(def.href)}"${titleAttr}>${_esc(label)}</a>`;
595
+ return `<a href="${_escAttr(def.href)}"${titleAttr}>${label}</a>`;
434
596
  });
435
- // Bare/implicit reference link [text] — only when a definition exists
436
597
  text = text.replace(/\[([^\]]+)\]/g, (m, label) => {
437
- const def = _linkDefs.get(label.trim().toLowerCase());
598
+ const def = _linkDefs.get(_unescAmpLtGt(label).trim().toLowerCase());
438
599
  if (!def) return m;
439
600
  const titleAttr = def.title ? ` title="${_escAttr(def.title)}"` : '';
440
- return `<a href="${_escAttr(def.href)}"${titleAttr}>${_esc(label)}</a>`;
601
+ return `<a href="${_escAttr(def.href)}"${titleAttr}>${label}</a>`;
602
+ });
603
+ text = text.replace(/\[\^([^\]]+)\]/g, (m, id) => (_footnoteIds.has(_unescAmpLtGt(id)) ? `<sup>[${id}]</sup>` : m));
604
+ return text;
605
+ }
606
+
607
+ /**
608
+ * Converts angle-bracket (`<https://...>`) and bare (`https://...`)
609
+ * autolinks. Runs after _resolveLinksAndFootnotes() so an already-linked URL
610
+ * isn't reprocessed, and on already-_esc()'d text (see _inline()).
611
+ * @param {string} text
612
+ * @returns {string}
613
+ */
614
+ function _applyAutolinks(text) {
615
+ text = text.replace(/&lt;(https?:\/\/[^\s&]+?)&gt;/g, (_, url) => `<a href="${_escAttrQuotes(url)}">${url}</a>`);
616
+ text = text.replace(/(^|[\s(])(https?:\/\/[^\s()]+)/g, (m, pre, rawUrl) => {
617
+ const trail = /[.,;:!?)]+$/.exec(rawUrl);
618
+ const url = trail ? rawUrl.slice(0, -trail[0].length) : rawUrl;
619
+ if (!url) return m;
620
+ const suffix = trail ? trail[0] : '';
621
+ return `${pre}<a href="${_escAttrQuotes(url)}">${url}</a>${suffix}`;
441
622
  });
442
- // Footnote reference marker [^id] — run last
443
- text = text.replace(/\[\^([^\]]+)\]/g, (m, id) => (_footnoteIds.has(id) ? `<sup>[${_esc(id)}]</sup>` : m));
444
- // Bold + italic ***text***
445
- text = text.replace(/\*{3}([^*\n]+?)\*{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
446
- text = text.replace(/_{3}([^_\n]+?)_{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
447
- // Bold **text**
448
- text = text.replace(/\*{2}([^*\n]+?)\*{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
449
- text = text.replace(/_{2}([^_\n]+?)_{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
450
- // Italic *text* _text_
451
- text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${_esc(c)}</em>`);
452
- text = text.replace(/_([^_\n]+?)_/g, (_, c) => `<em>${_esc(c)}</em>`);
453
- // Strikethrough ~~text~~
454
- text = text.replace(/~~([^~\n]+?)~~/g, (_, c) => `<del>${_esc(c)}</del>`);
455
- // Inline code `code`
456
- text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${_esc(c)}</code>`);
457
623
  return text;
458
624
  }
459
625
 
626
+ /**
627
+ * Applies bold/italic/bold-italic (asterisk and underscore forms — underscore
628
+ * requires a non-word-character boundary per CommonMark), strikethrough, and
629
+ * inline code.
630
+ * @param {string} text
631
+ * @returns {string}
632
+ */
633
+ function _applyEmphasisAndCode(text) {
634
+ text = text.replace(/\*{3}([^*\n]+?)\*{3}/g, (_, c) => `<strong><em>${c}</em></strong>`);
635
+ text = text.replace(/(?<!\w)_{3}([^_\n]+?)_{3}(?!\w)/g, (_, c) => `<strong><em>${c}</em></strong>`);
636
+ text = text.replace(/\*{2}([^*\n]+?)\*{2}/g, (_, c) => `<strong>${c}</strong>`);
637
+ text = text.replace(/(?<!\w)_{2}([^_\n]+?)_{2}(?!\w)/g, (_, c) => `<strong>${c}</strong>`);
638
+ text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${c}</em>`);
639
+ text = text.replace(/(?<!\w)_([^_\n]+?)_(?!\w)/g, (_, c) => `<em>${c}</em>`);
640
+ text = text.replace(/~~([^~\n]+?)~~/g, (_, c) => `<del>${c}</del>`);
641
+ // Double-backtick code spans first (tolerates a single literal ` inside),
642
+ // then single-backtick spans.
643
+ text = text.replace(/``([\s\S]*?)``/g, (_, c) => `<code>${c}</code>`);
644
+ text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
645
+ return text;
646
+ }
647
+
648
+ function _inline(text) {
649
+ // Step 0: backslash escapes (\* \_ \` \# \[ \] \( \) \> \\ \~ \|) — replaced
650
+ // with inert placeholders before any syntax regex below can match them, so
651
+ // e.g. \*not bold\* never gets treated as emphasis. Restored at the end.
652
+ const { text: withoutEscapes, literals } = _extractBackslashEscapes(text);
653
+
654
+ // Step 1: escape raw &/</> in the plain-text parts of the string exactly
655
+ // once, up front — none of these are markdown-syntax characters used below,
656
+ // so this doesn't interfere with matching. Capture-group content in the
657
+ // steps below is therefore ALREADY escaped and must NOT be re-escaped;
658
+ // attribute values captured from `text` only need quotes escaped
659
+ // (_escAttrQuotes), since & < > are already entities. Values that come from
660
+ // _linkDefs (sourced from the raw, unescaped line array) still need the
661
+ // full _escAttr/_esc treatment.
662
+ let result = _esc(withoutEscapes);
663
+
664
+ result = _resolveLinksAndFootnotes(result);
665
+ result = _applyAutolinks(result);
666
+ result = _applyEmphasisAndCode(result);
667
+
668
+ return _restoreBackslashEscapes(result, literals);
669
+ }
670
+
460
671
  function _esc(v) {
461
672
  return String(v)
462
673
  .replaceAll('&', '&amp;')
@@ -472,3 +683,13 @@ function _escAttr(v) {
472
683
  .replaceAll('<', '&lt;')
473
684
  .replaceAll('>', '&gt;');
474
685
  }
686
+
687
+ /** Escapes only quote characters — for attribute values already run through _esc(). */
688
+ function _escAttrQuotes(v) {
689
+ return String(v).replaceAll('"', '&quot;').replaceAll("'", '&#39;');
690
+ }
691
+
692
+ /** Reverses _esc()'s &amp;/&lt;/&gt; substitutions, for matching against un-escaped _linkDefs/_footnoteIds keys. */
693
+ function _unescAmpLtGt(v) {
694
+ return String(v).replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
695
+ }
package/src/js/index.js CHANGED
@@ -144,7 +144,7 @@ const AutumnNote = {
144
144
  buttons,
145
145
 
146
146
  /** Library version */
147
- version: '1.10.0',
147
+ version: '1.11.0',
148
148
  };
149
149
 
150
150
  // ---------------------------------------------------------------------------
@@ -194,6 +194,20 @@ export class Clipboard {
194
194
  return doc.body.innerHTML;
195
195
  }
196
196
 
197
+ /**
198
+ * Checks whether an HTML payload has no semantic markup beyond plain
199
+ * wrapper elements (e.g. a bare <div>/<p>). Used to decide whether a
200
+ * markdown-shaped plain-text paste should win over an accompanying HTML
201
+ * payload that isn't actually carrying any real rich-text formatting.
202
+ * @param {string} html
203
+ * @returns {boolean}
204
+ */
205
+ _isTriviallyPlainHtml(html) {
206
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
207
+ const SIGNIFICANT = 'a,img,table,ul,ol,li,blockquote,pre,code,h1,h2,h3,h4,h5,h6,strong,b,em,i,u,s,del,strike,hr,br';
208
+ return !doc.body.querySelector(SIGNIFICANT);
209
+ }
210
+
197
211
  /**
198
212
  * Forces the next paste operation to strip all HTML formatting.
199
213
  * Called by Editor when Ctrl+Shift+V is pressed.
@@ -219,6 +233,9 @@ export class Clipboard {
219
233
  const size = Math.max(text.length, html.length);
220
234
  if (size > maxBytes) {
221
235
  event.preventDefault();
236
+ const message = `Pasted content (${size} bytes) exceeds the ${this.options.maxPasteSize ?? 5} MB paste size limit.`;
237
+ this.context.triggerEvent('pasteError', { size, maxBytes, message });
238
+ console.warn(`[AutumnNote] ${message}`);
222
239
  return;
223
240
  }
224
241
  }
@@ -257,13 +274,20 @@ export class Clipboard {
257
274
  return;
258
275
  }
259
276
 
260
- // 3. Markdown paste — only when no HTML is on the clipboard (pure text source)
261
- if (this.options.markdownPaste !== false && !clipboardData.types.includes('text/html')) {
277
+ // 3. Markdown paste — when there's no HTML on the clipboard, or the
278
+ // accompanying HTML has no semantic markup (e.g. some terminal/clipboard
279
+ // tools put both a markdown-shaped text/plain and a trivial <div>-wrapped
280
+ // text/html on the clipboard). Real rich-text sources (Word, Docs, etc.)
281
+ // always have semantic tags after cleaning, so this is unaffected.
282
+ if (this.options.markdownPaste !== false) {
283
+ const hasHtml = clipboardData.types.includes('text/html');
284
+ const html = hasHtml ? clipboardData.getData('text/html') : '';
285
+ const htmlTriviallyPlain = !hasHtml || this._isTriviallyPlainHtml(html);
262
286
  const text = clipboardData.getData('text/plain');
263
- if (text && isMarkdown(text)) {
287
+ if (text && htmlTriviallyPlain && isMarkdown(text)) {
264
288
  event.preventDefault();
265
- const html = sanitiseHTML(markdownToHTML(text));
266
- execCommand('insertHTML', html);
289
+ const converted = sanitiseHTML(markdownToHTML(text));
290
+ execCommand('insertHTML', converted);
267
291
  this.context.invoke('editor.afterCommand');
268
292
  return;
269
293
  }
@@ -307,14 +331,48 @@ export class Clipboard {
307
331
  if (!dt?.files?.length) return;
308
332
 
309
333
  const imageFiles = Array.from(dt.files).filter((f) => f.type.startsWith('image/'));
310
- if (imageFiles.length === 0) return;
334
+ if (imageFiles.length > 0) {
335
+ event.preventDefault();
336
+ event.stopPropagation();
337
+ // Place the caret at the drop coordinates before inserting
338
+ this._placeCaretAtPoint(event.clientX, event.clientY);
339
+ this._insertImageFiles(imageFiles);
340
+ return;
341
+ }
311
342
 
312
- event.preventDefault();
313
- event.stopPropagation();
343
+ if (this.options.markdownPaste !== false) {
344
+ const mdFile = Array.from(dt.files).find((f) => /\.md$/i.test(f.name) || f.type === 'text/markdown');
345
+ if (mdFile) {
346
+ event.preventDefault();
347
+ event.stopPropagation();
348
+ this._placeCaretAtPoint(event.clientX, event.clientY);
349
+ this._insertMarkdownFile(mdFile);
350
+ }
351
+ }
352
+ }
314
353
 
315
- // Place the caret at the drop coordinates before inserting
316
- this._placeCaretAtPoint(event.clientX, event.clientY);
317
- this._insertImageFiles(imageFiles);
354
+ /**
355
+ * Reads a dropped `.md` File and inserts it converted to HTML at the
356
+ * current caret. Skips the isMarkdown() heuristic — an explicit `.md`
357
+ * extension/MIME type is an unambiguous signal, unlike pasted plain text.
358
+ * @param {File} file
359
+ */
360
+ _insertMarkdownFile(file) {
361
+ const maxBytes = (this.options.maxPasteSize ?? 5) * 1024 * 1024;
362
+ if (maxBytes > 0 && file.size > maxBytes) {
363
+ const message = `Dropped file "${file.name}" (${file.size} bytes) exceeds the ${this.options.maxPasteSize ?? 5} MB paste size limit.`;
364
+ this.context.triggerEvent('pasteError', { size: file.size, maxBytes, message });
365
+ console.warn(`[AutumnNote] ${message}`);
366
+ return;
367
+ }
368
+ const reader = new FileReader();
369
+ reader.onload = (e) => {
370
+ const html = sanitiseHTML(markdownToHTML(/** @type {string} */ (e.target.result) || ''));
371
+ execCommand('insertHTML', html);
372
+ this.context.invoke('editor.afterCommand');
373
+ };
374
+ reader.onerror = () => console.warn('[AutumnNote] Failed to read dropped markdown file', file.name);
375
+ reader.readAsText(file);
318
376
  }
319
377
 
320
378
  // ---------------------------------------------------------------------------