mnfst 0.5.218 → 0.5.220

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.
@@ -333,26 +333,7 @@ async function handleRouteChange() {
333
333
  if (!window.location.hash) {
334
334
  setTimeout(() => {
335
335
  window.scrollTo({ top: 0, behavior: 'smooth' });
336
-
337
- // Also reset any scrollable containers.
338
- const potentialContainers = document.querySelectorAll('div, main, section, article, aside, nav, header, footer, .prose');
339
- potentialContainers.forEach(element => {
340
- const computedStyle = window.getComputedStyle(element);
341
- const isScrollable = (
342
- computedStyle.overflowY === 'auto' ||
343
- computedStyle.overflowY === 'scroll' ||
344
- computedStyle.overflow === 'auto' ||
345
- computedStyle.overflow === 'scroll'
346
- ) && element.scrollHeight > element.clientHeight;
347
-
348
- if (isScrollable) {
349
- element.scrollTop = 0;
350
- }
351
- });
352
- }, 50);
353
- } else {
354
- setTimeout(() => {
355
- // Let the browser scroll to the anchor once content has loaded.
336
+ resetScrolledContainers();
356
337
  }, 50);
357
338
  }
358
339
 
@@ -377,6 +358,51 @@ async function handleRouteChange() {
377
358
  }
378
359
  }
379
360
 
361
+ // Scroll containers reset on route change. Only elements that fired a scroll
362
+ // event can be off top, so track those instead of sweeping the document.
363
+ const SCROLL_CONTAINER_SELECTOR = 'div, main, section, article, aside, nav, header, footer, .prose';
364
+ const scrolledContainers = new Set();
365
+ // Loaded after parse began → elements may have scrolled unseen; first reset sweeps once
366
+ let scrollSweepPending = document.readyState !== 'loading';
367
+
368
+ document.addEventListener('scroll', (event) => {
369
+ const el = event.target;
370
+ if (!el || el.nodeType !== 1 || scrolledContainers.has(el)) return;
371
+ // Prune detached entries so long single-route sessions don't retain them
372
+ if (scrolledContainers.size >= 64) {
373
+ for (const tracked of scrolledContainers) if (!tracked.isConnected) scrolledContainers.delete(tracked);
374
+ }
375
+ scrolledContainers.add(el);
376
+ }, { capture: true, passive: true });
377
+
378
+ // True when done with the element: reset, or rendered and nothing to reset.
379
+ // A container under a hidden route reads scrollTop 0 but keeps its offset: stay tracked.
380
+ // data-scroll-keep (on the container or an ancestor) opts out of every reset.
381
+ function resetContainer(element) {
382
+ if (element.closest('[data-scroll-keep]')) return false;
383
+ if (!element.getClientRects().length) return false;
384
+ if (!element.scrollTop) return true;
385
+ const computedStyle = window.getComputedStyle(element);
386
+ const isScrollable = (
387
+ computedStyle.overflowY === 'auto' ||
388
+ computedStyle.overflowY === 'scroll' ||
389
+ computedStyle.overflow === 'auto' ||
390
+ computedStyle.overflow === 'scroll'
391
+ ) && element.scrollHeight > element.clientHeight;
392
+ if (isScrollable) element.scrollTop = 0;
393
+ return true;
394
+ }
395
+
396
+ function resetScrolledContainers() {
397
+ if (scrollSweepPending) {
398
+ scrollSweepPending = false;
399
+ document.querySelectorAll(SCROLL_CONTAINER_SELECTOR).forEach(resetContainer);
400
+ }
401
+ for (const element of scrolledContainers) {
402
+ if (!element.isConnected || !element.matches(SCROLL_CONTAINER_SELECTOR) || resetContainer(element)) scrolledContainers.delete(element);
403
+ }
404
+ }
405
+
380
406
  // Whether SPA route changes run inside a View Transition. Priority:
381
407
  // data-no-view-transitions → off, data-view-transitions → on, else auto
382
408
  // (on under VT_AUTO_THRESHOLD elements). Auto threshold: VT rasterizes the
