mnfst 0.5.232 → 0.5.234

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.
@@ -19,6 +19,37 @@
19
19
  const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
20
20
  const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
21
21
 
22
+ /* ---- Adjacent marks ----
23
+ Neighbouring inline elements alike (same tag and attributes; for markdown, the same
24
+ delimiter) are one: merged, children first, the selection kept on the nodes it was in. */
25
+ const sameInline = (a, b) => !!b && b.nodeType === 1 && a.tagName === b.tagName
26
+ && a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
27
+ const sameDelimiter = (a, b) => !!b && b.nodeType === 1 && (a.tagName === 'A' ? sameInline(a, b) : !!MARKS[a.tagName] && MARKS[a.tagName] === MARKS[b.tagName]);
28
+ function mergeAdjacent(root, same = sameInline) {
29
+ const r = root.ownerDocument === document && root.isConnected ? range() : null;
30
+ const keep = r && root.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
31
+ let any = false;
32
+ const walk = (p) => {
33
+ let merged = false;
34
+ [...p.children].forEach(walk);
35
+ for (let c = p.firstChild; c; c = c.nextSibling) {
36
+ while (c.nodeType === 1 && INLINE_OK.has(c.tagName) && !/^(BR|WBR|IMG)$/.test(c.tagName)) {
37
+ const gaps = [];
38
+ let n = c.nextSibling;
39
+ while (n && n.nodeType === 3 && !n.length) { gaps.push(n); n = n.nextSibling; } // empty text between them is nothing
40
+ if (!same(c, n)) break;
41
+ const base = c.childNodes.length;
42
+ if (keep) for (const i of [0, 2]) { if (gaps.includes(keep[i])) { keep[i] = c; keep[i + 1] = base; } else if (keep[i] === n) { keep[i] = c; keep[i + 1] += base; } }
43
+ gaps.forEach(g => g.remove()); c.append(...n.childNodes); n.remove(); merged = any = true;
44
+ }
45
+ }
46
+ if (merged) walk(p);
47
+ };
48
+ walk(root);
49
+ if (any && keep && keep[0].isConnected && keep[2].isConnected) try { const nr = document.createRange(); nr.setStart(keep[0], keep[1]); nr.setEnd(keep[2], keep[3]); const s = sel(); s.removeAllRanges(); s.addRange(nr); } catch { }
50
+ return any;
51
+ }
52
+
22
53
  /* ---- URL policy ---- */
23
54
  // No script schemes; data: only as an image.
