mnfst 0.5.231 → 0.5.233

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