mnfst 0.5.230 → 0.5.232

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.
@@ -0,0 +1,2848 @@
1
+ /* Manifest Rich Text */
2
+
3
+ (function () {
4
+
5
+ /* ---- Tag tables ----
6
+ Commands are tag names; `md` marks tags markdown can carry (others are .html only). */
7
+ const INLINE_TAGS = {
8
+ strong: { md: 1 }, b: { md: 1 }, em: { md: 1 }, i: { md: 1 }, s: { md: 1 }, del: { md: 1 }, code: { md: 1 },
9
+ abbr: { attr: 'title' }, bdi: {}, bdo: { attr: 'dir', def: 'rtl' }, cite: {}, data: { attr: 'value' },
10
+ dfn: { attr: 'title' }, ins: {}, kbd: {}, mark: {}, q: {}, samp: {}, small: {}, span: {}, sub: {}, sup: {},
11
+ time: { attr: 'datetime' }, u: {}, var: {}
12
+ };
13
+ const BLOCK_TAGS = {
14
+ p: { md: 1 }, h1: { md: 1 }, h2: { md: 1 }, h3: { md: 1 }, h4: { md: 1 }, h5: { md: 1 }, h6: { md: 1 },
15
+ blockquote: { md: 1 }, pre: { md: 1 }, address: {}
16
+ };
17
+ const BLOCK = [...Object.keys(BLOCK_TAGS), 'figure', 'figcaption', 'dl', 'dt', 'dd', 'ul', 'ol', 'li', 'hr', 'table', 'td', 'th'].map(t => t.toUpperCase());
18
+ const BLOCK_SEL = BLOCK.join(',');
19
+ const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
20
+ const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
21
+
22
+ /* ---- URL policy ---- */
23
+ // No script schemes; data: only as an image.
24
+ function unsafeUrl(url, image) {
25
+ const u = String(url == null ? '' : url).replace(/[\x00-\x20\x7f]/g, '');
26
+ const scheme = (u.match(/^([a-z][a-z0-9+.-]*):/i) || [])[1];
27
+ if (!scheme) return false;
28
+ const s = scheme.toLowerCase();
29
+ if (s === 'javascript' || s === 'vbscript') return true;
30
+ return s === 'data' && !(image && /^data:image\/(png|jpeg|gif|webp|avif)[;,]/i.test(u)); // raster only: an SVG can carry script
31
+ }
32
+
33
+ /* ---- HTML → Markdown ---- */
34
+ const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
35
+ const esc = (t) => t.replace(/([\\`*_~[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
36
+ // Line starts that would parse as a heading, list or rule
37
+ const escLead = (text) => text.split('\n').map(l => l
38
+ .replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
39
+ .replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
40
+ .replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
41
+ .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, '\\&');
43
+
44
+ function inline(node) {
45
+ let out = '';
46
+ node.childNodes.forEach(n => {
47
+ if (n.nodeType === 3) { out += esc(n.textContent); return; }
48
+ if (n.nodeType !== 1) return;
49
+ const tag = n.tagName;
50
+ if (tag === 'BR') { out += ' \n'; return; }
51
+ 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;
57
+ });
58
+ return out;
59
+ }
60
+
61
+ const preText = (n) => [...n.childNodes].map(c =>
62
+ c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
63
+
64
+ function block(node, depth) {
65
+ if (node.nodeType === 3) { const t = node.textContent.trim(); return t ? escLead(esc(t)) : ''; }
66
+ if (node.nodeType !== 1) return '';
67
+ const tag = node.tagName;
68
+ if (tag === 'HR') return '---';
69
+ if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
70
+ if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
71
+ if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
72
+ if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
73
+ if (tag === 'UL' || tag === 'OL') {
74
+ const pad = ' '.repeat(depth);
75
+ return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
76
+ const box = li.querySelector(':scope > input[type=checkbox]');
77
+ const bullet = tag === 'OL' ? `${i + 1}. ` : box ? (box.checked ? '- [x] ' : '- [ ] ') : '- ';
78
+ const nested = [...li.children].filter(c => c.tagName === 'UL' || c.tagName === 'OL');
79
+ const own = inline(nestedStripped(li, nested)).trim();
80
+ const sub = nested.map(n => block(n, depth + 1)).join('\n');
81
+ return pad + bullet + own + (sub ? '\n' + sub : '');
82
+ }).join('\n');
83
+ }
84
+ // A container of blocks (dl, figure, a cell's paragraphs) keeps them apart
85
+ if ([...node.children].some(c => BLOCK.includes(c.tagName))) return blocks(node, depth);
86
+ return escLead(inline(node).trim());
87
+ }
88
+
89
+ function nestedStripped(li, nested) {
90
+ if (!nested.length) return li;
91
+ const c = li.cloneNode(true);
92
+ [...c.children].forEach(ch => { if (ch.tagName === 'UL' || ch.tagName === 'OL') ch.remove(); });
93
+ return c;
94
+ }
95
+
96
+ // Consecutive inline nodes (loose text, marks) are one paragraph, formatting kept
97
+ function blocks(root, depth) {
98
+ const parts = [];
99
+ let run = null;
100
+ const flush = () => { if (run) { const t = escLead(inline(run).trim()); if (t) parts.push(t); run = null; } };
101
+ root.childNodes.forEach(n => {
102
+ if (n.nodeType === 1 && (BLOCK.includes(n.tagName) || [...n.children].some(c => BLOCK.includes(c.tagName)))) {
103
+ flush();
104
+ const b = block(n, depth || 0); if (b) parts.push(b);
105
+ return;
106
+ }
107
+ if (n.nodeType === 1 || n.nodeType === 3) (run = run || document.createElement('div')).appendChild(n.cloneNode(true));
108
+ });
109
+ flush();
110
+ return parts.join('\n\n');
111
+ }
112
+
113
+ const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
114
+ // Single-line values never parse block syntax, so line starts need no escape
115
+ const inlineMarkdown = (root) => {
116
+ const flat = document.createElement('div');
117
+ flat.innerHTML = flattenLines(root.innerHTML, true);
118
+ return inline(flat).trim();
119
+ };
120
+
121
+ // A cell's blocks joined with a space
122
+ const cellMarkdown = (cell) => {
123
+ if (![...cell.children].some(c => BLOCK.includes(c.tagName))) return inline(cell).trim();
124
+ const c = cell.cloneNode(true);
125
+ [...c.children].forEach(b => { if (BLOCK.includes(b.tagName)) { b.before(' '); b.after(' '); } });
126
+ return inline(c).replace(/\s+/g, ' ').trim();
127
+ };
128
+
129
+ /* ---- Tables in markdown ---- */
130
+ // One line per row, cells joined by " · ".
131
+ const CELL_SEP = ' · ';
132
+ const tableLines = (table) => [...table.rows].map(r => [...r.cells])
133
+ .filter(cells => cells.some(c => c.textContent.trim() || c.querySelector('img')));
134
+
135
+ function flattenTables(html, blocks) {
136
+ const t = document.createElement('template');
137
+ t.innerHTML = String(html == null ? '' : html);
138
+ [...t.content.querySelectorAll('table')].reverse().forEach(table => {
139
+ const frag = document.createDocumentFragment();
140
+ tableLines(table).forEach((cells, i) => {
141
+ const line = blocks ? document.createElement('p') : frag;
142
+ if (!blocks && i) frag.appendChild(document.createElement('br'));
143
+ cells.forEach((cell, j) => {
144
+ cell.querySelectorAll(BLOCK_SEL).forEach(b => b.replaceWith(...b.childNodes, ' '));
145
+ cell.normalize();
146
+ const a = cell.firstChild, z = cell.lastChild;
147
+ if (a && a.nodeType === 3) a.nodeValue = a.nodeValue.replace(/^\s+/, '');
148
+ if (z && z.nodeType === 3) z.nodeValue = z.nodeValue.replace(/\s+$/, '');
149
+ if (j) line.appendChild(document.createTextNode(CELL_SEP));
150
+ while (cell.firstChild) line.appendChild(cell.firstChild);
151
+ });
152
+ if (blocks) frag.appendChild(line);
153
+ });
154
+ table.replaceWith(frag);
155
+ });
156
+ return t.innerHTML;
157
+ }
158
+
159
+ // Html-only blocks become paragraphs for a markdown document
160
+ // Markdown keeps only its own marks: other inline tags (u, mark, sub, span...) are unwrapped
161
+ function markdownMarks(html) {
162
+ const t = document.createElement('template');
163
+ t.innerHTML = html;
164
+ [...t.content.querySelectorAll('*')].reverse().forEach(el => {
165
+ if (INLINE_OK.has(el.tagName) && !MARKS[el.tagName] && !/^(A|IMG|BR)$/.test(el.tagName)) el.replaceWith(...el.childNodes);
166
+ });
167
+ return t.innerHTML;
168
+ }
169
+
170
+ function markdownBlocks(html) {
171
+ const t = document.createElement('template');
172
+ t.innerHTML = html;
173
+ [...t.content.querySelectorAll('dt, dd, figcaption, address')].reverse().forEach(b => {
174
+ if ([...b.children].some(c => BLOCK.includes(c.tagName))) b.replaceWith(...b.childNodes);
175
+ else retagBlock(b, 'p');
176
+ });
177
+ [...t.content.querySelectorAll('dl, figure')].reverse().forEach(b => b.replaceWith(...b.childNodes));
178
+ return t.innerHTML;
179
+ }
180
+
181
+ /* ---- Markdown → HTML ---- */
182
+ const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
183
+
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]);
197
+ }
198
+
199
+ // Four spaces per nesting level.
200
+ function renderList(items, i, depth) {
201
+ const tag = items[i].tag;
202
+ let html = `<${tag}>`;
203
+ while (i < items.length && items[i].depth >= depth) {
204
+ if (items[i].depth > depth) {
205
+ const [sub, next] = renderList(items, i, items[i].depth);
206
+ html = html.endsWith(`<${tag}>`) ? html + `<li>${sub}</li>` : html.replace(/<\/li>$/, sub + '</li>');
207
+ i = next;
208
+ continue;
209
+ }
210
+ if (items[i].tag !== tag) break;
211
+ const task = items[i].text.match(/^\[([ xX])\]\s+(.*)$/);
212
+ html += task
213
+ ? `<li><input type="checkbox"${/[xX]/.test(task[1]) ? ' checked' : ''}>${inlineHtml(task[2])}</li>`
214
+ : `<li>${inlineHtml(items[i].text)}</li>`;
215
+ i++;
216
+ }
217
+ return [html + `</${tag}>`, i];
218
+ }
219
+
220
+ function fromMarkdown(md) {
221
+ const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
222
+ const out = [];
223
+ let para = [], list = null, quote = null, fence = null;
224
+ const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
225
+ 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
+ const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
227
+ const flushAll = () => { flushPara(); flushList(); flushQuote(); };
228
+
229
+ for (const raw of lines) {
230
+ 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; }
233
+ if (!line.trim()) { flushAll(); continue; }
234
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
235
+ if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
236
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { flushAll(); out.push('<hr>'); continue; }
237
+ const q = line.match(/^>\s?(.*)$/);
238
+ if (q) { flushPara(); flushList(); (quote = quote || []).push(q[1]); continue; }
239
+ const li = line.match(/^([ \t]*)([-*+]|\d+\.)\s+(.*)$/);
240
+ if (li) {
241
+ flushPara(); flushQuote();
242
+ (list = list || []).push({ depth: Math.floor(li[1].replace(/\t/g, ' ').length / 4), tag: /\d/.test(li[2]) ? 'ol' : 'ul', text: li[3] });
243
+ continue;
244
+ }
245
+ flushList(); flushQuote();
246
+ para.push(line);
247
+ }
248
+ if (fence) out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`);
249
+ flushAll();
250
+ return out.join('');
251
+ }
252
+
253
+ /* ---- Sanitize ---- */
254
+ // Payload elements are dropped; anything else outside the allowlist is unwrapped.
255
+ const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'MATH']);
256
+
257
+ function sanitize(html, allowBlocks, keepStyle) {
258
+ const t = document.createElement('template');
259
+ t.innerHTML = String(html == null ? '' : html);
260
+ const ok = new Set(allowBlocks ? [...INLINE_OK, ...BLOCK, 'INPUT', 'TR', 'TBODY', 'THEAD', 'CAPTION', 'COLGROUP', 'COL'] : INLINE_OK);
261
+ // Depth-first, so nothing hoisted out of an unwrapped parent escapes the walk
262
+ const walk = (node) => [...node.childNodes].forEach(c => {
263
+ if (c.nodeType === 8) return c.remove();
264
+ if (c.nodeType !== 1) return;
265
+ if (DROP.has(c.tagName)) return c.remove();
266
+ walk(c);
267
+ if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
268
+ const meta = INLINE_TAGS[c.tagName.toLowerCase()];
269
+ [...c.attributes].forEach(a => {
270
+ const n = a.name.toLowerCase();
271
+ if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
272
+ if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
273
+ if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
274
+ if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
275
+ if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
276
+ if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
277
+ c.removeAttribute(a.name);
278
+ });
279
+ });
280
+ walk(t.content);
281
+ return t.innerHTML;
282
+ }
283
+
284
+ const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
285
+
286
+ // Pasted spans left bare once their styles are stripped carry nothing
287
+ function unwrapBareSpans(html) {
288
+ const t = document.createElement('template');
289
+ t.innerHTML = html;
290
+ [...t.content.querySelectorAll('span')].reverse().forEach(s => { if (!s.attributes.length) s.replaceWith(...s.childNodes); });
291
+ return t.innerHTML;
292
+ }
293
+
294
+ /* ---- Single-line content ---- */
295
+ const flatText = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ');
296
+
297
+ // Blocks and breaks become spaces, then inline-only and whitespace-collapsed.
298
+ function flattenLines(html, keepStyle) {
299
+ const t = document.createElement('template');
300
+ t.innerHTML = String(html == null ? '' : html);
301
+ t.content.querySelectorAll('br').forEach(b => b.replaceWith(' '));
302
+ t.content.querySelectorAll('*').forEach(el => { if (!INLINE_OK.has(el.tagName)) { el.before(' '); el.after(' '); } });
303
+ t.innerHTML = sanitize(t.innerHTML, false, keepStyle);
304
+ // One space per boundary, across element edges too
305
+ // Images and other replaced elements are content: the space after one stays
306
+ const walker = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
307
+ let spaced = true, last = null;
308
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
309
+ if (n.nodeType === 1) { if (/^(IMG|WBR)$/.test(n.tagName)) { spaced = false; last = n; } continue; }
310
+ let v = n.nodeValue.replace(/[ \t\n\r\f]+/g, ' ');
311
+ if (spaced) v = v.replace(/^ /, '');
312
+ if (v) { spaced = v.endsWith(' '); last = n; }
313
+ n.nodeValue = v;
314
+ }
315
+ if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/ $/, '');
316
+ return t.innerHTML;
317
+ }
318
+
319
+ // One line per block or <br> (.inline with breaks): boundaries become single <br>s, none at the ends.
320
+ const LINE = '\u2028', PARA = '\u2029';
321
+ function breakLines(html, keepStyle) {
322
+ const t = document.createElement('template');
323
+ t.innerHTML = String(html == null ? '' : html);
324
+ t.content.querySelectorAll('br').forEach(b => b.replaceWith(LINE));
325
+ t.content.querySelectorAll(BLOCK_SEL).forEach(b => { b.before(PARA); b.after(PARA); });
326
+ return flattenLines(t.innerHTML, keepStyle)
327
+ .replace(/ *([\u2028\u2029]) */g, '$1')
328
+ .replace(/\u2029+/g, PARA).replace(/\u2028\u2029|\u2029\u2028/g, LINE)
329
+ .replace(/^[\u2028\u2029]+|[\u2028\u2029]+$/g, '')
330
+ .replace(/[\u2028\u2029]/g, '<br>');
331
+ }
332
+ const textLines = (text) => String(text == null ? '' : text).split(/\r\n?|\n/).map(l => l.trim()).filter(Boolean)
333
+ .map(l => sanitize(inlineHtml(l), false)).join('<br>');
334
+
335
+ /* ---- Selection primitives ---- */
336
+ const sel = () => window.getSelection();
337
+ // `probe` stands in for the live range while a control is judged against an area's saved one
338
+ let probe = null;
339
+ const liveRange = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
340
+ const range = () => probe || liveRange();
341
+
342
+ function elementAt(node) { while (node && node.nodeType !== 1) node = node.parentNode; return node; }
343
+
344
+ // Bounded by the area: an area that is itself <strong> never reports as formatting
345
+ function ancestor(match) {
346
+ const r = range(); if (!r) return null;
347
+ const el = elementAt(r.startContainer); if (!el) return null;
348
+ const found = el.closest(match); if (!found) return null;
349
+ const area = el.closest('[data-rich-text]');
350
+ if (area && (found === area || !area.contains(found))) return null;
351
+ return found;
352
+ }
353
+
354
+ const withinArea = (node, found) => {
355
+ if (!found) return false;
356
+ const el = elementAt(node), area = el && el.closest('[data-rich-text]');
357
+ return !area || (found !== area && area.contains(found));
358
+ };
359
+
360
+ // Bounded by the area, so a block command never replaces the editor
361
+ function blockEl() {
362
+ let n = elementAt(range() && range().startContainer);
363
+ while (n && !areas.has(n)) {
364
+ if (BLOCK.includes(n.tagName)) return n;
365
+ n = n.parentElement;
366
+ }
367
+ return null;
368
+ }
369
+
370
+ function ensureBlock(area) {
371
+ let b = blockEl();
372
+ if (b) return b;
373
+ b = document.createElement('p');
374
+ const r = range();
375
+ if (r && area.contains(r.startContainer)) r.insertNode(b); else area.appendChild(b);
376
+ caretIn(b, 0);
377
+ return b;
378
+ }
379
+ const blockTag = () => { const b = blockEl(); return b && b.tagName; };
380
+
381
+ function selectNode(node) {
382
+ const s = sel(), r = document.createRange();
383
+ r.selectNodeContents(node); s.removeAllRanges(); s.addRange(r);
384
+ }
385
+
386
+ // Caret as a character offset, so it survives rebuilt nodes
387
+ function offsetIn(root) {
388
+ const r = range(); if (!r || !root.contains(r.startContainer)) return null;
389
+ const pre = document.createRange();
390
+ pre.selectNodeContents(root);
391
+ pre.setEnd(r.startContainer, r.startOffset);
392
+ return pre.toString().length;
393
+ }
394
+
395
+ function caretIn(root, offset) {
396
+ const s = sel(), r = document.createRange();
397
+ if (offset == null) offset = 0;
398
+ // An empty block holds a caret only with a <br> and an element anchor
399
+ if (!root.textContent.length) {
400
+ [...root.childNodes].forEach(n => { if (n.nodeType === 3) n.remove(); });
401
+ if (!root.querySelector('br')) root.appendChild(document.createElement('br'));
402
+ r.setStart(root, 0); r.collapse(true);
403
+ s.removeAllRanges(); s.addRange(r);
404
+ return;
405
+ }
406
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
407
+ let seen = 0;
408
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
409
+ if (!n.length) continue;
410
+ if (seen + n.length >= offset) {
411
+ r.setStart(n, Math.max(0, offset - seen)); r.collapse(true);
412
+ s.removeAllRanges(); s.addRange(r);
413
+ return;
414
+ }
415
+ seen += n.length;
416
+ }
417
+ r.setStart(root, root.childNodes.length); r.collapse(true);
418
+ s.removeAllRanges(); s.addRange(r);
419
+ }
420
+
421
+ // Boundary text nodes are split so every node returned lies wholly inside the selection
422
+ function selectedTextNodes() {
423
+ const r = range(); if (!r || r.collapsed) return [];
424
+ if (r.endContainer.nodeType === 3 && r.endOffset < r.endContainer.length) {
425
+ const end = r.endContainer, at = r.endOffset;
426
+ end.splitText(at); r.setEnd(end, end.length);
427
+ }
428
+ if (r.startContainer.nodeType === 3 && r.startOffset > 0) {
429
+ const start = r.startContainer, at = r.startOffset, same = r.endContainer === start, endAt = r.endOffset;
430
+ const tail = start.splitText(at);
431
+ r.setStart(tail, 0);
432
+ if (same) r.setEnd(tail, endAt - at);
433
+ }
434
+ const root = r.commonAncestorContainer;
435
+ const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
436
+ const out = [];
437
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
438
+ if (r.intersectsNode(n) && n.nodeValue && r.comparePoint(n, 0) >= 0 && r.comparePoint(n, n.length) <= 0) out.push(n);
439
+ }
440
+ return out.length ? out : (r.startContainer.nodeType === 3 ? [r.startContainer] : []);
441
+ }
442
+
443
+ /* ---- Inline tags ---- */
444
+ function wrapInline(tag, style, attrs) {
445
+ const nodes = selectedTextNodes(); if (!nodes.length) return [];
446
+ const made = [];
447
+ nodes.forEach(n => {
448
+ if (!n.nodeValue.length) return;
449
+ const el = document.createElement(tag);
450
+ if (style) Object.assign(el.style, style);
451
+ if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
452
+ n.parentNode.insertBefore(el, n);
453
+ el.appendChild(n);
454
+ made.push(el);
455
+ el.querySelectorAll(tag).forEach(dupe => dupe.replaceWith(...dupe.childNodes));
456
+ });
457
+ if (!made.length) return made;
458
+ // Boundaries land inside the new elements
459
+ const s = sel(), r = document.createRange(), last = made[made.length - 1];
460
+ r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length);
461
+ s.removeAllRanges(); s.addRange(r);
462
+ return made;
463
+ }
464
+
465
+ function unwrapInline(tag) {
466
+ const nodes = selectedTextNodes();
467
+ const targets = new Set();
468
+ nodes.forEach(n => { const el = elementAt(n) && elementAt(n).closest(tag); if (withinArea(n, el)) targets.add(el); });
469
+ const host = ancestor(tag); if (host) targets.add(host);
470
+ targets.forEach(el => el.replaceWith(...el.childNodes));
471
+ // Reselect the same text
472
+ const live = nodes.filter(n => n.isConnected && n.length);
473
+ if (!live.length) return;
474
+ const r = document.createRange();
475
+ r.setStart(live[0], 0);
476
+ r.setEnd(live[live.length - 1], live[live.length - 1].length);
477
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
478
+ }
479
+
480
+ // Every selected text node carries the tag
481
+ function selectionMarked(tag) {
482
+ const r = range(); if (!r) return false;
483
+ if (r.collapsed) return !!ancestor(tag);
484
+ const root = r.commonAncestorContainer;
485
+ const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
486
+ let any = false;
487
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
488
+ if (!r.intersectsNode(n) || !n.nodeValue.trim()) continue;
489
+ any = true;
490
+ if (!n.parentElement) return false;
491
+ if (!withinArea(n, n.parentElement.closest(tag))) return false;
492
+ }
493
+ return any;
494
+ }
495
+
496
+ const toggleInline = (tag, style, attrs) => selectionMarked(tag) ? unwrapInline(tag) : wrapInline(tag, style, attrs);
497
+
498
+ const attrsFor = (tag, v) => {
499
+ const m = INLINE_TAGS[tag], val = v != null && v !== '' ? String(v) : m.def;
500
+ return m.attr && val ? { [m.attr]: val } : null;
501
+ };
502
+
503
+ function insertVoid(tag) {
504
+ const r = range(); if (!r) return;
505
+ r.deleteContents();
506
+ const n = document.createElement(tag);
507
+ r.insertNode(n);
508
+ const s = sel(), nr = document.createRange();
509
+ nr.setStartAfter(n); nr.collapse(true);
510
+ s.removeAllRanges(); s.addRange(nr);
511
+ }
512
+
513
+ /* ---- Block tags ---- */
514
+ // A cell's loose content into a <p> inside it, so block commands nest rather than replace the cell
515
+ function innerBlock(cell) {
516
+ const off = offsetIn(cell), p = document.createElement('p');
517
+ while (cell.firstChild) p.appendChild(cell.firstChild);
518
+ cell.appendChild(p);
519
+ caretIn(p, off);
520
+ return p;
521
+ }
522
+
523
+ // The block a command may replace: never a cell, list item, term or container
524
+ function freeBlock() {
525
+ const b = blockEl(); if (!b) return null;
526
+ if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return null;
527
+ if (/^(TD|TH)$/.test(b.tagName)) return innerBlock(b);
528
+ if (/^(LI|DT|DD)$/.test(b.tagName)) return liftItem(b);
529
+ return b;
530
+ }
531
+
532
+ function setBlock(tag) {
533
+ let b = blockEl(); if (!b) return;
534
+ if (/^(UL|OL|DL|FIGURE|TABLE)$/.test(b.tagName)) return;
535
+ if (/^(TD|TH)$/.test(b.tagName)) b = innerBlock(b);
536
+ const want = (b.tagName === tag.toUpperCase() ? 'p' : tag).toLowerCase();
537
+ if (b.tagName === want.toUpperCase()) return;
538
+ // Out of the list first (outdenting nested items to the top), then retag
539
+ if (b.tagName === 'LI' && want !== 'li') {
540
+ const area = b.closest('[data-rich-text]');
541
+ for (let guard = 0; guard < 8; guard++) {
542
+ const list = b && b.tagName === 'LI' && b.closest('ul, ol');
543
+ if (!list || !list.parentElement || !list.parentElement.closest('li')) break;
544
+ indent(-1, area);
545
+ b = blockEl(); if (!b) return;
546
+ }
547
+ if (b.tagName === 'LI') { liftItem(b); b = blockEl(); if (!b) return; }
548
+ if (b.tagName === want.toUpperCase()) return;
549
+ }
550
+ if (/^(DT|DD)$/.test(b.tagName)) { b = liftItem(b); if (want === 'p') return; }
551
+ const n = document.createElement(want);
552
+ n.setAttribute('style', b.getAttribute('style') || '');
553
+ if (!n.getAttribute('style')) n.removeAttribute('style');
554
+ const off = offsetIn(b);
555
+ while (b.firstChild) n.appendChild(b.firstChild);
556
+ b.replaceWith(n);
557
+ // A sublist that rode along steps out after the new block
558
+ [...n.querySelectorAll(':scope > ul, :scope > ol')].reverse().forEach(l => n.after(l));
559
+ caretIn(n, off);
560
+ }
561
+
562
+ const styleBlock = (prop, value) => { const b = blockEl(); if (b) b.style[prop] = value; };
563
+ const readBlockStyle = (prop) => { const b = blockEl(); return b ? getComputedStyle(b)[prop] : ''; };
564
+
565
+ function retagKeep(b, tag) { const off = offsetIn(b); caretIn(retagBlock(b, tag), off); }
566
+
567
+ /* ---- Figures and description lists ---- */
568
+ function toggleFigure() {
569
+ const existing = ancestor('figure');
570
+ if (existing) {
571
+ const caption = existing.querySelector(':scope > figcaption');
572
+ if (caption) caption.remove();
573
+ existing.replaceWith(...existing.childNodes);
574
+ return;
575
+ }
576
+ const b = freeBlock(); if (!b) return;
577
+ const fig = document.createElement('figure');
578
+ b.replaceWith(fig);
579
+ fig.appendChild(b);
580
+ const caption = document.createElement('figcaption');
581
+ caption.appendChild(document.createElement('br'));
582
+ fig.appendChild(caption);
583
+ caretIn(caption, 0);
584
+ }
585
+
586
+ // One caption per figure: an existing one takes the caret instead
587
+ function setCaption() {
588
+ const fig = ancestor('figure'), b = blockEl(); if (!fig || !b) return;
589
+ const caption = fig.querySelector(':scope > figcaption');
590
+ if (caption && caption !== b && !caption.contains(b)) return caretIn(caption, caption.textContent.length);
591
+ if (b.parentElement === fig) setBlock('figcaption');
592
+ }
593
+
594
+ function toggleDl() {
595
+ const dl = ancestor('dl');
596
+ if (dl) {
597
+ const term = ancestor('dt, dd'), off = term ? offsetIn(term) : 0;
598
+ let land = null;
599
+ [...dl.children].forEach(c => {
600
+ if ([...c.children].some(x => BLOCK.includes(x.tagName))) { if (c === term) land = c.firstElementChild; c.replaceWith(...c.childNodes); }
601
+ else { const p = retagBlock(c, 'p'); if (c === term) land = p; }
602
+ });
603
+ dl.replaceWith(...dl.childNodes);
604
+ if (land) caretIn(land, off);
605
+ return;
606
+ }
607
+ const b = freeBlock(); if (!b) return;
608
+ const list = document.createElement('dl'), dt = document.createElement('dt'), dd = document.createElement('dd');
609
+ while (b.firstChild) dt.appendChild(b.firstChild);
610
+ dd.appendChild(document.createElement('br'));
611
+ list.append(dt, dd);
612
+ b.replaceWith(list);
613
+ caretIn(dd, 0);
614
+ }
615
+
616
+ // Retags the term itself, never a block nested in one
617
+ const setTerm = (tag) => () => {
618
+ const term = ancestor('dt, dd');
619
+ if (!term || term.tagName === tag.toUpperCase()) return;
620
+ retagKeep(term, tag);
621
+ };
622
+ const termIs = (tag) => { const t = ancestor('dt, dd'); return !!t && t.tagName === tag; };
623
+
624
+ /* ---- Lists, tables, indent ---- */
625
+ const inList = () => !!ancestor('li');
626
+ const inTable = () => !!cellAt();
627
+
628
+ /* ---- Tables ---- */
629
+ let cellDrag = null; // pointer-picked block of cells
630
+ const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
631
+ const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
632
+ const rowsOf = (table) => [...table.rows];
633
+ const emptyCell = () => { const td = document.createElement('td'); td.appendChild(document.createElement('br')); return td; };
634
+
635
+ // Occupancy grid: every slot maps to the cell covering it, spans included
636
+ function tableGrid(table) {
637
+ const grid = [];
638
+ rowsOf(table).forEach((row, r) => {
639
+ grid[r] = grid[r] || [];
640
+ let c = 0;
641
+ for (const cell of row.cells) {
642
+ while (grid[r][c]) c++;
643
+ for (let dr = 0; dr < (cell.rowSpan || 1); dr++) {
644
+ grid[r + dr] = grid[r + dr] || [];
645
+ for (let dc = 0; dc < (cell.colSpan || 1); dc++) grid[r + dr][c + dc] = cell;
646
+ }
647
+ c += cell.colSpan || 1;
648
+ }
649
+ });
650
+ return grid;
651
+ }
652
+
653
+ function gridPosition(cell) {
654
+ const table = cell.closest('table'), grid = tableGrid(table);
655
+ for (let r = 0; r < grid.length; r++) {
656
+ for (let c = 0; c < (grid[r] || []).length; c++) if (grid[r][c] === cell) return { row: r, col: c, grid };
657
+ }
658
+ return { row: 0, col: 0, grid };
659
+ }
660
+
661
+ const cellCovering = (row, col) => {
662
+ const table = row.closest('table'), grid = tableGrid(table);
663
+ const r = rowsOf(table).indexOf(row);
664
+ return (grid[r] || [])[col] || row.lastElementChild;
665
+ };
666
+
667
+ const columnCount = (table) => Math.max(0, ...tableGrid(table).map(r => r.length));
668
+
669
+ // Short rows are padded back out
670
+ function normalizeTables(root) {
671
+ root.querySelectorAll('table').forEach(table => {
672
+ const rows = rowsOf(table); if (!rows.length) return;
673
+ const grid = tableGrid(table);
674
+ const width = Math.max(0, ...grid.map(r => r.length));
675
+ rows.forEach((row, r) => {
676
+ const have = (grid[r] || []).filter(Boolean).length;
677
+ for (let i = have; i < width; i++) row.appendChild(emptyCell());
678
+ });
679
+ if (!table.querySelector('tbody, thead')) {
680
+ const body = document.createElement('tbody');
681
+ while (table.firstChild) body.appendChild(table.firstChild);
682
+ table.appendChild(body);
683
+ }
684
+ });
685
+ }
686
+
687
+ function addRow(where) {
688
+ const cell = cellAt(); if (!cell) return;
689
+ const row = cell.parentElement, table = row.closest('table');
690
+ const fresh = document.createElement('tr');
691
+ for (let i = 0; i < columnCount(table); i++) fresh.appendChild(emptyCell());
692
+ if (where < 0) row.before(fresh); else row.after(fresh);
693
+ caretIn(fresh.firstElementChild, 0);
694
+ }
695
+
696
+ function addColumn(where) {
697
+ const cell = cellAt(); if (!cell) return;
698
+ const table = cell.closest('table'), { col, grid } = gridPosition(cell);
699
+ const seen = new Set();
700
+ rowsOf(table).forEach((row, r) => {
701
+ const at = (grid[r] || [])[col];
702
+ if (!at || seen.has(at)) return;
703
+ seen.add(at);
704
+ const fresh = document.createElement(at.tagName === 'TH' ? 'th' : 'td');
705
+ fresh.appendChild(document.createElement('br'));
706
+ if (where < 0) at.before(fresh); else at.after(fresh);
707
+ });
708
+ normalizeTables(table.parentElement || table);
709
+ caretIn(cellCovering(rowsOf(table)[gridPosition(cell).row], where < 0 ? col : col + 1), 0);
710
+ }
711
+
712
+ function removeRow() {
713
+ const cell = cellAt(); if (!cell) return;
714
+ const row = cell.parentElement, table = row.closest('table');
715
+ if (rowsOf(table).length <= 1) return removeTable();
716
+ const next = row.nextElementSibling || row.previousElementSibling;
717
+ row.remove();
718
+ caretIn(next.firstElementChild, 0);
719
+ }
720
+
721
+ function removeColumn() {
722
+ const cell = cellAt(); if (!cell) return;
723
+ const table = cell.closest('table'), { col, grid } = gridPosition(cell);
724
+ if (columnCount(table) <= 1) return removeTable();
725
+ const seen = new Set();
726
+ grid.forEach(row => { const at = row[col]; if (!at || seen.has(at)) return; seen.add(at); at.remove(); });
727
+ normalizeTables(table.parentElement || table);
728
+ caretIn(cellCovering(rowsOf(table)[0], Math.max(0, col - 1)), 0);
729
+ }
730
+
731
+ function removeTable() {
732
+ const table = tableAt(); if (!table) return;
733
+ const p = document.createElement('p');
734
+ table.replaceWith(p);
735
+ caretIn(p, 0);
736
+ }
737
+
738
+ // Selected cells merge as a rectangle; a lone caret merges with the cell to its right
739
+ function mergeCells() {
740
+ const cell = cellAt(); if (!cell) return;
741
+ const picked = selectedCells();
742
+ const cells = picked.length > 1 ? picked : [cell, nextCell(cell)].filter(Boolean);
743
+ if (cells.length < 2) return;
744
+
745
+ const at = cells.map(gridPosition);
746
+ const r0 = Math.min(...at.map(p => p.row));
747
+ const r1 = Math.max(...at.map((p, i) => p.row + (cells[i].rowSpan || 1) - 1));
748
+ const c0 = Math.min(...at.map(p => p.col));
749
+ const c1 = Math.max(...at.map((p, i) => p.col + (cells[i].colSpan || 1) - 1));
750
+
751
+ const grid = at[0].grid;
752
+ const first = (grid[r0] || [])[c0] || cells[0];
753
+ const cols = c1 - c0 + 1, rows = r1 - r0 + 1;
754
+ if (cols > 1) first.colSpan = cols; else first.removeAttribute('colspan');
755
+ if (rows > 1) first.rowSpan = rows; else first.removeAttribute('rowspan');
756
+
757
+ cells.forEach(c => {
758
+ if (c === first) return;
759
+ if (c.textContent.trim()) first.append(' ', ...c.childNodes);
760
+ c.remove();
761
+ });
762
+ tidyCell(first);
763
+ const table = first.closest('table');
764
+ normalizeTables(table.parentElement || table);
765
+ cellDrag = null;
766
+ paintCells(first.closest('[data-rich-text]') || table);
767
+ caretIn(first, first.textContent.length);
768
+ }
769
+
770
+ const nextCell = (cell) => {
771
+ const { row, col, grid } = gridPosition(cell);
772
+ const span = cell.colSpan || 1;
773
+ return (grid[row] || [])[col + span] || null;
774
+ };
775
+
776
+ const tidyCell = (cell) => {
777
+ if (!cell.textContent.trim()) { cell.replaceChildren(document.createElement('br')); return; }
778
+ cell.querySelectorAll(':scope > br').forEach(br => br.remove());
779
+ };
780
+
781
+ function splitCell() {
782
+ const cell = cellAt(); if (!cell) return;
783
+ const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
784
+ if (cols < 2 && rows < 2) return;
785
+ cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
786
+ for (let i = 1; i < cols; i++) cell.after(emptyCell());
787
+ if (rows > 1) {
788
+ const table = cell.closest('table'), { row, col } = gridPosition(cell);
789
+ for (let r = 1; r < rows; r++) {
790
+ const target = rowsOf(table)[row + r]; if (!target) break;
791
+ const at = cellCovering(target, col);
792
+ const fresh = emptyCell();
793
+ if (at) at.before(fresh); else target.appendChild(fresh);
794
+ }
795
+ }
796
+ normalizeTables(cell.closest('table').parentElement || cell.closest('table'));
797
+ caretIn(cell, 0);
798
+ }
799
+
800
+ // Empty cells hold no text, so cell selection follows the pointer
801
+ const cellUnder = (x, y) => {
802
+ const el = document.elementFromPoint(x, y);
803
+ const cell = el && el.closest && el.closest('td, th');
804
+ return cell && cell.closest('[data-rich-text]') ? cell : null;
805
+ };
806
+
807
+ function rectBetween(a, b) {
808
+ if (!a || !b) return [];
809
+ const pa = gridPosition(a), pb = gridPosition(b), grid = pa.grid;
810
+ const r0 = Math.min(pa.row, pb.row), r1 = Math.max(pa.row, pb.row);
811
+ const c0 = Math.min(pa.col, pb.col), c1 = Math.max(pa.col, pb.col);
812
+ const out = [];
813
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
814
+ const cell = (grid[r] || [])[c];
815
+ if (cell && !out.includes(cell)) out.push(cell);
816
+ }
817
+ return out;
818
+ }
819
+
820
+ function selectedCells() {
821
+ if (cellDrag && cellDrag.anchor !== cellDrag.focus && cellDrag.anchor.isConnected && cellDrag.focus.isConnected) {
822
+ return rectBetween(cellDrag.anchor, cellDrag.focus);
823
+ }
824
+ const table = tableAt(); if (!table) return [];
825
+ const s = sel(); if (!s || !s.anchorNode || !s.focusNode) return [];
826
+ const from = elementAt(s.anchorNode), to = elementAt(s.focusNode);
827
+ const a = from && from.closest('td, th'), b = to && to.closest('td, th');
828
+ if (!a || !b || a === b) return [];
829
+ return rectBetween(a, b);
830
+ }
831
+
832
+ function toggleHeaderRow() {
833
+ const table = tableAt(); if (!table) return;
834
+ const first = rowsOf(table)[0]; if (!first) return;
835
+ const isHeader = first.firstElementChild && first.firstElementChild.tagName === 'TH';
836
+ [...first.children].forEach(c => {
837
+ const next = document.createElement(isHeader ? 'td' : 'th');
838
+ while (c.firstChild) next.appendChild(c.firstChild);
839
+ if (c.colSpan > 1) next.colSpan = c.colSpan;
840
+ if (c.rowSpan > 1) next.rowSpan = c.rowSpan;
841
+ c.replaceWith(next);
842
+ });
843
+ // <thead> is a child of the table, never of the tbody
844
+ const head = table.querySelector('thead');
845
+ if (!isHeader && !head) {
846
+ const t = document.createElement('thead');
847
+ table.prepend(t);
848
+ t.appendChild(first);
849
+ }
850
+ if (isHeader && head) {
851
+ let body = table.querySelector('tbody');
852
+ if (!body) { body = document.createElement('tbody'); table.appendChild(body); }
853
+ body.prepend(first);
854
+ head.remove();
855
+ }
856
+ caretIn(table.rows[0].firstElementChild, 0);
857
+ }
858
+
859
+ /* ---- Column and row sizing ----
860
+ Widths on a <colgroup>, heights on the row. */
861
+ const EDGE = 5;
862
+
863
+ function ensureCols(table) {
864
+ let group = table.querySelector(':scope > colgroup');
865
+ if (!group) { group = document.createElement('colgroup'); table.prepend(group); }
866
+ const want = columnCount(table);
867
+ while (group.children.length < want) group.appendChild(document.createElement('col'));
868
+ while (group.children.length > want) group.lastElementChild.remove();
869
+ // Freeze current widths so one drag doesn't reflow the rest
870
+ [...group.children].forEach((col, i) => {
871
+ if (col.style.width) return;
872
+ const at = (tableGrid(table)[0] || [])[i];
873
+ if (at) col.style.width = Math.round(at.getBoundingClientRect().width / (at.colSpan || 1)) + 'px';
874
+ });
875
+ return group;
876
+ }
877
+
878
+ function borderAt(x, y) {
879
+ const el = document.elementFromPoint(x, y);
880
+ let cell = el && el.closest && el.closest('td, th');
881
+ if (!cell) {
882
+ // Outer edge: on the table, or just past it
883
+ const scope = el && el.closest && (el.closest('table') || el.closest('[data-rich-text]'));
884
+ if (!scope) return null;
885
+ const tables = scope.tagName === 'TABLE' ? [scope] : [...scope.querySelectorAll('table')];
886
+ for (const table of tables) {
887
+ const box = table.getBoundingClientRect();
888
+ const row = [...table.rows].find(rw => { const b = rw.getBoundingClientRect(); return y >= b.top - EDGE && y <= b.bottom + EDGE; });
889
+ if (!row || !row.cells.length) continue;
890
+ if (Math.abs(x - box.right) <= EDGE) return { axis: 'x', cell: row.cells[row.cells.length - 1] };
891
+ if (Math.abs(y - box.bottom) <= EDGE && x >= box.left && x <= box.right) return { axis: 'y', cell: row.cells[0] };
892
+ }
893
+ return null;
894
+ }
895
+ if (!cell.closest('[data-rich-text]')) return null;
896
+ const b = cell.getBoundingClientRect();
897
+ if (Math.abs(x - b.right) <= EDGE) return { axis: 'x', cell };
898
+ if (Math.abs(x - b.left) <= EDGE) { const prev = previousCell(cell); return prev ? { axis: 'x', cell: prev } : null; }
899
+ if (Math.abs(y - b.bottom) <= EDGE) return { axis: 'y', cell };
900
+ if (Math.abs(y - b.top) <= EDGE) { const row = cell.parentElement.previousElementSibling; return row ? { axis: 'y', cell: row.cells[0] } : null; }
901
+ return null;
902
+ }
903
+
904
+ function startResize(e, hit) {
905
+ const cell = hit.cell, table = cell.closest('table');
906
+ const area = cell.closest('[data-rich-text]');
907
+ 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
+ }
932
+
933
+ const move = (ev) => { ev.preventDefault(); apply(ev); };
934
+ const up = () => {
935
+ document.removeEventListener('pointermove', move);
936
+ document.removeEventListener('pointerup', up);
937
+ area.removeAttribute('data-rich-text-resizing');
938
+ area.dispatchEvent(new Event('input', { bubbles: true }));
939
+ };
940
+ document.addEventListener('pointermove', move);
941
+ document.addEventListener('pointerup', up);
942
+ }
943
+
944
+ // Tab walks the grid and grows it at the end
945
+ function moveCell(dir) {
946
+ const cell = cellAt(); if (!cell) return false;
947
+ const cells = [...cell.closest('table').querySelectorAll('td, th')];
948
+ let next = cells[cells.indexOf(cell) + dir];
949
+ if (!next && dir > 0) {
950
+ const row = cell.closest('tr'), fresh = row.cloneNode(true);
951
+ [...fresh.children].forEach(c => { c.innerHTML = '<br>'; c.removeAttribute('colspan'); c.removeAttribute('rowspan'); });
952
+ row.parentNode.appendChild(fresh);
953
+ next = fresh.children[0];
954
+ }
955
+ if (!next) return false;
956
+ caretIn(next, 0);
957
+ return true;
958
+ }
959
+
960
+ function caretAtPoint(x, y) {
961
+ if (document.caretPositionFromPoint) {
962
+ const pos = document.caretPositionFromPoint(x, y);
963
+ return pos ? { node: pos.offsetNode, offset: pos.offset } : null;
964
+ }
965
+ if (document.caretRangeFromPoint) {
966
+ const r = document.caretRangeFromPoint(x, y);
967
+ return r ? { node: r.startContainer, offset: r.startOffset } : null;
968
+ }
969
+ return null;
970
+ }
971
+
972
+ function placeAtPoint(x, y) {
973
+ const hit = caretAtPoint(x, y); if (!hit) return null;
974
+ const r = document.createRange();
975
+ try { r.setStart(hit.node, hit.offset); } catch { return null; }
976
+ r.collapse(true);
977
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
978
+ return hit;
979
+ }
980
+
981
+ const cellOf = (node) => { const el = elementAt(node); return el && el.closest('td, th'); };
982
+
983
+ // Arrows move by line within a cell, then cell to cell, keeping the column
984
+ function arrowInTable(e) {
985
+ const cell = cellAt(); if (!cell) return false;
986
+ const r = range(); if (!r || !r.collapsed) return false;
987
+ const here = r.getBoundingClientRect();
988
+
989
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
990
+ const at = offsetIn(cell), len = cell.textContent.length;
991
+ if (e.key === 'ArrowRight' && at < len) return false;
992
+ if (e.key === 'ArrowLeft' && at > 0) return false;
993
+ const next = e.key === 'ArrowRight' ? nextCell(cell) : previousCell(cell);
994
+ if (!next) return e.key === 'ArrowRight' ? moveCell(1) : moveCell(-1);
995
+ caretIn(next, e.key === 'ArrowRight' ? 0 : next.textContent.length);
996
+ return true;
997
+ }
998
+
999
+ const down = e.key === 'ArrowDown';
1000
+ const line = here.height || parseFloat(getComputedStyle(cell).lineHeight) || 16;
1001
+ const x = here.left;
1002
+ const probeY = down ? here.bottom + line * 0.5 : here.top - line * 0.5;
1003
+
1004
+ // Another line in this cell means the native move is right
1005
+ const ahead = caretAtPoint(x, probeY);
1006
+ if (ahead && cellOf(ahead.node) === cell) {
1007
+ const probe = document.createRange();
1008
+ let onAnotherLine = false;
1009
+ try {
1010
+ probe.setStart(ahead.node, ahead.offset); probe.collapse(true);
1011
+ const box = probe.getBoundingClientRect();
1012
+ onAnotherLine = !!box.height && Math.abs(box.top - here.top) > 2;
1013
+ } catch { }
1014
+ if (onAnotherLine) return false;
1015
+ }
1016
+
1017
+ const table = cell.closest('table'), { row, col, grid } = gridPosition(cell);
1018
+ const targetRow = row + (down ? (cell.rowSpan || 1) : -1);
1019
+ const next = (grid[targetRow] || [])[col];
1020
+ if (!next) return escapeTable(table, down);
1021
+
1022
+ const box = next.getBoundingClientRect();
1023
+ const aimY = down ? box.top + line * 0.5 : box.bottom - line * 0.5;
1024
+ if (placeAtPoint(Math.min(Math.max(x, box.left + 2), box.right - 2), aimY)) return true;
1025
+ caretIn(next, down ? 0 : next.textContent.length);
1026
+ return true;
1027
+ }
1028
+
1029
+ const previousCell = (cell) => {
1030
+ const { row, col, grid } = gridPosition(cell);
1031
+ for (let c = col - 1; c >= 0; c--) if ((grid[row] || [])[c] && grid[row][c] !== cell) return grid[row][c];
1032
+ return null;
1033
+ };
1034
+
1035
+ function escapeTable(table, down) {
1036
+ const sibling = down ? table.nextElementSibling : table.previousElementSibling;
1037
+ if (sibling) { caretIn(sibling, down ? 0 : sibling.textContent.length); return true; }
1038
+ if (!down) return false;
1039
+ const p = document.createElement('p');
1040
+ table.after(p);
1041
+ caretIn(p, 0);
1042
+ return true;
1043
+ }
1044
+
1045
+ /* ---- Lists ---- */
1046
+ function retagList(list, tag) {
1047
+ const n = document.createElement(tag);
1048
+ while (list.firstChild) n.appendChild(list.firstChild);
1049
+ list.replaceWith(n);
1050
+ return n;
1051
+ }
1052
+
1053
+ // An item (li, dt, dd) out of its list into a <p>; following items keep their own list
1054
+ function liftItem(item) {
1055
+ const list = item.parentElement, off = offsetIn(item);
1056
+ const p = document.createElement('p');
1057
+ [...item.childNodes].forEach(n => { if (n.tagName !== 'INPUT') p.appendChild(n); });
1058
+ const after = [...list.children].slice([...list.children].indexOf(item) + 1);
1059
+ list.after(p);
1060
+ if (after.length) {
1061
+ const tail = document.createElement(list.tagName.toLowerCase());
1062
+ if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
1063
+ after.forEach(n => tail.appendChild(n));
1064
+ p.after(tail);
1065
+ }
1066
+ item.remove();
1067
+ if (!list.children.length) list.remove();
1068
+ caretIn(p, off);
1069
+ return p;
1070
+ }
1071
+
1072
+ function toggleList(tag) {
1073
+ const li = ancestor('li');
1074
+ if (li) {
1075
+ const list = li.closest('ul, ol');
1076
+ if (list.tagName.toLowerCase() === tag) return liftItem(li);
1077
+ const next = retagList(list, tag);
1078
+ next.removeAttribute('data-checklist');
1079
+ next.querySelectorAll('input[type=checkbox]').forEach(b => b.remove());
1080
+ caretIn(next.querySelector('li') || next, 0);
1081
+ return;
1082
+ }
1083
+ const b = freeBlock(); if (!b) return;
1084
+ const off = offsetIn(b);
1085
+ const list = document.createElement(tag), item = document.createElement('li');
1086
+ while (b.firstChild) item.appendChild(b.firstChild);
1087
+ list.appendChild(item);
1088
+ b.replaceWith(list);
1089
+ caretIn(item, off);
1090
+ }
1091
+
1092
+ // .li: into the list above when there is one, else a new bulleted list
1093
+ function toggleItem() {
1094
+ const li = ancestor('li');
1095
+ if (li) return liftItem(li);
1096
+ const b = freeBlock(); if (!b) return;
1097
+ const prev = b.previousElementSibling;
1098
+ if (!prev || !/^(UL|OL)$/.test(prev.tagName)) return toggleList('ul');
1099
+ const off = offsetIn(b), item = document.createElement('li');
1100
+ if (prev.hasAttribute('data-checklist')) item.appendChild(Object.assign(document.createElement('input'), { type: 'checkbox' }));
1101
+ while (b.firstChild) item.appendChild(b.firstChild);
1102
+ prev.appendChild(item);
1103
+ b.remove();
1104
+ caretIn(item, off);
1105
+ }
1106
+
1107
+ function toggleChecklist() {
1108
+ if (!ancestor('li')) toggleList('ul');
1109
+ const item = ancestor('li'); if (!item) return;
1110
+ const list = item.closest('ul, ol'); if (!list) return;
1111
+ const on = !list.hasAttribute('data-checklist');
1112
+ list.toggleAttribute('data-checklist', on);
1113
+ [...list.children].forEach(row => {
1114
+ const box = row.querySelector(':scope > input[type=checkbox]');
1115
+ if (on && !box) { const b = document.createElement('input'); b.type = 'checkbox'; row.insertBefore(b, row.firstChild); }
1116
+ if (!on && box) box.remove();
1117
+ });
1118
+ // Caret after the checkbox, not before it
1119
+ const box = item.querySelector(':scope > input[type=checkbox]');
1120
+ if (!box) return caretIn(item, 0);
1121
+ if (!item.textContent.length && !item.querySelector('br')) item.appendChild(document.createElement('br'));
1122
+ const sl = sel(), r = document.createRange();
1123
+ r.setStartAfter(box); r.collapse(true);
1124
+ sl.removeAllRanges(); sl.addRange(r);
1125
+ }
1126
+
1127
+ // Sublists belong inside the preceding item; task items keep one leading box
1128
+ function normalizeLists(root) {
1129
+ root.querySelectorAll(':is(ul, ol) > :is(ul, ol)').forEach(nested => {
1130
+ const prev = nested.previousElementSibling;
1131
+ if (prev && prev.tagName === 'LI') return prev.appendChild(nested);
1132
+ const li = document.createElement('li');
1133
+ nested.parentNode.insertBefore(li, nested);
1134
+ li.appendChild(nested);
1135
+ });
1136
+ root.querySelectorAll('[data-checklist] > li').forEach(li => {
1137
+ const boxes = [...li.querySelectorAll(':scope > input[type=checkbox]')];
1138
+ boxes.slice(1).forEach(b => b.remove());
1139
+ const box = boxes[0] || Object.assign(document.createElement('input'), { type: 'checkbox' });
1140
+ if (li.firstChild !== box) li.insertBefore(box, li.firstChild);
1141
+ if (li.textContent.length) li.querySelectorAll(':scope > br').forEach(b => b.remove());
1142
+ });
1143
+ }
1144
+
1145
+ // Nest at most one level below the item above
1146
+ function moveItem(by) {
1147
+ const li = ancestor('li'); if (!li) return;
1148
+ const list = li.parentElement, off = offsetIn(li);
1149
+ if (by > 0) {
1150
+ const prev = li.previousElementSibling;
1151
+ if (!prev || prev.tagName !== 'LI') return;
1152
+ let sub = prev.querySelector(':scope > ul, :scope > ol');
1153
+ if (!sub) { sub = document.createElement(list.tagName.toLowerCase()); prev.appendChild(sub); }
1154
+ sub.appendChild(li);
1155
+ } else {
1156
+ const parentItem = list.parentElement;
1157
+ if (!parentItem || parentItem.tagName !== 'LI') return;
1158
+ parentItem.parentElement.insertBefore(li, parentItem.nextSibling);
1159
+ if (!list.children.length) list.remove();
1160
+ }
1161
+ if (off != null) caretIn(li, off); // the caret stays in the item's text, so Tab and Shift+Tab chain
1162
+ }
1163
+
1164
+ function canIndent(area) {
1165
+ const li = ancestor('li');
1166
+ if (li) return !!li.previousElementSibling;
1167
+ return area._rt.format === 'html';
1168
+ }
1169
+
1170
+ function canOutdent(area) {
1171
+ const li = ancestor('li');
1172
+ if (li) return li.closest('ul, ol').parentElement.tagName === 'LI';
1173
+ const b = blockEl();
1174
+ return area._rt.format === 'html' && !!b && parseFloat(b.style.marginInlineStart) > 0;
1175
+ }
1176
+
1177
+ const STEP = 2; // rem per indent level
1178
+ // List items nest; other blocks indent by margin (.html only)
1179
+ function indent(by, area) {
1180
+ if (inList()) return moveItem(by);
1181
+ if (area && area._rt.format !== 'html') return;
1182
+ const b = blockEl(); if (!b) return;
1183
+ const now = parseFloat(b.style.marginInlineStart) || 0;
1184
+ const next = Math.max(0, now + by * STEP);
1185
+ if (next) b.style.marginInlineStart = next + 'rem'; else b.style.removeProperty('margin-inline-start');
1186
+ }
1187
+
1188
+ /* ---- Links ---- */
1189
+ // Scheme, relative path, anchor, or bare domain (gets https://); anything else is refused
1190
+ function normalizeUrl(url) {
1191
+ if (/\s/.test(url) || unsafeUrl(url)) return null;
1192
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
1193
+ if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
1194
+ return null;
1195
+ }
1196
+ // An area that is, or sits in, a link takes no links: they cannot nest
1197
+ // Links cannot go inside a link or a button
1198
+ const linkHost = (area) => !!(area && area.closest('a, button'));
1199
+ const unwrapEl = (el) => el.replaceWith(...el.childNodes);
1200
+ const textIn = (el) => { const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), out = []; for (let n = w.nextNode(); n; n = w.nextNode()) out.push(n); return out; };
1201
+ const sameEl = (a, b) => !!a && !!b && a.nodeType === 1 && b.nodeType === 1 && a.tagName === b.tagName
1202
+ && a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
1203
+ // Links go outermost: a link inside formatting swaps with it (<b><a>x</a></b> → <a><b>x</b></a>);
1204
+ // then identical neighbours merge and links left without content go. The selection is kept.
1205
+ function tidyLinks(area) {
1206
+ const r = range(), keep = r && area.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
1207
+ for (let guard = 0, moved = true; moved && guard < 50; guard++) {
1208
+ moved = false;
1209
+ [...area.querySelectorAll('a')].forEach(a => {
1210
+ const w = a.parentElement;
1211
+ if (!a.isConnected || !w || w === area || !area.contains(w) || w.closest('a') || !INLINE_OK.has(w.tagName)) return;
1212
+ w.replaceWith(...[...w.childNodes].map(n => {
1213
+ const wc = w.cloneNode(false);
1214
+ if (n.nodeType === 1 && n.tagName === 'A') { wc.append(...n.childNodes); n.appendChild(wc); return n; }
1215
+ wc.appendChild(n); return wc;
1216
+ }));
1217
+ moved = true;
1218
+ });
1219
+ }
1220
+ const merge = (p) => { [...p.children].forEach(merge); for (let c = p.firstChild; c; c = c.nextSibling) while (c.nodeType === 1 && !/^(BR|WBR|IMG)$/.test(c.tagName) && sameEl(c, c.nextSibling)) { const n = c.nextSibling; c.append(...n.childNodes); n.remove(); } };
1221
+ for (let i = 0; i < 3; i++) merge(area);
1222
+ area.querySelectorAll('a').forEach(a => { if (!a.textContent.trim() && !a.querySelector('img')) unwrapEl(a); });
1223
+ if (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 { }
1224
+ }
1225
+ function setLink(href, area) {
1226
+ if (linkHost(area)) return;
1227
+ const existing = ancestor('a'), r = range();
1228
+ const raw = href == null ? '' : String(href).trim();
1229
+ const url = raw ? normalizeUrl(raw) : '';
1230
+ if (raw && !url) return;
1231
+ // A caret in a link retargets or unlinks all of it
1232
+ if (r && r.collapsed) {
1233
+ if (existing) { if (url) existing.setAttribute('href', url); else unwrapEl(existing); return; }
1234
+ if (url) insertHTML(`<a href="${escHtml(url)}">${escHtml(url)}</a>`);
1235
+ return;
1236
+ }
1237
+ if (!r) return;
1238
+ // A selection: each link it touches keeps its unselected text; the selection loses its link, or gets the new one
1239
+ const nodes = selectedTextNodes(); if (!nodes.length) return;
1240
+ const touched = [...new Set(nodes.map(n => elementAt(n) && elementAt(n).closest('a')).filter(a => a && withinArea(a, a)))];
1241
+ touched.forEach(a => {
1242
+ const old = a.getAttribute('href'), keep = textIn(a).filter(t => !nodes.includes(t) && t.nodeValue.length);
1243
+ unwrapEl(a);
1244
+ keep.forEach(t => { const x = document.createElement('a'); if (old != null) x.setAttribute('href', old); t.before(x); x.appendChild(t); });
1245
+ });
1246
+ const s = sel(), nr = document.createRange(), last = nodes[nodes.length - 1];
1247
+ nr.setStart(nodes[0], 0); nr.setEnd(last, last.length);
1248
+ s.removeAllRanges(); s.addRange(nr);
1249
+ if (url) wrapInline('a').forEach(a => a.setAttribute('href', url));
1250
+ const root = area || elementAt(nodes[0]).closest('[data-rich-text]');
1251
+ if (root) tidyLinks(root);
1252
+ }
1253
+ const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
1254
+
1255
+ /* ---- Insertions ---- */
1256
+ function insertHTML(html) { document.execCommand('insertHTML', false, html); }
1257
+
1258
+ const PLAINTEXT_ONLY = (() => {
1259
+ try { const d = document.createElement('div'); d.contentEditable = 'plaintext-only'; return d.contentEditable === 'plaintext-only'; } catch { return false; }
1260
+ })();
1261
+
1262
+ // Lines as <br>, inserted by hand
1263
+ function insertPlain(area, text) {
1264
+ const frag = document.createDocumentFragment();
1265
+ String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
1266
+ if (i) frag.appendChild(document.createElement('br'));
1267
+ if (line) frag.appendChild(document.createTextNode(line));
1268
+ });
1269
+ insertNodes(area, frag);
1270
+ }
1271
+
1272
+ // Inserted by hand, so the browser adds no style wrappers of its own
1273
+ function insertNodes(area, frag) {
1274
+ const r = range(); if (!r || !area.contains(r.startContainer)) return;
1275
+ // Pasted or dropped links never land inside a link
1276
+ const at = elementAt(r.startContainer);
1277
+ if (at && at.closest('a')) [...frag.querySelectorAll('a')].forEach(unwrapEl);
1278
+ if (!frag.lastChild) return;
1279
+ record(area);
1280
+ r.deleteContents();
1281
+ // An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
1282
+ const holder = blockEl() || area;
1283
+ if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
1284
+ if (offsetIn(holder) === 0) {
1285
+ const w = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT), first = w.nextNode();
1286
+ if (first) first.nodeValue = first.nodeValue.replace(/^\s+/, '');
1287
+ }
1288
+ let end = frag.lastChild;
1289
+ if (!end) return;
1290
+ const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
1291
+ // The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
1292
+ const s = sel(), nr = document.createRange();
1293
+ 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; }
1294
+ if (end.nodeType === 3) nr.setStart(end, end.length); else nr.setStartAfter(end);
1295
+ nr.collapse(true);
1296
+ s.removeAllRanges(); s.addRange(nr);
1297
+ record(area);
1298
+ area.dispatchEvent(new Event('input', { bubbles: true }));
1299
+ }
1300
+
1301
+ // A selection across blocks is deleted as the browser would: the partly selected end
1302
+ // block merges into the start block (never across a table cell), emptied wrappers go.
1303
+ // A selection of everything leaves one empty paragraph.
1304
+ function clearSpan(area) {
1305
+ const r = range(); if (!r || r.collapsed || !area.contains(r.startContainer)) return;
1306
+ const texts = textIn(area).filter(t => r.intersectsNode(t) && t.nodeValue.length);
1307
+ const whole = document.createRange(); whole.selectNodeContents(area);
1308
+ const all = (r.compareBoundaryPoints(Range.START_TO_START, whole) <= 0 && r.compareBoundaryPoints(Range.END_TO_END, whole) >= 0)
1309
+ || (texts.length && r.toString() === area.textContent && !area.querySelector('img, hr, table'));
1310
+ if (all) {
1311
+ area.innerHTML = '<p></p>'; caretIn(area.firstElementChild, 0); return;
1312
+ }
1313
+ const blockOf = (n) => { for (let e = elementAt(n); e && e !== area; e = e.parentElement) if (BLOCK.includes(e.tagName)) return e; return null; };
1314
+ // Boundaries on elements move into text, so the blocks they name are the ones holding text
1315
+ if (texts.length) {
1316
+ if (r.startContainer.nodeType !== 3) r.setStart(texts[0], 0);
1317
+ if (r.endContainer.nodeType !== 3) { const l = texts[texts.length - 1]; r.setEnd(l, l.length); }
1318
+ }
1319
+ const a = blockOf(r.startContainer), b = blockOf(r.endContainer), at = [r.startContainer, r.startOffset];
1320
+ r.deleteContents();
1321
+ if (a && b && a !== b && b.isConnected && !/^(TD|TH)$/.test(a.tagName) && !/^(TD|TH)$/.test(b.tagName) && !b.contains(a) && !a.contains(b)) {
1322
+ a.append(...b.childNodes);
1323
+ let gone = b;
1324
+ while (gone.parentElement && gone.parentElement !== area && !gone.parentElement.contains(a) && gone.parentElement.children.length === 1) gone = gone.parentElement;
1325
+ gone.remove();
1326
+ }
1327
+ // The caret stays where the selection started, inside its block (deleteContents leaves it between blocks)
1328
+ const c = document.createRange();
1329
+ if (at[0].isConnected && area.contains(at[0])) c.setStart(at[0], Math.min(at[1], at[0].nodeType === 3 ? at[0].length : at[0].childNodes.length));
1330
+ else if (a && a.isConnected) c.selectNodeContents(a);
1331
+ else c.setStart(area, 0);
1332
+ c.collapse(c.startContainer === a ? false : true);
1333
+ const s = sel(); s.removeAllRanges(); s.addRange(c);
1334
+ }
1335
+ // A <br> at the caret; with nothing after it, a second one so the new line shows
1336
+ function insertBreak(area) {
1337
+ const r = range(); if (!r || !area.contains(r.startContainer)) return;
1338
+ r.deleteContents();
1339
+ const br = document.createElement('br');
1340
+ r.insertNode(br);
1341
+ const tail = document.createRange();
1342
+ tail.setStartAfter(br); tail.setEnd(area, area.childNodes.length);
1343
+ if (!tail.toString() && !tail.cloneContents().querySelector('br, img')) br.after(document.createElement('br'));
1344
+ const nr = document.createRange(); nr.setStartAfter(br); nr.collapse(true);
1345
+ const s = sel(); s.removeAllRanges(); s.addRange(nr);
1346
+ }
1347
+ const insertImage = (src, alt) => { if (src && !unsafeUrl(src, true)) insertHTML(`<img src="${escHtml(src)}" alt="${escHtml(alt || '')}">`); };
1348
+
1349
+ // Columns × rows: [3, 2], '3x2' or 3
1350
+ function insertTable(spec) {
1351
+ // Never nest: land after the outermost table
1352
+ let host = ancestor('table');
1353
+ while (host && host.parentElement && withinArea(host, host.parentElement.closest('table'))) host = host.parentElement.closest('table');
1354
+ if (host) { const p = document.createElement('p'); p.appendChild(document.createElement('br')); host.after(p); caretIn(p, 0); }
1355
+ if (Array.isArray(spec) && spec.length === 1) spec = spec[0]; // table('3x2'), table([3, 2])
1356
+ const parts = Array.isArray(spec) ? spec : String(spec == null ? '' : spec).split(/[x×,\s]+/);
1357
+ const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
1358
+ const cols = n(parts[0]), rows = n(parts[1]);
1359
+ const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
1360
+ insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1361
+ }
1362
+
1363
+ // A trailing table, rule or similar block gets a line after it to land on
1364
+ // Text and inline marks sitting directly in a block field are wrapped into one <p> per run, caret
1365
+ // kept. Authored empty blocks (spacers, an empty figure) are left alone.
1366
+ function looseInline(area) {
1367
+ const loose = [...area.childNodes].some(n => (n.nodeType === 3 && n.nodeValue.trim()) || (n.nodeType === 1 && INLINE_OK.has(n.tagName) && n.tagName !== 'BR'));
1368
+ if (!loose) return;
1369
+ const off = offsetIn(area);
1370
+ let run = null;
1371
+ [...area.childNodes].forEach(n => {
1372
+ const inl = n.nodeType === 3 || (n.nodeType === 1 && INLINE_OK.has(n.tagName));
1373
+ if (!inl) { run = null; return; }
1374
+ if (!run) { if (n.nodeType === 3 && !n.nodeValue.trim()) return; run = document.createElement('p'); n.before(run); }
1375
+ run.appendChild(n);
1376
+ });
1377
+ if (off != null && document.activeElement === area) caretIn(area, off);
1378
+ }
1379
+
1380
+ function ensureTrailingBlock(area) {
1381
+ const last = area.lastElementChild;
1382
+ if (!last || !/^(TABLE|HR|FIGURE|PRE|UL|OL|BLOCKQUOTE|DL)$/.test(last.tagName)) return false;
1383
+ const p = document.createElement('p');
1384
+ p.appendChild(document.createElement('br'));
1385
+ area.appendChild(p);
1386
+ return true;
1387
+ }
1388
+
1389
+ // A value styles the selection; an empty one removes that style from it (spans left bare go)
1390
+ function styleSpan(prop, v) {
1391
+ if (v != null && v !== '') return wrapInline('span', { [prop]: v });
1392
+ const nodes = selectedTextNodes(), spans = new Set();
1393
+ 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
+ spans.forEach(s => { s.style[prop] = ''; if (!s.getAttribute('style')) s.removeAttribute('style'); if (!s.attributes.length) unwrapEl(s); });
1395
+ return [];
1396
+ }
1397
+
1398
+ function clearFormatting() {
1399
+ Object.keys(INLINE_TAGS).forEach(t => unwrapInline(t));
1400
+ unwrapInline('a');
1401
+ }
1402
+
1403
+ /* ---- Document-wide styles (.all) ----
1404
+ Custom properties on the area, read back by $rich.all; never in the stored markup. */
1405
+ const ALL_VARS = {
1406
+ font: '--rich-text-font', size: '--rich-text-size', leading: '--rich-text-leading',
1407
+ align: '--rich-text-align', color: '--rich-text-color', background: '--rich-text-background'
1408
+ };
1409
+ const allStyle = (area) => Object.fromEntries(Object.entries(ALL_VARS)
1410
+ .map(([k, v]) => [k, area.style.getPropertyValue(v).trim()]).filter(([, v]) => v));
1411
+ const setAllStyle = (area, values) => Object.entries(ALL_VARS).forEach(([k, v]) => {
1412
+ const next = values && values[k];
1413
+ if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
1414
+ });
1415
+
1416
+ /* ---- Command table ---- */
1417
+ const COMMANDS = Object.create(null);
1418
+ Object.entries(INLINE_TAGS).forEach(([tag, meta]) => {
1419
+ COMMANDS[tag] = {
1420
+ md: meta.md,
1421
+ run: (v, area) => hasSelection() ? toggleInline(tag, null, attrsFor(tag, v)) : armPending(area, tag, attrsFor(tag, v)),
1422
+ active: (v, area) => selectionMarked(tag) !== pendingHas(area, tag) // pending inside the tag ends it
1423
+ };
1424
+ });
1425
+ Object.entries(BLOCK_TAGS).forEach(([tag, meta]) => {
1426
+ COMMANDS[tag] = { md: meta.md, block: 1, run: () => setBlock(tag), active: () => blockTag() === tag.toUpperCase() };
1427
+ });
1428
+ Object.assign(COMMANDS, {
1429
+ a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: (area) => !linkHost(area) && (!!ancestor('a') || hasSelection()) },
1430
+ unlink: { md: 1, run: (v, area) => setLink('', area), active: () => false, enabled: (area) => !linkHost(area) && (!!ancestor('a') || (hasSelection() && [...area.querySelectorAll('a')].some(a => range().intersectsNode(a)))) },
1431
+ br: { md: 1, run: (v, area) => area && area._rt && area._rt.breaks ? insertBreak(area) : insertHTML('<br>'), active: () => false },
1432
+ wbr: { run: () => insertVoid('wbr'), active: () => false },
1433
+ img: { md: 1, args: 2, run: (v) => Array.isArray(v) ? insertImage(v[0], v[1]) : insertImage(v), active: () => false },
1434
+
1435
+ ul: { md: 1, block: 1, run: () => toggleList('ul'), active: () => !!ancestor('ul') && !ancestor('ul').hasAttribute('data-checklist') },
1436
+ ol: { md: 1, block: 1, run: () => toggleList('ol'), active: () => !!ancestor('ol') },
1437
+ li: { md: 1, block: 1, run: toggleItem, active: () => !!ancestor('li') },
1438
+ checklist: { md: 1, block: 1, run: toggleChecklist, active: () => !!ancestor('ul[data-checklist]') },
1439
+ hr: { md: 1, block: 1, run: () => insertHTML('<hr><p><br></p>'), active: () => false },
1440
+ figure: { block: 1, run: toggleFigure, active: () => !!ancestor('figure') },
1441
+ figcaption: { block: 1, run: setCaption, active: () => !!ancestor('figcaption'), enabled: () => !!ancestor('figure') },
1442
+ dl: { block: 1, run: toggleDl, active: () => !!ancestor('dl') },
1443
+ dt: { block: 1, run: setTerm('dt'), active: () => termIs('DT'), enabled: () => !!ancestor('dt, dd') },
1444
+ dd: { block: 1, run: setTerm('dd'), active: () => termIs('DD'), enabled: () => !!ancestor('dt, dd') },
1445
+
1446
+ table: { block: 1, args: 2, run: (v) => insertTable(v), active: () => false },
1447
+ 'row-before': { block: 1, run: () => addRow(-1), active: () => false, enabled: () => inTable() },
1448
+ 'row-after': { block: 1, run: () => addRow(1), active: () => false, enabled: () => inTable() },
1449
+ 'row-remove': { block: 1, run: removeRow, active: () => false, enabled: () => inTable() },
1450
+ 'column-before': { block: 1, run: () => addColumn(-1), active: () => false, enabled: () => inTable() },
1451
+ 'column-after': { block: 1, run: () => addColumn(1), active: () => false, enabled: () => inTable() },
1452
+ 'column-remove': { block: 1, run: removeColumn, active: () => false, enabled: () => inTable() },
1453
+ 'table-remove': { block: 1, run: removeTable, active: () => false, enabled: () => inTable() },
1454
+ 'table-header': { block: 1, run: toggleHeaderRow, active: () => !!(tableAt() && tableAt().querySelector('th')), enabled: () => inTable() },
1455
+ merge: { block: 1, run: mergeCells, active: () => false, enabled: () => inTable() },
1456
+ split: { block: 1, run: splitCell, active: () => false, enabled: () => { const c = cellAt(); return !!c && ((c.colSpan || 1) > 1 || (c.rowSpan || 1) > 1); } },
1457
+ indent: { md: 1, block: 1, run: (v, area) => indent(1, area), active: () => false, enabled: (a) => canIndent(a) },
1458
+ outdent: { md: 1, block: 1, run: (v, area) => indent(-1, area), active: () => false, enabled: (a) => canOutdent(a) },
1459
+ align: { block: 1, all: 1, run: (v) => styleBlock('textAlign', v || 'start'), active: () => false, reflect: () => readBlockStyle('textAlign') },
1460
+ color: { all: 1, needsSelection: 1, run: (v) => styleSpan('color', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('color')) },
1461
+ background: { all: 1, needsSelection: 1, run: (v) => styleSpan('backgroundColor', v), active: () => false, reflect: () => rgbToHex(readInlineStyle('backgroundColor')) },
1462
+ font: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontFamily', v), active: () => false, reflect: () => readInlineStyle('fontFamily').split(',')[0].replace(/['"]/g, '') },
1463
+ size: { all: 1, needsSelection: 1, run: (v) => styleSpan('fontSize', v ? px(v) : ''), active: () => false, reflect: () => readInlineStyle('fontSize') },
1464
+ leading: { all: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
1465
+
1466
+ clear: { md: 1, run: clearFormatting, active: () => false },
1467
+ // A burst still waiting to be recorded is undoable, and leaves nothing to redo
1468
+ undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 || !!a._history.timer },
1469
+ redo: { md: 1, history: 1, run: (v, area) => step(area, 1), active: () => false, enabled: (a) => !a._history.timer && a._history.at < a._history.stack.length - 1 },
1470
+ block: { md: 1, block: 1, run: (v, area) => runBlock(area, v), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
1471
+ });
1472
+
1473
+ // .block="h2": any block tag command, under that command's own availability
1474
+ const BLOCK_CHOICES = new Set([...Object.keys(BLOCK_TAGS), 'ul', 'ol', 'li', 'figure', 'figcaption', 'dl', 'dt', 'dd']);
1475
+ function runBlock(area, v) {
1476
+ const tag = String(v || 'p').toLowerCase(), spec = COMMANDS[tag];
1477
+ if (!BLOCK_CHOICES.has(tag)) return;
1478
+ if (area._rt.format !== 'html' && !spec.md) return;
1479
+ if (spec.enabled && !spec.enabled(area)) return;
1480
+ spec.run(undefined, area);
1481
+ }
1482
+
1483
+ const px = (v) => /^\d+(\.\d+)?$/.test(String(v)) ? v + 'px' : v;
1484
+ const hasSelection = () => { const r = range(); return !!r && !r.collapsed; };
1485
+
1486
+ /* ---- Pending marks ----
1487
+ An inline command at a collapsed caret arms the tag for the next typed text. */
1488
+ function armPending(area, tag, attrs) {
1489
+ const r = range(); if (!r || !r.collapsed) return;
1490
+ const at = area._pending;
1491
+ const tags = at && at.node === r.startContainer && at.offset === r.startOffset ? at.tags : new Map();
1492
+ tags.has(tag) ? tags.delete(tag) : tags.set(tag, attrs || null);
1493
+ area._pending = tags.size ? { tags, node: r.startContainer, offset: r.startOffset } : null;
1494
+ }
1495
+
1496
+ const pendingHas = (area, tag) => !!(area && area._pending && area._pending.tags.has(tag));
1497
+
1498
+ function applyPending(area, typed) {
1499
+ const at = area._pending; if (!at) return false;
1500
+ const r = range();
1501
+ if (!r || !r.collapsed || r.startContainer !== at.node || r.startOffset !== at.offset + typed.length) {
1502
+ area._pending = null;
1503
+ return false;
1504
+ }
1505
+ const node = r.startContainer, start = at.offset;
1506
+ const span = document.createRange();
1507
+ span.setStart(node, start); span.setEnd(node, r.startOffset);
1508
+ const text = span.toString(); // as the browser stored it (a trailing space may be U+00A0)
1509
+ span.deleteContents();
1510
+ // A pending tag already around the caret ends there: the text goes after it, keeping other formatting inside it
1511
+ const off = [], on = [];
1512
+ at.tags.forEach((attrs, tag) => { const h = elementAt(node).closest(tag); (h && withinArea(node, h) ? off : on).push([tag, attrs]); });
1513
+ const offTags = new Set(off.map(([t]) => t.toUpperCase()));
1514
+ let pos = { node, offset: start }, keep = [];
1515
+ off.forEach(([tag]) => {
1516
+ const host = elementAt(pos.node).closest(tag); if (!host) return;
1517
+ for (let n = elementAt(pos.node); n && n !== host; n = n.parentElement) if (!offTags.has(n.tagName)) keep.unshift(n.cloneNode(false));
1518
+ const rest = document.createRange(); rest.setStart(pos.node, pos.offset); rest.setEnd(host, host.childNodes.length);
1519
+ const tail = host.cloneNode(false); tail.appendChild(rest.extractContents());
1520
+ host.after(tail);
1521
+ if (!tail.textContent) tail.remove();
1522
+ pos = { node: host.parentNode, offset: [...host.parentNode.childNodes].indexOf(host) + 1 };
1523
+ if (!host.textContent) { pos.offset--; host.remove(); }
1524
+ });
1525
+ let outer = null, inner = null;
1526
+ const wrap = (el) => { if (inner) inner.appendChild(el); else outer = el; inner = el; };
1527
+ keep.forEach(wrap);
1528
+ on.forEach(([tag, attrs]) => {
1529
+ const el = document.createElement(tag);
1530
+ if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
1531
+ wrap(el);
1532
+ });
1533
+ const t = document.createTextNode(text);
1534
+ if (inner) inner.appendChild(t); else outer = t;
1535
+ const ins = document.createRange(); ins.setStart(pos.node, pos.offset); ins.collapse(true);
1536
+ ins.insertNode(outer);
1537
+ area._pending = null;
1538
+ const s = sel(), nr = document.createRange(); nr.setStart(t, text.length); nr.collapse(true);
1539
+ s.removeAllRanges(); s.addRange(nr);
1540
+ return true;
1541
+ }
1542
+
1543
+ function readInlineStyle(prop) {
1544
+ const el = elementAt(range() && range().startContainer);
1545
+ return el ? getComputedStyle(el)[prop] : '';
1546
+ }
1547
+ // <input type=color> only speaks hex; painting a pixel converts any colour
1548
+ let _hexCtx;
1549
+ function rgbToHex(v) {
1550
+ if (!v) return '#000000';
1551
+ try {
1552
+ _hexCtx = _hexCtx || document.createElement('canvas').getContext('2d', { willReadFrequently: true });
1553
+ _hexCtx.clearRect(0, 0, 1, 1);
1554
+ _hexCtx.fillStyle = v;
1555
+ _hexCtx.fillRect(0, 0, 1, 1);
1556
+ const [r, g, b] = _hexCtx.getImageData(0, 0, 1, 1).data;
1557
+ return '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('');
1558
+ } catch { return '#000000'; }
1559
+ }
1560
+
1561
+ const MODES = new Set(['html', 'plain', 'inline', 'literal', 'autofocus']);
1562
+
1563
+ // Nearest command for an unknown modifier (console warning only)
1564
+ const HINTS = {
1565
+ bold: 'strong', italic: 'em', underline: 'u', strike: 's', strikethrough: 's', link: 'a', quote: 'blockquote',
1566
+ codeblock: 'pre', bullet: 'ul', bullets: 'ul', numbered: 'ol', heading: 'h2', paragraph: 'p', image: 'img',
1567
+ rule: 'hr', highlight: 'mark', task: 'checklist', tasks: 'checklist', subscript: 'sub', superscript: 'sup',
1568
+ minimal: 'inline'
1569
+ };
1570
+ function suggest(m) {
1571
+ if (!m) return ' — name a command, e.g. .strong';
1572
+ const dist = (a, b) => {
1573
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
1574
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
1575
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++)
1576
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1577
+ return d[a.length][b.length];
1578
+ };
1579
+ let hit = HINTS[m.toLowerCase()];
1580
+ if (!hit) {
1581
+ const [best] = Object.keys(COMMANDS).map(c => [c, dist(m.toLowerCase(), c)]).sort((x, y) => x[1] - y[1]);
1582
+ if (best && best[1] <= Math.min(2, m.length - 1)) hit = best[0];
1583
+ }
1584
+ return hit ? ` — did you mean .${hit}?` : '';
1585
+ }
1586
+
1587
+ /* ---- Autoformat ----
1588
+ Block markdown converts on Enter; inline marks convert as their closing marker is typed. */
1589
+ const LINE_RULES = [
1590
+ { re: /^(#{1,6})\s+/, make: (b, m) => retagBlock(b, 'h' + m[1].length) },
1591
+ { re: /^[-*+]\s+/, make: (b) => listify(b, 'ul') },
1592
+ { re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
1593
+ { re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
1594
+ { re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
1595
+ { re: /^```\s*$/, make: (b) => retagBlock(b, 'pre') },
1596
+ { re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
1597
+ ];
1598
+
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
+ function retagBlock(b, tag) {
1610
+ const n = document.createElement(tag);
1611
+ while (b.firstChild) n.appendChild(b.firstChild);
1612
+ b.replaceWith(n);
1613
+ return n;
1614
+ }
1615
+
1616
+ // Consecutive lines join the list above
1617
+ function listify(b, tag, checked) {
1618
+ const item = document.createElement('li');
1619
+ while (b.firstChild) item.appendChild(b.firstChild);
1620
+ const prev = b.previousElementSibling;
1621
+ const list = prev && prev.tagName === tag.toUpperCase() ? prev : document.createElement(tag);
1622
+ if (checked != null) {
1623
+ list.setAttribute('data-checklist', '');
1624
+ const box = document.createElement('input');
1625
+ box.type = 'checkbox'; box.checked = checked;
1626
+ item.insertBefore(box, item.firstChild);
1627
+ }
1628
+ list.appendChild(item);
1629
+ if (list !== prev) b.replaceWith(list); else b.remove();
1630
+ return item;
1631
+ }
1632
+
1633
+ function stripLead(el, count) {
1634
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
1635
+ let left = count;
1636
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
1637
+ const take = Math.min(left, n.length);
1638
+ n.deleteData(0, take);
1639
+ left -= take;
1640
+ }
1641
+ }
1642
+
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;
1649
+ }
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;
1681
+ }
1682
+
1683
+ function convertInlineAtCaret(area) {
1684
+ const r = range(); if (!r || !r.collapsed) return false;
1685
+ const node = r.startContainer;
1686
+ if (!node || node.nodeType !== 3 || !node.parentElement) return false;
1687
+ const host = node.parentElement.closest('code, pre, a');
1688
+ 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;
1692
+ record(area);
1693
+ const tail = node.splitText(hit.start);
1694
+ tail.deleteData(0, hit.len);
1695
+ tail.parentNode.insertBefore(el, tail);
1696
+ const s = sel(), nr = document.createRange();
1697
+ nr.setStart(tail, 0); nr.collapse(true);
1698
+ s.removeAllRanges(); s.addRange(nr);
1699
+ area._exit = el.tagName === 'IMG' ? null : el;
1700
+ record(area);
1701
+ return true;
1702
+ }
1703
+
1704
+ const lastTextIn = (el) => { let last = null; const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let t = w.nextNode(); t; t = w.nextNode()) last = t; return last; };
1705
+ // A collapsed caret at a mark's end edge: after it in its parent, at the start of the text after it, or at the end of its text
1706
+ function atMarkEnd(el, r) {
1707
+ if (!el || !el.isConnected || !r || !r.collapsed) return false;
1708
+ const n = r.startContainer, o = r.startOffset, last = lastTextIn(el);
1709
+ return (n === el.parentNode && n.childNodes[o - 1] === el) || (n === el && o === el.childNodes.length)
1710
+ || (n.nodeType === 3 && n === el.nextSibling && o === 0) || (n.nodeType === 3 && n === last && o === n.length);
1711
+ }
1712
+ const tailOf = (el) => { let t = el.nextSibling; if (!t || t.nodeType !== 3) { t = document.createTextNode(''); el.after(t); } return t; };
1713
+ const caretAt = (node, offset) => { const s = sel(), nr = document.createRange(); nr.setStart(node, offset); nr.collapse(true); s.removeAllRanges(); s.addRange(nr); };
1714
+ const isWs = (c) => !c || /\s/.test(c);
1715
+
1716
+ // Text typed right after a conversion goes into a text node after the mark, never into it.
1717
+ // A space that would collapse is stored as U+00A0 until text follows it.
1718
+ function typeAfterMark(area, e) {
1719
+ const el = area._exit;
1720
+ if (e.inputType !== 'insertText' || !e.cancelable || !e.data || /[\r\n]/.test(e.data) || !atMarkEnd(el, range())) return false;
1721
+ e.preventDefault();
1722
+ area._exit = null; area._after = el;
1723
+ const last = lastTextIn(el), tail = tailOf(el);
1724
+ const next = tail.nodeValue[0] || '', prev = (last && last.nodeValue.slice(-1)) || '';
1725
+ const chars = [...e.data];
1726
+ const text = chars.map((c, i) => c === ' ' && (isWs(i ? chars[i - 1] : prev) || isWs(i < chars.length - 1 ? chars[i + 1] : next)) ? '\u00a0' : c).join('');
1727
+ tail.insertData(0, text);
1728
+ const nbs = []; [...text].forEach((c, i) => { if (c === '\u00a0') nbs.push(i); });
1729
+ area._nb = nbs.length ? { node: tail, at: nbs } : null;
1730
+ caretAt(tail, text.length);
1731
+ area.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: e.data }));
1732
+ return true;
1733
+ }
1734
+
1735
+ // A U+00A0 placed after a mark, or just before typed text, becomes a plain space once text is on both sides of it
1736
+ function relaxSpaces(area, typed) {
1737
+ const r0 = range();
1738
+ if (typed && r0 && r0.collapsed && r0.startContainer.nodeType === 3) {
1739
+ const at = r0.startOffset - typed.length - 1;
1740
+ if (at >= 0 && r0.startContainer.nodeValue[at] === '\u00a0') area._nb = area._nb && area._nb.node === r0.startContainer ? { node: area._nb.node, at: [...new Set([...area._nb.at, at])] } : { node: r0.startContainer, at: [at] };
1741
+ }
1742
+ const rec = area._nb; if (!rec) return;
1743
+ const node = rec.node;
1744
+ if (!node.isConnected) { area._nb = null; return; }
1745
+ const r = range(), keep = r && r.startContainer === node ? r.startOffset : null;
1746
+ rec.at = rec.at.filter(i => {
1747
+ const v = node.nodeValue;
1748
+ if (v[i] !== '\u00a0') return false;
1749
+ const p = node.previousSibling, x = node.nextSibling;
1750
+ const prev = i ? v[i - 1] : (p ? p.textContent.slice(-1) : ''), next = i < v.length - 1 ? v[i + 1] : (x ? x.textContent[0] : '');
1751
+ if (isWs(prev) || isWs(next)) return true;
1752
+ node.replaceData(i, 1, ' ');
1753
+ return false;
1754
+ });
1755
+ if (keep != null) caretAt(node, keep);
1756
+ if (!rec.at.length) area._nb = null;
1757
+ }
1758
+
1759
+ // IME text composed right after a conversion is moved out of the mark, the caret with it
1760
+ function leaveMark(area, typed) {
1761
+ const el = area._exit; area._exit = null;
1762
+ const r = range();
1763
+ if (!el || !el.isConnected || !r || !r.collapsed) return false;
1764
+ const node = r.startContainer;
1765
+ // Chrome stores a typed trailing space as U+00A0
1766
+ const nb = (s) => s.replace(/\u00a0/g, ' ');
1767
+ if (node.nodeType !== 3 || !el.contains(node) || r.startOffset !== node.length || !nb(node.nodeValue).endsWith(nb(typed))) return false;
1768
+ if (lastTextIn(el) !== node || node.length === typed.length) return false;
1769
+ const moved = node.nodeValue.slice(node.length - typed.length);
1770
+ node.deleteData(node.length - typed.length, typed.length);
1771
+ const tail = tailOf(el);
1772
+ tail.insertData(0, moved);
1773
+ caretAt(tail, typed.length);
1774
+ area._after = el;
1775
+ return true;
1776
+ }
1777
+
1778
+ 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;
1792
+ }
1793
+ });
1794
+ }
1795
+
1796
+ // Returns the element now holding the line (an <li> continues the list)
1797
+ function convertLine(b) {
1798
+ if (!b) return b;
1799
+ // Only a <pre> inside the editor is a code block
1800
+ const area = b.closest('[data-rich-text]');
1801
+ const pre = b.closest('pre');
1802
+ if (pre && (!area || area.contains(pre))) return b;
1803
+ const text = b.textContent;
1804
+ for (const rule of LINE_RULES) {
1805
+ const m = text.match(rule.re); if (!m) continue;
1806
+ stripLead(b, m[0].length);
1807
+ const made = rule.make(b, m);
1808
+ if (made.tagName !== 'HR') convertInline(made);
1809
+ return made;
1810
+ }
1811
+ convertInline(b);
1812
+ return b;
1813
+ }
1814
+
1815
+ /* ---- Enter ---- */
1816
+ function splitInto(b, tag) {
1817
+ const n = document.createElement(tag);
1818
+ const r = range();
1819
+ if (b.lastChild && r && b.contains(r.startContainer)) {
1820
+ const tail = document.createRange();
1821
+ tail.setStart(r.startContainer, r.startOffset);
1822
+ tail.setEndAfter(b.lastChild);
1823
+ n.appendChild(tail.extractContents());
1824
+ }
1825
+ b.after(n);
1826
+ if (!b.textContent.length && !b.querySelector('br, img')) b.appendChild(document.createElement('br'));
1827
+ return n;
1828
+ }
1829
+
1830
+ // Native Enter inside code and for Shift+Enter; Chrome writes <br> pairs in a <pre>
1831
+ const codeEndsBlank = (pre) => {
1832
+ const last = pre.lastChild;
1833
+ if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
1834
+ return /\n[^\S\n]*$/.test(pre.textContent);
1835
+ };
1836
+ const leavingCode = () => {
1837
+ const pre = ancestor('pre'); if (!pre) return false;
1838
+ const r = range();
1839
+ return !!r && r.collapsed && codeEndsBlank(pre);
1840
+ };
1841
+ const ownsEnter = (e) => !e.shiftKey && (!ancestor('pre') || leavingCode());
1842
+
1843
+ /* ---- Backspace ----
1844
+ Owned at a block start (no style spans from merged headings) and in a table's last cell. */
1845
+ const atBlockStart = () => { const b = ancestor('li') || blockEl(); return !!b && offsetIn(b) === 0; };
1846
+
1847
+ function cellCoversAll() {
1848
+ const cell = ancestor('td, th'); if (!cell) return null;
1849
+ const r = range(); if (!r) return null;
1850
+ const covers = r.collapsed
1851
+ ? cell.textContent.length <= 1
1852
+ : r.toString().length >= cell.textContent.length;
1853
+ return covers ? cell : null;
1854
+ }
1855
+
1856
+ function backspaceAtStart() {
1857
+ const li = ancestor('li');
1858
+ if (li) {
1859
+ if (li.previousElementSibling) {
1860
+ const prev = li.previousElementSibling, at = prev.textContent.length;
1861
+ while (li.firstChild) { const n = li.firstChild; if (n.nodeName === 'INPUT') n.remove(); else prev.appendChild(n); }
1862
+ li.remove();
1863
+ caretIn(prev, at);
1864
+ return;
1865
+ }
1866
+ const list = li.closest('ul, ol'), p = document.createElement('p');
1867
+ [...li.childNodes].forEach(n => { if (n.nodeName !== 'INPUT') p.appendChild(n); });
1868
+ list.before(p);
1869
+ li.remove();
1870
+ if (!list.children.length) list.remove();
1871
+ caretIn(p, 0);
1872
+ return;
1873
+ }
1874
+ const b = blockEl(); if (!b || /^(TD|TH)$/.test(b.tagName)) return;
1875
+ const prev = b.previousElementSibling;
1876
+ if (!prev && /^(DT|DD)$/.test(b.tagName)) { liftItem(b); return; }
1877
+ if (!prev) { if (b.tagName !== 'P') retagBlock(b, 'p'); caretIn(blockEl() || b, 0); return; }
1878
+ if (prev.tagName === 'HR') { prev.remove(); caretIn(b, 0); return; }
1879
+ if (/^(UL|OL)$/.test(prev.tagName)) {
1880
+ const last = prev.lastElementChild; if (!last) return;
1881
+ const at = last.textContent.length;
1882
+ while (b.firstChild) last.appendChild(b.firstChild);
1883
+ b.remove();
1884
+ caretIn(last, at);
1885
+ return;
1886
+ }
1887
+ const at = prev.textContent.length;
1888
+ prev.querySelectorAll(':scope > br:last-child').forEach(br => br.remove());
1889
+ while (b.firstChild) prev.appendChild(b.firstChild);
1890
+ b.remove();
1891
+ caretIn(prev, at);
1892
+ }
1893
+
1894
+ function onEnter(area, autoformat) {
1895
+ let holder = ancestor('li') || blockEl() || ensureBlock(area);
1896
+ // Paragraphs inside a cell, never siblings of it
1897
+ if (/^(TD|TH)$/.test(holder.tagName)) holder = innerBlock(holder);
1898
+
1899
+ // An empty last line leaves the code block (bounded to the area)
1900
+ const pre = holder.closest('pre');
1901
+ 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);
1906
+ if (!pre.textContent.length) pre.remove();
1907
+ caretIn(p, 0);
1908
+ return;
1909
+ }
1910
+
1911
+ if (holder.tagName === 'LI' && !holder.textContent.length) return liftItem(holder);
1912
+
1913
+ // An empty last paragraph leaves its quote (a quote from markdown holds paragraphs)
1914
+ const quote = holder.parentElement;
1915
+ if (holder.tagName === 'P' && quote && quote.tagName === 'BLOCKQUOTE' && quote !== area && area.contains(quote) && holder === quote.lastElementChild && !holder.textContent.trim()) {
1916
+ quote.after(holder);
1917
+ if (!quote.textContent.trim() && !quote.querySelector('img')) quote.remove();
1918
+ caretIn(holder, 0);
1919
+ return;
1920
+ }
1921
+
1922
+ // Terms alternate dt → dd → dt; an empty one leaves the list
1923
+ if (/^(DT|DD)$/.test(holder.tagName)) {
1924
+ if (!holder.textContent.length) return liftItem(holder);
1925
+ caretIn(splitInto(holder, holder.tagName === 'DT' ? 'dd' : 'dt'), 0);
1926
+ return;
1927
+ }
1928
+
1929
+ // A caption continues after its figure
1930
+ if (holder.tagName === 'FIGCAPTION') {
1931
+ const next = splitInto(holder, 'p');
1932
+ holder.closest('figure').after(next);
1933
+ caretIn(next, 0);
1934
+ return;
1935
+ }
1936
+
1937
+ // Split before converting: conversion strips marker text under the caret
1938
+ const next = splitInto(holder, holder.tagName === 'LI' ? 'li' : 'p');
1939
+ if (autoformat) holder = convertLine(holder) || holder;
1940
+
1941
+ if (holder.tagName === 'HR') { caretIn(next, 0); return; }
1942
+
1943
+ // A finished ``` line opens the code block
1944
+ if (holder.tagName === 'PRE') { next.remove(); caretIn(holder, 0); return; }
1945
+
1946
+ if (holder.tagName === 'LI') {
1947
+ const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
1948
+ holder.after(item);
1949
+ const box = holder.querySelector(':scope > input[type=checkbox]');
1950
+ if (box && !item.querySelector(':scope > input[type=checkbox]')) {
1951
+ const fresh = document.createElement('input');
1952
+ fresh.type = 'checkbox';
1953
+ item.insertBefore(fresh, item.firstChild);
1954
+ }
1955
+ caretIn(item, 0);
1956
+ return;
1957
+ }
1958
+
1959
+ // Headings and quotes don't continue themselves
1960
+ caretIn(next, 0);
1961
+ }
1962
+
1963
+ const MAC = /Mac|iPhone|iPad|iPod/.test((typeof navigator !== 'undefined' && (navigator.userAgentData && navigator.userAgentData.platform || navigator.platform)) || '');
1964
+
1965
+ /* ---- History ----
1966
+ HTML snapshots plus caret offset; typing bursts coalesce into one step. */
1967
+ const HISTORY_CAP = 200;
1968
+ const IDLE = 400;
1969
+
1970
+ // Every entry is a settled state (trailing block, tables), so settling never makes the field differ from its history
1971
+ function record(area) {
1972
+ const h = area._history; if (!h || h.restoring) return;
1973
+ clearTimeout(h.timer); h.timer = null;
1974
+ if (area._rt && area._rt.settle) area._rt.settle();
1975
+ const html = area.innerHTML;
1976
+ // Unchanged: the entry learns where the next edit starts (`before`, for undo);
1977
+ // `caret` stays where the edit that produced it ended (for redo)
1978
+ if (h.stack[h.at] && h.stack[h.at].html === html) {
1979
+ const caret = offsetIn(area);
1980
+ if (caret != null) h.stack[h.at].before = caret;
1981
+ return;
1982
+ }
1983
+ h.stack.length = h.at + 1;
1984
+ h.stack.push({ html, caret: offsetIn(area) });
1985
+ if (h.stack.length > HISTORY_CAP) h.stack.shift();
1986
+ h.at = h.stack.length - 1;
1987
+ }
1988
+
1989
+ // A burst about to start: the entry it will undo to remembers where it starts (never adds a step)
1990
+ function markCaret(area) {
1991
+ const h = area._history; if (!h || h.timer || h.restoring) return;
1992
+ const top = h.stack[h.at], caret = offsetIn(area);
1993
+ if (top && caret != null && top.html === area.innerHTML) top.before = caret;
1994
+ }
1995
+
1996
+ // Re-sync once the burst lands, so Undo lights up
1997
+ const recordSoon = (area) => {
1998
+ const h = area._history; if (!h || h.restoring) return;
1999
+ clearTimeout(h.timer);
2000
+ h.timer = setTimeout(() => { record(area); sync(); }, IDLE);
2001
+ };
2002
+
2003
+ function step(area, by) {
2004
+ const h = area._history; if (!h) return;
2005
+ // 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);
2007
+ const next = h.at + by;
2008
+ if (next < 0 || next >= h.stack.length) return;
2009
+ h.at = next; h.restoring = true;
2010
+ const entry = h.stack[next];
2011
+ area.innerHTML = entry.html;
2012
+ if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
2013
+ area.focus();
2014
+ caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
2015
+ saveRange(area);
2016
+ area.dispatchEvent(new Event('input', { bubbles: true }));
2017
+ h.restoring = false;
2018
+ }
2019
+
2020
+ /* ---- Registry + resolution ----
2021
+ Controls resolve by containment (nearest ancestor holding exactly one area), then
2022
+ by focus; x-rich-text-for pins either. */
2023
+ const areas = new Set();
2024
+ const controls = new Set();
2025
+ let lastFocused = null;
2026
+
2027
+ const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
2028
+ const inside = (root) => [...areas].filter(a => root.contains(a) && a !== root);
2029
+ const preferred = (list) => list.find(a => a === lastFocused && onScreen(a)) || list.find(onScreen) || list[0] || null;
2030
+
2031
+ function resolve(control) {
2032
+ const pin = control.closest('[x-rich-text-for]');
2033
+ if (pin) {
2034
+ const target = document.querySelector(pin.getAttribute('x-rich-text-for'));
2035
+ if (target) return areas.has(target) ? target : preferred(inside(target));
2036
+ }
2037
+ for (let n = control.parentElement; n; n = n.parentElement) {
2038
+ const found = inside(n);
2039
+ if (found.length === 1) return found[0];
2040
+ if (found.length > 1) return unambiguous(found);
2041
+ }
2042
+ return unambiguous([...areas]);
2043
+ }
2044
+
2045
+ // Releasing the target is announced with a null selection
2046
+ function release(area) {
2047
+ const was = area || lastFocused;
2048
+ if (!area || area === lastFocused) lastFocused = null;
2049
+ if (!was) return;
2050
+ was.removeAttribute('data-rich-text-focused');
2051
+ was._selection = null;
2052
+ was.removeAttribute('data-rich-text-selected');
2053
+ document.documentElement.removeAttribute('data-rich-text-selected');
2054
+ was.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: null }));
2055
+ }
2056
+
2057
+ // Stands in for relatedTarget during the blur a pointer caused
2058
+ let pointerTarget = null;
2059
+
2060
+ function keeps(area, node) {
2061
+ const el = elementAt(node);
2062
+ if (!el) return false;
2063
+ if (area.contains(el)) return true;
2064
+ const control = el.closest('[data-rich-text-control]');
2065
+ return !!control && resolve(control) === area;
2066
+ }
2067
+
2068
+ const TEXTLESS = /^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i;
2069
+ const editableElsewhere = (node) => {
2070
+ const el = elementAt(node);
2071
+ return !!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !TEXTLESS.test(el.type)));
2072
+ };
2073
+
2074
+ // Several areas in scope and none focused: no target
2075
+ function unambiguous(found) {
2076
+ if (lastFocused && found.includes(lastFocused) && lastFocused.isConnected && onScreen(lastFocused)) return lastFocused;
2077
+ const visible = found.filter(onScreen);
2078
+ return visible.length === 1 ? visible[0] : null;
2079
+ }
2080
+
2081
+ /* ---- Caret custody ----
2082
+ Each area keeps its last range, so a focus-taking control can put it back. */
2083
+ function saveRange(area) {
2084
+ const r = range();
2085
+ if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
2086
+ }
2087
+
2088
+ const forget = (keep) => areas.forEach(a => { if (!keep || !a.contains(keep)) a._range = null; });
2089
+
2090
+ function restoreRange(area) {
2091
+ const r = liveRange();
2092
+ if (r && area.contains(r.startContainer)) return;
2093
+ const saved = area._range;
2094
+ if (!saved || !area.contains(saved.startContainer)) return;
2095
+ area.focus(); // its focusin saves the caret focus placed: put ours back
2096
+ area._range = saved;
2097
+ const s = sel(); s.removeAllRanges(); s.addRange(saved);
2098
+ }
2099
+
2100
+ // With focus in a control (an <input> for a link, a <select>), commands read the area's saved range
2101
+ function judged(area, fn) {
2102
+ const live = liveRange();
2103
+ if ((live && area.contains(live.startContainer)) || !area._range || !area.contains(area._range.startContainer)) return fn();
2104
+ const was = probe; probe = area._range;
2105
+ try { return fn(); } finally { probe = was; }
2106
+ }
2107
+ // Focus or caret in another editable element (not this area, not one of the controls): stay quiet
2108
+ function busyElsewhere(area) {
2109
+ const a = document.activeElement;
2110
+ if (a && a !== document.body && a.closest && !area.contains(a)) {
2111
+ if (a.closest('[data-rich-text-control]')) return false;
2112
+ if (editableElsewhere(a)) return true;
2113
+ }
2114
+ const live = liveRange();
2115
+ return !!live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer);
2116
+ }
2117
+
2118
+ function allows(area, id, ctrl) {
2119
+ const spec = COMMANDS[id], cfg = area && area._rt;
2120
+ if (!spec || !cfg) return false;
2121
+ const all = !!(ctrl && ctrl.all && spec.all);
2122
+ if (all && cfg.noAll) return false;
2123
+ if (!all && busyElsewhere(area)) return false;
2124
+ if (spec.history) return spec.enabled(area);
2125
+ if (cfg.format === 'plain') return false;
2126
+ if (cfg.inline && !all && (spec.block || (id === 'br' && !cfg.breaks))) return false;
2127
+ if (cfg.format !== 'html' && !spec.md && !all) return false;
2128
+ if (all) return true;
2129
+ if (!area._range || !area.contains(area._range.startContainer)) return false;
2130
+ return judged(area, () => !(spec.needsSelection && !hasSelection()) && (!spec.enabled || spec.enabled(area)));
2131
+ }
2132
+
2133
+ function run(area, id, arg, ctrl) {
2134
+ const spec = COMMANDS[id]; if (!spec) return;
2135
+ const all = !!(ctrl && ctrl.all && spec.all);
2136
+ if (!all) {
2137
+ // Judged before restoring, so a refused (or keyboard-activated) control never takes focus back
2138
+ if (!allows(area, id, ctrl)) return;
2139
+ restoreRange(area);
2140
+ const r = range();
2141
+ if (!r || !area.contains(r.startContainer)) return;
2142
+ }
2143
+ if (!allows(area, id, ctrl)) return;
2144
+ if (all) {
2145
+ area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
2146
+ if (!arg) area.style.removeProperty(ALL_VARS[id]);
2147
+ area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
2148
+ sync();
2149
+ return;
2150
+ }
2151
+ const arming = INLINE_TAGS[id] && !hasSelection();
2152
+ if (arming) { spec.run(arg, area); sync(); return; }
2153
+ if (!spec.history) record(area);
2154
+ spec.run(arg, area);
2155
+ normalizeLists(area);
2156
+ if (!spec.history) record(area);
2157
+ saveRange(area);
2158
+ if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true }));
2159
+ sync();
2160
+ }
2161
+
2162
+ // Reflect caret state onto every control
2163
+ function sync() {
2164
+ controls.forEach(c => {
2165
+ const cfg = c._rt, area = resolve(c);
2166
+ const usable = !!area && allows(area, cfg.id, cfg);
2167
+ if (!area) { c.removeAttribute('data-rich-text-active'); c.setAttribute('aria-disabled', 'true'); if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', 'false'); return; }
2168
+ const spec = COMMANDS[cfg.id];
2169
+ const all = !!(cfg.all && spec.all);
2170
+ c.toggleAttribute('data-rich-text-active', usable && !all && !!judged(area, () => spec.active(cfg.literal, area)));
2171
+ c.setAttribute('aria-disabled', String(!usable));
2172
+ if (usable && (all || spec.reflect) && 'value' in c && c !== document.activeElement) {
2173
+ 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
2175
+ const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
2176
+ if (v != null && offered && c.value !== v) c.value = v;
2177
+ }
2178
+ if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-rich-text-active')));
2179
+ });
2180
+ }
2181
+
2182
+ /* ---- Document listeners ---- */
2183
+ let listening = false;
2184
+ function listen() {
2185
+ if (listening) return;
2186
+ listening = true;
2187
+ // A link clicked in a field is content: neither the router nor the browser follows it.
2188
+ // A contenteditable=false island inside the field keeps its links. Window capture runs first.
2189
+ const blockLink = (e) => {
2190
+ const link = e.target && e.target.closest && e.target.closest('a[href]');
2191
+ const field = link && link.closest('[data-rich-text]');
2192
+ if (!field || !areas.has(field)) return;
2193
+ const off = link.closest('[contenteditable="false"]');
2194
+ if (off && field.contains(off)) return;
2195
+ e.preventDefault();
2196
+ };
2197
+ window.addEventListener('click', blockLink, true);
2198
+ window.addEventListener('auxclick', blockLink, true);
2199
+ // No inline computed styles; Enter makes <p>, not <div>
2200
+ try { document.execCommand('styleWithCSS', false, false); } catch { }
2201
+ try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch { }
2202
+
2203
+ document.addEventListener('pointerdown', (e) => {
2204
+ pointerTarget = e.target;
2205
+ setTimeout(() => { pointerTarget = null; }, 0);
2206
+ }, true);
2207
+
2208
+ document.addEventListener('selectionchange', () => {
2209
+ const r = range(); if (!r) return;
2210
+ const a = [...areas].find(x => x.contains(r.startContainer));
2211
+ if (a) {
2212
+ if (lastFocused && lastFocused !== a) release(lastFocused);
2213
+ saveRange(a); lastFocused = a; report(a);
2214
+ }
2215
+ else if (editableElsewhere(r.startContainer)) { forget(); if (lastFocused) release(); }
2216
+ sync();
2217
+ });
2218
+
2219
+ // Caret moved into something else editable: controls lose their target
2220
+ document.addEventListener('focusin', (e) => {
2221
+ const t = e.target;
2222
+ if (!t || !t.closest) return;
2223
+ if (t.closest('[data-rich-text-control]')) return;
2224
+ const elsewhere = editableElsewhere(t);
2225
+ if (elsewhere) forget(t);
2226
+ if (t.closest('[data-rich-text]')) return;
2227
+ if (!lastFocused && !elsewhere) return;
2228
+ if (lastFocused) release();
2229
+ sync();
2230
+ }, true);
2231
+ }
2232
+
2233
+ /* ---- Directive ---- */
2234
+ function init() {
2235
+ if (!window.Alpine || !Alpine.directive) return;
2236
+ // Alpine walks only x-data trees: a page with none still gets its fields
2237
+ if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
2238
+ listen();
2239
+
2240
+ Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
2241
+ const id = modifiers.find(m => COMMANDS[m]);
2242
+ if (id) return control(el, id, modifiers, expression, evaluate, cleanup);
2243
+ if (/^(BUTTON|SELECT|INPUT|TEXTAREA|A)$/.test(el.tagName)) {
2244
+ const m = modifiers.find(x => !MODES.has(x) && x !== 'all');
2245
+ console.warn(`[Manifest Rich Text] unknown command${m ? ` .${m}` : ''} on <${el.tagName.toLowerCase()}>${suggest(m)}`);
2246
+ return;
2247
+ }
2248
+ return areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup });
2249
+ });
2250
+
2251
+ // x-rich-text-menu: a popover shown over its area's selection
2252
+ Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
2253
+
2254
+ // $rich: the area this element sits in, else the one controls would resolve to
2255
+ const richMagic = (el) => {
2256
+ const own = el.closest('[data-rich-text]');
2257
+ const a = own && areas.has(own) ? own : resolve(el);
2258
+ return a ? a._rt.api : idleApi();
2259
+ };
2260
+ Alpine.magic('rich', richMagic);
2261
+ // The loader's stand-in answered $rich until now: it hands over, and effects that read it
2262
+ // run again once adopted areas exist
2263
+ window.__manifestRichMagic = richMagic;
2264
+ if (window.__manifestRichStub) setTimeout(() => { window.__manifestRichStub.v++; }, 0);
2265
+
2266
+ adopt();
2267
+ }
2268
+
2269
+ // Loaded after Alpine started: elements it already walked get their x-rich-text*
2270
+ // attributes re-set, which Alpine handles as newly added directives
2271
+ function adopt() {
2272
+ document.querySelectorAll('*').forEach(el => {
2273
+ if (!el._x_marker || el._rt || el._rtMenu || controls.has(el)) return;
2274
+ [...el.attributes].forEach(a => {
2275
+ if (a.name.startsWith('x-rich-text') && !a.name.startsWith('x-rich-text-for')) el.setAttribute(a.name, a.value);
2276
+ });
2277
+ });
2278
+ }
2279
+
2280
+ function idleApi() {
2281
+ const api = {
2282
+ value: '', link: '', selection: null, all: {}, run() { }, active: () => false,
2283
+ can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
2284
+ };
2285
+ Object.keys(COMMANDS).forEach(id => { api[id] = () => { }; });
2286
+ return api;
2287
+ }
2288
+
2289
+ /* ---- Selection reporting ----
2290
+ Geometry as a rich-text:selection event and as --rich-text-selection-* properties. */
2291
+ const SELECTION_VARS = ['x', 'y', 'width', 'height', 'center'].map(k => '--rich-text-selection-' + k);
2292
+
2293
+ function selectionOf(area) {
2294
+ const r = range();
2295
+ if (!r || !area.contains(r.startContainer)) return null;
2296
+ const box = r.getBoundingClientRect();
2297
+ return {
2298
+ collapsed: r.collapsed,
2299
+ text: r.toString(),
2300
+ x: box.left, y: box.top, width: box.width, height: box.height,
2301
+ top: box.top, bottom: box.bottom, left: box.left, right: box.right
2302
+ };
2303
+ }
2304
+
2305
+ function paintCells(area) {
2306
+ area.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
2307
+ selectedCells().forEach(c => c.setAttribute('data-rich-text-cell', ''));
2308
+ }
2309
+
2310
+ // On the root only, where a top-layer menu reads them: never in the content's own style
2311
+ function place(area, shape) {
2312
+ if (!shape || shape.collapsed) return;
2313
+ const px = (n) => Math.round(n) + 'px', t = document.documentElement;
2314
+ t.style.setProperty('--rich-text-selection-x', px(shape.x));
2315
+ t.style.setProperty('--rich-text-selection-y', px(shape.y));
2316
+ t.style.setProperty('--rich-text-selection-width', px(shape.width));
2317
+ t.style.setProperty('--rich-text-selection-height', px(shape.height));
2318
+ t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
2319
+ }
2320
+
2321
+ function report(area) {
2322
+ const live = sel();
2323
+ if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
2324
+ if (area.querySelector('table')) paintCells(area);
2325
+ const shape = selectionOf(area);
2326
+ area._selection = shape;
2327
+ place(area, shape);
2328
+ area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
2329
+ document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
2330
+ area.dispatchEvent(new CustomEvent('rich-text:selection', { bubbles: true, detail: shape }));
2331
+ }
2332
+
2333
+ // popover="manual": an auto popover closes on the click that makes the selection
2334
+ function selectionMenu(el, cleanup) {
2335
+ el._rtMenu = true;
2336
+ if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
2337
+ 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
+ const onSel = (e) => {
2357
+ const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
2358
+ if (!area || resolve(el) !== area) return;
2359
+ const want = !!e.detail && !e.detail.collapsed;
2360
+ if (want) track(area); else untrack();
2361
+ try {
2362
+ if (want && !el.matches(':popover-open')) el.showPopover();
2363
+ if (!want && el.matches(':popover-open')) el.hidePopover();
2364
+ } catch { }
2365
+ };
2366
+ document.addEventListener('rich-text:selection', onSel);
2367
+ cleanup(() => {
2368
+ document.removeEventListener('rich-text:selection', onSel);
2369
+ untrack(); if (frame) cancelAnimationFrame(frame);
2370
+ try { el.hidePopover(); } catch { }
2371
+ });
2372
+ }
2373
+
2374
+ function control(el, id, modifiers, expression, evaluate, cleanup) {
2375
+ if (controls.has(el)) return;
2376
+ // `.align.center`, `.align.all.center`: the first other modifier after the command is its argument
2377
+ const all = modifiers.includes('all');
2378
+ const literal = modifiers.slice(modifiers.indexOf(id) + 1).find(m => m !== 'all');
2379
+ const arg = () => literal !== undefined ? literal : (expression ? evaluate(expression) : undefined);
2380
+ el._rt = { id, arg, all, literal }; // the expression is evaluated only when the control runs
2381
+ if (all) el.setAttribute('data-rich-text-all', '');
2382
+ el.setAttribute('data-rich-text-control', id);
2383
+ if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.type = 'button';
2384
+ controls.add(el);
2385
+
2386
+ const valued = 'value' in el && el.tagName !== 'BUTTON';
2387
+ // Never take the caret
2388
+ const disabled = () => el.getAttribute('aria-disabled') === 'true';
2389
+ el.addEventListener('mousedown', e => { if (!valued || disabled()) e.preventDefault(); });
2390
+ if (valued) el.addEventListener('click', e => { if (disabled()) e.preventDefault(); });
2391
+ if (valued) {
2392
+ const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._rt); };
2393
+ if (el.type === 'color') {
2394
+ // Live preview: the first tick wraps (one undo step), later ticks restyle the same spans
2395
+ const prop = id === 'background' ? 'backgroundColor' : 'color';
2396
+ let live = null;
2397
+ const dropLive = () => {
2398
+ if (!live) return;
2399
+ const r = range();
2400
+ if (!r || !live.some(sp => sp.isConnected && sp.contains(r.startContainer))) live = null;
2401
+ };
2402
+ document.addEventListener('rich-text:selection', dropLive);
2403
+ cleanup(() => document.removeEventListener('rich-text:selection', dropLive));
2404
+ el.addEventListener('input', () => {
2405
+ const a = resolve(el); if (!a) return;
2406
+ if (el._rt.all) { run(a, id, el.value, el._rt); return; }
2407
+ if (live && live.length && live.every(s => s.isConnected)) {
2408
+ live.forEach(s => { s.style[prop] = el.value; });
2409
+ a.dispatchEvent(new Event('input', { bubbles: true }));
2410
+ return;
2411
+ }
2412
+ if (!allows(a, id, el._rt)) return;
2413
+ restoreRange(a);
2414
+ const r = range(); if (!r || !a.contains(r.startContainer)) return;
2415
+ record(a);
2416
+ live = wrapInline('span', { [prop]: el.value });
2417
+ saveRange(a);
2418
+ a.dispatchEvent(new Event('input', { bubbles: true }));
2419
+ });
2420
+ el.addEventListener('change', () => {
2421
+ if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; }
2422
+ fire();
2423
+ });
2424
+ } else {
2425
+ el.addEventListener('change', fire);
2426
+ }
2427
+ el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
2428
+ } else {
2429
+ el.addEventListener('pointerdown', e => e.preventDefault());
2430
+ el.addEventListener('click', e => { e.preventDefault(); const a = resolve(el); if (a) run(a, id, arg(), el._rt); });
2431
+ }
2432
+ setTimeout(sync, 0);
2433
+ cleanup(() => { controls.delete(el); });
2434
+ }
2435
+
2436
+ function areaDirective(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
2437
+ if (el._rt) return;
2438
+ const format = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
2439
+ modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Rich Text] unknown modifier: .${m}${suggest(m)}`));
2440
+
2441
+ let last = null, setValue = null;
2442
+ const core = setup(el, {
2443
+ format, inline: modifiers.includes('inline'), literal: modifiers.includes('literal'),
2444
+ bound: !!expression, controlled: !!expression,
2445
+ onChange: expression ? (v) => { last = v; setValue(() => { }, { scope: { __richTextValue: v } }); } : null
2446
+ });
2447
+ if (expression) {
2448
+ const getValue = evaluateLater(expression);
2449
+ setValue = evaluateLater(`${expression} = __richTextValue`);
2450
+ effect(() => getValue(v => {
2451
+ const s = v == null ? '' : String(v);
2452
+ if (s === last) return; // our own write coming back
2453
+ last = s;
2454
+ core.load(s);
2455
+ }));
2456
+ }
2457
+ if (modifiers.includes('autofocus')) setTimeout(() => el.focus(), 0);
2458
+ cleanup(core.destroy);
2459
+ }
2460
+
2461
+ /* ---- Area engine ----
2462
+ Shared by the directive and ManifestRichText.attach(). */
2463
+ function setup(el, { format = 'markdown', inline: single = false, breaks = false, all: allStyles = true, literal = false, bound = false, controlled = false, onChange = null } = {}) {
2464
+ // .inline html with breaks: one block of lines; <br> kept, Shift+Enter makes one
2465
+ const lines = single && breaks && format === 'html';
2466
+ const flat = (html) => lines ? breakLines(html, true) : flattenLines(html, true);
2467
+ const ac = new AbortController();
2468
+ const on = (type, fn) => el.addEventListener(type, fn, { signal: ac.signal });
2469
+ const prior = ['contenteditable', 'role', 'aria-multiline'].map(n => [n, el.getAttribute(n)]);
2470
+ const priorAll = Object.values(ALL_VARS).map(v => [v, el.style.getPropertyValue(v)]);
2471
+
2472
+ el.setAttribute('data-rich-text', format);
2473
+ // Tells x-edit whether the value already has an owner
2474
+ el.toggleAttribute('data-rich-text-bound', bound);
2475
+ el.toggleAttribute('data-rich-text-inline', single);
2476
+ el.setAttribute('contenteditable', format === 'plain' && PLAINTEXT_ONLY ? 'plaintext-only' : 'true');
2477
+ el.setAttribute('role', 'textbox');
2478
+ el.setAttribute('aria-multiline', String(!single || lines));
2479
+
2480
+ const read = () => {
2481
+ if (format === 'plain') return single ? el.innerText.replace(/\s*\n\s*/g, ' ').trim() : el.innerText.replace(/\n{3,}/g, '\n\n').trim();
2482
+ if (single) return format === 'html' ? flat(el.innerHTML) : inlineMarkdown(el);
2483
+ return format === 'html' ? sanitize(el.innerHTML, true, true) : toMarkdown(el);
2484
+ };
2485
+ const write = (v) => {
2486
+ if (format === 'plain') { el.textContent = single ? flatText(v).trim() : v == null ? '' : String(v); return; }
2487
+ if (single) { el.innerHTML = format === 'html' ? flat(v) : sanitize(inlineHtml(flatText(v).trim()), false); return; }
2488
+ el.innerHTML = format === 'html' ? sanitize(v, true, true) : markdownHtml(v);
2489
+ if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
2490
+ normalizeLists(el);
2491
+ if (el.querySelector('table')) normalizeTables(el);
2492
+ ensureTrailingBlock(el);
2493
+ };
2494
+
2495
+ // Single-line insertion: clipboard or drop data flattened, at a range when given
2496
+ const htmlText = (html) => { const t = document.createElement('template'); t.innerHTML = html || ''; return t.content.textContent; };
2497
+ const mdOnly = (html) => format === 'markdown' ? markdownMarks(html) : html;
2498
+ const flatClip = (html, text) => html ? mdOnly(unwrapBareSpans(lines ? breakLines(flattenTables(html, false)) : flattenLines(flattenTables(html, false))))
2499
+ : lines ? textLines(text) : sanitize(inlineHtml(flatText(text).trim()), false);
2500
+ const multiline = (html, text) => {
2501
+ if (!html) return /[\r\n]/.test(text || '');
2502
+ const t = document.createElement('template'); t.innerHTML = html;
2503
+ return !!t.content.querySelector(BLOCK_SEL + ',br,div');
2504
+ };
2505
+ const insertFlat = (html, at, text) => {
2506
+ if (at) { const s = sel(); s.removeAllRanges(); s.addRange(at); }
2507
+ if (format === 'plain') return insertPlain(el, flatText(text != null ? text : htmlText(html)).trim());
2508
+ const t = document.createElement('template');
2509
+ t.innerHTML = html;
2510
+ insertNodes(el, t.content);
2511
+ };
2512
+
2513
+ let writing = false;
2514
+ const commit =() => { if (onChange) onChange(read()); };
2515
+ on('input', () => { if (!writing) commit(); });
2516
+
2517
+ // Uncontrolled: the markup already there is the initial content
2518
+ if (!controlled && format !== 'plain') {
2519
+ if (single) { if (el.querySelector(BLOCK_SEL)) el.innerHTML = flat(el.innerHTML); }
2520
+ else {
2521
+ if (el.firstChild && ![...el.children].some(c => BLOCK.includes(c.tagName))) el.innerHTML = `<p>${el.innerHTML}</p>`;
2522
+ if (!el.firstChild) el.innerHTML = '<p><br></p>';
2523
+ }
2524
+ }
2525
+
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; };
2528
+
2529
+ // Empty flag for the placeholder; a wiped document restarts as a paragraph
2530
+ function settle() {
2531
+ if (single) {
2532
+ // Backstop: whatever got a block or break in is flattened, caret kept
2533
+ if (el.querySelector(lines ? BLOCK_SEL : BLOCK_SEL + ',br')) {
2534
+ const off = offsetIn(el), html = flat(el.innerHTML);
2535
+ if (format === 'plain') { const t = document.createElement('template'); t.innerHTML = html; el.textContent = t.content.textContent; }
2536
+ else el.innerHTML = html;
2537
+ if (off != null && el.textContent.length) caretIn(el, off);
2538
+ }
2539
+ const empty = !el.textContent.trim() && !el.querySelector('img');
2540
+ if (empty && el.firstChild && !el.textContent.length) el.replaceChildren();
2541
+ el.toggleAttribute('data-rich-text-empty', empty);
2542
+ return;
2543
+ }
2544
+ if (el.querySelector('table')) normalizeTables(el);
2545
+ if (format !== 'plain') looseInline(el);
2546
+ ensureTrailingBlock(el);
2547
+ if (!el.textContent.trim() && !el.querySelector('img, table, hr')) {
2548
+ if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
2549
+ if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
2550
+ el.toggleAttribute('data-rich-text-empty', true);
2551
+ } else el.toggleAttribute('data-rich-text-empty', false);
2552
+ }
2553
+
2554
+ const load = (v) => {
2555
+ writing = true; write(v); writing = false;
2556
+ resetHistory();
2557
+ settle();
2558
+ };
2559
+
2560
+ const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
2561
+ const enter = (shiftKey) => el.dispatchEvent(new CustomEvent('rich-text:enter', { bubbles: true, detail: { value: read(), shiftKey: !!shiftKey } }));
2562
+
2563
+ // Table borders resize; drags pick cells
2564
+ on('pointermove', (ev) => {
2565
+ if (ev.buttons || !el.querySelector('table')) return;
2566
+ const hit = borderAt(ev.clientX, ev.clientY);
2567
+ el.setAttribute('data-rich-text-border', hit ? hit.axis : '');
2568
+ if (!hit) el.removeAttribute('data-rich-text-border');
2569
+ });
2570
+ on('pointerdown', (ev) => {
2571
+ el._exit = null; el._after = null;
2572
+ if (ev.button !== 0) return;
2573
+ if (el.querySelector('table')) {
2574
+ const hit = borderAt(ev.clientX, ev.clientY);
2575
+ if (hit) return startResize(ev, hit);
2576
+ const from = cellUnder(ev.clientX, ev.clientY);
2577
+ if (from) cellDrag = { anchor: from, focus: from };
2578
+ }
2579
+ });
2580
+ on('pointermove', (ev) => {
2581
+ if (!cellDrag || !ev.buttons) return;
2582
+ const over = cellUnder(ev.clientX, ev.clientY);
2583
+ if (!over || over === cellDrag.focus) return;
2584
+ cellDrag.focus = over;
2585
+ paintCells(el);
2586
+ });
2587
+ on('pointerup', () => {
2588
+ if (cellDrag && cellDrag.anchor === cellDrag.focus) cellDrag = null;
2589
+ paintCells(el);
2590
+ sync();
2591
+ });
2592
+
2593
+ // Links are content here, not navigation
2594
+ on('click', (e) => {
2595
+ const a = e.target && e.target.closest && e.target.closest('a');
2596
+ const island = a && a.closest('[contenteditable="false"]');
2597
+ if (a && el.contains(a) && !(island && el.contains(island))) e.preventDefault();
2598
+ });
2599
+
2600
+ on('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-rich-text-focused', ''); saveRange(el); sync(); });
2601
+ on('focusout', (e) => {
2602
+ if (keeps(el, e.relatedTarget || pointerTarget)) return;
2603
+ release(el);
2604
+ sync();
2605
+ });
2606
+
2607
+ const autoformat = format !== 'plain' && !literal;
2608
+ on('input', (e) => {
2609
+ // Mid-composition the IME owns the text
2610
+ if (!writing && e.data && !e.isComposing) {
2611
+ if (applyPending(el, e.data)) commit();
2612
+ if (leaveMark(el, e.data)) commit();
2613
+ if (autoformat && convertInlineAtCaret(el)) commit();
2614
+ }
2615
+ // A deletion of text typed after a mark, back to its edge, is right after the mark again
2616
+ if (el._delOut && /^delete/.test(e.inputType || '') && atMarkEnd(el._after, range())) el._exit = el._after;
2617
+ el._delOut = false;
2618
+ if (!writing && !e.isComposing) relaxSpaces(el, e.inputType === 'insertText' ? e.data : null);
2619
+ if (!writing) recordSoon(el);
2620
+ settle(); sync();
2621
+ });
2622
+ on('compositionend', (e) => {
2623
+ if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
2624
+ else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
2625
+ if (!writing) relaxSpaces(el);
2626
+ });
2627
+ on('change', e => {
2628
+ if (e.target.type !== 'checkbox') return;
2629
+ // Only the attribute survives serialization
2630
+ e.target.toggleAttribute('checked', e.target.checked);
2631
+ record(el);
2632
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2633
+ });
2634
+ on('input', (e) => {
2635
+ if (writing || e.isComposing) return;
2636
+ if (ancestor('li')) normalizeLists(el);
2637
+ });
2638
+
2639
+ // Native undo would desync the plugin's stack
2640
+ on('beforeinput', (e) => {
2641
+ if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
2642
+ e.preventDefault();
2643
+ run(el, e.inputType === 'historyUndo' ? 'undo' : 'redo');
2644
+ return;
2645
+ }
2646
+ markCaret(el);
2647
+ if (typeAfterMark(el, e)) return;
2648
+ if (/^delete/.test(e.inputType || '')) { const m = el._after, r = range(); el._delOut = !!(m && m.isConnected && r && !m.contains(r.startContainer) && !atMarkEnd(m, r)); }
2649
+ if (lines && e.inputType === 'insertLineBreak') { e.preventDefault(); lineBreak(); return; }
2650
+ if (single && /^insert(Paragraph|LineBreak)$/.test(e.inputType || '')) { e.preventDefault(); enter(false); return; }
2651
+ // Text carrying line breaks (autocorrect, IME, replacement) lands as one line
2652
+ if (single && e.cancelable && /^insert(Text|ReplacementText|CompositionText)$/.test(e.inputType || '')) {
2653
+ const d = e.data != null ? e.data : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
2654
+ if (/[\r\n]/.test(d)) {
2655
+ e.preventDefault();
2656
+ const tr = e.getTargetRanges ? e.getTargetRanges()[0] : null;
2657
+ let at = null;
2658
+ if (tr) { at = document.createRange(); at.setStart(tr.startContainer, tr.startOffset); at.setEnd(tr.endContainer, tr.endOffset); }
2659
+ insertFlat(lines ? textLines(d) : escHtml(flatText(d).trim()), at, d);
2660
+ return;
2661
+ }
2662
+ }
2663
+ if (format === 'plain' && /^format/.test(e.inputType || '')) e.preventDefault();
2664
+ });
2665
+
2666
+ // A multi-line drop is flattened at the drop point
2667
+ on('drop', (e) => {
2668
+ const dt = e.dataTransfer; if (!single || !dt) return;
2669
+ const html = dt.getData('text/html'), text = dt.getData('text/plain');
2670
+ if (!multiline(html, text)) return;
2671
+ e.preventDefault();
2672
+ const hit = caretAtPoint(e.clientX, e.clientY);
2673
+ let at = null;
2674
+ if (hit && el.contains(hit.node)) { at = document.createRange(); at.setStart(hit.node, hit.offset); at.collapse(true); }
2675
+ el.focus();
2676
+ insertFlat(flatClip(html, text), at, text || htmlText(html));
2677
+ });
2678
+
2679
+ on('keydown', (e) => {
2680
+ if (e.isComposing || e.keyCode === 229) return;
2681
+ if (e.key === 'Enter' && e.target !== el && e.target.matches && e.target.matches('a[href]') && e.target.closest('[contenteditable="false"]') && el.contains(e.target.closest('[contenteditable="false"]'))) return;
2682
+ if ((e.key || '').length > 1 && !/^(Shift|Alt|Control|Meta|CapsLock)$/.test(e.key)) { el._exit = null; if (!/^(Backspace|Delete)$/.test(e.key)) el._after = null; }
2683
+ if (e.key === 'Enter' && single) { e.preventDefault(); if (lines && e.shiftKey) lineBreak(); else enter(e.shiftKey); return; }
2684
+ if (e.key === 'Enter' && format !== 'plain') {
2685
+ if (!ownsEnter(e)) return;
2686
+ e.preventDefault();
2687
+ record(el);
2688
+ onEnter(el, autoformat);
2689
+ normalizeLists(el);
2690
+ record(el);
2691
+ commit();
2692
+ sync();
2693
+ return;
2694
+ }
2695
+ if (e.key === 'Backspace' && format !== 'plain' && !single) {
2696
+ const cell = cellCoversAll();
2697
+ if (cell) { // clear the cell, keep the table
2698
+ e.preventDefault(); record(el);
2699
+ cell.replaceChildren(document.createElement('br'));
2700
+ caretIn(cell, 0);
2701
+ record(el); commit(); sync();
2702
+ return;
2703
+ }
2704
+ const r = range();
2705
+ if (r && r.collapsed && atBlockStart()) {
2706
+ e.preventDefault(); record(el);
2707
+ backspaceAtStart();
2708
+ normalizeLists(el);
2709
+ record(el); commit(); sync();
2710
+ return;
2711
+ }
2712
+ }
2713
+ if (/^Arrow(Up|Down|Left|Right)$/.test(e.key) && !e.shiftKey && !e.metaKey && !e.ctrlKey && inTable()) {
2714
+ if (arrowInTable(e)) { e.preventDefault(); sync(); return; }
2715
+ return;
2716
+ }
2717
+ // Tab indents (single-line fields keep native focus order); Escape is the way out
2718
+ if (e.key === 'Tab') {
2719
+ if (format === 'plain' || single) return;
2720
+ e.preventDefault();
2721
+ if (moveCell(e.shiftKey ? -1 : 1)) return;
2722
+ run(el, e.shiftKey ? 'outdent' : 'indent');
2723
+ return;
2724
+ }
2725
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); return; }
2726
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
2727
+ e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
2728
+ }
2729
+ // Ctrl+Y redoes off the Mac (where it is yank)
2730
+ if (e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'y' && !MAC) {
2731
+ e.preventDefault(); run(el, 'redo'); return;
2732
+ }
2733
+ // No Cmd/Ctrl+K: that chord belongs to site search
2734
+ if (!(e.metaKey || e.ctrlKey)) return;
2735
+ const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
2736
+ if (!id) return;
2737
+ // Never the browser's own formatting
2738
+ e.preventDefault();
2739
+ if (allows(el, id)) run(el, id);
2740
+ });
2741
+
2742
+ // Paste as our own subset, never the source app's markup or styles
2743
+ on('paste', (e) => {
2744
+ e.preventDefault();
2745
+ const dt = e.clipboardData; if (!dt) return;
2746
+ const text = dt.getData('text/plain');
2747
+ if (format === 'plain') return insertPlain(el, single ? flatText(text).trim() : text);
2748
+ const html = dt.getData('text/html');
2749
+ if (single) return insertFlat(flatClip(html, text), null, text);
2750
+ const clip = html ? unwrapBareSpans(format === 'markdown' ? markdownMarks(markdownBlocks(sanitize(flattenTables(html, true), true))) : sanitize(html, true))
2751
+ : markdownHtml(text.replace(/([^\n])\n(?!\n)/g, '$1 \n'));
2752
+ // Inline content (or a single paragraph of it) goes in at the caret, inside the caret's block,
2753
+ // leading space and formatting kept; the browser's insertHTML would place it beside the block
2754
+ const t = document.createElement('template'); t.innerHTML = clip;
2755
+ const only = t.content.children.length === 1 && t.content.firstElementChild.tagName === 'P' && !t.content.textContent.replace(t.content.firstElementChild.textContent, '').trim() ? t.content.firstElementChild : null;
2756
+ if (only) t.innerHTML = only.innerHTML;
2757
+ if (!t.content.querySelector(BLOCK_SEL)) { clearSpan(el); return insertNodes(el, t.content); }
2758
+ // Its own undo step: a pending burst lands first, the paste records after
2759
+ record(el);
2760
+ insertHTML(clip);
2761
+ record(el);
2762
+ });
2763
+
2764
+ const api = {
2765
+ get value() { return read(); },
2766
+ // The value it already holds keeps the markup and caret; history restarts either way
2767
+ set value(v) {
2768
+ if (v !== read()) write(v);
2769
+ resetHistory();
2770
+ settle();
2771
+ el._history.restoring = true;
2772
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2773
+ el._history.restoring = false;
2774
+ },
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); },
2780
+ focus: () => el.focus(),
2781
+ run: (id, arg) => run(el, id, arg),
2782
+ active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
2783
+ can: (id) => allows(el, id),
2784
+ selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
2785
+ // The newest step in this field's history (a pending typing burst lands first)
2786
+ 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; },
2787
+ markdown: () => single ? inlineMarkdown(el) : toMarkdown(el),
2788
+ html: () => sanitize(el.innerHTML, !single, true)
2789
+ };
2790
+ // $rich.strong(), $rich.table(3, 2)
2791
+ Object.keys(COMMANDS).forEach(id => { api[id] = (...args) => run(el, id, COMMANDS[id].args > 1 ? args : args[0]); });
2792
+
2793
+ el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
2794
+ areas.add(el);
2795
+ settle();
2796
+ el._history.stack[0].html = el.innerHTML;
2797
+ setTimeout(sync, 0);
2798
+
2799
+ const destroy = () => {
2800
+ if (!areas.has(el)) return;
2801
+ ac.abort();
2802
+ clearTimeout(el._history && el._history.timer);
2803
+ if (lastFocused === el) release(el);
2804
+ areas.delete(el);
2805
+ prior.forEach(([n, v]) => v == null ? el.removeAttribute(n) : el.setAttribute(n, v));
2806
+ [...el.attributes].forEach(a => { if (a.name.startsWith('data-rich-text')) el.removeAttribute(a.name); });
2807
+ el.querySelectorAll('[data-rich-text-cell]').forEach(c => c.removeAttribute('data-rich-text-cell'));
2808
+ SELECTION_VARS.forEach(v => el.style.removeProperty(v));
2809
+ priorAll.forEach(([v, was]) => { if (was) el.style.setProperty(v, was); else el.style.removeProperty(v); });
2810
+ if (!areas.size) {
2811
+ SELECTION_VARS.forEach(v => document.documentElement.style.removeProperty(v));
2812
+ document.documentElement.removeAttribute('data-rich-text-selected');
2813
+ if (document.documentElement.getAttribute('style') === '') document.documentElement.removeAttribute('style');
2814
+ }
2815
+ if (el.getAttribute('style') === '') el.removeAttribute('style');
2816
+ ['_rt', '_history', '_range', '_pending', '_exit', '_after', '_nb', '_delOut', '_selection'].forEach(k => { delete el[k]; });
2817
+ sync();
2818
+ };
2819
+
2820
+ return { api, load, destroy };
2821
+ }
2822
+
2823
+ /* ---- Engine API ---- */
2824
+ const FORMATS = new Set(['markdown', 'html', 'plain']);
2825
+ function attach(el, opts = {}) {
2826
+ if (!el || el.nodeType !== 1) return null;
2827
+ if (el._rt) return el._rt.handle || null;
2828
+ listen();
2829
+ const controlled = opts.value !== undefined;
2830
+ const core = setup(el, {
2831
+ format: FORMATS.has(opts.format) ? opts.format : 'markdown',
2832
+ inline: opts.mode === 'inline', breaks: !!opts.breaks, all: opts.all !== false, literal: !!opts.literal,
2833
+ bound: typeof opts.onChange === 'function', controlled,
2834
+ onChange: typeof opts.onChange === 'function' ? opts.onChange : null
2835
+ });
2836
+ if (controlled) core.load(opts.value);
2837
+ const handle = Object.create(core.api);
2838
+ handle.el = el;
2839
+ handle.detach = core.destroy;
2840
+ el._rt.handle = handle;
2841
+ return handle;
2842
+ }
2843
+
2844
+ document.addEventListener('alpine:init', init);
2845
+ if (window.Alpine && Alpine.directive) init();
2846
+
2847
+ window.ManifestRichText = { toMarkdown, fromMarkdown: markdownHtml, sanitize, commands: () => Object.keys(COMMANDS), attach };
2848
+ })();