mnfst 0.5.231 → 0.5.232

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