mnfst 0.5.195 → 0.5.196

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,2020 @@
1
+ /* Manifest Text Edit — rich text editing.
2
+
3
+ One directive, two roles. Without a command modifier it marks the editable area
4
+ and binds its value: x-text-edit="post". With one, it turns any element into a
5
+ control for that area: <button x-text-edit.bold>. Controls need no wrapper and
6
+ no shared parent — they resolve to an area by containment, then by focus, so a
7
+ toolbar can sit anywhere and two dialogs in one DOM never cross wires.
8
+
9
+ Markdown by default (portable, and x-markdown renders it straight back);
10
+ .html stores sanitized HTML; .plain stores text.
11
+
12
+ Serialization is a closed round trip over exactly what the commands can produce —
13
+ headings, bold, italic, strike, code, links, lists, quote, rule. Markdown the
14
+ editor can't author (tables, footnotes) is not a silent lossy convert: use .html. */
15
+
16
+ (function () {
17
+
18
+ /* ---- Tag tables ----
19
+ Commands are named for the tag they produce, so there is no Manifest
20
+ vocabulary to map onto HTML: .blockquote makes a <blockquote>. `md` marks the
21
+ tags markdown can carry; the rest are available in .html mode only, where they
22
+ survive, rather than being written and silently dropped on the next save. */
23
+ const INLINE_TAGS = {
24
+ strong: { md: 1 }, b: { md: 1 }, em: { md: 1 }, i: { md: 1 }, s: { md: 1 }, del: { md: 1 }, code: { md: 1 },
25
+ u: {}, mark: {}, small: {}, sub: {}, sup: {}, kbd: {}, samp: {}, var: {}, abbr: {}, cite: {}, q: {}, ins: {}, dfn: {}, time: {}, span: {}
26
+ };
27
+ const BLOCK_TAGS = {
28
+ p: { md: 1 }, h1: { md: 1 }, h2: { md: 1 }, h3: { md: 1 }, h4: { md: 1 }, h5: { md: 1 }, h6: { md: 1 },
29
+ blockquote: { md: 1 }, pre: { md: 1 },
30
+ address: {}, figure: {}, figcaption: {}, dl: {}, dt: {}, dd: {}
31
+ };
32
+ const BLOCK = [...Object.keys(BLOCK_TAGS), 'ul', 'ol', 'li', 'hr', 'table', 'td', 'th'].map(t => t.toUpperCase());
33
+ const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
34
+ const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'A', 'IMG']);
35
+
36
+ /* ---- HTML → Markdown ---- */
37
+ const esc = (t) => t.replace(/([\\`*_[\]])/g, '\\$1');
38
+
39
+ function inline(node) {
40
+ let out = '';
41
+ node.childNodes.forEach(n => {
42
+ if (n.nodeType === 3) { out += esc(n.textContent); return; }
43
+ if (n.nodeType !== 1) return;
44
+ const tag = n.tagName;
45
+ if (tag === 'BR') { out += ' \n'; return; }
46
+ if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src ? `![${n.getAttribute('alt') || ''}](${src})` : ''; return; }
47
+ if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h ? `[${inline(n)}](${h})` : inline(n); return; }
48
+ const m = MARKS[tag];
49
+ if (!m) { out += inline(n); return; }
50
+ const body = tag === 'CODE' ? n.textContent : inline(n);
51
+ out += body.trim() ? m + body + m : body;
52
+ });
53
+ return out;
54
+ }
55
+
56
+ // <br> inside a <pre> is a line break; textContent alone would run the lines together.
57
+ const preText = (n) => [...n.childNodes].map(c =>
58
+ c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
59
+
60
+ function block(node, depth) {
61
+ if (node.nodeType === 3) { const t = node.textContent.trim(); return t ? esc(t) : ''; }
62
+ if (node.nodeType !== 1) return '';
63
+ const tag = node.tagName;
64
+ if (tag === 'HR') return '---';
65
+ if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
66
+ if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
67
+ if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
68
+ if (tag === 'UL' || tag === 'OL') {
69
+ const pad = ' '.repeat(depth);
70
+ return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
71
+ const box = li.querySelector(':scope > input[type=checkbox]');
72
+ const bullet = tag === 'OL' ? `${i + 1}. ` : box ? (box.checked ? '- [x] ' : '- [ ] ') : '- ';
73
+ const nested = [...li.children].filter(c => c.tagName === 'UL' || c.tagName === 'OL');
74
+ const own = inline(nestedStripped(li, nested)).trim();
75
+ const sub = nested.map(n => block(n, depth + 1)).join('\n');
76
+ return pad + bullet + own + (sub ? '\n' + sub : '');
77
+ }).join('\n');
78
+ }
79
+ return inline(node).trim();
80
+ }
81
+
82
+ // A list item's own text is everything except its nested lists.
83
+ function nestedStripped(li, nested) {
84
+ if (!nested.length) return li;
85
+ const c = li.cloneNode(true);
86
+ [...c.children].forEach(ch => { if (ch.tagName === 'UL' || ch.tagName === 'OL') ch.remove(); });
87
+ return c;
88
+ }
89
+
90
+ function blocks(root, depth) {
91
+ const parts = [];
92
+ root.childNodes.forEach(n => { const b = block(n, depth || 0); if (b) parts.push(b); });
93
+ return parts.join('\n\n');
94
+ }
95
+
96
+ const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
97
+
98
+ /* ---- Markdown → HTML ---- */
99
+ const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
100
+
101
+ // Escapes are masked out before the mark passes and restored last, so a literal
102
+ // \* never gets read as emphasis and never comes back double-escaped.
103
+ const HOLD = '\uE000';
104
+ function inlineHtml(s) {
105
+ const held = [];
106
+ return escHtml(s)
107
+ .replace(/\\([\\`*_~[\]])/g, (_, c) => HOLD + (held.push(c) - 1) + HOLD)
108
+ .replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
109
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
110
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
111
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
112
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => /^\s*javascript:/i.test(src) ? '' : `<img src="${src}" alt="${alt}">`)
113
+ .replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) => /^\s*javascript:/i.test(h) ? t : `<a href="${h}">${t}</a>`)
114
+ .replace(/ {2}\n/g, '<br>')
115
+ .replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
116
+ }
117
+
118
+ // Indented items nest. Depth is one level per four spaces — what the serializer emits.
119
+ function renderList(items, i, depth) {
120
+ const tag = items[i].tag;
121
+ let html = `<${tag}>`;
122
+ while (i < items.length && items[i].depth >= depth) {
123
+ if (items[i].depth > depth) {
124
+ const [sub, next] = renderList(items, i, items[i].depth);
125
+ html = html.endsWith(`<${tag}>`) ? html + `<li>${sub}</li>` : html.replace(/<\/li>$/, sub + '</li>');
126
+ i = next;
127
+ continue;
128
+ }
129
+ if (items[i].tag !== tag) break;
130
+ const task = items[i].text.match(/^\[([ xX])\]\s+(.*)$/);
131
+ html += task
132
+ ? `<li><input type="checkbox"${/[xX]/.test(task[1]) ? ' checked' : ''}>${inlineHtml(task[2])}</li>`
133
+ : `<li>${inlineHtml(items[i].text)}</li>`;
134
+ i++;
135
+ }
136
+ return [html + `</${tag}>`, i];
137
+ }
138
+
139
+ function fromMarkdown(md) {
140
+ const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
141
+ const out = [];
142
+ let para = [], list = null, quote = null, fence = null;
143
+ const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
144
+ 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; };
145
+ const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
146
+ const flushAll = () => { flushPara(); flushList(); flushQuote(); };
147
+
148
+ for (const raw of lines) {
149
+ const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : ''); // keep a hard break, drop stray trailing space
150
+ if (fence !== null) { if (/^```/.test(line)) { out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
151
+ if (/^```/.test(line)) { flushAll(); fence = []; continue; }
152
+ if (!line.trim()) { flushAll(); continue; }
153
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
154
+ if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
155
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { flushAll(); out.push('<hr>'); continue; }
156
+ const q = line.match(/^>\s?(.*)$/);
157
+ if (q) { flushPara(); flushList(); (quote = quote || []).push(q[1]); continue; }
158
+ const li = line.match(/^([ \t]*)([-*+]|\d+\.)\s+(.*)$/);
159
+ if (li) {
160
+ flushPara(); flushQuote();
161
+ (list = list || []).push({ depth: Math.floor(li[1].replace(/\t/g, ' ').length / 4), tag: /\d/.test(li[2]) ? 'ol' : 'ul', text: li[3] });
162
+ continue;
163
+ }
164
+ flushList(); flushQuote();
165
+ para.push(line);
166
+ }
167
+ if (fence) out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`);
168
+ flushAll();
169
+ return out.join('');
170
+ }
171
+
172
+ /* ---- Sanitize (paste, and .html round trips) ---- */
173
+ // Elements whose content IS the payload — removed outright. Everything else
174
+ // outside the allowlist is unwrapped, which keeps a pasted document's text and
175
+ // marks while dropping its markup.
176
+ const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'MATH']);
177
+
178
+ function sanitize(html, allowBlocks, keepStyle) {
179
+ const t = document.createElement('template');
180
+ t.innerHTML = String(html == null ? '' : html);
181
+ const ok = new Set(allowBlocks ? [...INLINE_OK, ...BLOCK, 'INPUT', 'TR', 'TBODY', 'THEAD', 'CAPTION', 'DT', 'DD', 'COLGROUP', 'COL'] : INLINE_OK);
182
+ // Depth-first: children are cleaned before their parent unwraps, so nothing
183
+ // hoisted out of a stripped wrapper escapes the walk.
184
+ const walk = (node) => [...node.childNodes].forEach(c => {
185
+ if (c.nodeType === 8) return c.remove();
186
+ if (c.nodeType !== 1) return;
187
+ if (DROP.has(c.tagName)) return c.remove();
188
+ walk(c);
189
+ if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
190
+ [...c.attributes].forEach(a => {
191
+ const n = a.name.toLowerCase();
192
+ if (c.tagName === 'A' && n === 'href' && !/^\s*javascript:/i.test(a.value)) return;
193
+ if (c.tagName === 'IMG' && (n === 'src' || n === 'alt') && !/^\s*javascript:/i.test(a.value)) return;
194
+ if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
195
+ // Without these a merged cell comes apart on the next save
196
+ if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
197
+ if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:/i.test(a.value)) return; // our own colour, font and alignment
198
+ c.removeAttribute(a.name);
199
+ });
200
+ });
201
+ walk(t.content);
202
+ return t.innerHTML;
203
+ }
204
+
205
+ /* ---- Selection primitives ---- */
206
+ const sel = () => window.getSelection();
207
+ const range = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
208
+
209
+ function elementAt(node) { while (node && node.nodeType !== 1) node = node.parentNode; return node; }
210
+
211
+ // Stops at the editable area, like blockEl does. Without that boundary an area
212
+ // that is itself a formatting element — <strong x-text-edit> is a perfectly
213
+ // reasonable thing to write — reports its own tag as formatting on the text
214
+ // inside it, and toggling that tag off unwraps the editor.
215
+ function ancestor(match) {
216
+ const r = range(); if (!r) return null;
217
+ const el = elementAt(r.startContainer); if (!el) return null;
218
+ const found = el.closest(match); if (!found) return null;
219
+ const area = el.closest('[data-text-edit]');
220
+ if (area && (found === area || !area.contains(found))) return null;
221
+ return found;
222
+ }
223
+
224
+ const withinArea = (node, found) => {
225
+ if (!found) return false;
226
+ const el = elementAt(node), area = el && el.closest('[data-text-edit]');
227
+ return !area || (found !== area && area.contains(found));
228
+ };
229
+
230
+ // Stops at the editable area. Without that boundary a caret sitting between blocks
231
+ // resolves to the area itself, and a block command replaces the whole editor.
232
+ function blockEl() {
233
+ let n = elementAt(range() && range().startContainer);
234
+ while (n && !areas.has(n)) {
235
+ if (BLOCK.includes(n.tagName)) return n;
236
+ n = n.parentElement;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ // deleteContents can take the whole paragraph with it, leaving the caret loose in
242
+ // the area. Put it back in a block before any command reads it.
243
+ function ensureBlock(area) {
244
+ let b = blockEl();
245
+ if (b) return b;
246
+ b = document.createElement('p');
247
+ const r = range();
248
+ if (r && area.contains(r.startContainer)) r.insertNode(b); else area.appendChild(b);
249
+ caretIn(b, 0);
250
+ return b;
251
+ }
252
+ const blockTag = () => { const b = blockEl(); return b && b.tagName; };
253
+
254
+ function selectNode(node) {
255
+ const s = sel(), r = document.createRange();
256
+ r.selectNodeContents(node); s.removeAllRanges(); s.addRange(r);
257
+ }
258
+
259
+ // Character offset of the caret within a root, and the inverse. Swapping a block
260
+ // element or restoring a history snapshot both rebuild nodes, so the caret has to
261
+ // be addressed by position in the text rather than by node identity.
262
+ function offsetIn(root) {
263
+ const r = range(); if (!r || !root.contains(r.startContainer)) return null;
264
+ const pre = document.createRange();
265
+ pre.selectNodeContents(root);
266
+ pre.setEnd(r.startContainer, r.startOffset);
267
+ return pre.toString().length;
268
+ }
269
+
270
+ function caretIn(root, offset) {
271
+ const s = sel(), r = document.createRange();
272
+ if (offset == null) offset = 0;
273
+ // An empty block accepts a caret only when a <br> holds the line open and the
274
+ // range is anchored on the element. Anchored on an empty text node instead,
275
+ // the browser normalizes the caret outward and the next keystroke lands as a
276
+ // sibling of the block rather than inside it. Verified in Chrome; the <br> is
277
+ // the same placeholder contenteditable inserts itself, and the serializer
278
+ // drops a block that holds only one.
279
+ if (!root.textContent.length) {
280
+ [...root.childNodes].forEach(n => { if (n.nodeType === 3) n.remove(); });
281
+ if (!root.querySelector('br')) root.appendChild(document.createElement('br'));
282
+ r.setStart(root, 0); r.collapse(true);
283
+ s.removeAllRanges(); s.addRange(r);
284
+ return;
285
+ }
286
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
287
+ let seen = 0;
288
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
289
+ if (!n.length) continue;
290
+ if (seen + n.length >= offset) {
291
+ r.setStart(n, Math.max(0, offset - seen)); r.collapse(true);
292
+ s.removeAllRanges(); s.addRange(r);
293
+ return;
294
+ }
295
+ seen += n.length;
296
+ }
297
+ r.setStart(root, root.childNodes.length); r.collapse(true);
298
+ s.removeAllRanges(); s.addRange(r);
299
+ }
300
+
301
+ // Split the boundary text nodes so every node returned lies wholly inside the
302
+ // selection — wrapping node by node never crosses an element boundary, which is
303
+ // what keeps <code> from swallowing half a paragraph.
304
+ function selectedTextNodes() {
305
+ const r = range(); if (!r || r.collapsed) return [];
306
+ if (r.endContainer.nodeType === 3 && r.endOffset < r.endContainer.length) {
307
+ const end = r.endContainer, at = r.endOffset;
308
+ end.splitText(at); r.setEnd(end, end.length);
309
+ }
310
+ if (r.startContainer.nodeType === 3 && r.startOffset > 0) {
311
+ const start = r.startContainer, at = r.startOffset, same = r.endContainer === start, endAt = r.endOffset;
312
+ const tail = start.splitText(at);
313
+ r.setStart(tail, 0);
314
+ if (same) r.setEnd(tail, endAt - at);
315
+ }
316
+ const root = r.commonAncestorContainer;
317
+ const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
318
+ const out = [];
319
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
320
+ if (r.intersectsNode(n) && n.nodeValue && r.comparePoint(n, 0) >= 0 && r.comparePoint(n, n.length) <= 0) out.push(n);
321
+ }
322
+ return out.length ? out : (r.startContainer.nodeType === 3 ? [r.startContainer] : []);
323
+ }
324
+
325
+ /* ---- Inline tags ----
326
+ One generic wrap/unwrap for every inline tag, rather than execCommand's fixed
327
+ handful — that is what lets the command set be the tag set. */
328
+ function wrapInline(tag, style) {
329
+ const nodes = selectedTextNodes(); if (!nodes.length) return [];
330
+ const made = [];
331
+ nodes.forEach(n => {
332
+ if (!n.nodeValue.length) return;
333
+ const el = document.createElement(tag);
334
+ if (style) Object.assign(el.style, style);
335
+ n.parentNode.insertBefore(el, n);
336
+ el.appendChild(n);
337
+ made.push(el);
338
+ el.querySelectorAll(tag).forEach(dupe => dupe.replaceWith(...dupe.childNodes)); // no nesting the same tag
339
+ });
340
+ if (!made.length) return made;
341
+ // Land the boundaries INSIDE the new elements, not around them: everything
342
+ // downstream — active state, setting an href — asks what the caret is in.
343
+ const s = sel(), r = document.createRange(), last = made[made.length - 1];
344
+ r.setStart(made[0], 0); r.setEnd(last, last.childNodes.length);
345
+ s.removeAllRanges(); s.addRange(r);
346
+ return made;
347
+ }
348
+
349
+ function unwrapInline(tag) {
350
+ const nodes = selectedTextNodes();
351
+ const targets = new Set();
352
+ nodes.forEach(n => { const el = elementAt(n) && elementAt(n).closest(tag); if (withinArea(n, el)) targets.add(el); });
353
+ const host = ancestor(tag); if (host) targets.add(host);
354
+ targets.forEach(el => el.replaceWith(...el.childNodes));
355
+ // replaceWith tears down the range, which left the caret parked in front of
356
+ // the text and nothing selected — so turning a style off lost the selection
357
+ // that turning it on had kept. The same text goes back under selection.
358
+ const live = nodes.filter(n => n.isConnected && n.length);
359
+ if (!live.length) return;
360
+ const r = document.createRange();
361
+ r.setStart(live[0], 0);
362
+ r.setEnd(live[live.length - 1], live[live.length - 1].length);
363
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
364
+ }
365
+
366
+ // Whether EVERY part of the selection already carries the tag. Asking the range's
367
+ // start container instead — as this did — gets the wrong answer whenever the
368
+ // selection starts on an element rather than inside the text: a triple-clicked
369
+ // paragraph reports "not bold", so the toggle wraps what was already wrapped and
370
+ // the nesting cleanup quietly undoes it. Which reads as the button doing nothing.
371
+ function selectionMarked(tag) {
372
+ const r = range(); if (!r) return false;
373
+ if (r.collapsed) return !!ancestor(tag);
374
+ const root = r.commonAncestorContainer;
375
+ const walker = document.createTreeWalker(root.nodeType === 1 ? root : root.parentNode, NodeFilter.SHOW_TEXT);
376
+ let any = false;
377
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
378
+ if (!r.intersectsNode(n) || !n.nodeValue.trim()) continue;
379
+ any = true;
380
+ if (!n.parentElement) return false;
381
+ if (!withinArea(n, n.parentElement.closest(tag))) return false;
382
+ }
383
+ return any;
384
+ }
385
+
386
+ const toggleInline = (tag, style) => selectionMarked(tag) ? unwrapInline(tag) : wrapInline(tag, style);
387
+
388
+ /* ---- Block tags ----
389
+ Swapping the element outright covers every block tag, where formatBlock only
390
+ accepts a short list and disagrees between engines. */
391
+ function setBlock(tag) {
392
+ const b = blockEl(); if (!b) return;
393
+ const want = (b.tagName === tag.toUpperCase() ? 'p' : tag).toLowerCase();
394
+ if (b.tagName === want.toUpperCase()) return;
395
+ const n = document.createElement(want);
396
+ n.setAttribute('style', b.getAttribute('style') || '');
397
+ if (!n.getAttribute('style')) n.removeAttribute('style');
398
+ const off = offsetIn(b);
399
+ while (b.firstChild) n.appendChild(b.firstChild);
400
+ b.replaceWith(n);
401
+ caretIn(n, off);
402
+ }
403
+
404
+ const styleBlock = (prop, value) => { const b = blockEl(); if (b) b.style[prop] = value; };
405
+ const readBlockStyle = (prop) => { const b = blockEl(); return b ? getComputedStyle(b)[prop] : ''; };
406
+
407
+ /* ---- Lists, tables, indent ---- */
408
+ const inList = () => !!ancestor('li');
409
+ const inTable = () => !!cellAt(); // caret in a cell, or a block picked by pointer
410
+
411
+ /* ---- Tables ----
412
+ A table is a small spreadsheet inside a document, and it needs the operations
413
+ one has: rows and columns added and removed where the caret is, cells merged
414
+ and split, and arrows that move the way the grid looks rather than the way the
415
+ markup is ordered. */
416
+ let cellDrag = null; // a rectangle of cells picked with the pointer
417
+ // A block of cells selected by pointer has no caret in it, so the table commands
418
+ // ask for either — otherwise selecting empty cells and pressing Merge does
419
+ // nothing, which is exactly how it looked.
420
+ const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
421
+ const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
422
+ const rowsOf = (table) => [...table.rows];
423
+ const emptyCell = () => { const td = document.createElement('td'); td.appendChild(document.createElement('br')); return td; };
424
+
425
+ /* The occupancy grid: every slot maps to the cell covering it, so a cell that
426
+ spans rows is present in each row it covers. Counting children instead — as
427
+ this did — loses track the moment anything is merged, which is how rows end up
428
+ with different lengths and cells appear to go missing. */
429
+ function tableGrid(table) {
430
+ const grid = [];
431
+ rowsOf(table).forEach((row, r) => {
432
+ grid[r] = grid[r] || [];
433
+ let c = 0;
434
+ for (const cell of row.cells) {
435
+ while (grid[r][c]) c++;
436
+ for (let dr = 0; dr < (cell.rowSpan || 1); dr++) {
437
+ grid[r + dr] = grid[r + dr] || [];
438
+ for (let dc = 0; dc < (cell.colSpan || 1); dc++) grid[r + dr][c + dc] = cell;
439
+ }
440
+ c += cell.colSpan || 1;
441
+ }
442
+ });
443
+ return grid;
444
+ }
445
+
446
+ function gridPosition(cell) {
447
+ const table = cell.closest('table'), grid = tableGrid(table);
448
+ for (let r = 0; r < grid.length; r++) {
449
+ for (let c = 0; c < (grid[r] || []).length; c++) if (grid[r][c] === cell) return { row: r, col: c, grid };
450
+ }
451
+ return { row: 0, col: 0, grid };
452
+ }
453
+
454
+ const cellCovering = (row, col) => {
455
+ const table = row.closest('table'), grid = tableGrid(table);
456
+ const r = rowsOf(table).indexOf(row);
457
+ return (grid[r] || [])[col] || row.lastElementChild;
458
+ };
459
+
460
+ const columnCount = (table) => Math.max(0, ...tableGrid(table).map(r => r.length));
461
+
462
+ // Rows left short by an edit are padded back out, which is what repairs a table
463
+ // whose cells look like they went missing — there is no way to click into a slot
464
+ // that does not exist, so it cannot be fixed by hand.
465
+ function normalizeTables(root) {
466
+ root.querySelectorAll('table').forEach(table => {
467
+ const rows = rowsOf(table); if (!rows.length) return;
468
+ const grid = tableGrid(table);
469
+ const width = Math.max(0, ...grid.map(r => r.length));
470
+ rows.forEach((row, r) => {
471
+ const have = (grid[r] || []).filter(Boolean).length;
472
+ for (let i = have; i < width; i++) row.appendChild(emptyCell());
473
+ });
474
+ if (!table.querySelector('tbody, thead')) { // stray rows outside a section
475
+ const body = document.createElement('tbody');
476
+ while (table.firstChild) body.appendChild(table.firstChild);
477
+ table.appendChild(body);
478
+ }
479
+ });
480
+ }
481
+
482
+ function addRow(where) {
483
+ const cell = cellAt(); if (!cell) return;
484
+ const row = cell.parentElement, table = row.closest('table');
485
+ const fresh = document.createElement('tr');
486
+ for (let i = 0; i < columnCount(table); i++) fresh.appendChild(emptyCell());
487
+ if (where < 0) row.before(fresh); else row.after(fresh);
488
+ caretIn(fresh.firstElementChild, 0);
489
+ }
490
+
491
+ function addColumn(where) {
492
+ const cell = cellAt(); if (!cell) return;
493
+ const table = cell.closest('table'), { col, grid } = gridPosition(cell);
494
+ const seen = new Set();
495
+ rowsOf(table).forEach((row, r) => {
496
+ const at = (grid[r] || [])[col];
497
+ if (!at || seen.has(at)) return; // a row-spanning cell is handled once
498
+ seen.add(at);
499
+ const fresh = document.createElement(at.tagName === 'TH' ? 'th' : 'td');
500
+ fresh.appendChild(document.createElement('br'));
501
+ if (where < 0) at.before(fresh); else at.after(fresh);
502
+ });
503
+ normalizeTables(table.parentElement || table);
504
+ caretIn(cellCovering(rowsOf(table)[gridPosition(cell).row], where < 0 ? col : col + 1), 0);
505
+ }
506
+
507
+ function removeRow() {
508
+ const cell = cellAt(); if (!cell) return;
509
+ const row = cell.parentElement, table = row.closest('table');
510
+ if (rowsOf(table).length <= 1) return removeTable();
511
+ const next = row.nextElementSibling || row.previousElementSibling;
512
+ row.remove();
513
+ caretIn(next.firstElementChild, 0);
514
+ }
515
+
516
+ function removeColumn() {
517
+ const cell = cellAt(); if (!cell) return;
518
+ const table = cell.closest('table'), { col, grid } = gridPosition(cell);
519
+ if (columnCount(table) <= 1) return removeTable();
520
+ const seen = new Set();
521
+ grid.forEach(row => { const at = row[col]; if (!at || seen.has(at)) return; seen.add(at); at.remove(); });
522
+ normalizeTables(table.parentElement || table);
523
+ caretIn(cellCovering(rowsOf(table)[0], Math.max(0, col - 1)), 0);
524
+ }
525
+
526
+ function removeTable() {
527
+ const table = tableAt(); if (!table) return;
528
+ const p = document.createElement('p');
529
+ table.replaceWith(p);
530
+ caretIn(p, 0);
531
+ }
532
+
533
+ // Merge takes the cells the selection touches; with a collapsed caret it takes
534
+ // the one to the right, which is what a single "merge" press usually means.
535
+ // A merge covers a RECTANGLE, so the spans come from the block's bounds — summing
536
+ // the cells' own spans turns a 2x2 selection into one cell four rows tall.
537
+ function mergeCells() {
538
+ const cell = cellAt(); if (!cell) return;
539
+ const picked = selectedCells();
540
+ // With nothing selected, merge with the neighbour to the right — the reading
541
+ // of a single "merge" press, and the only one available in an empty cell.
542
+ const cells = picked.length > 1 ? picked : [cell, nextCell(cell)].filter(Boolean);
543
+ if (cells.length < 2) return;
544
+
545
+ const at = cells.map(gridPosition);
546
+ const r0 = Math.min(...at.map(p => p.row));
547
+ const r1 = Math.max(...at.map((p, i) => p.row + (cells[i].rowSpan || 1) - 1));
548
+ const c0 = Math.min(...at.map(p => p.col));
549
+ const c1 = Math.max(...at.map((p, i) => p.col + (cells[i].colSpan || 1) - 1));
550
+
551
+ const grid = at[0].grid;
552
+ const first = (grid[r0] || [])[c0] || cells[0];
553
+ const cols = c1 - c0 + 1, rows = r1 - r0 + 1;
554
+ if (cols > 1) first.colSpan = cols; else first.removeAttribute('colspan');
555
+ if (rows > 1) first.rowSpan = rows; else first.removeAttribute('rowspan');
556
+
557
+ cells.forEach(c => {
558
+ if (c === first) return;
559
+ if (c.textContent.trim()) first.append(' ', ...c.childNodes);
560
+ c.remove();
561
+ });
562
+ tidyCell(first);
563
+ const table = first.closest('table');
564
+ normalizeTables(table.parentElement || table);
565
+ cellDrag = null; // the block has become one cell
566
+ paintCells(first.closest('[data-text-edit]') || table);
567
+ caretIn(first, first.textContent.length);
568
+ }
569
+
570
+ const nextCell = (cell) => {
571
+ const { row, col, grid } = gridPosition(cell);
572
+ const span = cell.colSpan || 1;
573
+ return (grid[row] || [])[col + span] || null;
574
+ };
575
+
576
+ // Joining cells leaves the placeholder breaks of the empty ones behind.
577
+ const tidyCell = (cell) => {
578
+ if (!cell.textContent.trim()) { cell.replaceChildren(document.createElement('br')); return; }
579
+ cell.querySelectorAll(':scope > br').forEach(br => br.remove());
580
+ };
581
+
582
+ function splitCell() {
583
+ const cell = cellAt(); if (!cell) return;
584
+ const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
585
+ if (cols < 2 && rows < 2) return;
586
+ cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
587
+ for (let i = 1; i < cols; i++) cell.after(emptyCell());
588
+ if (rows > 1) {
589
+ const table = cell.closest('table'), { row, col } = gridPosition(cell);
590
+ for (let r = 1; r < rows; r++) {
591
+ const target = rowsOf(table)[row + r]; if (!target) break;
592
+ const at = cellCovering(target, col);
593
+ const fresh = emptyCell();
594
+ if (at) at.before(fresh); else target.appendChild(fresh);
595
+ }
596
+ }
597
+ normalizeTables(cell.closest('table').parentElement || cell.closest('table'));
598
+ caretIn(cell, 0);
599
+ }
600
+
601
+ /* An empty cell holds no text, so a text selection can neither reach one nor
602
+ show one. Worse, dragging INTO a trailing empty cell leaves the selection's
603
+ focus clamped to the last position that holds text — which is why the final
604
+ empty cell was always the one left unhighlighted. The pointer is tracked
605
+ instead, and its cell is the far corner. */
606
+
607
+ const cellUnder = (x, y) => {
608
+ const el = document.elementFromPoint(x, y);
609
+ const cell = el && el.closest && el.closest('td, th');
610
+ return cell && cell.closest('[data-text-edit]') ? cell : null;
611
+ };
612
+
613
+ function rectBetween(a, b) {
614
+ if (!a || !b) return [];
615
+ const pa = gridPosition(a), pb = gridPosition(b), grid = pa.grid;
616
+ const r0 = Math.min(pa.row, pb.row), r1 = Math.max(pa.row, pb.row);
617
+ const c0 = Math.min(pa.col, pb.col), c1 = Math.max(pa.col, pb.col);
618
+ const out = [];
619
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {
620
+ const cell = (grid[r] || [])[c];
621
+ if (cell && !out.includes(cell)) out.push(cell);
622
+ }
623
+ return out;
624
+ }
625
+
626
+ function selectedCells() {
627
+ if (cellDrag && cellDrag.anchor !== cellDrag.focus && cellDrag.anchor.isConnected && cellDrag.focus.isConnected) {
628
+ return rectBetween(cellDrag.anchor, cellDrag.focus);
629
+ }
630
+ const table = tableAt(); if (!table) return [];
631
+ const s = sel(); if (!s || !s.anchorNode || !s.focusNode) return [];
632
+ const from = elementAt(s.anchorNode), to = elementAt(s.focusNode);
633
+ const a = from && from.closest('td, th'), b = to && to.closest('td, th');
634
+ if (!a || !b || a === b) return [];
635
+ return rectBetween(a, b);
636
+ }
637
+
638
+ function toggleHeaderRow() {
639
+ const table = tableAt(); if (!table) return;
640
+ const first = rowsOf(table)[0]; if (!first) return;
641
+ const isHeader = first.firstElementChild && first.firstElementChild.tagName === 'TH';
642
+ [...first.children].forEach(c => {
643
+ const next = document.createElement(isHeader ? 'td' : 'th');
644
+ while (c.firstChild) next.appendChild(c.firstChild);
645
+ if (c.colSpan > 1) next.colSpan = c.colSpan;
646
+ if (c.rowSpan > 1) next.rowSpan = c.rowSpan;
647
+ c.replaceWith(next);
648
+ });
649
+ // A <thead> is a child of the table, never of the tbody. Inserted before the
650
+ // row it would land inside tbody, which is invalid — and table.rows then
651
+ // reports one row, so the next toggle rewrites the wrong one.
652
+ const head = table.querySelector('thead');
653
+ if (!isHeader && !head) {
654
+ const t = document.createElement('thead');
655
+ table.prepend(t);
656
+ t.appendChild(first);
657
+ }
658
+ if (isHeader && head) {
659
+ let body = table.querySelector('tbody');
660
+ if (!body) { body = document.createElement('tbody'); table.appendChild(body); }
661
+ body.prepend(first);
662
+ head.remove();
663
+ }
664
+ // Re-parenting the row detaches the caret, and without it the command reads
665
+ // as unavailable the next time — the header could be turned on but not off.
666
+ caretIn(table.rows[0].firstElementChild, 0);
667
+ }
668
+
669
+ /* ---- Column and row sizing ----
670
+ Dragging a cell border, the way a spreadsheet does. Widths live on a <colgroup>
671
+ rather than on the first row's cells, so a merged or spanning cell in row one
672
+ cannot throw them off; heights live on the row. */
673
+ const EDGE = 5;
674
+
675
+ function ensureCols(table) {
676
+ let group = table.querySelector(':scope > colgroup');
677
+ if (!group) { group = document.createElement('colgroup'); table.prepend(group); }
678
+ const want = columnCount(table);
679
+ while (group.children.length < want) group.appendChild(document.createElement('col'));
680
+ while (group.children.length > want) group.lastElementChild.remove();
681
+ // Freeze what is there now, so dragging one border does not reflow the rest.
682
+ const widths = [...table.rows[0].cells];
683
+ [...group.children].forEach((col, i) => {
684
+ if (col.style.width) return;
685
+ const at = (tableGrid(table)[0] || [])[i];
686
+ if (at) col.style.width = Math.round(at.getBoundingClientRect().width / (at.colSpan || 1)) + 'px';
687
+ void widths;
688
+ });
689
+ return group;
690
+ }
691
+
692
+ // Which border, if any, the pointer is over.
693
+ function borderAt(x, y) {
694
+ const el = document.elementFromPoint(x, y);
695
+ let cell = el && el.closest && el.closest('td, th');
696
+ if (!cell) {
697
+ // Aiming at the outer edge puts the pointer past the last cell — on the
698
+ // table if you are exact, on the page behind it if you overshoot. Both
699
+ // are where people aim, so both count.
700
+ const scope = el && el.closest && (el.closest('table') || el.closest('[data-text-edit]'));
701
+ if (!scope) return null;
702
+ const tables = scope.tagName === 'TABLE' ? [scope] : [...scope.querySelectorAll('table')];
703
+ for (const table of tables) {
704
+ const box = table.getBoundingClientRect();
705
+ const row = [...table.rows].find(rw => { const b = rw.getBoundingClientRect(); return y >= b.top - EDGE && y <= b.bottom + EDGE; });
706
+ if (!row || !row.cells.length) continue;
707
+ if (Math.abs(x - box.right) <= EDGE) return { axis: 'x', cell: row.cells[row.cells.length - 1] };
708
+ if (Math.abs(y - box.bottom) <= EDGE && x >= box.left && x <= box.right) return { axis: 'y', cell: row.cells[0] };
709
+ }
710
+ return null;
711
+ }
712
+ if (!cell.closest('[data-text-edit]')) return null;
713
+ const b = cell.getBoundingClientRect();
714
+ if (Math.abs(x - b.right) <= EDGE) return { axis: 'x', cell };
715
+ if (Math.abs(x - b.left) <= EDGE) { const prev = previousCell(cell); return prev ? { axis: 'x', cell: prev } : null; }
716
+ if (Math.abs(y - b.bottom) <= EDGE) return { axis: 'y', cell };
717
+ if (Math.abs(y - b.top) <= EDGE) { const row = cell.parentElement.previousElementSibling; return row ? { axis: 'y', cell: row.cells[0] } : null; }
718
+ return null;
719
+ }
720
+
721
+ function startResize(e, hit) {
722
+ const cell = hit.cell, table = cell.closest('table');
723
+ const area = cell.closest('[data-text-edit]');
724
+ e.preventDefault();
725
+ area.setAttribute('data-text-edit-resizing', hit.axis);
726
+
727
+ let apply;
728
+ if (hit.axis === 'x') {
729
+ const group = ensureCols(table);
730
+ const { col } = gridPosition(cell);
731
+ const index = Math.min(col + (cell.colSpan || 1) - 1, group.children.length - 1);
732
+ const target = group.children[index];
733
+ const start = parseFloat(target.style.width) || cell.getBoundingClientRect().width;
734
+ const from = e.clientX;
735
+ // The rightmost border is the table's own edge. Sizing only the column
736
+ // there does nothing visible — the table still fills its width and the
737
+ // other columns simply absorb the difference — so the table is sized too.
738
+ const outer = index === group.children.length - 1;
739
+ const tableStart = table.getBoundingClientRect().width;
740
+ if (outer) table.style.width = Math.round(tableStart) + 'px';
741
+ apply = (ev) => {
742
+ const delta = ev.clientX - from;
743
+ target.style.width = Math.max(28, Math.round(start + delta)) + 'px';
744
+ if (outer) table.style.width = Math.max(60, Math.round(tableStart + delta)) + 'px';
745
+ };
746
+ } else {
747
+ const row = cell.parentElement;
748
+ const start = row.getBoundingClientRect().height, from = e.clientY;
749
+ apply = (ev) => { row.style.height = Math.max(20, Math.round(start + ev.clientY - from)) + 'px'; };
750
+ }
751
+
752
+ const move = (ev) => { ev.preventDefault(); apply(ev); };
753
+ const up = () => {
754
+ document.removeEventListener('pointermove', move);
755
+ document.removeEventListener('pointerup', up);
756
+ area.removeAttribute('data-text-edit-resizing');
757
+ area.dispatchEvent(new Event('input', { bubbles: true }));
758
+ };
759
+ document.addEventListener('pointermove', move);
760
+ document.addEventListener('pointerup', up);
761
+ }
762
+
763
+ // Tab walks the grid and grows it at the end, the way every editor's tables do.
764
+ function moveCell(dir) {
765
+ const cell = cellAt(); if (!cell) return false;
766
+ const cells = [...cell.closest('table').querySelectorAll('td, th')];
767
+ let next = cells[cells.indexOf(cell) + dir];
768
+ if (!next && dir > 0) {
769
+ const row = cell.closest('tr'), fresh = row.cloneNode(true);
770
+ [...fresh.children].forEach(c => { c.innerHTML = '<br>'; c.removeAttribute('colspan'); c.removeAttribute('rowspan'); });
771
+ row.parentNode.appendChild(fresh);
772
+ next = fresh.children[0];
773
+ }
774
+ if (!next) return false;
775
+ caretIn(next, 0);
776
+ return true;
777
+ }
778
+
779
+ // Where a caret would land at a point, whichever API the engine offers.
780
+ function caretAtPoint(x, y) {
781
+ if (document.caretPositionFromPoint) {
782
+ const pos = document.caretPositionFromPoint(x, y);
783
+ return pos ? { node: pos.offsetNode, offset: pos.offset } : null;
784
+ }
785
+ if (document.caretRangeFromPoint) {
786
+ const r = document.caretRangeFromPoint(x, y);
787
+ return r ? { node: r.startContainer, offset: r.startOffset } : null;
788
+ }
789
+ return null;
790
+ }
791
+
792
+ function placeAtPoint(x, y) {
793
+ const hit = caretAtPoint(x, y); if (!hit) return null;
794
+ const r = document.createRange();
795
+ try { r.setStart(hit.node, hit.offset); } catch { return null; }
796
+ r.collapse(true);
797
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
798
+ return hit;
799
+ }
800
+
801
+ const cellOf = (node) => { const el = elementAt(node); return el && el.closest('td, th'); };
802
+
803
+ /* Vertical arrows move a line at a time, and only leave the cell once the caret
804
+ is on its last or first line — a cell holding several lines has to be walkable
805
+ like any other text. Horizontal arrows cross the cell's text first.
806
+
807
+ Both aim at a point rather than an offset: stepping one line from where the
808
+ caret actually is keeps the column, which is what makes returning to a cell
809
+ land under where you left rather than at an arbitrary end. */
810
+ function arrowInTable(e) {
811
+ const cell = cellAt(); if (!cell) return false;
812
+ const r = range(); if (!r || !r.collapsed) return false;
813
+ const here = r.getBoundingClientRect();
814
+
815
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
816
+ const at = offsetIn(cell), len = cell.textContent.length;
817
+ if (e.key === 'ArrowRight' && at < len) return false;
818
+ if (e.key === 'ArrowLeft' && at > 0) return false;
819
+ const next = e.key === 'ArrowRight' ? nextCell(cell) : previousCell(cell);
820
+ if (!next) return e.key === 'ArrowRight' ? moveCell(1) : moveCell(-1);
821
+ caretIn(next, e.key === 'ArrowRight' ? 0 : next.textContent.length);
822
+ return true;
823
+ }
824
+
825
+ const down = e.key === 'ArrowDown';
826
+ const line = here.height || parseFloat(getComputedStyle(cell).lineHeight) || 16;
827
+ const x = here.left;
828
+ const probeY = down ? here.bottom + line * 0.5 : here.top - line * 0.5;
829
+
830
+ // Landing in the same cell is not enough to mean there is another line — a
831
+ // point just past the caret is still inside the cell's own padding. Only a
832
+ // position on a DIFFERENT line means there is somewhere left to go.
833
+ const ahead = caretAtPoint(x, probeY);
834
+ if (ahead && cellOf(ahead.node) === cell) {
835
+ const probe = document.createRange();
836
+ let onAnotherLine = false;
837
+ try {
838
+ probe.setStart(ahead.node, ahead.offset); probe.collapse(true);
839
+ const box = probe.getBoundingClientRect();
840
+ onAnotherLine = !!box.height && Math.abs(box.top - here.top) > 2;
841
+ } catch { }
842
+ if (onAnotherLine) return false;
843
+ }
844
+
845
+ const table = cell.closest('table'), { row, col, grid } = gridPosition(cell);
846
+ const targetRow = row + (down ? (cell.rowSpan || 1) : -1);
847
+ const next = (grid[targetRow] || [])[col];
848
+ if (!next) return escapeTable(table, down);
849
+
850
+ // Land under the column the caret was in, not at an end of the target.
851
+ const box = next.getBoundingClientRect();
852
+ const aimY = down ? box.top + line * 0.5 : box.bottom - line * 0.5;
853
+ if (placeAtPoint(Math.min(Math.max(x, box.left + 2), box.right - 2), aimY)) return true;
854
+ caretIn(next, down ? 0 : next.textContent.length);
855
+ return true;
856
+ }
857
+
858
+ const previousCell = (cell) => {
859
+ const { row, col, grid } = gridPosition(cell);
860
+ for (let c = col - 1; c >= 0; c--) if ((grid[row] || [])[c] && grid[row][c] !== cell) return grid[row][c];
861
+ return null;
862
+ };
863
+
864
+ // A table at the very end of a document has nothing after it to click into.
865
+ function escapeTable(table, down) {
866
+ const sibling = down ? table.nextElementSibling : table.previousElementSibling;
867
+ if (sibling) { caretIn(sibling, down ? 0 : sibling.textContent.length); return true; }
868
+ if (!down) return false;
869
+ const p = document.createElement('p');
870
+ table.after(p);
871
+ caretIn(p, 0);
872
+ return true;
873
+ }
874
+
875
+ // Built by hand for the same reason as indent: execCommand('insertUnorderedList')
876
+ // puts the <ul> *inside* the paragraph rather than replacing it, which is invalid
877
+ // and silently loses the list on the next serialize.
878
+ function retagList(list, tag) {
879
+ const n = document.createElement(tag);
880
+ while (list.firstChild) n.appendChild(list.firstChild);
881
+ list.replaceWith(n);
882
+ return n;
883
+ }
884
+
885
+ function unwrapItem(li) {
886
+ const list = li.closest('ul, ol'), off = offsetIn(li);
887
+ const p = document.createElement('p');
888
+ [...li.childNodes].forEach(n => { if (n.tagName !== 'INPUT') p.appendChild(n); });
889
+ const after = [...list.children].slice([...list.children].indexOf(li) + 1);
890
+ list.parentNode.insertBefore(p, list.nextSibling);
891
+ if (after.length) { // items below carry on in their own list
892
+ const tail = document.createElement(list.tagName.toLowerCase());
893
+ if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
894
+ after.forEach(n => tail.appendChild(n));
895
+ p.parentNode.insertBefore(tail, p.nextSibling);
896
+ }
897
+ li.remove();
898
+ if (!list.children.length) list.remove();
899
+ caretIn(p, off);
900
+ }
901
+
902
+ function toggleList(tag) {
903
+ const li = ancestor('li');
904
+ if (li) {
905
+ const list = li.closest('ul, ol');
906
+ if (list.tagName.toLowerCase() === tag) return unwrapItem(li);
907
+ const next = retagList(list, tag);
908
+ next.removeAttribute('data-checklist');
909
+ next.querySelectorAll('input[type=checkbox]').forEach(b => b.remove());
910
+ caretIn(next.querySelector('li') || next, 0);
911
+ return;
912
+ }
913
+ const b = blockEl(); if (!b) return;
914
+ const off = offsetIn(b);
915
+ const list = document.createElement(tag), item = document.createElement('li');
916
+ while (b.firstChild) item.appendChild(b.firstChild);
917
+ list.appendChild(item);
918
+ b.replaceWith(list);
919
+ caretIn(item, off);
920
+ }
921
+
922
+ function toggleChecklist() {
923
+ if (!ancestor('li')) toggleList('ul');
924
+ const item = ancestor('li'); if (!item) return;
925
+ const list = item.closest('ul, ol'); if (!list) return;
926
+ const on = !list.hasAttribute('data-checklist');
927
+ list.toggleAttribute('data-checklist', on);
928
+ [...list.children].forEach(row => {
929
+ const box = row.querySelector(':scope > input[type=checkbox]');
930
+ if (on && !box) { const b = document.createElement('input'); b.type = 'checkbox'; row.insertBefore(b, row.firstChild); }
931
+ if (!on && box) box.remove();
932
+ });
933
+ // Offset 0 of the item is *before* the checkbox, so typing would land there.
934
+ const box = item.querySelector(':scope > input[type=checkbox]');
935
+ if (!box) return caretIn(item, 0);
936
+ if (!item.textContent.length && !item.querySelector('br')) item.appendChild(document.createElement('br'));
937
+ const sl = sel(), r = document.createRange();
938
+ r.setStartAfter(box); r.collapse(true);
939
+ sl.removeAllRanges(); sl.addRange(r);
940
+ }
941
+
942
+ // execCommand('indent') nests by making a list a direct child of a list, which is
943
+ // invalid — the sublist belongs inside the preceding item. Left alone it costs the
944
+ // whole nested branch on the next serialize. Cheap enough to run after any command.
945
+ function normalizeLists(root) {
946
+ root.querySelectorAll(':is(ul, ol) > :is(ul, ol)').forEach(nested => {
947
+ const prev = nested.previousElementSibling;
948
+ if (prev && prev.tagName === 'LI') return prev.appendChild(nested);
949
+ const li = document.createElement('li');
950
+ nested.parentNode.insertBefore(li, nested);
951
+ li.appendChild(nested);
952
+ });
953
+ // Enter inside a task item clones the row's contents in whatever order the
954
+ // engine feels like, so the box can land after the text or twice over.
955
+ root.querySelectorAll('[data-checklist] > li').forEach(li => {
956
+ const boxes = [...li.querySelectorAll(':scope > input[type=checkbox]')];
957
+ boxes.slice(1).forEach(b => b.remove());
958
+ const box = boxes[0] || Object.assign(document.createElement('input'), { type: 'checkbox' });
959
+ if (li.firstChild !== box) li.insertBefore(box, li.firstChild);
960
+ if (li.textContent.length) li.querySelectorAll(':scope > br').forEach(b => b.remove());
961
+ });
962
+ }
963
+
964
+ // Moving the item ourselves rather than calling execCommand('indent'): the browser
965
+ // builds an empty parent item on the second level and sprinkles colour spans on
966
+ // outdent. This also caps nesting at one level deeper than the item above, which
967
+ // is what every list editor does.
968
+ function moveItem(by) {
969
+ const li = ancestor('li'); if (!li) return;
970
+ const list = li.parentElement;
971
+ if (by > 0) {
972
+ const prev = li.previousElementSibling;
973
+ if (!prev || prev.tagName !== 'LI') return; // nothing to nest under
974
+ let sub = prev.querySelector(':scope > ul, :scope > ol');
975
+ if (!sub) { sub = document.createElement(list.tagName.toLowerCase()); prev.appendChild(sub); }
976
+ sub.appendChild(li);
977
+ } else {
978
+ const parentItem = list.parentElement;
979
+ if (!parentItem || parentItem.tagName !== 'LI') return; // already top level
980
+ parentItem.parentElement.insertBefore(li, parentItem.nextSibling);
981
+ if (!list.children.length) list.remove();
982
+ }
983
+ }
984
+
985
+ // Enabled only where the command would actually move something — a control that
986
+ // is lit but inert is the same bug as one that is missing.
987
+ function canIndent(area) {
988
+ const li = ancestor('li');
989
+ if (li) return !!li.previousElementSibling; // nothing to nest under otherwise
990
+ return area._te.mode === 'html';
991
+ }
992
+
993
+ function canOutdent(area) {
994
+ const li = ancestor('li');
995
+ if (li) return li.closest('ul, ol').parentElement.tagName === 'LI';
996
+ const b = blockEl();
997
+ return area._te.mode === 'html' && !!b && parseFloat(b.style.marginInlineStart) > 0;
998
+ }
999
+
1000
+ const STEP = 2; // rem per indent level
1001
+ // Nesting a list item is markdown; indenting a paragraph is a CSS margin, which
1002
+ // markdown cannot carry — so the second half only runs where it would survive.
1003
+ function indent(by, area) {
1004
+ if (inList()) return moveItem(by);
1005
+ if (area && area._te.mode !== 'html') return;
1006
+ const b = blockEl(); if (!b) return;
1007
+ const now = parseFloat(b.style.marginInlineStart) || 0;
1008
+ const next = Math.max(0, now + by * STEP);
1009
+ if (next) b.style.marginInlineStart = next + 'rem'; else b.style.removeProperty('margin-inline-start');
1010
+ }
1011
+
1012
+ /* ---- Links ----
1013
+ An <input x-text-edit.a> is the whole define / edit / clear surface: it shows
1014
+ the current href, sets it on change, and clears the link when emptied. */
1015
+ function setLink(href) {
1016
+ const existing = ancestor('a');
1017
+ const url = href == null ? '' : String(href).trim();
1018
+ if (!url) { if (existing) existing.replaceWith(...existing.childNodes); return; }
1019
+ if (/^\s*javascript:/i.test(url)) return;
1020
+ if (existing) { existing.setAttribute('href', url); return; }
1021
+ const made = wrapInline('a');
1022
+ if (made.length) made.forEach(a => a.setAttribute('href', url));
1023
+ else insertHTML(`<a href="${url}">${url}</a>`); // nothing selected — drop the URL in as its own link
1024
+ }
1025
+ const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
1026
+
1027
+ /* ---- Insertions ---- */
1028
+ function insertHTML(html) { document.execCommand('insertHTML', false, html); }
1029
+ const insertImage = (src, alt) => { if (src && !/^\s*javascript:/i.test(src)) insertHTML(`<img src="${src}" alt="${alt || ''}">`); };
1030
+
1031
+ function insertTable(spec) {
1032
+ const [rows, cols] = String(spec || '3x3').split(/[x×,]/).map(n => Math.max(1, Math.min(20, parseInt(n, 10) || 3)));
1033
+ const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
1034
+ insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1035
+ }
1036
+
1037
+ // A table, image or rule sitting last leaves nowhere to put the caret after it —
1038
+ // there is no line to click on, and no key that reaches past the block.
1039
+ function ensureTrailingBlock(area) {
1040
+ const last = area.lastElementChild;
1041
+ if (!last || !/^(TABLE|HR|FIGURE|PRE|UL|OL|BLOCKQUOTE|DL)$/.test(last.tagName)) return false;
1042
+ const p = document.createElement('p');
1043
+ p.appendChild(document.createElement('br'));
1044
+ area.appendChild(p);
1045
+ return true;
1046
+ }
1047
+
1048
+ function clearFormatting() {
1049
+ selectedTextNodes().forEach(n => {
1050
+ let el = elementAt(n);
1051
+ while (el && !BLOCK.includes(el.tagName) && el.getAttribute && el.hasAttribute('contenteditable') === false) el = el.parentElement;
1052
+ });
1053
+ Object.keys(INLINE_TAGS).forEach(unwrapInline);
1054
+ unwrapInline('a');
1055
+ }
1056
+
1057
+ /* ---- Command table ----
1058
+ Every inline and block tag is a command named for itself; the rest are the
1059
+ operations that have no tag of their own. `md` means markdown can carry it. */
1060
+ /* ---- Page-level styling ----
1061
+ Document-wide font, size, colour and so on are presentation, not content — the
1062
+ stored value is the area's markup, and burying a wrapper in it to carry a font
1063
+ would be a lie about what the document says. They are written as custom
1064
+ properties on the area instead, which the stylesheet consumes and $text.page
1065
+ hands back as a plain object for the author to persist beside the document. */
1066
+ const PAGE_VARS = {
1067
+ font: '--text-edit-font', size: '--text-edit-size', leading: '--text-edit-leading',
1068
+ align: '--text-edit-align', color: '--text-edit-color', background: '--text-edit-background'
1069
+ };
1070
+ const pageStyle = (area) => Object.fromEntries(Object.entries(PAGE_VARS)
1071
+ .map(([k, v]) => [k, area.style.getPropertyValue(v).trim()]).filter(([, v]) => v));
1072
+ const setPageStyle = (area, values) => Object.entries(PAGE_VARS).forEach(([k, v]) => {
1073
+ const next = values && values[k];
1074
+ if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
1075
+ });
1076
+
1077
+ const COMMANDS = {};
1078
+ Object.entries(INLINE_TAGS).forEach(([tag, meta]) => {
1079
+ COMMANDS[tag] = {
1080
+ md: meta.md,
1081
+ run: (v, area) => hasSelection() ? toggleInline(tag) : armPending(area, tag),
1082
+ active: (v, area) => selectionMarked(tag) || pendingHas(area, tag)
1083
+ };
1084
+ });
1085
+ Object.entries(BLOCK_TAGS).forEach(([tag, meta]) => {
1086
+ COMMANDS[tag] = { md: meta.md, block: 1, run: () => setBlock(tag), active: () => blockTag() === tag.toUpperCase() };
1087
+ });
1088
+
1089
+ // A <figure> is a block WITH a caption — retagging a paragraph to one looks like
1090
+ // nothing happened, which is exactly what it did before.
1091
+ COMMANDS.figure = {
1092
+ block: 1,
1093
+ active: () => !!ancestor('figure'),
1094
+ run: () => {
1095
+ const existing = ancestor('figure');
1096
+ if (existing) { // unwrap, dropping the caption
1097
+ const caption = existing.querySelector('figcaption');
1098
+ if (caption) caption.remove();
1099
+ existing.replaceWith(...existing.childNodes);
1100
+ return;
1101
+ }
1102
+ const b = blockEl(); if (!b) return;
1103
+ const fig = document.createElement('figure');
1104
+ b.replaceWith(fig);
1105
+ fig.appendChild(b);
1106
+ const caption = document.createElement('figcaption');
1107
+ caption.appendChild(document.createElement('br'));
1108
+ fig.appendChild(caption);
1109
+ caretIn(caption, 0);
1110
+ }
1111
+ };
1112
+ Object.assign(COMMANDS, {
1113
+ a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: () => !!ancestor('a') || hasSelection() },
1114
+ ul: { md: 1, block: 1, run: () => toggleList('ul'), active: () => !!ancestor('ul') && !ancestor('ul').hasAttribute('data-checklist') },
1115
+ ol: { md: 1, block: 1, run: () => toggleList('ol'), active: () => !!ancestor('ol') },
1116
+ checklist: { md: 1, block: 1, run: toggleChecklist, active: () => !!ancestor('ul[data-checklist]') },
1117
+ hr: { md: 1, block: 1, run: () => insertHTML('<hr><p><br></p>'), active: () => false },
1118
+ img: { md: 1, run: (v) => insertImage(v), active: () => false },
1119
+ br: { md: 1, run: () => insertHTML('<br>'), active: () => false },
1120
+
1121
+ table: { block: 1, run: (v) => insertTable(v), active: () => false },
1122
+ 'row-before': { block: 1, run: () => addRow(-1), active: () => false, enabled: () => inTable() },
1123
+ 'row-after': { block: 1, run: () => addRow(1), active: () => false, enabled: () => inTable() },
1124
+ 'row-remove': { block: 1, run: removeRow, active: () => false, enabled: () => inTable() },
1125
+ 'column-before': { block: 1, run: () => addColumn(-1), active: () => false, enabled: () => inTable() },
1126
+ 'column-after': { block: 1, run: () => addColumn(1), active: () => false, enabled: () => inTable() },
1127
+ 'column-remove': { block: 1, run: removeColumn, active: () => false, enabled: () => inTable() },
1128
+ 'table-remove': { block: 1, run: removeTable, active: () => false, enabled: () => inTable() },
1129
+ 'table-header': { block: 1, run: toggleHeaderRow, active: () => !!(tableAt() && tableAt().querySelector('th')), enabled: () => inTable() },
1130
+ merge: { block: 1, run: mergeCells, active: () => false, enabled: () => inTable() },
1131
+ split: { block: 1, run: splitCell, active: () => false, enabled: () => { const c = cellAt(); return !!c && ((c.colSpan || 1) > 1 || (c.rowSpan || 1) > 1); } },
1132
+ indent: { md: 1, block: 1, run: (v, area) => indent(1, area), active: () => false, enabled: (a) => canIndent(a) },
1133
+ outdent: { md: 1, block: 1, run: (v, area) => indent(-1, area), active: () => false, enabled: (a) => canOutdent(a) },
1134
+ align: { block: 1, page: 1, run: (v) => styleBlock('textAlign', v || 'start'), active: () => false, reflect: () => readBlockStyle('textAlign') },
1135
+ color: { page: 1, needsSelection: 1, run: (v) => wrapInline('span', { color: v }), active: () => false, reflect: () => rgbToHex(readInlineStyle('color')) },
1136
+ background: { page: 1, needsSelection: 1, run: (v) => wrapInline('span', { backgroundColor: v }), active: () => false, reflect: () => rgbToHex(readInlineStyle('backgroundColor')) },
1137
+ font: { page: 1, needsSelection: 1, run: (v) => wrapInline('span', { fontFamily: v }), active: () => false, reflect: () => readInlineStyle('fontFamily').split(',')[0].replace(/['"]/g, '') },
1138
+ size: { page: 1, needsSelection: 1, run: (v) => wrapInline('span', { fontSize: px(v) }), active: () => false, reflect: () => readInlineStyle('fontSize') },
1139
+ leading: { page: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
1140
+
1141
+ clear: { md: 1, run: clearFormatting, active: () => false },
1142
+ undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 },
1143
+ redo: { md: 1, history: 1, run: (v, area) => step(area, 1), active: () => false, enabled: (a) => a._history.at < a._history.stack.length - 1 },
1144
+ block: { md: 1, block: 1, run: (v) => setBlock(String(v || 'p').toLowerCase()), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
1145
+ });
1146
+
1147
+ const px = (v) => /^\d+(\.\d+)?$/.test(String(v)) ? v + 'px' : v;
1148
+ const hasSelection = () => { const r = range(); return !!r && !r.collapsed; };
1149
+
1150
+ /* ---- Pending marks ----
1151
+ Bold with nothing selected arms the tag for what you type next, the way a word
1152
+ processor does. It cannot be done by wrapping an empty element and putting the
1153
+ caret inside — the browser normalizes the caret straight back out — so the mark
1154
+ is remembered and applied to the first characters that arrive. The anchor is
1155
+ checked on the way in: move the caret elsewhere and the arming lapses rather
1156
+ than firing somewhere unexpected. */
1157
+ function armPending(area, tag) {
1158
+ const r = range(); if (!r || !r.collapsed) return;
1159
+ const at = area._pending;
1160
+ const tags = at && at.node === r.startContainer && at.offset === r.startOffset ? at.tags : new Set();
1161
+ tags.has(tag) ? tags.delete(tag) : tags.add(tag);
1162
+ area._pending = tags.size ? { tags, node: r.startContainer, offset: r.startOffset } : null;
1163
+ }
1164
+
1165
+ const pendingHas = (area, tag) => !!(area && area._pending && area._pending.tags.has(tag));
1166
+
1167
+ function applyPending(area, typed) {
1168
+ const at = area._pending; if (!at) return false;
1169
+ const r = range();
1170
+ if (!r || !r.collapsed || r.startContainer !== at.node || r.startOffset !== at.offset + typed.length) {
1171
+ area._pending = null;
1172
+ return false;
1173
+ }
1174
+ const node = r.startContainer, start = at.offset;
1175
+ const span = document.createRange();
1176
+ span.setStart(node, start); span.setEnd(node, r.startOffset);
1177
+ span.deleteContents();
1178
+ let outer = null, inner = null;
1179
+ at.tags.forEach(tag => {
1180
+ const el = document.createElement(tag);
1181
+ if (inner) inner.appendChild(el); else outer = el;
1182
+ inner = el;
1183
+ });
1184
+ inner.textContent = typed;
1185
+ span.insertNode(outer);
1186
+ area._pending = null; // the caret is inside the tags now
1187
+ caretIn(inner, typed.length);
1188
+ return true;
1189
+ }
1190
+
1191
+ function readInlineStyle(prop) {
1192
+ const el = elementAt(range() && range().startContainer);
1193
+ return el ? getComputedStyle(el)[prop] : '';
1194
+ }
1195
+ // <input type=color> only speaks hex, but themes are authored in oklch and modern
1196
+ // engines return that verbatim — assigning it to fillStyle does not normalize it
1197
+ // either. Painting one pixel and reading it back converts anything paintable.
1198
+ let _hexCtx;
1199
+ function rgbToHex(v) {
1200
+ if (!v) return '#000000';
1201
+ try {
1202
+ _hexCtx = _hexCtx || document.createElement('canvas').getContext('2d', { willReadFrequently: true });
1203
+ _hexCtx.clearRect(0, 0, 1, 1);
1204
+ _hexCtx.fillStyle = v;
1205
+ _hexCtx.fillRect(0, 0, 1, 1);
1206
+ const [r, g, b] = _hexCtx.getImageData(0, 0, 1, 1).data;
1207
+ return '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('');
1208
+ } catch { return '#000000'; }
1209
+ }
1210
+
1211
+ const MODES = new Set(['html', 'plain', 'minimal', 'literal', 'autofocus']);
1212
+
1213
+ /* ---- Autoformat ----
1214
+ A line converts when it is finished, not while it is being typed: press Enter
1215
+ and the line you are leaving becomes what its markdown described. Converting
1216
+ mid-keystroke fought the writer — a "*" was reinterpreted the moment a second
1217
+ one appeared — and made literal markdown impossible to type. Deferring it also
1218
+ means what you see and what is stored only ever disagree within one line.
1219
+
1220
+ Without conversion the two genuinely drift: "## Title" left in a paragraph is
1221
+ stored as a heading and comes back as one, having looked like body text the
1222
+ whole time it was being written. */
1223
+ const LINE_RULES = [
1224
+ { re: /^(#{1,6})\s+/, make: (b, m) => retagBlock(b, 'h' + m[1].length) },
1225
+ { re: /^[-*+]\s+/, make: (b) => listify(b, 'ul') },
1226
+ { re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
1227
+ { re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
1228
+ { re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
1229
+ { re: /^```\s*$/, make: (b) => retagBlock(b, 'pre') },
1230
+ { re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
1231
+ ];
1232
+
1233
+ const INLINE_RULES = [
1234
+ { re: /\*\*([^*]+)\*\*/, tag: 'strong' },
1235
+ { re: /__([^_]+)__/, tag: 'strong' },
1236
+ { re: /(^|[^*])\*([^*\s][^*]*)\*/, tag: 'em', pre: 1, content: 2 },
1237
+ { re: /~~([^~]+)~~/, tag: 'del' },
1238
+ { re: /`([^`]+)`/, tag: 'code' },
1239
+ { re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
1240
+ { re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
1241
+ ];
1242
+ const unsafe = (url) => /^\s*javascript:/i.test(url || '');
1243
+
1244
+ function retagBlock(b, tag) {
1245
+ const n = document.createElement(tag);
1246
+ while (b.firstChild) n.appendChild(b.firstChild);
1247
+ b.replaceWith(n);
1248
+ return n;
1249
+ }
1250
+
1251
+ // Consecutive lines join the list above rather than each starting their own.
1252
+ function listify(b, tag, checked) {
1253
+ const item = document.createElement('li');
1254
+ while (b.firstChild) item.appendChild(b.firstChild);
1255
+ const prev = b.previousElementSibling;
1256
+ const list = prev && prev.tagName === tag.toUpperCase() ? prev : document.createElement(tag);
1257
+ if (checked != null) {
1258
+ list.setAttribute('data-checklist', '');
1259
+ const box = document.createElement('input');
1260
+ box.type = 'checkbox'; box.checked = checked;
1261
+ item.insertBefore(box, item.firstChild);
1262
+ }
1263
+ list.appendChild(item);
1264
+ if (list !== prev) b.replaceWith(list); else b.remove();
1265
+ return item;
1266
+ }
1267
+
1268
+ // Strip the marker text the rule matched, without disturbing the rest of the line.
1269
+ function stripLead(el, count) {
1270
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
1271
+ let left = count;
1272
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
1273
+ const take = Math.min(left, n.length);
1274
+ n.deleteData(0, take);
1275
+ left -= take;
1276
+ }
1277
+ }
1278
+
1279
+ function inlineEl(rule, m) {
1280
+ const el = document.createElement(rule.tag);
1281
+ if (rule.tag === 'img') {
1282
+ if (unsafe(m[rule.src])) return null;
1283
+ el.setAttribute('src', m[rule.src]); el.setAttribute('alt', m[rule.alt] || '');
1284
+ return el;
1285
+ }
1286
+ el.textContent = m[rule.content || 1];
1287
+ if (rule.href) { if (unsafe(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
1288
+ return el;
1289
+ }
1290
+
1291
+ function firstInline(text) {
1292
+ let best = null;
1293
+ for (const rule of INLINE_RULES) {
1294
+ const m = text.match(rule.re); if (!m) continue;
1295
+ const skip = rule.pre ? m[rule.pre].length : 0;
1296
+ const start = m.index + skip;
1297
+ if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1298
+ }
1299
+ return best;
1300
+ }
1301
+
1302
+ function convertInline(root) {
1303
+ const nodes = [];
1304
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1305
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
1306
+ nodes.forEach(node => {
1307
+ if (!node.parentElement || node.parentElement.closest('code, pre, a')) return;
1308
+ let cur = node;
1309
+ for (let guard = 0; guard < 50; guard++) {
1310
+ const hit = firstInline(cur.nodeValue); if (!hit) return;
1311
+ const el = inlineEl(hit.rule, hit.m); if (!el) return;
1312
+ const tail = cur.splitText(hit.start);
1313
+ tail.deleteData(0, hit.len);
1314
+ tail.parentNode.insertBefore(el, tail);
1315
+ cur = tail;
1316
+ }
1317
+ });
1318
+ }
1319
+
1320
+ // Returns the element now holding the line's content — an <li> for a list, so the
1321
+ // caller knows to continue the list rather than start a paragraph.
1322
+ function convertLine(b) {
1323
+ if (!b || b.closest('pre')) return b;
1324
+ const text = b.textContent;
1325
+ for (const rule of LINE_RULES) {
1326
+ const m = text.match(rule.re); if (!m) continue;
1327
+ stripLead(b, m[0].length);
1328
+ const made = rule.make(b, m);
1329
+ if (made.tagName !== 'HR') convertInline(made);
1330
+ return made;
1331
+ }
1332
+ convertInline(b);
1333
+ return b;
1334
+ }
1335
+
1336
+ /* ---- Enter ----
1337
+ Owned outright rather than post-processed: the split has to know what the line
1338
+ became, headings and quotes must not continue themselves, and lists and code
1339
+ blocks need a way out that is not the mouse. */
1340
+ const CONTINUES = new Set(['LI', 'PRE']);
1341
+
1342
+ function splitInto(b, tag) {
1343
+ const n = document.createElement(tag);
1344
+ const r = range();
1345
+ if (b.lastChild && r && b.contains(r.startContainer)) {
1346
+ const tail = document.createRange();
1347
+ tail.setStart(r.startContainer, r.startOffset);
1348
+ tail.setEndAfter(b.lastChild);
1349
+ n.appendChild(tail.extractContents());
1350
+ }
1351
+ b.after(n);
1352
+ if (!b.textContent.length && !b.querySelector('br, img')) b.appendChild(document.createElement('br'));
1353
+ return n;
1354
+ }
1355
+
1356
+ function exitList(li, area) {
1357
+ const list = li.closest('ul, ol');
1358
+ const p = document.createElement('p');
1359
+ const after = [...list.children].slice([...list.children].indexOf(li) + 1);
1360
+ list.after(p);
1361
+ if (after.length) {
1362
+ const tail = document.createElement(list.tagName.toLowerCase());
1363
+ if (list.hasAttribute('data-checklist')) tail.setAttribute('data-checklist', '');
1364
+ after.forEach(n => tail.appendChild(n));
1365
+ p.after(tail);
1366
+ }
1367
+ li.remove();
1368
+ if (!list.children.length) list.remove();
1369
+ caretIn(p, 0);
1370
+ void area;
1371
+ }
1372
+
1373
+ // Enter is only taken over where the wanted behaviour differs from the browser's.
1374
+ // Inside a code block, and for Shift+Enter, the native handling is already right —
1375
+ // and doing it by hand goes wrong, because a caret placed by script next to a <br>
1376
+ // or a bare newline gets normalized back over it before the next character lands.
1377
+ // Chrome writes <br> inside a <pre> rather than a newline, so an empty last line
1378
+ // is a pair of trailing breaks — not a trailing "\n" as the markup suggests.
1379
+ const codeEndsBlank = (pre) => {
1380
+ const last = pre.lastChild;
1381
+ if (last && last.nodeName === 'BR' && last.previousSibling && last.previousSibling.nodeName === 'BR') return true;
1382
+ return /\n[^\S\n]*$/.test(pre.textContent);
1383
+ };
1384
+ const leavingCode = () => {
1385
+ const pre = ancestor('pre'); if (!pre) return false;
1386
+ const r = range();
1387
+ return !!r && r.collapsed && codeEndsBlank(pre);
1388
+ };
1389
+ const ownsEnter = (e) => !e.shiftKey && (!ancestor('pre') || leavingCode());
1390
+
1391
+ /* ---- Backspace ----
1392
+ Left to the browser, Backspace at the start of a heading merges it into the
1393
+ paragraph above and inlines the heading's computed styles as a <span> to keep
1394
+ the look — so the document quietly fills with font-size and font-weight spans.
1395
+ Deleting the last character of a table's only cell takes the whole table with
1396
+ it. Both are surprising enough to be worth owning. */
1397
+ const atBlockStart = () => { const b = ancestor('li') || blockEl(); return !!b && offsetIn(b) === 0; };
1398
+
1399
+ function cellCoversAll() {
1400
+ const cell = ancestor('td, th'); if (!cell) return null;
1401
+ const r = range(); if (!r) return null;
1402
+ const all = document.createRange(); all.selectNodeContents(cell);
1403
+ const covers = r.collapsed
1404
+ ? cell.textContent.length <= 1
1405
+ : r.toString().length >= cell.textContent.length;
1406
+ return covers ? cell : null;
1407
+ }
1408
+
1409
+ function backspaceAtStart(area) {
1410
+ const li = ancestor('li');
1411
+ if (li) {
1412
+ if (li.previousElementSibling) { // merge into the item above
1413
+ const prev = li.previousElementSibling, at = prev.textContent.length;
1414
+ while (li.firstChild) { const n = li.firstChild; if (n.nodeName === 'INPUT') n.remove(); else prev.appendChild(n); }
1415
+ li.remove();
1416
+ caretIn(prev, at);
1417
+ return;
1418
+ }
1419
+ const list = li.closest('ul, ol'), p = document.createElement('p');
1420
+ [...li.childNodes].forEach(n => { if (n.nodeName !== 'INPUT') p.appendChild(n); });
1421
+ list.before(p);
1422
+ li.remove();
1423
+ if (!list.children.length) list.remove();
1424
+ caretIn(p, 0);
1425
+ return;
1426
+ }
1427
+ const b = blockEl(); if (!b) return;
1428
+ const prev = b.previousElementSibling;
1429
+ if (!prev) { if (b.tagName !== 'P') retagBlock(b, 'p').focus?.(); caretIn(blockEl() || b, 0); return; }
1430
+ if (prev.tagName === 'HR') { prev.remove(); caretIn(b, 0); return; }
1431
+ if (/^(UL|OL)$/.test(prev.tagName)) { // join onto the last item
1432
+ const last = prev.lastElementChild; if (!last) return;
1433
+ const at = last.textContent.length;
1434
+ while (b.firstChild) last.appendChild(b.firstChild);
1435
+ b.remove();
1436
+ caretIn(last, at);
1437
+ return;
1438
+ }
1439
+ const at = prev.textContent.length;
1440
+ prev.querySelectorAll(':scope > br:last-child').forEach(br => br.remove());
1441
+ while (b.firstChild) prev.appendChild(b.firstChild);
1442
+ b.remove();
1443
+ caretIn(prev, at);
1444
+ }
1445
+
1446
+ function onEnter(area, autoformat) {
1447
+ let holder = ancestor('li') || blockEl() || ensureBlock(area);
1448
+
1449
+ const pre = holder.closest('pre');
1450
+ if (pre) {
1451
+ // An empty last line leaves the code block — the one case the browser
1452
+ // cannot do, and without it the block has no keyboard exit.
1453
+ while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
1454
+ if (/\n[^\S\n]*$/.test(pre.textContent)) pre.textContent = pre.textContent.replace(/\n[^\S\n]*$/, '');
1455
+ const p = document.createElement('p');
1456
+ pre.after(p);
1457
+ if (!pre.textContent.length) pre.remove();
1458
+ caretIn(p, 0);
1459
+ return;
1460
+ }
1461
+
1462
+ if (holder.tagName === 'LI' && !holder.textContent.length) return exitList(holder, area);
1463
+
1464
+ // Split BEFORE converting. Conversion strips the marker text, which would
1465
+ // leave the caret offset pointing past the end of its own text node.
1466
+ const next = splitInto(holder, holder.tagName === 'LI' ? 'li' : 'p');
1467
+ if (autoformat) holder = convertLine(holder) || holder;
1468
+
1469
+ if (holder.tagName === 'HR') { caretIn(next, 0); return; }
1470
+
1471
+ // A finished ``` line opens the code block rather than starting a line after it.
1472
+ if (holder.tagName === 'PRE') { next.remove(); caretIn(holder, 0); return; }
1473
+
1474
+ // The line became a list item, so the new line continues the list.
1475
+ if (holder.tagName === 'LI') {
1476
+ const item = next.tagName === 'LI' ? next : retagBlock(next, 'li');
1477
+ holder.after(item);
1478
+ const box = holder.querySelector(':scope > input[type=checkbox]');
1479
+ if (box && !item.querySelector(':scope > input[type=checkbox]')) {
1480
+ const fresh = document.createElement('input');
1481
+ fresh.type = 'checkbox';
1482
+ item.insertBefore(fresh, item.firstChild);
1483
+ }
1484
+ caretIn(item, 0);
1485
+ return;
1486
+ }
1487
+
1488
+ // Everything else starts a plain paragraph. A heading or a quote does not
1489
+ // continue itself — the next line is body text, which is what a writer means.
1490
+ caretIn(next, 0);
1491
+ }
1492
+
1493
+ /* ---- History ----
1494
+ Wrapping tags and moving list items are plain DOM edits, which never enter the
1495
+ browser's own undo stack and can leave it inconsistent. The editor keeps its
1496
+ own: an HTML snapshot plus the caret's character offset, coalesced while
1497
+ typing so a burst of keystrokes undoes as one step. */
1498
+ const HISTORY_CAP = 200;
1499
+ const IDLE = 400;
1500
+
1501
+ function record(area) {
1502
+ const h = area._history; if (!h || h.restoring) return;
1503
+ clearTimeout(h.timer); h.timer = null;
1504
+ const html = area.innerHTML;
1505
+ if (h.stack[h.at] && h.stack[h.at].html === html) return;
1506
+ h.stack.length = h.at + 1;
1507
+ h.stack.push({ html, caret: offsetIn(area) });
1508
+ if (h.stack.length > HISTORY_CAP) h.stack.shift();
1509
+ h.at = h.stack.length - 1;
1510
+ }
1511
+
1512
+ const recordSoon = (area) => {
1513
+ const h = area._history; if (!h || h.restoring) return;
1514
+ clearTimeout(h.timer);
1515
+ h.timer = setTimeout(() => record(area), IDLE);
1516
+ };
1517
+
1518
+ function step(area, by) {
1519
+ const h = area._history; if (!h) return;
1520
+ record(area); // land any pending burst first
1521
+ const next = h.at + by;
1522
+ if (next < 0 || next >= h.stack.length) return;
1523
+ h.at = next; h.restoring = true;
1524
+ area.innerHTML = h.stack[next].html;
1525
+ area.focus();
1526
+ caretIn(area, h.stack[next].caret);
1527
+ h.restoring = false;
1528
+ area.dispatchEvent(new Event('input', { bubbles: true }));
1529
+ }
1530
+
1531
+ /* ---- Registry + resolution ----
1532
+ Controls find their area by containment first, focus second. The nearest
1533
+ ancestor holding exactly one area wins, which is what keeps two dialogs
1534
+ sharing a DOM from driving each other; a toolbar with no such ancestor falls
1535
+ through to whichever area was last focused. x-text-edit-for pins either. */
1536
+ const areas = new Set();
1537
+ const controls = new Set();
1538
+ let lastFocused = null;
1539
+
1540
+ const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
1541
+ const inside = (root) => [...areas].filter(a => root.contains(a) && a !== root);
1542
+ const preferred = (list) => list.find(a => a === lastFocused && onScreen(a)) || list.find(onScreen) || list[0] || null;
1543
+
1544
+ function resolve(control) {
1545
+ const pin = control.closest('[x-text-edit-for]');
1546
+ if (pin) {
1547
+ const target = document.querySelector(pin.getAttribute('x-text-edit-for'));
1548
+ if (target) return areas.has(target) ? target : preferred(inside(target));
1549
+ }
1550
+ for (let n = control.parentElement; n; n = n.parentElement) {
1551
+ const found = inside(n);
1552
+ if (found.length === 1) return found[0];
1553
+ if (found.length > 1) return unambiguous(found);
1554
+ }
1555
+ return unambiguous([...areas]);
1556
+ }
1557
+
1558
+ // A control acts on one area or on none. Where several are in scope the focused
1559
+ // one decides; before anything has been focused there is no answer, and guessing
1560
+ // sends the command somewhere the writer was not looking — a panel button that
1561
+ // silently rewrites the first heading on the page rather than the line they are
1562
+ // in. Reporting the control unavailable is the honest reading.
1563
+ // Letting go of the target has to be announced. A menu anchored to the selection
1564
+ // is watching for it, and without a final null it hangs about over content it no
1565
+ // longer has anything to do with.
1566
+ function release() {
1567
+ const was = lastFocused;
1568
+ lastFocused = null;
1569
+ if (!was) return;
1570
+ was._selection = null;
1571
+ was.removeAttribute('data-text-edit-selected');
1572
+ document.documentElement.removeAttribute('data-text-edit-selected');
1573
+ was.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: null }));
1574
+ }
1575
+
1576
+ const editableElsewhere = (node) => {
1577
+ const el = elementAt(node);
1578
+ return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA)$/.test(el.tagName));
1579
+ };
1580
+
1581
+ function unambiguous(found) {
1582
+ if (lastFocused && found.includes(lastFocused) && lastFocused.isConnected && onScreen(lastFocused)) return lastFocused;
1583
+ const visible = found.filter(onScreen);
1584
+ return visible.length === 1 ? visible[0] : null;
1585
+ }
1586
+
1587
+ /* ---- Caret custody ----
1588
+ A control anywhere in the document means the area is often not focused when a
1589
+ command runs — a <select>, a colour input or a URL field steals focus by
1590
+ design. Each area keeps its own last range so any control can put it back. */
1591
+ function saveRange(area) {
1592
+ const r = range();
1593
+ if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
1594
+ }
1595
+
1596
+ function restoreRange(area) {
1597
+ const r = range();
1598
+ if (r && area.contains(r.startContainer)) return;
1599
+ // Only go back somewhere we have actually been. Focusing regardless puts a
1600
+ // caret at the start of an area that was never edited, which is how a control
1601
+ // ends up rewriting the first field on the page instead of doing nothing.
1602
+ if (!area._range || !area.contains(area._range.startContainer)) return;
1603
+ area.focus();
1604
+ const s = sel(); s.removeAllRanges(); s.addRange(area._range);
1605
+ }
1606
+
1607
+ // markdown can only carry the tags it has syntax for. Rather than let a command
1608
+ // write something the next save would drop, it reports itself unavailable.
1609
+ function allows(area, id, ctrl) {
1610
+ const spec = COMMANDS[id], cfg = area && area._te;
1611
+ if (!spec || !cfg || cfg.mode === 'plain') return false;
1612
+ const page = !!(ctrl && ctrl.page && spec.page);
1613
+ if (cfg.minimal && spec.block && !page) return false;
1614
+ if (cfg.mode !== 'html' && !spec.md && !page) return false;
1615
+ if (page) return true; // writes to the area, needs no caret
1616
+ // A wrap has nothing to wrap without a selection, and a control that is lit
1617
+ // but does nothing reads as broken — so say so rather than no-op.
1618
+ if (spec.needsSelection && !hasSelection()) return false;
1619
+ return !spec.enabled || spec.enabled(area);
1620
+ }
1621
+
1622
+ function run(area, id, arg, ctrl) {
1623
+ const spec = COMMANDS[id]; if (!spec) return;
1624
+ const page = !!(ctrl && ctrl.page && spec.page);
1625
+ if (!page) {
1626
+ restoreRange(area);
1627
+ // If the caret could not be put in this area — it was never focused, or
1628
+ // the saved range has gone stale — do nothing. Running anyway rewrites
1629
+ // whichever area resolution happened to land on, which reads as the
1630
+ // command changing some other element's text.
1631
+ const r = range();
1632
+ if (!r || !area.contains(r.startContainer)) return;
1633
+ }
1634
+ if (!allows(area, id, ctrl)) return;
1635
+ if (page) {
1636
+ area.style.setProperty(PAGE_VARS[id], id === 'size' ? px(arg) : arg);
1637
+ if (!arg) area.style.removeProperty(PAGE_VARS[id]);
1638
+ area.dispatchEvent(new CustomEvent('text-edit:page', { bubbles: true, detail: pageStyle(area) }));
1639
+ sync();
1640
+ return;
1641
+ }
1642
+ const arming = spec.md !== undefined && INLINE_TAGS[id] && !hasSelection();
1643
+ if (arming) { spec.run(arg, area); sync(); return; } // nothing changed yet
1644
+ if (!spec.history) record(area); // undo/redo drive the stack themselves
1645
+ spec.run(arg, area);
1646
+ normalizeLists(area);
1647
+ if (!spec.history) record(area);
1648
+ saveRange(area);
1649
+ area.dispatchEvent(new Event('input', { bubbles: true }));
1650
+ sync();
1651
+ }
1652
+
1653
+ // Reflect caret state onto every control pointing at the area that owns it.
1654
+ function sync() {
1655
+ controls.forEach(c => {
1656
+ const cfg = c._te, area = resolve(c);
1657
+ const usable = !!area && allows(area, cfg.id, cfg);
1658
+ if (!area) { c.removeAttribute('data-text-edit-active'); c.setAttribute('aria-disabled', 'true'); if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', 'false'); return; }
1659
+ const spec = COMMANDS[cfg.id];
1660
+ const page = !!(cfg.page && spec.page);
1661
+ c.toggleAttribute('data-text-edit-active', usable && !page && !!spec.active(cfg.arg(), area));
1662
+ c.setAttribute('aria-disabled', String(!usable));
1663
+ if (usable && (page || spec.reflect) && 'value' in c && c !== document.activeElement) {
1664
+ const v = page ? area.style.getPropertyValue(PAGE_VARS[cfg.id]).trim() : spec.reflect();
1665
+ // Never blank a <select> by assigning a value the author didn't offer —
1666
+ // computed styles report plenty that no option list would carry.
1667
+ const offered = c.tagName !== 'SELECT' || [...c.options].some(o => o.value === v);
1668
+ if (v != null && offered && c.value !== v) c.value = v;
1669
+ }
1670
+ if (c.tagName === 'BUTTON') c.setAttribute('aria-pressed', String(c.hasAttribute('data-text-edit-active')));
1671
+ });
1672
+ }
1673
+
1674
+ /* ---- Directive ---- */
1675
+ function init() {
1676
+ if (!window.Alpine || !Alpine.directive) return;
1677
+ // Keep the browser from inlining computed styles, and make Enter produce <p>
1678
+ // rather than Chrome's default <div> so blocks are addressable by tag.
1679
+ try { document.execCommand('styleWithCSS', false, false); } catch { }
1680
+ try { document.execCommand('defaultParagraphSeparator', false, 'p'); } catch { }
1681
+
1682
+ Alpine.directive('text-edit', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
1683
+ const id = modifiers.find(m => COMMANDS[m]);
1684
+ return id ? control(el, id, modifiers, expression, evaluate, cleanup)
1685
+ : area(el, modifiers, expression, { effect, evaluateLater, cleanup });
1686
+ });
1687
+
1688
+ // $text — the area this element sits in, or the last focused one.
1689
+ Alpine.magic('text', (el) => {
1690
+ const own = el.closest('[data-text-edit]');
1691
+ const a = own && areas.has(own) ? own : preferred([...areas]);
1692
+ return a ? a._te.api : {
1693
+ value: '', link: '', selection: null, page: {}, run() { }, active: () => false,
1694
+ can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
1695
+ };
1696
+ });
1697
+
1698
+ document.addEventListener('selectionchange', () => {
1699
+ const r = range(); if (!r) return;
1700
+ const a = [...areas].find(x => x.contains(r.startContainer));
1701
+ if (a) { saveRange(a); lastFocused = a; report(a); }
1702
+ else if (lastFocused && editableElsewhere(r.startContainer)) release();
1703
+ sync();
1704
+ });
1705
+
1706
+ // The caret has moved into something else editable — another plugin's field,
1707
+ // a plain contenteditable, an input. Nothing here owns it, so the controls
1708
+ // have no target: they must go quiet rather than keep pointing at whichever
1709
+ // area was last visited, which is how a command ends up styling text the
1710
+ // writer can see is not selected.
1711
+ document.addEventListener('focusin', (e) => {
1712
+ const t = e.target;
1713
+ if (!t || !t.closest) return;
1714
+ if (t.closest('[data-text-edit]') || t.closest('[data-text-edit-control]')) return;
1715
+ if (!lastFocused) return;
1716
+ release();
1717
+ sync();
1718
+ }, true);
1719
+ }
1720
+
1721
+ /* ---- Selection reporting ----
1722
+ Everything a selection-anchored menu needs, in both the forms an author might
1723
+ reach for: an event carrying the geometry, and the same geometry as custom
1724
+ properties on the area, so a popover can be placed without any script at all.
1725
+ The plugin renders no menu of its own — where and what that is belongs to the
1726
+ project, not to us. */
1727
+ function selectionOf(area) {
1728
+ const r = range();
1729
+ if (!r || !area.contains(r.startContainer)) return null;
1730
+ const box = r.getBoundingClientRect();
1731
+ return {
1732
+ collapsed: r.collapsed,
1733
+ text: r.toString(),
1734
+ x: box.left, y: box.top, width: box.width, height: box.height,
1735
+ top: box.top, bottom: box.bottom, left: box.left, right: box.right
1736
+ };
1737
+ }
1738
+
1739
+ // A text selection cannot highlight an empty cell, so the block is painted here.
1740
+ function paintCells(area) {
1741
+ area.querySelectorAll('[data-text-edit-cell]').forEach(c => c.removeAttribute('data-text-edit-cell'));
1742
+ selectedCells().forEach(c => c.setAttribute('data-text-edit-cell', ''));
1743
+ }
1744
+
1745
+ function report(area) {
1746
+ // A caret placed inside one cell ends any block selection.
1747
+ const live = sel();
1748
+ if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
1749
+ if (area.querySelector('table')) paintCells(area);
1750
+ const shape = selectionOf(area);
1751
+ area._selection = shape;
1752
+ const px = (n) => Math.round(n) + 'px';
1753
+ // Mirrored onto the root as well as the area. A menu anchored to the
1754
+ // selection is rarely a descendant of the editor — and once it is a popover
1755
+ // it is in the top layer, where nothing inherits from the page at all — so
1756
+ // custom properties on the area alone are unreachable from where they are
1757
+ // needed. On the root they are readable from anywhere.
1758
+ const targets = [area, document.documentElement];
1759
+ if (shape && !shape.collapsed) targets.forEach(t => {
1760
+ t.style.setProperty('--text-edit-selection-x', px(shape.x));
1761
+ t.style.setProperty('--text-edit-selection-y', px(shape.y));
1762
+ t.style.setProperty('--text-edit-selection-width', px(shape.width));
1763
+ t.style.setProperty('--text-edit-selection-height', px(shape.height));
1764
+ t.style.setProperty('--text-edit-selection-center', px(shape.x + shape.width / 2));
1765
+ });
1766
+ area.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
1767
+ document.documentElement.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
1768
+ area.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: shape }));
1769
+ }
1770
+
1771
+ function control(el, id, modifiers, expression, evaluate, cleanup) {
1772
+ // `.h2` and `.align.center` carry a static argument as the next modifier; an
1773
+ // expression is the dynamic form (`x-text-edit.a="url"`).
1774
+ const page = modifiers.includes('page');
1775
+ const next = modifiers[modifiers.indexOf(id) + 1];
1776
+ const literal = next === 'page' ? undefined : next;
1777
+ const arg = () => literal !== undefined ? literal : (expression ? evaluate(expression) : undefined);
1778
+ el._te = { id, arg, page };
1779
+ if (page) el.setAttribute('data-text-edit-page', '');
1780
+ el.setAttribute('data-text-edit-control', id);
1781
+ if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.type = 'button';
1782
+ controls.add(el);
1783
+
1784
+ const valued = 'value' in el && el.tagName !== 'BUTTON';
1785
+ if (valued) {
1786
+ const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._te); };
1787
+ el.addEventListener('change', fire);
1788
+ el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
1789
+ } else {
1790
+ el.addEventListener('pointerdown', e => e.preventDefault()); // never take the caret
1791
+ el.addEventListener('click', e => { e.preventDefault(); const a = resolve(el); if (a) run(a, id, arg(), el._te); });
1792
+ }
1793
+ setTimeout(sync, 0);
1794
+ cleanup(() => { controls.delete(el); });
1795
+ }
1796
+
1797
+ function area(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
1798
+ const mode = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
1799
+ const minimal = modifiers.includes('minimal');
1800
+ modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text Edit] unknown modifier: .${m}`));
1801
+
1802
+ el.setAttribute('data-text-edit', mode);
1803
+ // Tells anything composing with this plugin — x-edit, chiefly — whether the
1804
+ // value already has an owner, or whether the surrounding editor should
1805
+ // capture the content itself.
1806
+ el.toggleAttribute('data-text-edit-bound', !!expression);
1807
+ el.setAttribute('contenteditable', 'true');
1808
+ el.setAttribute('role', 'textbox');
1809
+ el.setAttribute('aria-multiline', 'true');
1810
+ if (minimal) el.setAttribute('data-text-edit-minimal', '');
1811
+
1812
+ const read = () => mode === 'plain' ? el.innerText.replace(/\n{3,}/g, '\n\n').trim()
1813
+ : mode === 'html' ? sanitize(el.innerHTML, true, true)
1814
+ : toMarkdown(el);
1815
+ const write = (v) => {
1816
+ if (mode === 'plain') el.textContent = v == null ? '' : String(v);
1817
+ else el.innerHTML = mode === 'html' ? sanitize(v, true, true) : fromMarkdown(v);
1818
+ if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
1819
+ normalizeLists(el);
1820
+ if (el.querySelector('table')) normalizeTables(el);
1821
+ ensureTrailingBlock(el);
1822
+ };
1823
+
1824
+ let last = null, writing = false, commitValue = () => { };
1825
+ if (expression) {
1826
+ const getValue = evaluateLater(expression);
1827
+ const setValue = evaluateLater(`${expression} = __textEditValue`);
1828
+ effect(() => getValue(v => {
1829
+ const s = v == null ? '' : String(v);
1830
+ if (s === last) return; // our own write coming back
1831
+ last = s; writing = true; write(s); writing = false;
1832
+ // The document was replaced from outside — a new page, or a different
1833
+ // one loaded. Undo must not reach back into the document before it.
1834
+ if (el._history) { el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0; }
1835
+ settle();
1836
+ }));
1837
+ commitValue = () => { last = read(); setValue(() => { }, { scope: { __textEditValue: last } }); };
1838
+ el.addEventListener('input', () => { if (!writing) commitValue(); });
1839
+ } else {
1840
+ // Uncontrolled: the markup already there IS the initial content. Writing
1841
+ // an empty value over it would silently delete what the author wrote.
1842
+ // Loose inline content still needs a block to hold the caret.
1843
+ if (mode !== 'plain' && el.firstChild && ![...el.children].some(c => BLOCK.includes(c.tagName))) el.innerHTML = `<p>${el.innerHTML}</p>`;
1844
+ if (!el.firstChild) el.innerHTML = '<p><br></p>';
1845
+ }
1846
+
1847
+ el._history = { stack: [{ html: el.innerHTML, caret: 0 }], at: 0, timer: null, restoring: false };
1848
+
1849
+ // Wiping the document leaves whatever the first block was, so a page that
1850
+ // opened with a heading keeps making headings. Reset to a paragraph, and flag
1851
+ // emptiness for the placeholder — :empty never matches the <br> a
1852
+ // contenteditable keeps, and the tag it sits in is not predictable.
1853
+ function settle() {
1854
+ if (el.querySelector('table')) normalizeTables(el);
1855
+ if (ensureTrailingBlock(el)) { /* somewhere to land after a trailing block */ }
1856
+ if (!el.textContent.trim() && !el.querySelector('img, table, hr')) {
1857
+ if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
1858
+ if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
1859
+ el.toggleAttribute('data-text-edit-empty', true);
1860
+ } else el.toggleAttribute('data-text-edit-empty', false);
1861
+ }
1862
+
1863
+ // Table borders are draggable. Bound on the area so it costs nothing until a
1864
+ // table exists, and reads the pointer rather than injecting handles into the
1865
+ // grid — a handle per border would be markup inside the document's own table.
1866
+ el.addEventListener('pointermove', (ev) => {
1867
+ if (ev.buttons || !el.querySelector('table')) return;
1868
+ const hit = borderAt(ev.clientX, ev.clientY);
1869
+ el.setAttribute('data-text-edit-border', hit ? hit.axis : '');
1870
+ if (!hit) el.removeAttribute('data-text-edit-border');
1871
+ });
1872
+ el.addEventListener('pointerdown', (ev) => {
1873
+ if (ev.button !== 0) return;
1874
+ if (el.querySelector('table')) {
1875
+ const hit = borderAt(ev.clientX, ev.clientY);
1876
+ if (hit) return startResize(ev, hit);
1877
+ const from = cellUnder(ev.clientX, ev.clientY);
1878
+ if (from) cellDrag = { anchor: from, focus: from };
1879
+ }
1880
+ });
1881
+
1882
+ el.addEventListener('pointermove', (ev) => {
1883
+ if (!cellDrag || !ev.buttons) return;
1884
+ const over = cellUnder(ev.clientX, ev.clientY);
1885
+ if (!over || over === cellDrag.focus) return;
1886
+ cellDrag.focus = over;
1887
+ paintCells(el);
1888
+ });
1889
+
1890
+ el.addEventListener('pointerup', () => {
1891
+ if (cellDrag && cellDrag.anchor === cellDrag.focus) cellDrag = null;
1892
+ paintCells(el);
1893
+ sync();
1894
+ });
1895
+
1896
+ el.addEventListener('focusin', () => { lastFocused = el; sync(); });
1897
+ el.addEventListener('input', (e) => {
1898
+ if (!writing && e.data && applyPending(el, e.data)) { commitValue(); }
1899
+ settle(); sync();
1900
+ });
1901
+ el.addEventListener('change', e => {
1902
+ if (e.target.type !== 'checkbox') return;
1903
+ // Clicking sets the property; only the attribute survives serialization.
1904
+ e.target.toggleAttribute('checked', e.target.checked);
1905
+ record(el);
1906
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1907
+ });
1908
+
1909
+ // A finished line resolves to what its markdown described, unless .literal.
1910
+ const autoformat = mode !== 'plain' && !modifiers.includes('literal');
1911
+ el.addEventListener('input', () => {
1912
+ if (writing) return;
1913
+ recordSoon(el);
1914
+ if (ancestor('li')) normalizeLists(el); // Backspace reshapes lists too
1915
+ });
1916
+
1917
+ el.addEventListener('keydown', (e) => {
1918
+ // Tab indents rather than leaving the field, the way every editor behaves.
1919
+ // Escape is the way out, so keyboard users are never trapped.
1920
+ if (e.key === 'Enter' && mode !== 'plain') {
1921
+ if (!ownsEnter(e)) return; // let the browser do the ones it does well
1922
+ e.preventDefault();
1923
+ record(el);
1924
+ onEnter(el, autoformat);
1925
+ normalizeLists(el);
1926
+ record(el);
1927
+ commitValue();
1928
+ sync();
1929
+ return;
1930
+ }
1931
+ if (e.key === 'Backspace' && mode !== 'plain') {
1932
+ const cell = cellCoversAll();
1933
+ if (cell) { // clear the cell, keep the table
1934
+ e.preventDefault(); record(el);
1935
+ cell.replaceChildren(document.createElement('br'));
1936
+ caretIn(cell, 0);
1937
+ record(el); commitValue(); sync();
1938
+ return;
1939
+ }
1940
+ const r = range();
1941
+ if (r && r.collapsed && atBlockStart()) {
1942
+ e.preventDefault(); record(el);
1943
+ backspaceAtStart(el);
1944
+ normalizeLists(el);
1945
+ record(el); commitValue(); sync();
1946
+ return;
1947
+ }
1948
+ }
1949
+ if (/^Arrow(Up|Down|Left|Right)$/.test(e.key) && !e.shiftKey && !e.metaKey && !e.ctrlKey && inTable()) {
1950
+ if (arrowInTable(e)) { e.preventDefault(); sync(); return; }
1951
+ return;
1952
+ }
1953
+ if (e.key === 'Tab') {
1954
+ if (mode === 'plain') return;
1955
+ e.preventDefault();
1956
+ if (moveCell(e.shiftKey ? -1 : 1)) return; // tables walk cell to cell
1957
+ run(el, e.shiftKey ? 'outdent' : 'indent');
1958
+ return;
1959
+ }
1960
+ // Tab indents, so Escape is the way out. Stop it here so the first press
1961
+ // leaves the editor and only a second one closes an enclosing dialog.
1962
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); return; }
1963
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
1964
+ e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
1965
+ }
1966
+ if (!(e.metaKey || e.ctrlKey)) return;
1967
+ const id = { b: 'strong', i: 'em', u: 'u', k: 'a' }[e.key.toLowerCase()];
1968
+ if (!id || !allows(el, id)) return;
1969
+ e.preventDefault();
1970
+ run(el, id, id === 'a' ? undefined : undefined);
1971
+ });
1972
+
1973
+ // Paste as our own subset — never the source app's markup.
1974
+ el.addEventListener('paste', (e) => {
1975
+ e.preventDefault();
1976
+ const dt = e.clipboardData; if (!dt) return;
1977
+ const html = dt.getData('text/html');
1978
+ // No keepStyle: a paste brings the text and its marks, never the source
1979
+ // application's fonts and colours.
1980
+ insertHTML(mode === 'plain' ? escHtml(dt.getData('text/plain'))
1981
+ : html ? sanitize(html, !minimal)
1982
+ : fromMarkdown(dt.getData('text/plain').replace(/([^\n])\n(?!\n)/g, '$1 \n')));
1983
+ });
1984
+
1985
+ const api = {
1986
+ get value() { return read(); },
1987
+ set value(v) {
1988
+ write(v);
1989
+ el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0;
1990
+ settle();
1991
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1992
+ },
1993
+ get page() { return pageStyle(el); },
1994
+ set page(v) { setPageStyle(el, v); sync(); },
1995
+ get selection() { return el._selection || null; },
1996
+ get link() { return linkHref(); },
1997
+ set link(v) { run(el, 'a', v); },
1998
+ focus: () => el.focus(),
1999
+ run: (id, arg) => run(el, id, arg),
2000
+ active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
2001
+ can: (id) => allows(el, id),
2002
+ selectAll: () => { el.focus(); selectNode(el); sync(); },
2003
+ markdown: () => toMarkdown(el),
2004
+ html: () => sanitize(el.innerHTML, true, true)
2005
+ };
2006
+ el._te = { mode, minimal, api };
2007
+ areas.add(el);
2008
+ settle();
2009
+
2010
+ if (modifiers.includes('autofocus')) setTimeout(() => el.focus(), 0);
2011
+ setTimeout(sync, 0);
2012
+
2013
+ cleanup(() => { areas.delete(el); if (lastFocused === el) lastFocused = null; });
2014
+ }
2015
+
2016
+ document.addEventListener('alpine:init', init);
2017
+ if (window.Alpine && Alpine.directive) init();
2018
+
2019
+ window.ManifestTextEdit = { toMarkdown, fromMarkdown, sanitize, commands: () => Object.keys(COMMANDS) };
2020
+ })();