mnfst 0.5.217 → 0.5.219

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.217';
19
+ const BUILD_VERSION = '0.5.219';
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,26 @@
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
+
42
62
  /* ---- Controls ----
43
63
  A control is marked wherever it lives, so a toolbar is optional markup and
44
64
  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) || /^[/#?.]/.test(url)) return url;
1023
+ if (/^[\w-]+(\.[\w-]+)+(\/|$|\?|#)/.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,45 @@
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
+ best = { rule, m, start: m.index + skip, len: m[0].length - skip };
1333
+ break;
1334
+ }
1335
+ }
1336
+ return best;
1337
+ }
1338
+
1339
+ function convertInlineAtCaret(area) {
1340
+ const r = range(); if (!r || !r.collapsed) return false;
1341
+ const node = r.startContainer;
1342
+ if (!node || node.nodeType !== 3 || !node.parentElement) return false;
1343
+ const host = node.parentElement.closest('code, pre, a');
1344
+ if (host && area.contains(host)) return false; // literal contexts keep their text
1345
+ const hit = endInline(node.nodeValue.slice(0, r.startOffset));
1346
+ if (!hit) return false;
1347
+ const el = inlineEl(hit.rule, hit.m); if (!el) return false;
1348
+ record(area);
1349
+ const tail = node.splitText(hit.start);
1350
+ tail.deleteData(0, hit.len);
1351
+ tail.parentNode.insertBefore(el, tail);
1352
+ const s = sel(), nr = document.createRange();
1353
+ nr.setStart(tail, 0); nr.collapse(true);
1354
+ s.removeAllRanges(); s.addRange(nr);
1355
+ record(area);
1356
+ return true;
1357
+ }
1358
+
1302
1359
  function convertInline(root) {
1303
1360
  const nodes = [];
1304
1361
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -1320,7 +1377,13 @@
1320
1377
  // Returns the element now holding the line's content — an <li> for a list, so the
1321
1378
  // caller knows to continue the list rather than start a paragraph.
1322
1379
  function convertLine(b) {
1323
- if (!b || b.closest('pre')) return b;
1380
+ if (!b) return b;
1381
+ // Bounded like every other ancestor lookup: only a <pre> INSIDE the
1382
+ // editor means "we're in a code block". An editor hosted within someone
1383
+ // else's <pre> (a docs code-group wrapper) still converts markdown.
1384
+ const area = b.closest('[data-text-edit]');
1385
+ const pre = b.closest('pre');
1386
+ if (pre && (!area || area.contains(pre))) return b;
1324
1387
  const text = b.textContent;
1325
1388
  for (const rule of LINE_RULES) {
1326
1389
  const m = text.match(rule.re); if (!m) continue;
@@ -1446,8 +1509,13 @@
1446
1509
  function onEnter(area, autoformat) {
1447
1510
  let holder = ancestor('li') || blockEl() || ensureBlock(area);
1448
1511
 
1512
+ // Bounded to the area, like ancestor(): an editor that happens to LIVE
1513
+ // inside someone else's <pre> (a docs frame inside a code-group wrapper)
1514
+ // must not treat that wrapper as its own code block — the exit path below
1515
+ // rewrites and can remove the pre, destroying the host markup around the
1516
+ // editor.
1449
1517
  const pre = holder.closest('pre');
1450
- if (pre) {
1518
+ if (pre && pre !== area && area.contains(pre)) {
1451
1519
  // An empty last line leaves the code block — the one case the browser
1452
1520
  // cannot do, and without it the block has no keyboard exit.
1453
1521
  while (pre.lastChild && (pre.lastChild.nodeName === 'BR' || !pre.lastChild.textContent.trim())) pre.lastChild.remove();
@@ -1512,7 +1580,10 @@
1512
1580
  const recordSoon = (area) => {
1513
1581
  const h = area._history; if (!h || h.restoring) return;
1514
1582
  clearTimeout(h.timer);
1515
- h.timer = setTimeout(() => record(area), IDLE);
1583
+ // Re-sync once the burst lands: undo/redo controls gate on history state,
1584
+ // and the reset's [aria-disabled] rule blocks pointer events — without
1585
+ // this, Undo stays visually dead until some unrelated event syncs.
1586
+ h.timer = setTimeout(() => { record(area); sync(); }, IDLE);
1516
1587
  };
1517
1588
 
1518
1589
  function step(area, by) {
@@ -1663,19 +1734,6 @@
1663
1734
  sync();
1664
1735
  }
1665
1736
 
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
1737
  // Reflect caret state onto every control pointing at the area that owns it.
1680
1738
  function sync() {
1681
1739
  controls.forEach(c => {
@@ -1711,10 +1769,20 @@
1711
1769
  : area(el, modifiers, expression, { effect, evaluateLater, cleanup });
1712
1770
  });
1713
1771
 
1714
- // $text — the area this element sits in, or the last focused one.
1772
+ // x-text-edit-menu — the turnkey selection bubble: put commands inside,
1773
+ // and the plugin shows the element over the selection of the area it
1774
+ // resolves to (same rules as any control) and hides it when the
1775
+ // selection is gone. Positioning is the stylesheet's, off the
1776
+ // --text-edit-selection-* variables, so it can be restyled freely.
1777
+ Alpine.directive('text-edit-menu', (el, _, { cleanup }) => selectionMenu(el, cleanup));
1778
+
1779
+ // $text — the area this element sits in, resolved like a control's:
1780
+ // containment first (nearest ancestor holding exactly one area), the
1781
+ // last focused area second. Never "the first area on the page" — a
1782
+ // button at the bottom must not act on, and scroll to, the top one.
1715
1783
  Alpine.magic('text', (el) => {
1716
1784
  const own = el.closest('[data-text-edit]');
1717
- const a = own && areas.has(own) ? own : preferred([...areas]);
1785
+ const a = own && areas.has(own) ? own : resolve(el);
1718
1786
  return a ? a._te.api : {
1719
1787
  value: '', link: '', selection: null, page: {}, run() { }, active: () => false,
1720
1788
  can: () => false, focus() { }, selectAll() { }, markdown: () => '', html: () => ''
@@ -1802,6 +1870,25 @@
1802
1870
  area.dispatchEvent(new CustomEvent('text-edit:selection', { bubbles: true, detail: shape }));
1803
1871
  }
1804
1872
 
1873
+ // The selection bubble: a popover shown while its area has a selection. The
1874
+ // element is promoted to popover="manual" (an auto popover closes on the very
1875
+ // click that makes the selection); show/hide follow text-edit:selection.
1876
+ function selectionMenu(el, cleanup) {
1877
+ if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
1878
+ el.setAttribute('data-text-edit-menu', '');
1879
+ const onSel = (e) => {
1880
+ const area = elementAt(e.target) && elementAt(e.target).closest('[data-text-edit]');
1881
+ if (!area || resolve(el) !== area) return;
1882
+ const want = !!e.detail && !e.detail.collapsed;
1883
+ try {
1884
+ if (want && !el.matches(':popover-open')) el.showPopover();
1885
+ if (!want && el.matches(':popover-open')) el.hidePopover();
1886
+ } catch { }
1887
+ };
1888
+ document.addEventListener('text-edit:selection', onSel);
1889
+ cleanup(() => { document.removeEventListener('text-edit:selection', onSel); try { el.hidePopover(); } catch { } });
1890
+ }
1891
+
1805
1892
  function control(el, id, modifiers, expression, evaluate, cleanup) {
1806
1893
  // `.h2` and `.align.center` carry a static argument as the next modifier; an
1807
1894
  // expression is the dynamic form (`x-text-edit.a="url"`).
@@ -1818,7 +1905,36 @@
1818
1905
  const valued = 'value' in el && el.tagName !== 'BUTTON';
1819
1906
  if (valued) {
1820
1907
  const fire = () => { const a = resolve(el); if (a) run(a, id, el.value, el._te); };
1821
- el.addEventListener('change', fire);
1908
+ if (el.type === 'color') {
1909
+ // Colour previews live: the first input tick wraps the selection
1910
+ // (recorded once, so undo removes the whole pass in one step) and
1911
+ // every later tick restyles those same spans. Works with the
1912
+ // browser picker, Manifest's, or anything that fires `input`.
1913
+ const prop = id === 'background' ? 'backgroundColor' : 'color';
1914
+ let live = null;
1915
+ el.addEventListener('input', () => {
1916
+ const a = resolve(el); if (!a) return;
1917
+ if (el._te.page) { run(a, id, el.value, el._te); return; } // page vars are cheap to set per tick
1918
+ if (live && live.length && live.every(s => s.isConnected)) {
1919
+ live.forEach(s => { s.style[prop] = el.value; });
1920
+ a.dispatchEvent(new Event('input', { bubbles: true }));
1921
+ return;
1922
+ }
1923
+ if (!allows(a, id, el._te)) return;
1924
+ restoreRange(a);
1925
+ const r = range(); if (!r || !a.contains(r.startContainer)) return;
1926
+ record(a);
1927
+ live = wrapInline('span', { [prop]: el.value });
1928
+ saveRange(a);
1929
+ a.dispatchEvent(new Event('input', { bubbles: true }));
1930
+ });
1931
+ el.addEventListener('change', () => {
1932
+ if (live) { const a = resolve(el); live = null; if (a) { record(a); sync(); } return; } // applied live already
1933
+ fire();
1934
+ });
1935
+ } else {
1936
+ el.addEventListener('change', fire);
1937
+ }
1822
1938
  el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fire(); } });
1823
1939
  } else {
1824
1940
  el.addEventListener('pointerdown', e => e.preventDefault()); // never take the caret
@@ -1927,6 +2043,14 @@
1927
2043
  sync();
1928
2044
  });
1929
2045
 
2046
+ // A link inside the editor is content, not navigation: clicking it places
2047
+ // the caret to edit it. Without this, the router (or the browser) follows
2048
+ // the href and the page changes out from under the writer.
2049
+ el.addEventListener('click', (e) => {
2050
+ const a = e.target && e.target.closest && e.target.closest('a');
2051
+ if (a && el.contains(a)) e.preventDefault();
2052
+ });
2053
+
1930
2054
  el.addEventListener('focusin', () => { lastFocused = el; sync(); });
1931
2055
  // Focus gone somewhere that is neither this area nor one of its controls.
1932
2056
  el.addEventListener('focusout', (e) => {
@@ -1936,6 +2060,7 @@
1936
2060
  });
1937
2061
  el.addEventListener('input', (e) => {
1938
2062
  if (!writing && e.data && applyPending(el, e.data)) { commitValue(); }
2063
+ if (!writing && e.data && autoformat && convertInlineAtCaret(el)) { commitValue(); }
1939
2064
  settle(); sync();
1940
2065
  });
1941
2066
  el.addEventListener('change', e => {
@@ -2003,11 +2128,12 @@
2003
2128
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
2004
2129
  e.preventDefault(); run(el, e.shiftKey ? 'redo' : 'undo'); return;
2005
2130
  }
2131
+ // No Cmd/Ctrl+K: that chord belongs to site search almost everywhere
2132
+ // now (including our own docs) — links go through an `a` control.
2006
2133
  if (!(e.metaKey || e.ctrlKey)) return;
2007
- const id = { b: 'strong', i: 'em', u: 'u', k: 'a' }[e.key.toLowerCase()];
2134
+ const id = { b: 'strong', i: 'em', u: 'u' }[e.key.toLowerCase()];
2008
2135
  if (!id || !allows(el, id)) return;
2009
2136
  e.preventDefault();
2010
- if (id === 'a') { toggleLink(el); return; }
2011
2137
  run(el, id);
2012
2138
  });
2013
2139