@@ -393,7 +419,7 @@ function shouldUseViewTransition() {
393
419
  if (html.hasAttribute('data-no-view-transitions')) return false;
394
420
  if (html.hasAttribute('data-view-transitions')) return true;
395
421
  try {
396
- return document.querySelectorAll('*').length < VT_AUTO_THRESHOLD;
422
+ return !document.getElementsByTagName('*')[VT_AUTO_THRESHOLD - 1];
397
423
  } catch {
398
424
  return false;
399
425
  }
@@ -739,13 +765,14 @@ function isRouteActive(element, normalizedPath) {
739
765
  // Activation hook fires before the route becomes visible so deferred content renders first
740
766
  function showRoute(element) {
741
767
  element.dispatchEvent(new CustomEvent('manifest:route-activate'));
742
- element.removeAttribute('hidden');
743
- element.style.display = '';
768
+ if (element.hasAttribute('hidden')) element.removeAttribute('hidden');
769
+ if (element.style.display) element.style.display = '';
744
770
  }
745
771
 
772
+ // Skip no-op writes: each one is a mutation record for every observer
746
773
  function hideRoute(element) {
747
- element.setAttribute('hidden', '');
748
- element.style.display = 'none';
774
+ if (!element.hasAttribute('hidden')) element.setAttribute('hidden', '');
775
+ if (element.style.display !== 'none') element.style.display = 'none';
749
776
  }
750
777
 
751
778
  // Process visibility for all elements with x-route
@@ -830,11 +857,9 @@ window.ManifestRoutingVisibility = {
830
857
 
831
858
  // Router head
832
859
 
833
- function isPrerenderedStaticMPA() {
860
+ // Prerendered MPA output has route head content baked in
861
+ function headIsPrerenderedMPA() {
834
862
  try {
835
- if (window.ManifestRoutingVisibility && typeof window.ManifestRoutingVisibility.isPrerenderedStaticMPA === 'function') {
836
- return window.ManifestRoutingVisibility.isPrerenderedStaticMPA();
837
- }
838
863
  return document.querySelector('meta[name="manifest:prerendered"][content="1"]') !== null;
839
864
  } catch (e) {
840
865
  return false;
@@ -996,27 +1021,11 @@ function processElementHeadContent(element, normalizedPath) {
996
1021
 
997
1022
  // Process all head content in the DOM
998
1023
  function processAllHeadContent(normalizedPath) {
999
- if (isPrerenderedStaticMPA()) return;
1024
+ if (headIsPrerenderedMPA()) return;
1000
1025
 
1001
1026
  // Find all elements with head templates
1002
1027
  const elementsWithHead = document.querySelectorAll('template[data-head]');
1003
1028
 
1004
- // Debug: Let's see what's actually in the DOM
1005
- const allTemplates = document.querySelectorAll('template');
1006
- allTemplates.forEach((template, index) => {
1007
- if (template.hasAttribute('data-head')) {
1008
- } else {
1009
- // Check if this might be the about template
1010
- if (template.getAttribute('x-route') === 'about') {
1011
- }
1012
- }
1013
- });
1014
-
1015
- // Also try a more specific selector to see if we can find the about template
1016
- const aboutTemplate = document.querySelector('template[x-route="about"]');
1017
- if (aboutTemplate) {
1018
- }
1019
-
1020
1029
  // Process each element's head content
1021
1030
  elementsWithHead.forEach((template, index) => {
1022
1031
 
@@ -1075,27 +1084,17 @@ function initializeHeadContent() {
1075
1084
  function processHeadContentAfterComponentsReady() {
1076
1085
  // Process initial head content after a longer delay to let components settle
1077
1086
  setTimeout(() => {
1078
- if (isPrerenderedStaticMPA()) return;
1087
+ if (headIsPrerenderedMPA()) return;
1079
1088
  const currentPath = window.ManifestRoutingNavigation?.getCurrentRoute() ?? window.location.pathname;
1080
1089
  const normalizedPath = currentPath === '/' ? '/' : currentPath.replace(/^\/|\/$/g, '');
1081
1090
 
1082
- // Debug: Check if about component exists
1083
- const aboutComponent = document.querySelector('[data-component="about-1"]');
1084
- if (aboutComponent) {
1085
- }
1086
-
1087
- // Debug: Check what placeholders exist
1088
- const placeholders = document.querySelectorAll('x-about, x-home, x-ui');
1089
- placeholders.forEach((placeholder, index) => {
1090
- });
1091
-
1092
1091
  processAllHeadContent(normalizedPath);
1093
1092
  }, 200);
1094
1093
  }
1095
1094
 
1096
1095
  // Function to process head content immediately (for projects without components)
1097
1096
  function processHeadContentImmediately() {
1098
- if (isPrerenderedStaticMPA()) return;
1097
+ if (headIsPrerenderedMPA()) return;
1099
1098
  const currentPath = window.ManifestRoutingNavigation?.getCurrentRoute() ?? window.location.pathname;
1100
1099
  const normalizedPath = currentPath === '/' ? '/' : currentPath.replace(/^\/|\/$/g, '');
1101
1100
  processAllHeadContent(normalizedPath);
@@ -1129,21 +1128,11 @@ function initializeHeadContent() {
1129
1128
 
1130
1129
  // Wait a bit for components to settle after route change
1131
1130
  setTimeout(() => {
1132
- if (isPrerenderedStaticMPA()) return;
1131
+ if (headIsPrerenderedMPA()) return;
1133
1132
  // Process head content immediately to catch components before they're reverted
1134
1133
  const currentPath = window.ManifestRoutingNavigation?.getCurrentRoute() ?? window.location.pathname;
1135
1134
  const normalizedPath = currentPath === '/' ? '/' : currentPath.replace(/^\/|\/$/g, '');
1136
1135
 
1137
- // Debug: Check if about component exists
1138
- const aboutComponent = document.querySelector('[data-component="about-1"]');
1139
- if (aboutComponent) {
1140
- }
1141
-
1142
- // Debug: Check what placeholders exist
1143
- const placeholders = document.querySelectorAll('x-about, x-home, x-ui');
1144
- placeholders.forEach((placeholder, index) => {
1145
- });
1146
-
1147
1136
  processAllHeadContent(normalizedPath);
1148
1137
  }, 100);
1149
1138
  });
@@ -16,7 +16,7 @@
16
16
 
17
17
  // Build stamps the published version here; unstamped (dev) falls back to the
18
18
  // registration URL's `v`, then 'dev'.
19
- const BUILD_VERSION = '0.5.218';
19
+ const BUILD_VERSION = '0.5.220';
20
20
  const CACHE_PREFIX = 'mnfst-sw:';
21
21
  const META_CACHE = CACHE_PREFIX + 'meta';
22
22
  // Sized to hold a full precache.json warm-up (PRECACHE_MAX_ENTRIES server-side)
@@ -39,6 +39,35 @@
39
39
  }
40
40
  }
41
41
 
42
+ /* ---- Selection menu (x-text-edit-menu) ----
43
+ Positioned by the --text-edit-selection-* variables the plugin writes to
44
+ :root, so the default needs no script; restyle or reposition freely. */
45
+ :where([data-text-edit-menu]) {
46
+ position: fixed;
47
+ margin: 0;
48
+ inset: auto;
49
+ left: var(--text-edit-selection-center);
50
+ top: var(--text-edit-selection-y);
51
+ transform: translate(-50%, calc(-100% - 0.5rem));
52
+ display: flex;
53
+ gap: 0.25rem;
54
+ align-items: center;
55
+ padding: 0.25rem;
56
+ background: var(--color-surface-1);
57
+ border: 1px solid var(--color-line);
58
+ border-radius: var(--radius);
59
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.2);
60
+ }
61
+
62
+ /* Without the Popover API the attribute is inert and the menu would render
63
+ permanently fixed on the page — hide it there; the bubble is progressive
64
+ enhancement. (In supporting browsers a closed popover is hidden natively.) */
65
+ @supports not selector(:popover-open) {
66
+ :where([data-text-edit-menu]) {
67
+ display: none;
68
+ }
69
+ }
70
+
42
71
  /* ---- Controls ----
43
72
  A control is marked wherever it lives, so a toolbar is optional markup and
44
73
  never a requirement. The reset already dims [aria-disabled=true]. */
@@ -1011,12 +1011,24 @@
1011
1011
 
1012
1012
  /* ---- Links ----
1013
1013
  An <input x-text-edit.a> is the whole define / edit / clear surface: it shows
1014
- the current href, sets it on change, and clears the link when emptied. */
1014
+ the current href, sets it on change, and clears the link when emptied. The
1015
+ `unlink` command is the explicit button form of clearing it. */
1016
+ // What counts as a link: an explicit scheme (https, mailto, tel, …), a relative
1017
+ // path, an anchor — or a bare domain, which gets https:// so "example.com"
1018
+ // does what the writer meant. Anything else (spaces, no dot, no scheme) is
1019
+ // not applied; the field re-syncs to the real href, showing it didn't take.
1020
+ function normalizeUrl(url) {
1021
+ if (/\s/.test(url) || /^\s*javascript:/i.test(url)) return null;
1022
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
1023
+ if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
1024
+ return null;
1025
+ }
1015
1026
  function setLink(href) {
1016
1027
  const existing = ancestor('a');
1017
- const url = href == null ? '' : String(href).trim();
1018
- if (!url) { if (existing) existing.replaceWith(...existing.childNodes); return; }
1019
- if (/^\s*javascript:/i.test(url)) return;
1028
+ const raw = href == null ? '' : String(href).trim();
1029
+ if (!raw) { if (existing) existing.replaceWith(...existing.childNodes); return; }
1030
+ const url = normalizeUrl(raw);
1031
+ if (!url) return;
1020
1032
  if (existing) { existing.setAttribute('href', url); return; }
1021
1033
  const made = wrapInline('a');
1022
1034
  if (made.length) made.forEach(a => a.setAttribute('href', url));
@@ -1029,6 +1041,11 @@
1029
1041
  const insertImage = (src, alt) => { if (src && !/^\s*javascript:/i.test(src)) insertHTML(`<img src="${src}" alt="${alt || ''}">`); };
1030
1042
 
1031
1043
  function insertTable(spec) {
1044
+ // A table dropped at a caret that is already in a table would nest a grid
1045
+ // inside a cell. Land it after the outermost table instead.
1046
+ let host = ancestor('table');
1047
+ while (host && host.parentElement && withinArea(host, host.parentElement.closest('table'))) host = host.parentElement.closest('table');
1048
+ if (host) { const p = document.createElement('p'); p.appendChild(document.createElement('br')); host.after(p); caretIn(p, 0); }
1032
1049
  const [rows, cols] = String(spec || '3x3').split(/[x×,]/).map(n => Math.max(1, Math.min(20, parseInt(n, 10) || 3)));
1033
1050
  const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
1034
1051
  insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
@@ -1111,6 +1128,7 @@
1111
1128
  };
1112
1129
  Object.assign(COMMANDS, {
1113
1130
  a: { md: 1, run: setLink, active: () => !!ancestor('a'), reflect: linkHref, enabled: () => !!ancestor('a') || hasSelection() },
1131
+ unlink: { md: 1, run: () => { const a = ancestor('a'); if (a) a.replaceWith(...a.childNodes); }, active: () => false, enabled: () => !!ancestor('a') },
1114
1132
  ul: { md: 1, block: 1, run: () => toggleList('ul'), active: () => !!ancestor('ul') && !ancestor('ul').hasAttribute('data-checklist') },
1115
1133
  ol: { md: 1, block: 1, run: () => toggleList('ol'), active: () => !!ancestor('ol') },
1116
1134
  checklist: { md: 1, block: 1, run: toggleChecklist, active: () => !!ancestor('ul[data-checklist]') },
@@ -1299,6 +1317,48 @@
1299
1317
  return best;
1300
1318
  }
1301
1319
 
1320
+ // A finished inline mark converts the moment its closing marker is typed —
1321
+ // **bold**, *italic*, `code`, ~~struck~~, [text](url) — like any modern
1322
+ // editor, rather than waiting for Enter. Only a pattern ending exactly at
1323
+ // the caret is taken, so half-typed markers never convert under the caret.
1324
+ function endInline(text) {
1325
+ let best = null;
1326
+ for (const rule of INLINE_RULES) {
1327
+ const re = new RegExp(rule.re.source, 'g');
1328
+ let m;
1329
+ while ((m = re.exec(text))) {
1330
+ if (m.index + m[0].length !== text.length) continue;
1331
+ const skip = rule.pre ? m[rule.pre].length : 0;
1332
+ const start = m.index + skip;
1333
+ // Earliest start wins, like firstInline — ![alt](src) must be
1334
+ // the image rule, not the link rule matching one char later.
1335
+ if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1336
+ break;
1337
+ }
1338
+ }
1339
+ return best;
1340
+ }
1341
+
1342
+ function convertInlineAtCaret(area) {
1343
+ const r = range(); if (!r || !r.collapsed) return false;
1344
+ const node = r.startContainer;
1345
+ if (!node || node.nodeType !== 3 || !node.parentElement) return false;
1346
+ const host = node.parentElement.closest('code, pre, a');
1347
+ if (host && area.contains(host)) return false; // literal contexts keep their text
1348
+ const hit = endInline(node.nodeValue.slice(0, r.startOffset));
1349
+ if (!hit) return false;
1350
+ const el = inlineEl(hit.rule, hit.m); if (!el) return false;
1351
+ record(area);
1352
+ const tail = node.splitText(hit.start);
1353
+ tail.deleteData(0, hit.len);
1354
+ tail.parentNode.insertBefore(el, tail);
1355
+ const s = sel(), nr = document.createRange();
1356
+ nr.setStart(tail, 0); nr.collapse(true);
1357
+ s.removeAllRanges(); s.addRange(nr);
1358
+ record(area);
1359
+ return true;
1360
+ }
1361
+
1302
1362
  function convertInline(root) {
1303
1363
  const nodes = [];
1304
1364
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -1320,7 +1380,13 @@
1320
1380
  // Returns the element now holding the line's content — an <li> for a list, so the
1321
1381
  // caller knows to continue the list rather than start a paragraph.
1322
1382
  function convertLine(b) {
1323
- if (!b || b.closest('pre')) return b;
1383
+ if (!b) return b;
1384
+ // Bounded like every other ancestor lookup: only a <pre> INSIDE the
1385
+ // editor means "we're in a code block". An editor hosted within someone
1386
+ // else's <pre> (a docs code-group wrapper) still converts markdown.
1387
+ const area = b.closest('[data-text-edit]');
1388
+ const pre = b.closest('pre');
1389
+ if (pre && (!area || area.contains(pre))) return b;
1324
1390
  const text = b.textContent;
1325
1391
  for (const rule of LINE_RULES) {
1326
1392
  const m = text.match(rule.re); if (!m) continue;
@@ -1446,8 +1512,13 @@
1446
1512
  function onEnter(area, autoformat) {
1447
1513
  let holder = ancestor('li') || blockEl() || ensureBlock(area);
1448
1514
 
1515
+ // Bounded to the area, like ancestor(): an editor that happens to LIVE
1516
+ // inside someone else's <pre> (a docs frame inside a code-group wrapper)
1517
+ // must not treat that wrapper as its own code block — the exit path below
1518
+ // rewrites and can remove the pre, destroying the host markup around the
1519
+ // editor.
1449
1520
  const pre = holder.closest('pre');
1450
- if (pre) {
1521
+ if (pre && pre !== area && area.contains(pre)) {
1451
1522
  // An empty last line leaves the code block — the one case the browser
1452
1523
  // cannot do, and without it the block has no keyboard exit.
1453
1524
  while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
@@ -1512,7 +1583,10 @@
1512
1583
  const recordSoon = (area) => {
1513
1584
  const h = area._history; if (!h || h.restoring) return;
1514
1585
  clearTimeout(h.timer);
1515
- h.timer = setTimeout(() => record(area), IDLE);
1586
+ // Re-sync once the burst lands: undo/redo controls gate on history state,
1587
+ // and the reset's [aria-disabled] rule blocks pointer events — without
1588
+ // this, Undo stays visually dead until some unrelated event syncs.
1589
+ h.timer = setTimeout(() => { record(area); sync(); }, IDLE);
1516
1590
  };
1517
1591
 
1518
1592
  function step(area, by) {
@@ -1663,19 +1737,6 @@
1663
1737
  sync();
1664
1738
  }
1665
1739
 
1666
- // A link at the caret comes off; a selection gets one, through the area's own url
1667
- // control where there is one.
1668
- function toggleLink(area) {
1669
- if (ancestor('a')) { run(area, 'a'); return; }
1670
- if (!hasSelection()) return;
1671
- saveRange(area);
1672
- const field = [...controls].find(c => c._te && c._te.id === 'a' && !c._te.page
1673
- && 'value' in c && c.tagName !== 'BUTTON' && resolve(c) === area);
1674
- if (field) { field.focus(); if (field.select) field.select(); return; }
1675
- const url = typeof window.prompt === 'function' ? window.prompt('Link URL', '') : null;
1676
- if (url) run(area, 'a', url);
1677
- }
1678
-
1679
1740
  // Reflect caret state onto every control pointing at the area that owns it.
1680
1741
  function sync() {
1681
1742
  controls.forEach(c => {
@@ -1711,10 +1772,20 @@
1711
1772
  : area(el, modifiers, expression, { effect, evaluateLater, cleanup });
1712
1773
  });
1713
1774
 
1714
- // $text — the area this element sits in, or the last focused one.
1775
+ // x-text-edit-menu — the turnkey selection bubble: put commands inside,
1776
+ // and the plugin shows the element over the selection of the area it
1777
+ // resolves to (same rules as any control) and hides it when the
1778
+ // selection is gone. Positioning is the stylesheet's, off the
1779
+ // --text-edit-selection-* variables, so it can be restyled freely.
1780
+ Alpine.directive('text-edit-menu', (el, _, { cleanup }) => selectionMenu(el, cleanup));
1781
+
1782
+ // $text — the area this element sits in, resolved like a control's:
1783
+ // containment first (nearest ancestor holding exactly one area), the
1784
+ // last focused area second. Never "the first area on the page" — a
1785
+ // button at the bottom must not act on, and scroll to, the top one.
1715
1786
  Alpine.magic('text', (el) => {
1716
1787
  const own = el.closest('[data-text-edit]');
1717
- const a = own && areas.has(own) ? own : preferred([...areas]);
1788
+ const a = own && areas.has(own) ? own : resolve(el);
1718
1789
  return a ? a._te.api : {
1719
1790
  value: '', link: '', selection: null, page: {}, run() { }, active: () => false,
1720
1791
  can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
@@ -1802,6 +1873,25 @@
1802
1873
  area.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: shape }));
1803
1874
  }
1804
1875
 
1876
+ // The selection bubble: a popover shown while its area has a selection. The
1877
+ // element is promoted to popover="manual" (an auto popover closes on the very
1878
+ // click that makes the selection); show/hide follow text-edit:selection.
1879
+ function selectionMenu(el, cleanup) {
1880
+ if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
1881
+ el.setAttribute('data-text-edit-menu', '');
1882
+ const onSel = (e) => {
1883
+ const area = elementAt(e.target) && elementAt(e.target).closest('[data-text-edit]');
1884
+ if (!area || resolve(el) !== area) return;
1885
+ const want = !!e.detail && !e.detail.collapsed;
1886
+ try {
1887
+ if (want && !el.matches(':popover-open')) el.showPopover();
1888
+ if (!want && el.matches(':popover-open')) el.hidePopover();
1889
+ } catch { }
1890
+ };
1891
+ document.addEventListener('text-edit:selection', onSel);
1892
+ cleanup(() => { document.removeEventListener('text-edit:selection', onSel); try { el.hidePopover(); } catch { } });
1893
+ }
1894
+
1805
1895
  function control(el, id, modifiers, expression, evaluate, cleanup) {
1806
1896
  // `.h2` and `.align.center` carry a static argument as the next modifier; an
1807
1897
  // expression is the dynamic form (`x-text-edit.a="url"`).
@@ -1818,7 +1908,46 @@
1818
1908
  const valued = 'value' in el && el.tagName !== 'BUTTON';
1819
1909
  if (valued) {
1820
1910
  const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._te); };
1821
- el.addEventListener('change', fire);
1911
+ if (el.type === 'color') {
1912
+ // Colour previews live: the first input tick wraps the selection
1913
+ // (recorded once, so undo removes the whole pass in one step) and
1914
+ // every later tick restyles those same spans. Works with the
1915
+ // browser picker, Manifest's, or anything that fires `input`.
1916
+ const prop = id === 'background' ? 'backgroundColor' : 'color';
1917
+ let live = null;
1918
+ // A new selection elsewhere ends the pass: without this, a
1919
+ // picker that never fires `change` keeps recolouring the OLD
1920
+ // spans after the writer has selected different text.
1921
+ const dropLive = () => {
1922
+ if (!live) return;
1923
+ const r = range();
1924
+ if (!r || !live.some(sp => sp.isConnected && sp.contains(r.startContainer))) live = null;
1925
+ };
1926
+ document.addEventListener('text-edit:selection', dropLive);
1927
+ cleanup(() => document.removeEventListener('text-edit:selection', dropLive));
1928
+ el.addEventListener('input', () => {
1929
+ const a = resolve(el); if (!a) return;
1930
+ if (el._te.page) { run(a, id, el.value, el._te); return; } // page vars are cheap to set per tick
1931
+ if (live && live.length && live.every(s => s.isConnected)) {
1932
+ live.forEach(s => { s.style[prop] = el.value; });
1933
+ a.dispatchEvent(new Event('input', { bubbles: true }));
1934
+ return;
1935
+ }
1936
+ if (!allows(a, id, el._te)) return;
1937
+ restoreRange(a);
1938
+ const r = range(); if (!r || !a.contains(r.startContainer)) return;
1939
+ record(a);
1940
+ live = wrapInline('span', { [prop]: el.value });
1941
+ saveRange(a);
1942
+ a.dispatchEvent(new Event('input', { bubbles: true }));
1943
+ });
1944
+ el.addEventListener('change', () => {
1945
+ if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; } // applied live already
1946
+ fire();
1947
+ });
1948
+ } else {
1949
+ el.addEventListener('change', fire);
1950
+ }
1822
1951
  el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
1823
1952
  } else {
1824
1953
  el.addEventListener('pointerdown', e => e.preventDefault()); // never take the caret
@@ -1927,6 +2056,14 @@
1927
2056
  sync();
1928
2057
  });
1929
2058
 
2059
+ // A link inside the editor is content, not navigation: clicking it places
2060
+ // the caret to edit it. Without this, the router (or the browser) follows
2061
+ // the href and the page changes out from under the writer.
2062
+ el.addEventListener('click', (e) => {
2063
+ const a = e.target && e.target.closest && e.target.closest('a');
2064
+ if (a && el.contains(a)) e.preventDefault();
2065
+ });
2066
+
1930
2067
  el.addEventListener('focusin', () => { lastFocused = el; sync(); });
1931
2068
  // Focus gone somewhere that is neither this area nor one of its controls.
1932
2069
  el.addEventListener('focusout', (e) => {
@@ -1936,6 +2073,7 @@
1936
2073
  });
1937
2074
  el.addEventListener('input', (e) => {
1938
2075
  if (!writing && e.data && applyPending(el, e.data)) { commitValue(); }
2076
+ if (!writing && e.data && !e.isComposing && autoformat && convertInlineAtCaret(el)) { commitValue(); }
1939
2077
  settle(); sync();
1940
2078
  });
1941
2079
  el.addEventListener('change', e => {
@@ -2003,11 +2141,12 @@
2003
2141
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
2004
2142
  e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
2005
2143
  }
2144
+ // No Cmd/Ctrl+K: that chord belongs to site search almost everywhere
2145
+ // now (including our own docs) — links go through an `a` control.
2006
2146
  if (!(e.metaKey || e.ctrlKey)) return;
2007
- const id = { b: 'strong', i: 'em', u: 'u', k: 'a' }[e.key.toLowerCase()];
2147
+ const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
2008
2148
  if (!id || !allows(el, id)) return;
2009
2149
  e.preventDefault();
2010
- if (id === 'a') { toggleLink(el); return; }
2011
2150
  run(el, id);
2012
2151
  });
2013
2152