24
55
  function unsafeUrl(url, image) {
@@ -32,32 +63,137 @@
32
63
 
33
64
  /* ---- HTML → Markdown ---- */
34
65
  const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
35
- const esc = (t) => t.replace(/([\\`*_~[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
66
+ const MD_PUNCT = /[!-\/:-@\[-`{-~\u2000-\u206f\u2e00-\u2e7f]/;
67
+ // An _ between letters or digits cannot open or close emphasis: it is left as written
68
+ const esc = (t) => t.replace(/[\\`*_~[\]<>]/g, (c, i) => c === '_' && /[\p{L}\p{N}]/u.test(t[i - 1] || '') && /[\p{L}\p{N}]/u.test(t[i + 1] || '') ? c : '\\' + c).replace(ENTITY, '\\&');
36
69
  // Line starts that would parse as a heading, list or rule
37
70
  const escLead = (text) => text.split('\n').map(l => l
38
71
  .replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
39
72
  .replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
40
73
  .replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
41
74
  .replace(/^(\s*)(\d+)\.(?=\s)/, '$1$2\\.')).join('\n');
42
- const mdUrl = (u) => u.replace(/[\s()<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')).replace(ENTITY, '\\&');
75
+ // A link destination that reads back to the same URL: spaces and angle brackets encoded,
76
+ // backslashes and unbalanced parentheses escaped (balanced ones stay, as CommonMark reads them)
77
+ const mdUrl = (u) => {
78
+ const s = String(u).replace(/[\s<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
79
+ const open = [], loose = new Set();
80
+ for (let i = 0; i < s.length; i++) { if (s[i] === '(') open.push(i); else if (s[i] === ')') { if (open.length) open.pop(); else loose.add(i); } }
81
+ open.forEach(i => loose.add(i));
82
+ return s.split('').map((c, i) => c === '\\' ? '\\\\' : loose.has(i) ? '\\' + c : c).join('').replace(ENTITY, '\\&');
83
+ };
43
84
 
44
- function inline(node) {
85
+ // `ctx`: the characters just outside this content, and whether an `_` mark encloses it.
86
+ // em/strong use _/__ where no word character is outside them (and none encloses them), so
87
+ // their delimiters cannot pair with an inner * run; *, ** otherwise.
88
+ const wordChar = (c) => !!c && !/\s/.test(c) && !MD_PUNCT.test(c);
89
+ const textEdge = (n, last) => {
90
+ if (!n) return null;
91
+ if (n.nodeType === 3) return n.nodeValue ? (last ? n.nodeValue[n.nodeValue.length - 1] : n.nodeValue[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
92
+ if (n.nodeType !== 1) return textEdge(last ? n.previousSibling : n.nextSibling, last);
93
+ if (n.tagName === 'BR') return '\n';
94
+ if (n.tagName === 'IMG') return last ? ')' : '!';
95
+ if (n.tagName === 'A') return last ? ')' : '[';
96
+ if (MARKS[n.tagName]) return MARKS[n.tagName][0]; // its delimiter
97
+ const t = n.textContent; return t ? (last ? t[t.length - 1] : t[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
98
+ };
99
+ function inlineRaw(node, ctx) {
45
100
  let out = '';
46
101
  node.childNodes.forEach(n => {
47
- if (n.nodeType === 3) { out += esc(n.textContent); return; }
102
+ if (n.nodeType === 3) { out += ctx.link ? esc(n.textContent).replace(/[()]/g, '\\$&') : esc(n.textContent); return; } // in link text, no "(" can start a destination
48
103
  if (n.nodeType !== 1) return;
49
104
  const tag = n.tagName;
50
105
  if (tag === 'BR') { out += ' \n'; return; }
51
106
  if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src && !unsafeUrl(src, true) ? `![${esc(n.getAttribute('alt') || '')}](${mdUrl(src)})` : ''; return; }
52
- if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h && !unsafeUrl(h) ? `[${inline(n)}](${mdUrl(h)})` : inline(n); return; }
53
- const m = MARKS[tag];
54
- if (!m) { out += inline(n); return; }
55
- const body = tag === 'CODE' ? n.textContent : inline(n);
56
- out += body.trim() ? m + body + m : body;
107
+ if (tag === 'A' && /(^|[^\\])!$/.test(out) && n.getAttribute('href')) out = out.slice(0, -1) + '\\!'; // not an image
108
+ if (tag === 'A' && !n.hasAttribute('title') && n.children.length === 0 && /^[a-z][a-z0-9+.-]{1,31}:[^\s<>]*$/i.test(n.getAttribute('href') || '') && n.textContent === n.getAttribute('href') && !unsafeUrl(n.textContent)) { out += '<' + n.textContent + '>'; return; }
109
+ if (tag === 'A') {
110
+ const h = n.getAttribute('href') || '', title = n.getAttribute('title');
111
+ const tt = title ? title.replace(/\n/g, ' ') : '';
112
+ const t = !tt ? '' : !tt.includes('"') ? ` "${tt.replace(/\\/g, '\\\\')}"` : !tt.includes("'") ? ` '${tt.replace(/\\/g, '\\\\')}'` : ` "${tt.replace(/[\\"]/g, '\\$&')}"`;
113
+ out += h && !unsafeUrl(h) ? `[${inlineRaw(n, { ...ctx, prev: '[', next: ']', link: true, trims: false })}](${mdUrl(h)}${t})` : inlineRaw(n, ctx);
114
+ return;
115
+ }
116
+ let m = MARKS[tag];
117
+ if (!m) { out += inlineRaw(n, ctx); return; }
118
+ if (tag === 'CODE') {
119
+ // A fence longer than any backtick run inside; padded where the code starts or ends with one
120
+ const code = n.textContent; if (!code) return;
121
+ // Code holding a backtick needs a longer fence, which renderers do not all read alike
122
+ // around emphasis or in link text: written as HTML
123
+ if (code.includes('`')) { out += '<code>' + esc(code) + '</code>'; return; }
124
+ const fence = '`'.repeat(Math.max(0, ...(code.match(/`+/g) || []).map(r => r.length)) + 1);
125
+ const pad = /^`|`$/.test(code) || (/^ /.test(code) && / $/.test(code) && code.trim()) ? ' ' : '';
126
+ out += fence + pad + code + pad + fence;
127
+ return;
128
+ }
129
+ if (!n.textContent.trim()) { out += inlineRaw(n, ctx); return; }
130
+ let body = inlineRaw(n, { ...ctx, prev: m[0], next: m[0], trims: true });
131
+ // Delimiters hug the text: spaces go outside
132
+ let [, lead, core, trail] = body.match(/^(\s*)([\s\S]*?)(\s*)$/);
133
+ const after = textEdge(n.nextSibling, false);
134
+ // What will be against the delimiters: a space moved out of a markdown parent's edge is
135
+ // not there, the parent's delimiter is
136
+ const prev = lead ? (!out && ctx.trims ? ctx.prev : ' ') : (out ? out[out.length - 1] : ctx.prev);
137
+ const next = trail ? (after == null && ctx.trims ? ctx.next : ' ') : (after == null ? ctx.next : after);
138
+ const nonspace = /\S/.test(prev || '') || /\S/.test(next || '');
139
+ // Delimiters every renderer reads the same way: whitespace or plain punctuation outside them
140
+ // (an enclosing mark's same delimiter too), text inside them that starts and ends plainly.
141
+ // Anything else (letters, another delimiter or an HTML tag against them) is written as HTML.
142
+ const isS = (c) => !c || /\s/.test(c), isD = (c) => c === '*' || c === '_' || c === '~';
143
+ // Punctuation outside only where text is inside: the delimiter can then only open (or close)
144
+ // Letters outside only between letters (intraword, as in foo**bar**), and only outside any other * mark
145
+ const wordy = (c) => !!c && !isS(c) && !MD_PUNCT.test(c);
146
+ const outside = (c, fromCtx, inner) => isS(c) || (fromCtx && c === m[0]) || (MD_PUNCT.test(c) && !isD(c) && c !== '<' && c !== '>' && c !== '`' && wordy(inner))
147
+ || (wordy(c) && wordy(inner) && !ctx.star && m[0] === '*' && !n.querySelector('strong, b, em, i'));
148
+ const f = core[0], l = core[core.length - 1], bare = core.replace(new RegExp('^\\' + m[0] + '+|\\' + m[0] + '+$', 'g'), '');
149
+ const clean = outside(prev, !lead && !out, bare[0]) && outside(next, !trail && after == null, bare[bare.length - 1])
150
+ && !isS(f) && !isS(l) && f !== '\\' && !/\\.$/.test(core) && f !== '<' && l !== '>'
151
+ && !(isD(f) && f !== m[0]) && !(isD(l) && l !== m[0])
152
+ // runs merged at both ends (***a* b *c***) only around one inner mark (***all***)
153
+ && !(f === m[0] && l === m[0] && [...n.childNodes].filter(c => c.nodeType === 1 || (c.nodeType === 3 && c.nodeValue.trim())).length > 1);
154
+ if ((m[0] === '*' || m[0] === '~') && (ctx.html === 'all' || (ctx.html === 'touch' && nonspace) || !clean)) {
155
+ const t = tag === 'STRONG' || tag === 'B' ? 'strong' : m[0] === '~' ? 's' : 'em';
156
+ // spaces stay inside a tag: what is against its children stays as computed
157
+ out += `<${t}>` + inlineRaw(n, { ...ctx, prev: '>', next: '<', trims: false }) + `</${t}>`;
158
+ return;
159
+ }
160
+ if (m[0] === '*') [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '*', next: '*', star: true, trims: true }).match(/^(\s*)([\s\S]*?)(\s*)$/);
161
+ out += lead + m + core + m + trail;
57
162
  });
58
163
  return out;
59
164
  }
60
165
 
166
+ // Inline markdown for a node, checked by reading it back: in the rare arrangement where a
167
+ // * or _ run would pair differently, em/strong touching text go as HTML, else all of them
168
+ function inline(node) {
169
+ const want = shapeOf(node), ctx = { prev: '', next: '', star: false, link: false, html: '' };
170
+ let first = null;
171
+ for (const html of ['', 'touch', 'all']) {
172
+ const md = inlineRaw(node, { ...ctx, html });
173
+ if (first == null) first = md;
174
+ // Read back as this reader does and as other markdown renderers (x-markdown) do
175
+ const t = document.createElement('template'); t.innerHTML = inlineHtml(md);
176
+ const u = document.createElement('template'); u.innerHTML = inlineHtml(md, { strict: true });
177
+ if (shapeOf(t.content) === want && shapeOf(u.content) === want) return md;
178
+ }
179
+ return first; // what no form can carry (an unsafe link, say) is not a reason to change notation
180
+ }
181
+ // Every character with the formatting it shows (strong/em/strike on spaces show nothing)
182
+ function shapeOf(root) {
183
+ const out = [], KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', S: 's', DEL: 's', CODE: 'c' };
184
+ const walk = (n, f) => n.childNodes.forEach(c => {
185
+ if (c.nodeType === 3) { for (const ch of c.nodeValue) out.push(ch + (/\s/.test(ch) ? f.filter(x => x[0] !== 'b' && x[0] !== 'i' && x[0] !== 's') : f).join()); return; }
186
+ if (c.nodeType !== 1) return;
187
+ if (c.tagName === 'BR') { out.push('\n'); return; }
188
+ if (c.tagName === 'IMG') { const src = c.getAttribute('src') || ''; if (src && !unsafeUrl(src, true)) out.push('img:' + src + '|' + (c.getAttribute('alt') || '')); return; }
189
+ const href = c.tagName === 'A' ? c.getAttribute('href') || '' : '';
190
+ const k = KIND[c.tagName], h = href && !unsafeUrl(href) ? 'a:' + href : null;
191
+ walk(c, [...new Set([...f, ...(k ? [k] : []), ...(h ? [h] : [])])].sort());
192
+ });
193
+ walk(root, []);
194
+ return out.join('\u0001').replace(/^(\s(\u0001|$))+|(\u0001\s)+$/g, '');
195
+ }
196
+
61
197
  const preText = (n) => [...n.childNodes].map(c =>
62
198
  c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
63
199
 
@@ -66,7 +202,7 @@
66
202
  if (node.nodeType !== 1) return '';
67
203
  const tag = node.tagName;
68
204
  if (tag === 'HR') return '---';
69
- if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
205
+ if (tag === 'PRE') return '```' + (node.getAttribute('data-lang') || '').replace(/[^\w+#.-]/g, '') + '\n' + preText(node).replace(/\n+$/, '') + '\n```';
70
206
  if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
71
207
  if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
72
208
  if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
@@ -110,11 +246,18 @@
110
246
  return parts.join('\n\n');
111
247
  }
112
248
 
113
- const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
249
+ // Markdown carries a mark once: one inside another with the same delimiter adds nothing
250
+ const flattenMarks = (root) => [...root.querySelectorAll('*')].forEach(el => {
251
+ const m = MARKS[el.tagName]; if (!m) return;
252
+ for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (MARKS[p.tagName] === m) { el.replaceWith(...el.childNodes); return; }
253
+ });
254
+ const mdCopy = (root) => { const c = root.cloneNode(true); flattenMarks(c); mergeAdjacent(c, sameDelimiter); return c; };
255
+ const toMarkdown = (root) => blocks(mdCopy(root), 0).replace(/\n{3,}/g, '\n\n').trim();
114
256
  // Single-line values never parse block syntax, so line starts need no escape
115
257
  const inlineMarkdown = (root) => {
116
258
  const flat = document.createElement('div');
117
259
  flat.innerHTML = flattenLines(root.innerHTML, true);
260
+ flattenMarks(flat); mergeAdjacent(flat, sameDelimiter);
118
261
  return inline(flat).trim();
119
262
  };
120
263
 
@@ -181,19 +324,150 @@
181
324
  /* ---- Markdown → HTML ---- */
182
325
  const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
183
326
 
184
- // Escapes and code spans (verbatim) are held out of the mark passes, left to right, and restored last
185
- const HOLD = '';
186
- function inlineHtml(s) {
187
- const held = [];
188
- return escHtml(s)
189
- .replace(/\\(&lt;|&gt;|&amp;|[\\`*_~[\]#+.-])|`([^`]+)`/g, (_, c, code) => HOLD + (held.push(code != null ? `<code>${code}</code>` : c) - 1) + HOLD)
190
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
191
- .replace(/~~([^~]+)~~/g, '<s>$1</s>')
192
- .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
193
- .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => unsafeUrl(src, true) ? '' : `<img src="${src}" alt="${alt}">`)
194
- .replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) => unsafeUrl(h) ? t : `<a href="${h}">${t}</a>`)
195
- .replace(/ {2}\n/g, '<br>')
196
- .replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
327
+ /* Inline markdown: a small CommonMark-style reader. Code spans, links and images are found
328
+ left to right; runs of * _ ~ are delimiters, matched innermost first with CommonMark's
329
+ rules (the rule of 3 included). `*` and `~~` open before any non-space and close after
330
+ one (CommonMark also weighs punctuation); `_` keeps CommonMark's word-boundary rules. */
331
+ const PUNCT = /[!-\/:-@\[-`{-~ -⸀-⹿]/;
332
+ const isSpace = (c) => !c || /\s/.test(c);
333
+ // opts.raw: backslashes are text and no inline HTML is read (markdown being typed);
334
+ // opts.after: the character after the source, for the flanking of a run that ends it
335
+ function inlineHtml(src, opts = {}) {
336
+ const s = String(src == null ? '' : src);
337
+ const parts = [], delims = [];
338
+ let text = '';
339
+ const flush = () => { if (text) { parts.push(escHtml(text)); text = ''; } };
340
+ // A code span's closing run: the next backtick run of the same length
341
+ const codeEnd = (from, len) => {
342
+ for (let j = from; j < s.length;) {
343
+ const k = s.indexOf('`', j); if (k < 0) return -1;
344
+ const n = s.slice(k).match(/^`+/)[0].length;
345
+ if (n === len) return k;
346
+ j = k + n;
347
+ }
348
+ return -1;
349
+ };
350
+ // A link destination from the "(" at `at`: <…>, or balanced parentheses; an optional title is skipped
351
+ const destination = (at) => {
352
+ let j = at + 1, url;
353
+ while (s[j] === ' ' || s[j] === '\t') j++;
354
+ if (s[j] === '<') {
355
+ const e = s.indexOf('>', j); if (e < 0 || /[\n<]/.test(s.slice(j + 1, e))) return null;
356
+ url = s.slice(j + 1, e); j = e + 1;
357
+ } else {
358
+ const from = j;
359
+ for (let depth = 0; j < s.length; j++) {
360
+ const c = s[j];
361
+ if (c === '\\' && /[!-\/:-@\[-`{-~]/.test(s[j + 1] || '')) { j++; continue; }
362
+ if (/[\s\x00-\x1f]/.test(c)) break;
363
+ if (c === '(') depth++;
364
+ else if (c === ')') { if (!depth) break; depth--; }
365
+ }
366
+ url = s.slice(from, j);
367
+ }
368
+ const gap = j;
369
+ while (/[ \t\n]/.test(s[j] || '')) j++;
370
+ const q = s[j];
371
+ let title = null;
372
+ if (j > gap && (q === '"' || q === "'" || q === '(')) {
373
+ const close = q === '(' ? ')' : q;
374
+ let e = j + 1; for (; e < s.length && s[e] !== close; e++) if (s[e] === '\\') e++;
375
+ if (e >= s.length) return null;
376
+ title = s.slice(j + 1, e).replace(/\\([!-\/:-@\[-`{-~])/g, '$1');
377
+ j = e + 1; while (/[ \t\n]/.test(s[j] || '')) j++;
378
+ }
379
+ if (s[j] !== ')') return null;
380
+ return { url: url.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'), title, end: j + 1 };
381
+ };
382
+ // A bracket's matching close, skipping escapes and code spans
383
+ const closeBracket = (from) => {
384
+ for (let i = from, depth = 0; i < s.length; i++) {
385
+ const c = s[i];
386
+ if (c === '\\') { i++; continue; }
387
+ if (c === '`') { const n = s.slice(i).match(/^`+/)[0].length, e = codeEnd(i + n, n); i = (e < 0 ? i + n : e + n) - 1; continue; }
388
+ if (c === '[') depth++;
389
+ else if (c === ']') { if (!depth) return i; depth--; }
390
+ }
391
+ return -1;
392
+ };
393
+ for (let i = 0; i < s.length;) {
394
+ const c = s[i];
395
+ if (c === '\\' && !opts.raw && i + 1 < s.length && /[!-\/:-@\[-`{-~]/.test(s[i + 1])) { text += s[i + 1]; i += 2; continue; }
396
+ if (c === '`') {
397
+ const run = s.slice(i).match(/^`+/)[0], end = codeEnd(i + run.length, run.length);
398
+ if (end < 0) { text += run; i += run.length; continue; }
399
+ let code = s.slice(i + run.length, end).replace(/\n/g, ' ');
400
+ if (code.length > 1 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()) code = code.slice(1, -1);
401
+ flush(); parts.push(`<code>${escHtml(code)}</code>`);
402
+ i = end + run.length; continue;
403
+ }
404
+ if (c === '[' || (c === '!' && s[i + 1] === '[')) {
405
+ const img = c === '!', open = i + (img ? 2 : 1), close = closeBracket(open);
406
+ const m = close > 0 && s[close + 1] === '(' && destination(close + 1);
407
+ if (m) {
408
+ const url = m.url, body = s.slice(open, close);
409
+ flush();
410
+ if (img) parts.push(unsafeUrl(url, true) ? '' : `<img src="${escHtml(url)}" alt="${escHtml(body.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'))}">`);
411
+ else parts.push(unsafeUrl(url) || !url ? inlineHtml(body, { raw: opts.raw, strict: opts.strict }) : `<a href="${escHtml(url)}"${m.title != null ? ` title="${escHtml(m.title)}"` : ''}>${inlineHtml(body, { raw: opts.raw, strict: opts.strict })}</a>`);
412
+ i = m.end; continue;
413
+ }
414
+ }
415
+ // The inline HTML this writer uses where markdown has no form (raw tags, as CommonMark passes them)
416
+ if (c === '<' && !opts.raw) {
417
+ // Inline HTML for marks markdown has (as CommonMark passes it); other tags stay text
418
+ const t = s.slice(i).match(/^<(\/?)(strong|em|b|i|s|del|code)>/i);
419
+ if (t) { const tag = { b: 'strong', i: 'em', del: 's' }[t[2].toLowerCase()] || t[2].toLowerCase(); flush(); parts.push(`<${t[1]}${tag}>`); i += t[0].length; continue; }
420
+ // An autolink: <scheme:…>
421
+ const a = s.slice(i).match(/^<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)>/i);
422
+ if (a && !unsafeUrl(a[1])) { flush(); parts.push(`<a href="${escHtml(a[1])}">${escHtml(a[1])}</a>`); i += a[0].length; continue; }
423
+ }
424
+ if (c === '\n' && / {2,}$/.test(text)) { text = text.replace(/ +$/, ''); flush(); parts.push('<br>'); i++; continue; }
425
+ if (c === '*' || c === '_' || c === '~') {
426
+ const run = s.slice(i).match(c === '*' ? /^\*+/ : c === '_' ? /^_+/ : /^~+/)[0];
427
+ const before = i ? s[i - 1] : '', after = i + run.length < s.length ? s[i + run.length] : (opts.after || '');
428
+ let open, close;
429
+ if (c === '_') {
430
+ const left = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
431
+ const right = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
432
+ open = left && (!right || PUNCT.test(before)); close = right && (!left || PUNCT.test(after));
433
+ } else if (opts.strict) {
434
+ // CommonMark's flanking, punctuation weighed (what other markdown renderers do)
435
+ open = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
436
+ close = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
437
+ } else { open = !isSpace(after); close = !isSpace(before); }
438
+ if (c === '~' && run.length !== 2) { open = close = false; }
439
+ if (!open && !close) { text += run; i += run.length; continue; }
440
+ flush();
441
+ const d = { ch: c, count: run.length, orig: run.length, open, close };
442
+ parts.push(d); delims.push(d);
443
+ i += run.length; continue;
444
+ }
445
+ text += c; i++;
446
+ }
447
+ flush();
448
+ // Innermost first: each closer takes the nearest opener it can pair with
449
+ for (let ci = 0; ci < delims.length; ci++) {
450
+ const closer = delims[ci];
451
+ while (closer.close && closer.count) {
452
+ let oi = ci - 1;
453
+ for (; oi >= 0; oi--) {
454
+ const o = delims[oi];
455
+ if (o.ch !== closer.ch || !o.open || !o.count) continue;
456
+ if (closer.ch === '~') { if (o.count === 2 && closer.count === 2) break; continue; }
457
+ if ((o.close || closer.open) && (o.orig + closer.orig) % 3 === 0 && !(o.orig % 3 === 0 && closer.orig % 3 === 0)) continue;
458
+ break;
459
+ }
460
+ if (oi < 0) break;
461
+ const opener = delims[oi], use = closer.ch === '~' ? 2 : (opener.count >= 2 && closer.count >= 2 ? 2 : 1);
462
+ const tag = closer.ch === '~' ? 's' : use === 2 ? 'strong' : 'em';
463
+ const a = parts.indexOf(opener), b = parts.indexOf(closer);
464
+ const inner = parts.splice(a + 1, b - a - 1).map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
465
+ parts.splice(a + 1, 0, `<${tag}>${inner}</${tag}>`);
466
+ for (let k = oi + 1; k < ci; k++) delims[k].count = 0; // delimiters inside are text now (already rendered)
467
+ opener.count -= use; closer.count -= use;
468
+ }
469
+ }
470
+ return parts.map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
197
471
  }
198
472
 
199
473
  // Four spaces per nesting level.
@@ -220,7 +494,7 @@
220
494
  function fromMarkdown(md) {
221
495
  const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
222
496
  const out = [];
223
- let para = [], list = null, quote = null, fence = null;
497
+ let para = [], list = null, quote = null, fence = null, lang = '';
224
498
  const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
225
499
  const flushList = () => { if (!list) return; for (let i = 0; i < list.length;) { const [html, next] = renderList(list, i, list[i].depth); out.push(html); i = next > i ? next : i + 1; } list = null; };
226
500
  const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
@@ -228,8 +502,8 @@
228
502
 
229
503
  for (const raw of lines) {
230
504
  const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
231
- if (fence !== null) { if (/^```/.test(line)) { out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
232
- if (/^```/.test(line)) { flushAll(); fence = []; continue; }
505
+ if (fence !== null) { if (/^```\s*$/.test(line)) { out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
506
+ if (/^```/.test(line)) { flushAll(); fence = []; lang = (line.slice(3).trim().match(/^[\w+#.-]+/) || [''])[0]; continue; }
233
507
  if (!line.trim()) { flushAll(); continue; }
234
508
  const h = line.match(/^(#{1,6})\s+(.*)$/);
235
509
  if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
@@ -245,7 +519,7 @@
245
519
  flushList(); flushQuote();
246
520
  para.push(line);
247
521
  }
248
- if (fence) out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`);
522
+ if (fence) out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`);
249
523
  flushAll();
250
524
  return out.join('');
251
525
  }
@@ -268,9 +542,10 @@
268
542
  const meta = INLINE_TAGS[c.tagName.toLowerCase()];
269
543
  [...c.attributes].forEach(a => {
270
544
  const n = a.name.toLowerCase();
271
- if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
545
+ if (c.tagName === 'A' && ((n === 'href' && !unsafeUrl(a.value)) || n === 'title')) return;
272
546
  if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
273
547
  if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
548
+ if (c.tagName === 'PRE' && n === 'data-lang' && /^[\w+#.-]{1,40}$/.test(a.value)) return; // a code fence's language
274
549
  if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
275
550
  if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
276
551
  if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
@@ -278,8 +553,20 @@
278
553
  });
279
554
  });
280
555
  walk(t.content);
556
+ mergeAdjacent(t.content);
557
+ if (allowBlocks) dropTrailingEmpty(t.content);
281
558
  return t.innerHTML;
282
559
  }
560
+ // The empty paragraph after a closing list, table or code block is an editing affordance only:
561
+ // values never end in one (nor in several)
562
+ function dropTrailingEmpty(root) {
563
+ for (let n = root.lastChild; n;) {
564
+ const prev = n.previousSibling;
565
+ if ((n.nodeType === 3 && !n.nodeValue.trim()) || (n.nodeType === 1 && n.tagName === 'P' && !n.textContent.trim() && !n.querySelector('img, input'))) n.remove();
566
+ else break;
567
+ n = prev;
568
+ }
569
+ }
283
570
 
284
571
  const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
285
572
 
@@ -392,6 +679,28 @@
392
679
  return pre.toString().length;
393
680
  }
394
681
 
682
+ // The selection as text offsets in root, and back
683
+ function textSpan(root) {
684
+ const r = range(); if (!r || !root.contains(r.startContainer) || !root.contains(r.endContainer)) return null;
685
+ const at = (node, off) => { const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(node, off); return pre.toString().length; };
686
+ return [at(r.startContainer, r.startOffset), at(r.endContainer, r.endOffset)];
687
+ }
688
+ function selectSpan(root, [from, to]) {
689
+ const point = (offset, end) => {
690
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
691
+ let seen = 0, last = null;
692
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
693
+ last = n;
694
+ if (end ? seen + n.length >= offset : seen + n.length > offset) return [n, offset - seen];
695
+ seen += n.length;
696
+ }
697
+ return last ? [last, last.length] : [root, 0];
698
+ };
699
+ const r = document.createRange(), a = point(from, false), b = point(to, true);
700
+ r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
701
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
702
+ }
703
+
395
704
  function caretIn(root, offset) {
396
705
  const s = sel(), r = document.createRange();
397
706
  if (offset == null) offset = 0;
@@ -630,7 +939,9 @@
630
939
  const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
631
940
  const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
632
941
  const rowsOf = (table) => [...table.rows];
633
- const emptyCell = () => { const td = document.createElement('td'); td.appendChild(document.createElement('br')); return td; };
942
+ const emptyCell = (tag = 'td') => { const td = document.createElement(tag); td.appendChild(document.createElement('br')); return td; };
943
+ // A header row (in a thead, or of header cells only) is padded with header cells
944
+ const rowCellTag = (row) => row.parentElement && row.parentElement.tagName === 'THEAD' || (row.cells.length && [...row.cells].every(c => c.tagName === 'TH')) ? 'th' : 'td';
634
945
 
635
946
  // Occupancy grid: every slot maps to the cell covering it, spans included
636
947
  function tableGrid(table) {
@@ -674,7 +985,7 @@
674
985
  const width = Math.max(0, ...grid.map(r => r.length));
675
986
  rows.forEach((row, r) => {
676
987
  const have = (grid[r] || []).filter(Boolean).length;
677
- for (let i = have; i < width; i++) row.appendChild(emptyCell());
988
+ for (let i = have; i < width; i++) row.appendChild(emptyCell(rowCellTag(row)));
678
989
  });
679
990
  if (!table.querySelector('tbody, thead')) {
680
991
  const body = document.createElement('tbody');
@@ -783,13 +1094,14 @@
783
1094
  const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
784
1095
  if (cols < 2 && rows < 2) return;
785
1096
  cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
786
- for (let i = 1; i < cols; i++) cell.after(emptyCell());
1097
+ // A header cell splits into header cells; rows below take their row's kind
1098
+ for (let i = 1; i < cols; i++) cell.after(emptyCell(cell.tagName.toLowerCase()));
787
1099
  if (rows > 1) {
788
1100
  const table = cell.closest('table'), { row, col } = gridPosition(cell);
789
1101
  for (let r = 1; r < rows; r++) {
790
1102
  const target = rowsOf(table)[row + r]; if (!target) break;
791
1103
  const at = cellCovering(target, col);
792
- const fresh = emptyCell();
1104
+ const fresh = emptyCell(rowCellTag(target));
793
1105
  if (at) at.before(fresh); else target.appendChild(fresh);
794
1106
  }
795
1107
  }
@@ -901,39 +1213,48 @@
901
1213
  return null;
902
1214
  }
903
1215
 
1216
+ // Sizes are written only once the pointer actually drags: a click on a border writes nothing
904
1217
  function startResize(e, hit) {
905
1218
  const cell = hit.cell, table = cell.closest('table');
906
1219
  const area = cell.closest('[data-rich-text]');
907
1220
  e.preventDefault();
908
- area.setAttribute('data-rich-text-resizing', hit.axis);
909
-
910
- let apply;
911
- if (hit.axis === 'x') {
912
- const group = ensureCols(table);
913
- const { col } = gridPosition(cell);
914
- const index = Math.min(col + (cell.colSpan || 1) - 1, group.children.length - 1);
915
- const target = group.children[index];
916
- const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
917
- const from = e.clientX;
918
- // The outer border sizes the table too
919
- const outer = index === group.children.length - 1;
920
- const tableStart = table.getBoundingClientRect().width;
921
- if (outer) table.style.width = Math.round(tableStart) + 'px';
922
- apply = (ev) => {
923
- const delta = ev.clientX - from;
924
- target.style.width = Math.max(28, Math.round(start + delta)) + 'px';
925
- if (outer) table.style.width = Math.max(60, Math.round(tableStart + delta)) + 'px';
926
- };
927
- } else {
928
- const row = cell.parentElement;
929
- const start = row.getBoundingClientRect().height, from = e.clientY;
930
- apply = (ev) => { row.style.height = Math.max(20, Math.round(start + ev.clientY - from)) + 'px'; };
931
- }
1221
+ const fromX = e.clientX, fromY = e.clientY;
1222
+ let apply = null;
1223
+ const begin = () => {
1224
+ area.setAttribute('data-rich-text-resizing', hit.axis);
1225
+ if (hit.axis === 'x') {
1226
+ const group = ensureCols(table);
1227
+ const { col } = gridPosition(cell);
1228
+ const index = Math.min(col + (cell.colSpan || 1) - 1, group.children.length - 1);
1229
+ const target = group.children[index];
1230
+ const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
1231
+ // The outer border sizes the table too
1232
+ const outer = index === group.children.length - 1;
1233
+ const tableStart = table.getBoundingClientRect().width;
1234
+ if (outer) table.style.width = Math.round(tableStart) + 'px';
1235
+ return (ev) => {
1236
+ const delta = ev.clientX - fromX;
1237
+ target.style.width = Math.max(28, Math.round(start + delta)) + 'px';
1238
+ if (outer) table.style.width = Math.max(60, Math.round(tableStart + delta)) + 'px';
1239
+ };
1240
+ }
1241
+ const row = cell.parentElement, start = row.getBoundingClientRect().height;
1242
+ return (ev) => { row.style.height = Math.max(20, Math.round(start + ev.clientY - fromY)) + 'px'; };
1243
+ };
932
1244
 
933
- const move = (ev) => { ev.preventDefault(); apply(ev); };
1245
+ const move = (ev) => {
1246
+ ev.preventDefault();
1247
+ if (!apply) {
1248
+ if (Math.abs(ev.clientX - fromX) < 3 && Math.abs(ev.clientY - fromY) < 3) return;
1249
+ record(area);
1250
+ apply = begin();
1251
+ }
1252
+ apply(ev);
1253
+ };
934
1254
  const up = () => {
935
1255
  document.removeEventListener('pointermove', move);
936
1256
  document.removeEventListener('pointerup', up);
1257
+ if (!apply) return;
937
1258
  area.removeAttribute('data-rich-text-resizing');
938
1259
  area.dispatchEvent(new Event('input', { bubbles: true }));
939
1260
  };
@@ -1278,6 +1599,13 @@
1278
1599
  if (!frag.lastChild) return;
1279
1600
  record(area);
1280
1601
  r.deleteContents();
1602
+ // A caret between a block field's blocks (an empty field clicked) goes into the block beside it
1603
+ const c0 = range();
1604
+ if (c0 && c0.startContainer === area && !(area._rt && area._rt.inline)) {
1605
+ const into = (b) => b && b.nodeType === 1 && /^(P|H[1-6]|BLOCKQUOTE|PRE)$/.test(b.tagName);
1606
+ const before = area.childNodes[c0.startOffset - 1], after = area.childNodes[c0.startOffset];
1607
+ if (into(before)) caretIn(before, before.textContent.length); else if (into(after)) caretIn(after, 0);
1608
+ }
1281
1609
  // An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
1282
1610
  const holder = blockEl() || area;
1283
1611
  if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
@@ -1287,7 +1615,16 @@
1287
1615
  }
1288
1616
  let end = frag.lastChild;
1289
1617
  if (!end) return;
1618
+ const lastTop = end;
1290
1619
  const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
1620
+ // Pasted content ending in a mark: the caret sits after it, as after a conversion
1621
+ if (lastTop.nodeType === 1 && INLINE_OK.has(lastTop.tagName) && !/^(BR|WBR|IMG)$/.test(lastTop.tagName) && lastTop.isConnected) {
1622
+ caretAt(tailOf(lastTop), 0);
1623
+ area._exit = lastTop;
1624
+ record(area);
1625
+ area.dispatchEvent(new Event('input', { bubbles: true }));
1626
+ return;
1627
+ }
1291
1628
  // The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
1292
1629
  const s = sel(), nr = document.createRange();
1293
1630
  if (end.nodeType === 1) { const w = document.createTreeWalker(end, NodeFilter.SHOW_TEXT); let t = null; for (let n = w.nextNode(); n; n = w.nextNode()) t = n; if (t) end = t; }
@@ -1357,7 +1694,16 @@
1357
1694
  const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
1358
1695
  const cols = n(parts[0]), rows = n(parts[1]);
1359
1696
  const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
1360
- insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1697
+ insertHTML(`<table data-rich-text-new><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1698
+ // The caret goes into the first cell, so typing and table commands start there
1699
+ const made = document.querySelector('table[data-rich-text-new]');
1700
+ if (made) {
1701
+ made.removeAttribute('data-rich-text-new');
1702
+ // One empty line after it to land on, not one per insertion (from inside a table, it lands after it)
1703
+ const blank = (e) => e && e.tagName === 'P' && !e.textContent.trim() && !e.querySelector('img');
1704
+ for (let n = made.nextElementSibling; blank(n) && blank(n.nextElementSibling);) { const x = n.nextElementSibling; x.remove(); }
1705
+ const first = made.querySelector('td, th'); if (first) caretIn(first, 0);
1706
+ }
1361
1707
  }
1362
1708
 
1363
1709
  // A trailing table, rule or similar block gets a line after it to land on
@@ -1387,8 +1733,21 @@
1387
1733
  }
1388
1734
 
1389
1735
  // A value styles the selection; an empty one removes that style from it (spans left bare go)
1736
+ // A style span covering exactly what a style span around it covers is folded into it, so one span
1737
+ // carries the combined styles and a re-applied value replaces the old one instead of nesting
1390
1738
  function styleSpan(prop, v) {
1391
- if (v != null && v !== '') return wrapInline('span', { [prop]: v });
1739
+ if (v != null && v !== '') {
1740
+ const made = wrapInline('span', { [prop]: v }).map(sp => {
1741
+ for (let p = sp.parentElement; p && p.tagName === 'SPAN' && !p.hasAttribute('data-rich-text') && [...p.attributes].every(a => a.name === 'style') && [...p.childNodes].filter(c => c.nodeType === 1 || c.nodeValue.length).length === 1; p = sp.parentElement) {
1742
+ for (let i = 0; i < sp.style.length; i++) { const name = sp.style.item(i); p.style.setProperty(name, sp.style.getPropertyValue(name)); }
1743
+ sp.replaceWith(...sp.childNodes);
1744
+ sp = p;
1745
+ }
1746
+ return sp;
1747
+ });
1748
+ if (made.length) { const r = document.createRange(), last = made[made.length - 1]; r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length); const s = sel(); s.removeAllRanges(); s.addRange(r); }
1749
+ return made;
1750
+ }
1392
1751
  const nodes = selectedTextNodes(), spans = new Set();
1393
1752
  nodes.forEach(n => { for (let el = elementAt(n); el && el.closest && !el.hasAttribute('data-rich-text'); el = el.parentElement) if (el.tagName === 'SPAN' && el.style[prop]) spans.add(el); });
1394
1753
  spans.forEach(s => { s.style[prop] = ''; if (!s.getAttribute('style')) s.removeAttribute('style'); if (!s.attributes.length) unwrapEl(s); });
@@ -1412,6 +1771,17 @@
1412
1771
  const next = values && values[k];
1413
1772
  if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
1414
1773
  });
1774
+ const allKey = (area) => JSON.stringify(allStyle(area));
1775
+ // .all styles set from code: every history entry takes the change, so undo never removes it
1776
+ function rebaseAll(area, was, now) {
1777
+ const h = area._history; if (!h) return;
1778
+ const keys = [...new Set([...Object.keys(was), ...Object.keys(now)])].filter(k => was[k] !== now[k]);
1779
+ h.stack.forEach(e => {
1780
+ const o = e.all ? JSON.parse(e.all) : {};
1781
+ keys.forEach(k => { if (now[k]) o[k] = now[k]; else delete o[k]; });
1782
+ e.all = JSON.stringify(Object.fromEntries(Object.keys(ALL_VARS).filter(k => o[k]).map(k => [k, o[k]])));
1783
+ });
1784
+ }
1415
1785
 
1416
1786
  /* ---- Command table ---- */
1417
1787
  const COMMANDS = Object.create(null);
@@ -1592,20 +1962,10 @@
1592
1962
  { re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
1593
1963
  { re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
1594
1964
  { re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
1595
- { re: /^```\s*$/, make: (b) => retagBlock(b, 'pre') },
1965
+ { re: /^```([\w+#.-]{0,40})\s*$/, make: (b, m) => { const pre = retagBlock(b, 'pre'); if (m[1]) pre.setAttribute('data-lang', m[1]); return pre; } },
1596
1966
  { re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
1597
1967
  ];
1598
1968
 
1599
- const INLINE_RULES = [
1600
- { re: /\*\*([^*]+)\*\*/, tag: 'strong' },
1601
- { re: /__([^_]+)__/, tag: 'strong' },
1602
- { re: /(^|[^*])\*([^*\s][^*]*)\*/, tag: 'em', pre: 1, content: 2 },
1603
- { re: /~~([^~]+)~~/, tag: 'del' },
1604
- { re: /`([^`]+)`/, tag: 'code' },
1605
- { re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
1606
- { re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
1607
- ];
1608
-
1609
1969
  function retagBlock(b, tag) {
1610
1970
  const n = document.createElement(tag);
1611
1971
  while (b.firstChild) n.appendChild(b.firstChild);
@@ -1640,44 +2000,35 @@
1640
2000
  }
1641
2001
  }
1642
2002
 
1643
- function inlineEl(rule, m) {
1644
- const el = document.createElement(rule.tag);
1645
- if (rule.tag === 'img') {
1646
- if (unsafeUrl(m[rule.src], true)) return null;
1647
- el.setAttribute('src', m[rule.src]); el.setAttribute('alt', m[rule.alt] || '');
1648
- return el;
2003
+ // Markdown typed in a run of inline content (up to the caret, or all of it) re-read with the
2004
+ // markdown reader, so what converts while typing is what a saved value reads back as. Elements
2005
+ // already there go through as opaque characters. Null when nothing new would be made.
2006
+ const ATOM = 0xE000;
2007
+ function reparse(box, until, upto, after) {
2008
+ const kids = [...box.childNodes], end = until ? kids.indexOf(until) : kids.length;
2009
+ let from = 0;
2010
+ for (let i = 0; i < end; i++) if (kids[i].nodeType === 1 && (kids[i].tagName === 'BR' || BLOCK.includes(kids[i].tagName))) from = i + 1;
2011
+ const run = kids.slice(from, end), atoms = [];
2012
+ let src = '';
2013
+ for (const n of run) {
2014
+ if (n.nodeType === 3) { if (/[\uE000-\uF8FF]/.test(n.nodeValue)) return null; src += n.nodeValue; }
2015
+ else if (n.nodeType === 1) { if (atoms.length > 6000) return null; src += String.fromCharCode(ATOM + atoms.length); atoms.push(n); }
1649
2016
  }
1650
- el.textContent = m[rule.content || 1];
1651
- if (rule.href) { if (unsafeUrl(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
1652
- return el;
1653
- }
1654
-
1655
- function firstInline(text) {
1656
- let best = null;
1657
- for (const rule of INLINE_RULES) {
1658
- const m = text.match(rule.re); if (!m) continue;
1659
- const skip = rule.pre ? m[rule.pre].length : 0;
1660
- const start = m.index + skip;
1661
- if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1662
- }
1663
- return best;
1664
- }
1665
-
1666
- // Only a pattern ending exactly at the caret; earliest start wins (image over link)
1667
- function endInline(text) {
1668
- let best = null;
1669
- for (const rule of INLINE_RULES) {
1670
- const re = new RegExp(rule.re.source, 'g');
1671
- let m;
1672
- while ((m = re.exec(text))) {
1673
- if (m.index + m[0].length !== text.length) continue;
1674
- const skip = rule.pre ? m[rule.pre].length : 0;
1675
- const start = m.index + skip;
1676
- if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1677
- break;
1678
- }
1679
- }
1680
- return best;
2017
+ if (until) src += until.nodeValue.slice(0, upto);
2018
+ if (!/[*_~`\[]/.test(src)) return null;
2019
+ const t = document.createElement('template');
2020
+ t.innerHTML = inlineHtml(src, { raw: true, after });
2021
+ const made = [...t.content.querySelectorAll('*')];
2022
+ if (!made.length) return null;
2023
+ // Elements already there land whole and once, never in a code span or an attribute
2024
+ if (made.some(e => [...e.attributes].some(x => /[\uE000-\uF8FF]/.test(x.value)))) return null;
2025
+ const texts = []; const w = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT);
2026
+ for (let n = w.nextNode(); n; n = w.nextNode()) if (/[\uE000-\uF8FF]/.test(n.nodeValue)) { if (n.parentElement && n.parentElement.closest('code')) return null; texts.push(n); }
2027
+ const found = texts.flatMap(n => n.nodeValue.match(/[\uE000-\uF8FF]/g)).map(c => c.charCodeAt(0) - ATOM);
2028
+ if (found.length !== atoms.length || new Set(found).size !== atoms.length) return null;
2029
+ // The elements move in only when the conversion is taken
2030
+ const fill = () => texts.forEach(n => n.replaceWith(...n.nodeValue.split(/([\uE000-\uF8FF])/).filter(Boolean).map(p => p.length === 1 && p.charCodeAt(0) >= ATOM && p.charCodeAt(0) - ATOM < atoms.length ? atoms[p.charCodeAt(0) - ATOM] : document.createTextNode(p))));
2031
+ return { run, frag: t.content, fill };
1681
2032
  }
1682
2033
 
1683
2034
  function convertInlineAtCaret(area) {
@@ -1686,17 +2037,24 @@
1686
2037
  if (!node || node.nodeType !== 3 || !node.parentElement) return false;
1687
2038
  const host = node.parentElement.closest('code, pre, a');
1688
2039
  if (host && area.contains(host)) return false;
1689
- const hit = endInline(node.nodeValue.slice(0, r.startOffset));
1690
- if (!hit) return false;
1691
- const el = inlineEl(hit.rule, hit.m); if (!el) return false;
2040
+ if (!/[*_~`)]/.test(node.nodeValue[r.startOffset - 1] || '')) return false;
2041
+ const next = node.nodeValue[r.startOffset] != null ? node.nodeValue[r.startOffset] : textEdge(node.nextSibling, false);
2042
+ const hit = reparse(node.parentElement, node, r.startOffset, next == null ? '' : next);
2043
+ // Only a construct closed by the character just typed, and whole: an opener with marker
2044
+ // left over (`**test*`, so far) waits for the rest of the closer
2045
+ const last = hit && hit.frag.lastChild, typed = node.nodeValue[r.startOffset - 1];
2046
+ if (!last || last.nodeType !== 1 || (!last.textContent && last.tagName !== 'IMG')) return false;
2047
+ const before = last.previousSibling;
2048
+ if (/[*_~]/.test(typed) && before && before.nodeType === 3 && before.nodeValue.endsWith(typed)) return false;
1692
2049
  record(area);
1693
- const tail = node.splitText(hit.start);
1694
- tail.deleteData(0, hit.len);
1695
- tail.parentNode.insertBefore(el, tail);
2050
+ const tail = node.splitText(r.startOffset);
2051
+ hit.run.forEach(n => n.remove()); node.remove();
2052
+ hit.fill();
2053
+ tail.parentNode.insertBefore(hit.frag, tail);
1696
2054
  const s = sel(), nr = document.createRange();
1697
2055
  nr.setStart(tail, 0); nr.collapse(true);
1698
2056
  s.removeAllRanges(); s.addRange(nr);
1699
- area._exit = el.tagName === 'IMG' ? null : el;
2057
+ area._exit = last.tagName === 'IMG' ? null : last;
1700
2058
  record(area);
1701
2059
  return true;
1702
2060
  }
@@ -1775,21 +2133,26 @@
1775
2133
  return true;
1776
2134
  }
1777
2135
 
2136
+ // A finished line's markdown, in each run of inline content (marks' own content first)
1778
2137
  function convertInline(root) {
1779
- const nodes = [];
1780
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1781
- for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
1782
- nodes.forEach(node => {
1783
- if (!node.parentElement || node.parentElement.closest('code, pre, a')) return;
1784
- let cur = node;
1785
- for (let guard = 0; guard < 50; guard++) {
1786
- const hit = firstInline(cur.nodeValue); if (!hit) return;
1787
- const el = inlineEl(hit.rule, hit.m); if (!el) return;
1788
- const tail = cur.splitText(hit.start);
1789
- tail.deleteData(0, hit.len);
1790
- tail.parentNode.insertBefore(el, tail);
1791
- cur = tail;
2138
+ [...root.children].forEach(c => { if (!/^(CODE|PRE|A|BR|IMG)$/.test(c.tagName)) convertInline(c); });
2139
+ if (root.closest('code, pre, a')) return;
2140
+ const segments = [];
2141
+ let start = root.firstChild;
2142
+ for (let n = root.firstChild; ; n = n ? n.nextSibling : null) {
2143
+ if (!n || (n.nodeType === 1 && (n.tagName === 'BR' || BLOCK.includes(n.tagName)))) {
2144
+ if (start !== n) segments.push([start, n]);
2145
+ if (!n) break;
2146
+ start = n.nextSibling;
1792
2147
  }
2148
+ }
2149
+ segments.forEach(([first, stop]) => {
2150
+ const box = document.createElement('span'), anchor = document.createComment('');
2151
+ first.before(anchor);
2152
+ for (let n = first; n && n !== stop;) { const nx = n.nextSibling; box.appendChild(n); n = nx; }
2153
+ const hit = reparse(box, null, 0, '');
2154
+ if (hit) { hit.fill(); box.replaceChildren(hit.frag); }
2155
+ anchor.replaceWith(...box.childNodes);
1793
2156
  });
1794
2157
  }
1795
2158
 
@@ -1828,7 +2191,10 @@
1828
2191
  }
1829
2192
 
1830
2193
  // Native Enter inside code and for Shift+Enter; Chrome writes <br> pairs in a <pre>
2194
+ // A code block's lines: in its <code> (the shape markdown gives), else in the <pre> itself
2195
+ const codeBox = (pre) => { const c = pre.firstElementChild; return c && c.tagName === 'CODE' && pre.children.length === 1 && !pre.textContent.replace(c.textContent, '').trim() ? c : pre; };
1831
2196
  const codeEndsBlank = (pre) => {
2197
+ pre = codeBox(pre);
1832
2198
  const last = pre.lastChild;
1833
2199
  if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
1834
2200
  return /\n[^\S\n]*$/.test(pre.textContent);
@@ -1899,10 +2265,13 @@
1899
2265
  // An empty last line leaves the code block (bounded to the area)
1900
2266
  const pre = holder.closest('pre');
1901
2267
  if (pre && pre !== area && area.contains(pre)) {
1902
- while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
1903
- if (/\n[^\S\n]*$/.test(pre.textContent)) pre.textContent = pre.textContent.replace(/\n[^\S\n]*$/, '');
1904
- const p = document.createElement('p');
1905
- pre.after(p);
2268
+ const box = codeBox(pre);
2269
+ while (box.lastChild && (box.lastChild.nodeName === 'BR' || !box.lastChild.textContent.trim())) box.lastChild.remove();
2270
+ if (/\n[^\S\n]*$/.test(box.textContent)) box.textContent = box.textContent.replace(/\n[^\S\n]*$/, '');
2271
+ // onto the empty line after it, if there is one
2272
+ const after = pre.nextElementSibling, blank = after && after.tagName === 'P' && !after.textContent.trim() && !after.querySelector('img');
2273
+ const p = blank ? after : document.createElement('p');
2274
+ if (!blank) pre.after(p);
1906
2275
  if (!pre.textContent.length) pre.remove();
1907
2276
  caretIn(p, 0);
1908
2277
  return;
@@ -1941,7 +2310,14 @@
1941
2310
  if (holder.tagName === 'HR') { caretIn(next, 0); return; }
1942
2311
 
1943
2312
  // A finished ``` line opens the code block
1944
- if (holder.tagName === 'PRE') { next.remove(); caretIn(holder, 0); return; }
2313
+ // (its text in a <code>, the shape a code block read from markdown has)
2314
+ if (holder.tagName === 'PRE') {
2315
+ next.remove();
2316
+ let code = holder.querySelector(':scope > code');
2317
+ if (!code) { code = document.createElement('code'); code.append(...holder.childNodes); holder.appendChild(code); }
2318
+ caretIn(code, 0);
2319
+ return;
2320
+ }
1945
2321
 
1946
2322
  if (holder.tagName === 'LI') {
1947
2323
  const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
@@ -1972,16 +2348,16 @@
1972
2348
  const h = area._history; if (!h || h.restoring) return;
1973
2349
  clearTimeout(h.timer); h.timer = null;
1974
2350
  if (area._rt && area._rt.settle) area._rt.settle();
1975
- const html = area.innerHTML;
2351
+ const html = area.innerHTML, all = allKey(area);
1976
2352
  // Unchanged: the entry learns where the next edit starts (`before`, for undo);
1977
2353
  // `caret` stays where the edit that produced it ended (for redo)
1978
- if (h.stack[h.at] && h.stack[h.at].html === html) {
2354
+ if (h.stack[h.at] && h.stack[h.at].html === html && h.stack[h.at].all === all) {
1979
2355
  const caret = offsetIn(area);
1980
2356
  if (caret != null) h.stack[h.at].before = caret;
1981
2357
  return;
1982
2358
  }
1983
2359
  h.stack.length = h.at + 1;
1984
- h.stack.push({ html, caret: offsetIn(area) });
2360
+ h.stack.push({ html, caret: offsetIn(area), all });
1985
2361
  if (h.stack.length > HISTORY_CAP) h.stack.shift();
1986
2362
  h.at = h.stack.length - 1;
1987
2363
  }
@@ -2003,12 +2379,14 @@
2003
2379
  function step(area, by) {
2004
2380
  const h = area._history; if (!h) return;
2005
2381
  // Land a pending burst; walking history alone leaves each entry's carets as recorded
2006
- if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML) record(area);
2382
+ if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML || h.stack[h.at].all !== allKey(area)) record(area);
2007
2383
  const next = h.at + by;
2008
2384
  if (next < 0 || next >= h.stack.length) return;
2009
2385
  h.at = next; h.restoring = true;
2010
2386
  const entry = h.stack[next];
2011
2387
  area.innerHTML = entry.html;
2388
+ // .all styles are steps too
2389
+ if (entry.all != null && entry.all !== allKey(area)) { setAllStyle(area, JSON.parse(entry.all)); area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) })); }
2012
2390
  if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
2013
2391
  area.focus();
2014
2392
  caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
@@ -2142,8 +2520,10 @@
2142
2520
  }
2143
2521
  if (!allows(area, id, ctrl)) return;
2144
2522
  if (all) {
2523
+ record(area);
2145
2524
  area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
2146
2525
  if (!arg) area.style.removeProperty(ALL_VARS[id]);
2526
+ record(area);
2147
2527
  area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
2148
2528
  sync();
2149
2529
  return;
@@ -2151,7 +2531,12 @@
2151
2531
  const arming = INLINE_TAGS[id] && !hasSelection();
2152
2532
  if (arming) { spec.run(arg, area); sync(); return; }
2153
2533
  if (!spec.history) record(area);
2534
+ const before = textSpan(area);
2154
2535
  spec.run(arg, area);
2536
+ // A command that rebuilt the blocks under the selection (a block style) leaves it where it was
2537
+ const after = range();
2538
+ if (before && before[1] > before[0] && (!after || !area.contains(after.startContainer) || after.collapsed)) selectSpan(area, before);
2539
+ mergeAdjacent(area);
2155
2540
  normalizeLists(area);
2156
2541
  if (!spec.history) record(area);
2157
2542
  saveRange(area);
@@ -2159,8 +2544,45 @@
2159
2544
  sync();
2160
2545
  }
2161
2546
 
2547
+ // $rich's reads (value, selection, can, active, …) depend on one reactive beat, bumped
2548
+ // (once a microtask) whenever the selection, value or formatting may have changed
2549
+ let pulse = null, beating = false;
2550
+ const watch = () => { if (pulse) void pulse.n; };
2551
+ // Bumped only when what $rich reports changed, so an effect that reads and writes it settles
2552
+ let lastSurface = null;
2553
+ const selKey = (x) => x ? [x.collapsed, x.text, x.x, x.y, x.width, x.height].join() : '';
2554
+ const surface = () => [...areas].map(a => [a === lastFocused, a === lastFocused ? String(textSpan(a)) : '', a.innerHTML, allKey(a), selKey(a._selection), a._pending ? [...a._pending.tags].join() : ''].join('\u0001')).join('\u0002');
2555
+ const beat = () => {
2556
+ if (!pulse || beating) return;
2557
+ beating = true;
2558
+ queueMicrotask(() => { beating = false; const now = surface(); if (now === lastSurface) return; lastSurface = now; pulse.n++; });
2559
+ };
2560
+
2561
+ // Any CSS colour (oklch(), a name, rgb()) as #rrggbb, read off a canvas pixel; null when it cannot be
2562
+ let colorCanvas = null;
2563
+ const hexCache = new Map();
2564
+ function hexColor(v) {
2565
+ if (v == null || v === '') return null;
2566
+ const s = String(v).trim();
2567
+ if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
2568
+ if (hexCache.has(s)) return hexCache.get(s);
2569
+ let out = null;
2570
+ try {
2571
+ colorCanvas = colorCanvas || document.createElement('canvas');
2572
+ const g = colorCanvas.getContext && colorCanvas.getContext('2d', { willReadFrequently: true });
2573
+ if (g) {
2574
+ g.clearRect(0, 0, 1, 1); g.fillStyle = '#000'; g.fillStyle = s; g.fillRect(0, 0, 1, 1);
2575
+ const [r, gr, b] = g.getImageData(0, 0, 1, 1).data;
2576
+ out = '#' + [r, gr, b].map(x => x.toString(16).padStart(2, '0')).join('');
2577
+ }
2578
+ } catch { out = null; }
2579
+ hexCache.set(s, out);
2580
+ return out;
2581
+ }
2582
+
2162
2583
  // Reflect caret state onto every control
2163
2584
  function sync() {
2585
+ beat();
2164
2586
  controls.forEach(c => {
2165
2587
  const cfg = c._rt, area = resolve(c);
2166
2588
  const usable = !!area && allows(area, cfg.id, cfg);
@@ -2171,9 +2593,10 @@
2171
2593
  c.setAttribute('aria-disabled', String(!usable));
2172
2594
  if (usable && (all || spec.reflect) && 'value' in c && c !== document.activeElement) {
2173
2595
  const v = all ? area.style.getPropertyValue(ALL_VARS[cfg.id]).trim() : judged(area, () => spec.reflect());
2174
- // Never blank a <select> with a value it doesn't offer
2596
+ // Never blank a <select> with a value it doesn't offer; a colour input takes #rrggbb only
2175
2597
  const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
2176
- if (v != null && offered && c.value !== v) c.value = v;
2598
+ const val = c.type === 'color' ? hexColor(v) : v;
2599
+ if (val != null && offered && c.value !== val) c.value = val;
2177
2600
  }
2178
2601
  if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-rich-text-active')));
2179
2602
  });
@@ -2235,6 +2658,7 @@
2235
2658
  if (!window.Alpine || !Alpine.directive) return;
2236
2659
  // Alpine walks only x-data trees: a page with none still gets its fields
2237
2660
  if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
2661
+ if (Alpine.reactive && !pulse) pulse = Alpine.reactive({ n: 0 });
2238
2662
  listen();
2239
2663
 
2240
2664
  Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
@@ -2252,20 +2676,37 @@
2252
2676
  Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
2253
2677
 
2254
2678
  // $rich: the area this element sits in, else the one controls would resolve to
2679
+ // Unresolved, it depends on the beat, so it resolves once a field is there
2255
2680
  const richMagic = (el) => {
2256
- const own = el.closest('[data-rich-text]');
2257
- const a = own && areas.has(own) ? own : resolve(el);
2681
+ const a = fieldFor(el);
2682
+ if (!a) watch();
2258
2683
  return a ? a._rt.api : idleApi();
2259
2684
  };
2260
2685
  Alpine.magic('rich', richMagic);
2261
2686
  // The loader's stand-in answered $rich until now: it hands over, and effects that read it
2262
2687
  // run again once adopted areas exist
2263
2688
  window.__manifestRichMagic = richMagic;
2264
- if (window.__manifestRichStub) setTimeout(() => { window.__manifestRichStub.v++; }, 0);
2689
+ if (window.__manifestRichStub) setTimeout(() => { flushStubWrites(); window.__manifestRichStub.v++; }, 0);
2265
2690
 
2266
2691
  adopt();
2267
2692
  }
2268
2693
 
2694
+ // $rich's field for an element: the one it sits in, else the one controls would resolve to
2695
+ const fieldFor = (el) => { const own = el.closest('[data-rich-text]'); return own && areas.has(own) ? own : resolve(el); };
2696
+ // Writes made on the loader's stand-in ($rich.value = … before Rich Text arrived) land on
2697
+ // their element's field once it exists
2698
+ function flushStubWrites() {
2699
+ const w = window.__manifestRichStubWrites; if (!w || !w.length) return;
2700
+ for (let i = 0; i < w.length;) {
2701
+ const [ref, k, v] = w[i], el = ref && typeof ref.deref === 'function' ? ref.deref() : ref;
2702
+ if (!el || !el.isConnected) { w.splice(i, 1); continue; }
2703
+ const a = fieldFor(el);
2704
+ if (!a) { i++; continue; }
2705
+ w.splice(i, 1);
2706
+ try { a._rt.api[k] = v; } catch { }
2707
+ }
2708
+ }
2709
+
2269
2710
  // Loaded after Alpine started: elements it already walked get their x-rich-text*
2270
2711
  // attributes re-set, which Alpine handles as newly added directives
2271
2712
  function adopt() {
@@ -2318,12 +2759,40 @@
2318
2759
  t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
2319
2760
  }
2320
2761
 
2762
+ // While a field holds a selection, scrolling and resizing re-report its geometry (once a frame)
2763
+ let tracked = null, trackFrame = 0;
2764
+ const untrack = () => {
2765
+ if (!tracked) return;
2766
+ tracked = null;
2767
+ window.removeEventListener('scroll', follow, { capture: true });
2768
+ window.removeEventListener('resize', follow);
2769
+ };
2770
+ function follow() {
2771
+ if (trackFrame) return;
2772
+ trackFrame = requestAnimationFrame(() => {
2773
+ trackFrame = 0;
2774
+ const area = tracked, shape = area && area.isConnected ? selectionOf(area) : null;
2775
+ if (!shape || shape.collapsed) return untrack();
2776
+ report(area);
2777
+ });
2778
+ }
2779
+ function track(area, on) {
2780
+ if (!on) { if (tracked === area) untrack(); return; }
2781
+ if (!tracked) {
2782
+ window.addEventListener('scroll', follow, { capture: true, passive: true });
2783
+ window.addEventListener('resize', follow, { passive: true });
2784
+ }
2785
+ tracked = area;
2786
+ }
2787
+
2321
2788
  function report(area) {
2322
2789
  const live = sel();
2323
2790
  if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
2324
2791
  if (area.querySelector('table')) paintCells(area);
2325
2792
  const shape = selectionOf(area);
2326
2793
  area._selection = shape;
2794
+ beat();
2795
+ track(area, !!shape && !shape.collapsed);
2327
2796
  place(area, shape);
2328
2797
  area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
2329
2798
  document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
@@ -2335,29 +2804,10 @@
2335
2804
  el._rtMenu = true;
2336
2805
  if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
2337
2806
  el.setAttribute('data-rich-text-menu', '');
2338
- let shown = null, frame = 0;
2339
- const follow = () => {
2340
- if (frame) return;
2341
- frame = requestAnimationFrame(() => { frame = 0; if (shown && shown.isConnected) place(shown, selectionOf(shown)); });
2342
- };
2343
- const track = (area) => {
2344
- if (!shown) {
2345
- window.addEventListener('scroll', follow, { capture: true, passive: true });
2346
- window.addEventListener('resize', follow, { passive: true });
2347
- }
2348
- shown = area;
2349
- };
2350
- const untrack = () => {
2351
- if (!shown) return;
2352
- shown = null;
2353
- window.removeEventListener('scroll', follow, { capture: true });
2354
- window.removeEventListener('resize', follow);
2355
- };
2356
2807
  const onSel = (e) => {
2357
2808
  const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
2358
2809
  if (!area || resolve(el) !== area) return;
2359
2810
  const want = !!e.detail && !e.detail.collapsed;
2360
- if (want) track(area); else untrack();
2361
2811
  try {
2362
2812
  if (want && !el.matches(':popover-open')) el.showPopover();
2363
2813
  if (!want && el.matches(':popover-open')) el.hidePopover();
@@ -2366,7 +2816,6 @@
2366
2816
  document.addEventListener('rich-text:selection', onSel);
2367
2817
  cleanup(() => {
2368
2818
  document.removeEventListener('rich-text:selection', onSel);
2369
- untrack(); if (frame) cancelAnimationFrame(frame);
2370
2819
  try { el.hidePopover(); } catch { }
2371
2820
  });
2372
2821
  }
@@ -2523,8 +2972,8 @@
2523
2972
  }
2524
2973
  }
2525
2974
 
2526
- el._history = { stack: [{ html: el.innerHTML, caret: 0 }], at: 0, timer: null, restoring: false };
2527
- const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0; };
2975
+ el._history = { stack: [{ html: el.innerHTML, caret: 0, all: allKey(el) }], at: 0, timer: null, restoring: false };
2976
+ const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0, all: allKey(el) }]; el._history.at = 0; };
2528
2977
 
2529
2978
  // Empty flag for the placeholder; a wiped document restarts as a paragraph
2530
2979
  function settle() {
@@ -2544,7 +2993,9 @@
2544
2993
  if (el.querySelector('table')) normalizeTables(el);
2545
2994
  if (format !== 'plain') looseInline(el);
2546
2995
  ensureTrailingBlock(el);
2547
- if (!el.textContent.trim() && !el.querySelector('img, table, hr')) {
2996
+ // A code block just opened (``` + Enter) holds the caret: it stays
2997
+ const r = range(), first = el.firstElementChild, opened = !!first && first.tagName === 'PRE' && !!r && first.contains(r.startContainer);
2998
+ if (!opened && !el.textContent.trim() && !el.querySelector('img, table, hr')) {
2548
2999
  if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
2549
3000
  if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
2550
3001
  el.toggleAttribute('data-rich-text-empty', true);
@@ -2555,6 +3006,8 @@
2555
3006
  writing = true; write(v); writing = false;
2556
3007
  resetHistory();
2557
3008
  settle();
3009
+ el._selection = selectionOf(el);
3010
+ sync();
2558
3011
  };
2559
3012
 
2560
3013
  const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
@@ -2608,7 +3061,7 @@
2608
3061
  on('input', (e) => {
2609
3062
  // Mid-composition the IME owns the text
2610
3063
  if (!writing && e.data && !e.isComposing) {
2611
- if (applyPending(el, e.data)) commit();
3064
+ if (applyPending(el, e.data)) { mergeAdjacent(el); commit(); }
2612
3065
  if (leaveMark(el, e.data)) commit();
2613
3066
  if (autoformat && convertInlineAtCaret(el)) commit();
2614
3067
  }
@@ -2622,6 +3075,8 @@
2622
3075
  on('compositionend', (e) => {
2623
3076
  if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
2624
3077
  else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
3078
+ // A closing marker committed through an IME converts as a typed one does
3079
+ else if (!writing && autoformat && e.data && /[*_~`)]$/.test(e.data) && convertInlineAtCaret(el)) { commit(); sync(); }
2625
3080
  if (!writing) relaxSpaces(el);
2626
3081
  });
2627
3082
  on('change', e => {
@@ -2762,7 +3217,7 @@
2762
3217
  });
2763
3218
 
2764
3219
  const api = {
2765
- get value() { return read(); },
3220
+ get value() { watch(); return read(); },
2766
3221
  // The value it already holds keeps the markup and caret; history restarts either way
2767
3222
  set value(v) {
2768
3223
  if (v !== read()) write(v);
@@ -2772,15 +3227,25 @@
2772
3227
  el.dispatchEvent(new Event('input', { bubbles: true }));
2773
3228
  el._history.restoring = false;
2774
3229
  },
2775
- get all() { return allStyle(el); },
2776
- set all(v) { if (!allStyles) return; setAllStyle(el, v); sync(); },
2777
- get selection() { return el._selection || null; },
2778
- get link() { return linkHref(); },
2779
- set link(v) { run(el, 'a', v); },
3230
+ get all() { watch(); return allStyle(el); },
3231
+ // Page styling, not an edit: no history step (the history takes it as its baseline)
3232
+ set all(v) {
3233
+ if (!allStyles) return;
3234
+ const was = allStyle(el);
3235
+ setAllStyle(el, v);
3236
+ const now = allStyle(el);
3237
+ if (JSON.stringify(now) === JSON.stringify(was)) return;
3238
+ rebaseAll(el, was, now);
3239
+ el.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: now }));
3240
+ sync();
3241
+ },
3242
+ get selection() { watch(); return el._selection || null; },
3243
+ get link() { watch(); return linkHref(); },
3244
+ set link(v) { if ((v == null ? '' : String(v)) === (linkHref() || '')) return; run(el, 'a', v); },
2780
3245
  focus: () => el.focus(),
2781
3246
  run: (id, arg) => run(el, id, arg),
2782
- active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
2783
- can: (id) => allows(el, id),
3247
+ active: (id) => { watch(); return allows(el, id) && COMMANDS[id].active(undefined, el); },
3248
+ can: (id) => { watch(); return allows(el, id); },
2784
3249
  selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
2785
3250
  // The newest step in this field's history (a pending typing burst lands first)
2786
3251
  newest: () => { const h = el._history; if (h.timer || h.stack[h.at].html !== el.innerHTML) record(el); return h.stack[h.stack.length - 1].html; },
@@ -2792,12 +3257,14 @@
2792
3257
 
2793
3258
  el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
2794
3259
  areas.add(el);
3260
+ if (window.__manifestRichStubWrites && window.__manifestRichStubWrites.length) setTimeout(flushStubWrites, 0);
2795
3261
  settle();
2796
- el._history.stack[0].html = el.innerHTML;
3262
+ el._history.stack[0].html = el.innerHTML; el._history.stack[0].all = allKey(el);
2797
3263
  setTimeout(sync, 0);
2798
3264
 
2799
3265
  const destroy = () => {
2800
3266
  if (!areas.has(el)) return;
3267
+ if (tracked === el) untrack();
2801
3268
  ac.abort();
2802
3269
  clearTimeout(el._history && el._history.timer);
2803
3270
  if (lastFocused === el) release(el);