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/dist/autumnnote.css +40 -0
- package/dist/autumnnote.es.js +298 -56
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +298 -56
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/js/core/markdown.js +282 -61
- package/src/js/index.js +1 -1
- package/src/js/module/Clipboard.js +69 -11
- package/src/styles/autumnnote.scss +23 -0
- package/types/index.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/src/js/core/markdown.js
CHANGED
|
@@ -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
|
|
77
|
-
|
|
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 ``;
|
|
87
113
|
}
|
|
88
114
|
case 'ul': {
|
|
89
|
-
const items =
|
|
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 =
|
|
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
|
|
115
|
-
if (!
|
|
116
|
-
const
|
|
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 += `| ${
|
|
157
|
+
md += `| ${headerCells.join(' | ')} |\n`;
|
|
123
158
|
md += `| ${new Array(cols).fill('---').join(' | ')} |\n`;
|
|
124
|
-
for (let 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() &&
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
272
|
+
if (BQ_RE.test(line)) {
|
|
215
273
|
const bqLines = [];
|
|
216
|
-
while (i < lines.length && lines[i]
|
|
217
|
-
bqLines.push(lines[i]
|
|
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
|
|
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}
|
|
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.
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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].
|
|
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
|
|
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(({
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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="${
|
|
425
|
-
// Links
|
|
588
|
+
`<img src="${_escAttrQuotes(src)}" alt="${_escAttrQuotes(alt)}" class="an-image">`);
|
|
426
589
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
|
|
427
|
-
`<a href="${
|
|
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}>${
|
|
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}>${
|
|
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(/<(https?:\/\/[^\s&]+?)>/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('&', '&')
|
|
@@ -472,3 +683,13 @@ function _escAttr(v) {
|
|
|
472
683
|
.replaceAll('<', '<')
|
|
473
684
|
.replaceAll('>', '>');
|
|
474
685
|
}
|
|
686
|
+
|
|
687
|
+
/** Escapes only quote characters — for attribute values already run through _esc(). */
|
|
688
|
+
function _escAttrQuotes(v) {
|
|
689
|
+
return String(v).replaceAll('"', '"').replaceAll("'", ''');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Reverses _esc()'s &/</> substitutions, for matching against un-escaped _linkDefs/_footnoteIds keys. */
|
|
693
|
+
function _unescAmpLtGt(v) {
|
|
694
|
+
return String(v).replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
|
|
695
|
+
}
|
package/src/js/index.js
CHANGED
|
@@ -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 —
|
|
261
|
-
|
|
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
|
|
266
|
-
execCommand('insertHTML',
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|