mnfst 0.5.229 → 0.5.231

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  One directive, two roles. Without a command modifier it marks the editable area
4
4
  and binds its value: x-text-edit="post". With one, it turns any element into a
5
- control for that area: <button x-text-edit.bold>. Controls need no wrapper and
5
+ control for that area: <button x-text-edit.strong>. Controls need no wrapper and
6
6
  no shared parent — they resolve to an area by containment, then by focus, so a
7
7
  toolbar can sit anywhere and two dialogs in one DOM never cross wires.
8
8
 
@@ -33,8 +33,24 @@
33
33
  const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
34
34
  const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'A', 'IMG']);
35
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
+
36
48
  /* ---- HTML → Markdown ---- */
37
- const esc = (t) => t.replace(/([\\`*_[\]])/g, '\\$1');
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, '\\&');
38
54
 
39
55
  function inline(node) {
40
56
  let out = '';
@@ -43,8 +59,8 @@
43
59
  if (n.nodeType !== 1) return;
44
60
  const tag = n.tagName;
45
61
  if (tag === 'BR') { out += ' \n'; return; }
46
- if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src ? `![${n.getAttribute('alt') || ''}](${src})` : ''; return; }
47
- if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h ? `[${inline(n)}](${h})` : inline(n); return; }
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; }
48
64
  const m = MARKS[tag];
49
65
  if (!m) { out += inline(n); return; }
50
66
  const body = tag === 'CODE' ? n.textContent : inline(n);
@@ -65,6 +81,7 @@
65
81
  if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
66
82
  if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
67
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');
68
85
  if (tag === 'UL' || tag === 'OL') {
69
86
  const pad = ' '.repeat(depth);
70
87
  return [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
@@ -95,8 +112,41 @@
95
112
 
96
113
  const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
97
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
+
98
148
  /* ---- Markdown → HTML ---- */
99
- const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
149
+ const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
100
150
 
101
151
  // Escapes are masked out before the mark passes and restored last, so a literal
102
152
  // \* never gets read as emphasis and never comes back double-escaped.
@@ -104,13 +154,13 @@
104
154
  function inlineHtml(s) {
105
155
  const held = [];
106
156
  return escHtml(s)
107
- .replace(/\\([\\`*_~[\]])/g, (_, c) => HOLD + (held.push(c) - 1) + HOLD)
157
+ .replace(/\\(&lt;|&gt;|&amp;|[\\`*_~[\]])/g, (_, c) => HOLD + (held.push(c) - 1) + HOLD)
108
158
  .replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
109
159
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
110
160
  .replace(/~~([^~]+)~~/g, '<s>$1</s>')
111
161
  .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
112
- .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => /^\s*javascript:/i.test(src) ? '' : `<img src="${src}" alt="${alt}">`)
113
- .replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) => /^\s*javascript:/i.test(h) ? t : `<a href="${h}">${t}</a>`)
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>`)
114
164
  .replace(/ {2}\n/g, '<br>')
115
165
  .replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
116
166
  }
@@ -189,8 +239,8 @@
189
239
  if (!ok.has(c.tagName)) return c.replaceWith(...c.childNodes);
190
240
  [...c.attributes].forEach(a => {
191
241
  const n = a.name.toLowerCase();
192
- if (c.tagName === 'A' && n === 'href' && !/^\s*javascript:/i.test(a.value)) return;
193
- if (c.tagName === 'IMG' && (n === 'src' || n === 'alt') && !/^\s*javascript:/i.test(a.value)) return;
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;
194
244
  if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
195
245
  // Without these a merged cell comes apart on the next save
196
246
  if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
@@ -202,6 +252,9 @@
202
252
  return t.innerHTML;
203
253
  }
204
254
 
255
+ // Markdown → HTML bound for the DOM: always through the sanitizer.
256
+ const markdownHtml = (md) => sanitize(fromMarkdown(md), true);
257
+
205
258
  /* ---- Selection primitives ---- */
206
259
  const sel = () => window.getSelection();
207
260
  const range = () => { const s = sel(); return s && s.rangeCount ? s.getRangeAt(0) : null; };
@@ -1037,7 +1090,7 @@
1037
1090
  // does what the writer meant. Anything else (spaces, no dot, no scheme) is
1038
1091
  // not applied; the field re-syncs to the real href, showing it didn't take.
1039
1092
  function normalizeUrl(url) {
1040
- if (/\s/.test(url) || /^\s*javascript:/i.test(url)) return null;
1093
+ if (/\s/.test(url) || unsafeUrl(url)) return null;
1041
1094
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
1042
1095
  if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
1043
1096
  return null;
@@ -1051,13 +1104,38 @@
1051
1104
  if (existing) { existing.setAttribute('href', url); return; }
1052
1105
  const made = wrapInline('a');
1053
1106
  if (made.length) made.forEach(a => a.setAttribute('href', url));
1054
- else insertHTML(`<a href="${url}">${url}</a>`); // nothing selected — drop the URL in as its own link
1107
+ else insertHTML(`<a href="${escHtml(url)}">${escHtml(url)}</a>`); // nothing selected — drop the URL in as its own link
1055
1108
  }
1056
1109
  const linkHref = () => { const a = ancestor('a'); return a ? a.getAttribute('href') || '' : ''; };
1057
1110
 
1058
1111
  /* ---- Insertions ---- */
1059
1112
  function insertHTML(html) { document.execCommand('insertHTML', false, html); }
1060
- const insertImage = (src, alt) => { if (src && !/^\s*javascript:/i.test(src)) insertHTML(`<img src="${src}" alt="${alt || ''}">`); };
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 || '')}">`); };
1061
1139
 
1062
1140
  function insertTable(spec) {
1063
1141
  // A table dropped at a caret that is already in a table would nest a grid
@@ -1176,8 +1254,9 @@
1176
1254
  leading: { page: 1, block: 1, run: (v) => styleBlock('lineHeight', v), active: () => false, reflect: () => readBlockStyle('lineHeight') },
1177
1255
 
1178
1256
  clear: { md: 1, run: clearFormatting, active: () => false },
1179
- undo: { md: 1, history: 1, run: (v, area) => step(area, -1), active: () => false, enabled: (a) => a._history.at > 0 },
1180
- redo: { md: 1, history: 1, run: (v, area) => step(area, 1), active: () => false, enabled: (a) => a._history.at < a._history.stack.length - 1 },
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 },
1181
1260
  block: { md: 1, block: 1, run: (v) => setBlock(String(v || 'p').toLowerCase()), active: () => false, reflect: () => (blockTag() || 'P').toLowerCase() }
1182
1261
  });
1183
1262
 
@@ -1247,6 +1326,29 @@
1247
1326
 
1248
1327
  const MODES = new Set(['html', 'plain', 'minimal', 'literal', 'autofocus']);
1249
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
+
1250
1352
  /* ---- Autoformat ----
1251
1353
  A line converts when it is finished, not while it is being typed: press Enter
1252
1354
  and the line you are leaving becomes what its markdown described. Converting
@@ -1276,7 +1378,6 @@
1276
1378
  { re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
1277
1379
  { re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
1278
1380
  ];
1279
- const unsafe = (url) => /^\s*javascript:/i.test(url || '');
1280
1381
 
1281
1382
  function retagBlock(b, tag) {
1282
1383
  const n = document.createElement(tag);
@@ -1316,12 +1417,12 @@
1316
1417
  function inlineEl(rule, m) {
1317
1418
  const el = document.createElement(rule.tag);
1318
1419
  if (rule.tag === 'img') {
1319
- if (unsafe(m[rule.src])) return null;
1420
+ if (unsafeUrl(m[rule.src], true)) return null;
1320
1421
  el.setAttribute('src', m[rule.src]); el.setAttribute('alt', m[rule.alt] || '');
1321
1422
  return el;
1322
1423
  }
1323
1424
  el.textContent = m[rule.content || 1];
1324
- if (rule.href) { if (unsafe(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
1425
+ if (rule.href) { if (unsafeUrl(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
1325
1426
  return el;
1326
1427
  }
1327
1428
 
@@ -1374,10 +1475,32 @@
1374
1475
  const s = sel(), nr = document.createRange();
1375
1476
  nr.setStart(tail, 0); nr.collapse(true);
1376
1477
  s.removeAllRanges(); s.addRange(nr);
1478
+ area._exit = el.tagName === 'IMG' ? null : el;
1377
1479
  record(area);
1378
1480
  return true;
1379
1481
  }
1380
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
+
1381
1504
  function convertInline(root) {
1382
1505
  const nodes = [];
1383
1506
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -1617,8 +1740,9 @@
1617
1740
  area.innerHTML = h.stack[next].html;
1618
1741
  area.focus();
1619
1742
  caretIn(area, h.stack[next].caret);
1620
- h.restoring = false;
1743
+ saveRange(area);
1621
1744
  area.dispatchEvent(new Event('input', { bubbles: true }));
1745
+ h.restoring = false; // after the event, so it records nothing
1622
1746
  }
1623
1747
 
1624
1748
  /* ---- Registry + resolution ----
@@ -1680,9 +1804,10 @@
1680
1804
  return !!control && resolve(control) === area;
1681
1805
  }
1682
1806
 
1807
+ const TEXTLESS = /^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i;
1683
1808
  const editableElsewhere = (node) => {
1684
1809
  const el = elementAt(node);
1685
- return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA)$/.test(el.tagName));
1810
+ return !!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !TEXTLESS.test(el.type)));
1686
1811
  };
1687
1812
 
1688
1813
  function unambiguous(found) {
@@ -1700,6 +1825,8 @@
1700
1825
  if (r && area.contains(r.startContainer)) area._range = r.cloneRange();
1701
1826
  }
1702
1827
 
1828
+ const forget = (keep) => areas.forEach(a => { if (!keep || !a.contains(keep)) a._range = null; });
1829
+
1703
1830
  function restoreRange(area) {
1704
1831
  const r = range();
1705
1832
  if (r && area.contains(r.startContainer)) return;
@@ -1715,7 +1842,7 @@
1715
1842
  // write something the next save would drop, it reports itself unavailable.
1716
1843
  function allows(area, id, ctrl) {
1717
1844
  const spec = COMMANDS[id], cfg = area && area._te;
1718
- if (!spec || !cfg || cfg.mode === 'plain') return false;
1845
+ if (!spec || !cfg) return false;
1719
1846
  const page = !!(ctrl && ctrl.page && spec.page);
1720
1847
  // The writer's caret is visibly in ANOTHER editable element (a plain
1721
1848
  // x-edit leaf, an input, someone else's contenteditable): acting now
@@ -1724,9 +1851,13 @@
1724
1851
  // commands write variables, never the selection — they stay live.
1725
1852
  const live = range();
1726
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;
1727
1856
  if (cfg.minimal && spec.block && !page) return false;
1728
1857
  if (cfg.mode !== 'html' && !spec.md && !page) return false;
1729
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;
1730
1861
  // A wrap has nothing to wrap without a selection, and a control that is lit
1731
1862
  // but does nothing reads as broken — so say so rather than no-op.
1732
1863
  if (spec.needsSelection && !hasSelection()) return false;
@@ -1767,7 +1898,7 @@
1767
1898
  normalizeLists(area);
1768
1899
  if (!spec.history) record(area);
1769
1900
  saveRange(area);
1770
- area.dispatchEvent(new Event('input', { bubbles: true }));
1901
+ if (!spec.history) area.dispatchEvent(new Event('input', { bubbles: true })); // step fires its own
1771
1902
  sync();
1772
1903
  }
1773
1904
 
@@ -1802,8 +1933,14 @@
1802
1933
 
1803
1934
  Alpine.directive('text-edit', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
1804
1935
  const id = modifiers.find(m => COMMANDS[m]);
1805
- return id ? control(el, id, modifiers, expression, evaluate, cleanup)
1806
- : area(el, modifiers, expression, { effect, evaluateLater, cleanup });
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 });
1807
1944
  });
1808
1945
 
1809
1946
  // x-text-edit-menu — the turnkey selection bubble: put commands inside,
@@ -1838,7 +1975,7 @@
1838
1975
  if (lastFocused && lastFocused !== a) release(lastFocused); // the one being left owes a null
1839
1976
  saveRange(a); lastFocused = a; report(a);
1840
1977
  }
1841
- else if (lastFocused && editableElsewhere(r.startContainer)) release();
1978
+ else if (editableElsewhere(r.startContainer)) { forget(); if (lastFocused) release(); }
1842
1979
  sync();
1843
1980
  });
1844
1981
 
@@ -1850,9 +1987,13 @@
1850
1987
  document.addEventListener('focusin', (e) => {
1851
1988
  const t = e.target;
1852
1989
  if (!t || !t.closest) return;
1853
- if (t.closest('[data-text-edit]') || t.closest('[data-text-edit-control]')) return;
1854
- if (!lastFocused) return;
1855
- release();
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();
1856
1997
  sync();
1857
1998
  }, true);
1858
1999
  }
@@ -1881,27 +2022,31 @@
1881
2022
  selectedCells().forEach(c => c.setAttribute('data-text-edit-cell', ''));
1882
2023
  }
1883
2024
 
1884
- function report(area) {
1885
- // A caret placed inside one cell ends any block selection.
1886
- const live = sel();
1887
- if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
1888
- if (area.querySelector('table')) paintCells(area);
1889
- const shape = selectionOf(area);
1890
- area._selection = shape;
2025
+ function place(area, shape) {
2026
+ if (!shape || shape.collapsed) return;
1891
2027
  const px = (n) => Math.round(n) + 'px';
1892
2028
  // Mirrored onto the root as well as the area. A menu anchored to the
1893
2029
  // selection is rarely a descendant of the editor — and once it is a popover
1894
2030
  // it is in the top layer, where nothing inherits from the page at all — so
1895
2031
  // custom properties on the area alone are unreachable from where they are
1896
2032
  // needed. On the root they are readable from anywhere.
1897
- const targets = [area, document.documentElement];
1898
- if (shape && !shape.collapsed) targets.forEach(t => {
2033
+ [area, document.documentElement].forEach(t => {
1899
2034
  t.style.setProperty('--text-edit-selection-x', px(shape.x));
1900
2035
  t.style.setProperty('--text-edit-selection-y', px(shape.y));
1901
2036
  t.style.setProperty('--text-edit-selection-width', px(shape.width));
1902
2037
  t.style.setProperty('--text-edit-selection-height', px(shape.height));
1903
2038
  t.style.setProperty('--text-edit-selection-center', px(shape.x + shape.width / 2));
1904
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);
1905
2050
  area.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
1906
2051
  document.documentElement.toggleAttribute('data-text-edit-selected', !!shape && !shape.collapsed);
1907
2052
  area.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: shape }));
@@ -1913,17 +2058,41 @@
1913
2058
  function selectionMenu(el, cleanup) {
1914
2059
  if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
1915
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
+ };
1916
2080
  const onSel = (e) => {
1917
2081
  const area = elementAt(e.target) && elementAt(e.target).closest('[data-text-edit]');
1918
2082
  if (!area || resolve(el) !== area) return;
1919
2083
  const want = !!e.detail && !e.detail.collapsed;
2084
+ if (want) track(area); else untrack();
1920
2085
  try {
1921
2086
  if (want && !el.matches(':popover-open')) el.showPopover();
1922
2087
  if (!want && el.matches(':popover-open')) el.hidePopover();
1923
2088
  } catch { }
1924
2089
  };
1925
2090
  document.addEventListener('text-edit:selection', onSel);
1926
- cleanup(() => { document.removeEventListener('text-edit:selection', onSel); try { el.hidePopover(); } catch { } });
2091
+ cleanup(() => {
2092
+ document.removeEventListener('text-edit:selection', onSel);
2093
+ untrack(); if (frame) cancelAnimationFrame(frame);
2094
+ try { el.hidePopover(); } catch { }
2095
+ });
1927
2096
  }
1928
2097
 
1929
2098
  function control(el, id, modifiers, expression, evaluate, cleanup) {
@@ -1940,6 +2109,10 @@
1940
2109
  controls.add(el);
1941
2110
 
1942
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(); });
1943
2116
  if (valued) {
1944
2117
  const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._te); };
1945
2118
  if (el.type === 'color') {
@@ -1994,14 +2167,14 @@
1994
2167
  function area(el, modifiers, expression, { effect, evaluateLater, cleanup }) {
1995
2168
  const mode = modifiers.includes('html') ? 'html' : modifiers.includes('plain') ? 'plain' : 'markdown';
1996
2169
  const minimal = modifiers.includes('minimal');
1997
- modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text Edit] unknown modifier: .${m}`));
2170
+ modifiers.filter(m => !MODES.has(m)).forEach(m => console.warn(`[Manifest Text Edit] unknown modifier: .${m}${suggest(m)}`));
1998
2171
 
1999
2172
  el.setAttribute('data-text-edit', mode);
2000
2173
  // Tells anything composing with this plugin — x-edit, chiefly — whether the
2001
2174
  // value already has an owner, or whether the surrounding editor should
2002
2175
  // capture the content itself.
2003
2176
  el.toggleAttribute('data-text-edit-bound', !!expression);
2004
- el.setAttribute('contenteditable', 'true');
2177
+ el.setAttribute('contenteditable', mode === 'plain' && PLAINTEXT_ONLY ? 'plaintext-only' : 'true');
2005
2178
  el.setAttribute('role', 'textbox');
2006
2179
  el.setAttribute('aria-multiline', 'true');
2007
2180
  if (minimal) el.setAttribute('data-text-edit-minimal', '');
@@ -2011,7 +2184,7 @@
2011
2184
  : toMarkdown(el);
2012
2185
  const write = (v) => {
2013
2186
  if (mode === 'plain') el.textContent = v == null ? '' : String(v);
2014
- else el.innerHTML = mode === 'html' ? sanitize(v, true, true) : fromMarkdown(v);
2187
+ else el.innerHTML = mode === 'html' ? sanitize(v, true, true) : markdownHtml(v);
2015
2188
  if (!el.childNodes.length) el.innerHTML = '<p><br></p>';
2016
2189
  normalizeLists(el);
2017
2190
  if (el.querySelector('table')) normalizeTables(el);
@@ -2098,7 +2271,7 @@
2098
2271
  if (a && el.contains(a)) e.preventDefault();
2099
2272
  });
2100
2273
 
2101
- el.addEventListener('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-text-edit-focused', ''); sync(); });
2274
+ el.addEventListener('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-text-edit-focused', ''); saveRange(el); sync(); });
2102
2275
  // Focus gone somewhere that is neither this area nor one of its controls.
2103
2276
  el.addEventListener('focusout', (e) => {
2104
2277
  if (keeps(el, e.relatedTarget || pointerTarget)) return;
@@ -2106,10 +2279,18 @@
2106
2279
  sync();
2107
2280
  });
2108
2281
  el.addEventListener('input', (e) => {
2109
- if (!writing && e.data && applyPending(el, e.data)) { commitValue(); }
2110
- if (!writing && e.data && !e.isComposing && autoformat && convertInlineAtCaret(el)) { commitValue(); }
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
2111
2289
  settle(); sync();
2112
2290
  });
2291
+ el.addEventListener('compositionend', (e) => {
2292
+ if (!writing && e.data && applyPending(el, e.data)) { commitValue(); sync(); }
2293
+ });
2113
2294
  el.addEventListener('change', e => {
2114
2295
  if (e.target.type !== 'checkbox') return;
2115
2296
  // Clicking sets the property; only the attribute survives serialization.
@@ -2120,13 +2301,26 @@
2120
2301
 
2121
2302
  // A finished line resolves to what its markdown described, unless .literal.
2122
2303
  const autoformat = mode !== 'plain' && !modifiers.includes('literal');
2123
- el.addEventListener('input', () => {
2124
- if (writing) return;
2125
- recordSoon(el);
2304
+ el.addEventListener('input', (e) => {
2305
+ if (writing || e.isComposing) return;
2126
2306
  if (ancestor('li')) normalizeLists(el); // Backspace reshapes lists too
2127
2307
  });
2128
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
+
2129
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;
2130
2324
  // Tab indents rather than leaving the field, the way every editor behaves.
2131
2325
  // Escape is the way out, so keyboard users are never trapped.
2132
2326
  if (e.key === 'Enter' && mode !== 'plain') {
@@ -2179,21 +2373,22 @@
2179
2373
  // now (including our own docs) — links go through an `a` control.
2180
2374
  if (!(e.metaKey || e.ctrlKey)) return;
2181
2375
  const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
2182
- if (!id || !allows(el, id)) return;
2376
+ if (!id) return;
2377
+ // Never the browser's own formatting: it would look applied and not be stored.
2183
2378
  e.preventDefault();
2184
- run(el, id);
2379
+ if (allows(el, id)) run(el, id);
2185
2380
  });
2186
2381
 
2187
2382
  // Paste as our own subset — never the source app's markup.
2188
2383
  el.addEventListener('paste', (e) => {
2189
2384
  e.preventDefault();
2190
2385
  const dt = e.clipboardData; if (!dt) return;
2386
+ if (mode === 'plain') return insertPlain(el, dt.getData('text/plain'));
2191
2387
  const html = dt.getData('text/html');
2192
2388
  // No keepStyle: a paste brings the text and its marks, never the source
2193
2389
  // application's fonts and colours.
2194
- insertHTML(mode === 'plain' ? escHtml(dt.getData('text/plain'))
2195
- : html ? sanitize(html, !minimal)
2196
- : fromMarkdown(dt.getData('text/plain').replace(/([^\n])\n(?!\n)/g, '$1 \n')));
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')));
2197
2392
  });
2198
2393
 
2199
2394
  const api = {
@@ -2202,7 +2397,9 @@
2202
2397
  write(v);
2203
2398
  el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0;
2204
2399
  settle();
2400
+ el._history.restoring = true;
2205
2401
  el.dispatchEvent(new Event('input', { bubbles: true }));
2402
+ el._history.restoring = false;
2206
2403
  },
2207
2404
  get page() { return pageStyle(el); },
2208
2405
  set page(v) { setPageStyle(el, v); sync(); },
@@ -2213,7 +2410,7 @@
2213
2410
  run: (id, arg) => run(el, id, arg),
2214
2411
  active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
2215
2412
  can: (id) => allows(el, id),
2216
- selectAll: () => { el.focus(); selectNode(el); sync(); },
2413
+ selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
2217
2414
  markdown: () => toMarkdown(el),
2218
2415
  html: () => sanitize(el.innerHTML, true, true)
2219
2416
  };
@@ -2230,5 +2427,5 @@
2230
2427
  document.addEventListener('alpine:init', init);
2231
2428
  if (window.Alpine && Alpine.directive) init();
2232
2429
 
2233
- window.ManifestTextEdit = { toMarkdown, fromMarkdown, sanitize, commands: () => Object.keys(COMMANDS) };
2430
+ window.ManifestTextEdit = { toMarkdown, fromMarkdown: markdownHtml, sanitize, commands: () => Object.keys(COMMANDS) };
2234
2431
  })();