mnfst 0.5.219 → 0.5.221

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.
@@ -9,13 +9,9 @@
9
9
  @layer components {
10
10
 
11
11
  :where([data-text-edit]) {
12
- min-height: var(--text-edit-min-height, 8rem);
13
- max-height: var(--text-edit-max-height, none);
12
+ position: relative;
14
13
  overflow-y: auto;
15
- padding: var(--text-edit-padding, 0.75rem);
16
- background: var(--text-edit-background, var(--color-surface-1));
17
- border: 1px solid var(--color-line);
18
- border-radius: var(--radius);
14
+ max-height: var(--text-edit-max-height, none);
19
15
  outline: none;
20
16
 
21
17
  /* Page-level styling — set by .page controls, read back by $text.page.
@@ -26,19 +22,45 @@
26
22
  text-align: var(--text-edit-align, start);
27
23
  color: var(--text-edit-color, inherit);
28
24
 
29
- &:focus {
30
- border-color: var(--color-content-subtle);
31
- }
32
-
33
25
  /* Flagged from script: :empty never matches the <br> a contenteditable
34
- keeps, and the tag holding it is not predictable after a wipe. */
26
+ keeps, and the tag holding it is not predictable after a wipe. The
27
+ placeholder OVERLAYS the first line (it is not a line of its own), so
28
+ the caret sits on top of it and nothing jumps when typing starts. */
35
29
  &[data-text-edit-empty]::before {
36
30
  content: attr(placeholder);
31
+ position: absolute;
32
+ inset-block-start: var(--text-edit-padding, 0.75rem);
33
+ inset-inline: var(--text-edit-padding, 0.75rem);
37
34
  color: var(--color-content-subtle);
38
35
  pointer-events: none;
39
36
  }
40
37
  }
41
38
 
39
+ /* An unstyled area has no field padding, so its placeholder hugs the line. */
40
+ :where([data-text-edit].unstyle)[data-text-edit-empty]::before {
41
+ inset-block-start: 0;
42
+ inset-inline: 0;
43
+ }
44
+
45
+ /* The FIELD look. `.unstyle` drops it so an area can sit seamlessly in a
46
+ page — a WYSIWYG document inherits its element's own type and colours;
47
+ everything above (caret custody, placeholder, commands) still works. */
48
+ :where([data-text-edit]:not(.unstyle)) {
49
+ min-height: var(--text-edit-min-height, 8rem);
50
+ padding: var(--text-edit-padding, 0.75rem);
51
+ background: var(--text-edit-background, var(--color-surface-1));
52
+ border: 1px solid var(--color-line);
53
+ border-radius: var(--radius);
54
+
55
+ /* Held while the area owns the editing session — a <select> or colour
56
+ control takes NATIVE focus by design, and the ring must not flicker
57
+ off while the writer is mid-command. */
58
+ &:focus,
59
+ &[data-text-edit-focused] {
60
+ border-color: var(--color-content-subtle);
61
+ }
62
+ }
63
+
42
64
  /* ---- Selection menu (x-text-edit-menu) ----
43
65
  Positioned by the --text-edit-selection-* variables the plugin writes to
44
66
  :root, so the default needs no script; restyle or reposition freely. */
@@ -49,7 +71,6 @@
49
71
  left: var(--text-edit-selection-center);
50
72
  top: var(--text-edit-selection-y);
51
73
  transform: translate(-50%, calc(-100% - 0.5rem));
52
- display: flex;
53
74
  gap: 0.25rem;
54
75
  align-items: center;
55
76
  padding: 0.25rem;
@@ -59,6 +80,22 @@
59
80
  box-shadow: 0 8px 30px rgb(0 0 0 / 0.2);
60
81
  }
61
82
 
83
+ /* display only while OPEN: an author display on the base rule would beat
84
+ the UA's closed-popover display:none, leaving the menu floating fixed
85
+ over the page before any selection exists. */
86
+ :where([data-text-edit-menu]:popover-open) {
87
+ display: flex;
88
+ }
89
+
90
+ /* Without the Popover API the attribute is inert and the menu would render
91
+ permanently fixed on the page — hide it there; the bubble is progressive
92
+ enhancement. (In supporting browsers a closed popover is hidden natively.) */
93
+ @supports not selector(:popover-open) {
94
+ :where([data-text-edit-menu]) {
95
+ display: none;
96
+ }
97
+ }
98
+
62
99
  /* ---- Controls ----
63
100
  A control is marked wherever it lives, so a toolbar is optional markup and
64
101
  never a requirement. The reset already dims [aria-disabled=true]. */
@@ -389,15 +389,34 @@
389
389
  Swapping the element outright covers every block tag, where formatBlock only
390
390
  accepts a short list and disagrees between engines. */
391
391
  function setBlock(tag) {
392
- const b = blockEl(); if (!b) return;
392
+ let b = blockEl(); if (!b) return;
393
393
  const want = (b.tagName === tag.toUpperCase() ? 'p' : tag).toLowerCase();
394
394
  if (b.tagName === want.toUpperCase()) return;
395
+ // A heading or quote cannot live inside a list — markdown has no
396
+ // <ul><h2> — so lift the item out of its list first, then retag.
397
+ // Retagging in place serialized to nothing: the content vanished.
398
+ // A NESTED item outdents to the top level first, or the lift would
399
+ // land the new block inside the outer <li>.
400
+ if (b.tagName === 'LI' && want !== 'li') {
401
+ const area = b.closest('[data-text-edit]');
402
+ for (let guard = 0; guard < 8; guard++) {
403
+ const list = b && b.tagName === 'LI' && b.closest('ul, ol');
404
+ if (!list || !list.parentElement || !list.parentElement.closest('li')) break;
405
+ indent(-1, area);
406
+ b = blockEl(); if (!b) return;
407
+ }
408
+ if (b.tagName === 'LI') { unwrapItem(b); b = blockEl(); if (!b) return; }
409
+ if (b.tagName === want.toUpperCase()) return;
410
+ }
395
411
  const n = document.createElement(want);
396
412
  n.setAttribute('style', b.getAttribute('style') || '');
397
413
  if (!n.getAttribute('style')) n.removeAttribute('style');
398
414
  const off = offsetIn(b);
399
415
  while (b.firstChild) n.appendChild(b.firstChild);
400
416
  b.replaceWith(n);
417
+ // A sublist that rode along out of a list item cannot live inside a
418
+ // heading or quote — it steps out right after the new block.
419
+ [...n.querySelectorAll(':scope > ul, :scope > ol')].reverse().forEach(l => n.after(l));
401
420
  caretIn(n, off);
402
421
  }
403
422
 
@@ -1019,8 +1038,8 @@
1019
1038
  // not applied; the field re-syncs to the real href, showing it didn't take.
1020
1039
  function normalizeUrl(url) {
1021
1040
  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;
1041
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || /^(mailto|tel|sms):/i.test(url) || /^[/#?.]/.test(url)) return url;
1042
+ if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/|$|\?|#)/.test(url)) return 'https://' + url;
1024
1043
  return null;
1025
1044
  }
1026
1045
  function setLink(href) {
@@ -1329,7 +1348,10 @@
1329
1348
  while ((m = re.exec(text))) {
1330
1349
  if (m.index + m[0].length !== text.length) continue;
1331
1350
  const skip = rule.pre ? m[rule.pre].length : 0;
1332
- best = { rule, m, start: m.index + skip, len: m[0].length - skip };
1351
+ const start = m.index + skip;
1352
+ // Earliest start wins, like firstInline — ![alt](src) must be
1353
+ // the image rule, not the link rule matching one char later.
1354
+ if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1333
1355
  break;
1334
1356
  }
1335
1357
  }
@@ -1638,6 +1660,7 @@
1638
1660
  const was = area || lastFocused;
1639
1661
  if (!area || area === lastFocused) lastFocused = null;
1640
1662
  if (!was) return;
1663
+ was.removeAttribute('data-text-edit-focused');
1641
1664
  was._selection = null;
1642
1665
  was.removeAttribute('data-text-edit-selected');
1643
1666
  document.documentElement.removeAttribute('data-text-edit-selected');
@@ -1694,6 +1717,13 @@
1694
1717
  const spec = COMMANDS[id], cfg = area && area._te;
1695
1718
  if (!spec || !cfg || cfg.mode === 'plain') return false;
1696
1719
  const page = !!(ctrl && ctrl.page && spec.page);
1720
+ // The writer's caret is visibly in ANOTHER editable element (a plain
1721
+ // x-edit leaf, an input, someone else's contenteditable): acting now
1722
+ // would restore this area's old saved range and edit text the writer
1723
+ // can see is not selected. The control goes quiet instead. Page
1724
+ // commands write variables, never the selection — they stay live.
1725
+ const live = range();
1726
+ if (!page && live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer)) return false;
1697
1727
  if (cfg.minimal && spec.block && !page) return false;
1698
1728
  if (cfg.mode !== 'html' && !spec.md && !page) return false;
1699
1729
  if (page) return true; // writes to the area, needs no caret
@@ -1707,6 +1737,13 @@
1707
1737
  const spec = COMMANDS[id]; if (!spec) return;
1708
1738
  const page = !!(ctrl && ctrl.page && spec.page);
1709
1739
  if (!page) {
1740
+ // Checked BEFORE restoring the saved range: if the writer's caret is
1741
+ // visibly in another editable element, restoring first would move the
1742
+ // selection back here and the command would edit text the writer can
1743
+ // see is not selected. (The reset blocks pointer clicks on disabled
1744
+ // controls; this covers keyboard activation.)
1745
+ const live = range();
1746
+ if (live && !area.contains(live.startContainer) && editableElsewhere(live.startContainer)) return;
1710
1747
  restoreRange(area);
1711
1748
  // If the caret could not be put in this area — it was never focused, or
1712
1749
  // the saved range has gone stale — do nothing. Running anyway rewrites
@@ -1912,6 +1949,16 @@
1912
1949
  // browser picker, Manifest's, or anything that fires `input`.
1913
1950
  const prop = id === 'background' ? 'backgroundColor' : 'color';
1914
1951
  let live = null;
1952
+ // A new selection elsewhere ends the pass: without this, a
1953
+ // picker that never fires `change` keeps recolouring the OLD
1954
+ // spans after the writer has selected different text.
1955
+ const dropLive = () => {
1956
+ if (!live) return;
1957
+ const r = range();
1958
+ if (!r || !live.some(sp => sp.isConnected && sp.contains(r.startContainer))) live = null;
1959
+ };
1960
+ document.addEventListener('text-edit:selection', dropLive);
1961
+ cleanup(() => document.removeEventListener('text-edit:selection', dropLive));
1915
1962
  el.addEventListener('input', () => {
1916
1963
  const a = resolve(el); if (!a) return;
1917
1964
  if (el._te.page) { run(a, id, el.value, el._te); return; } // page vars are cheap to set per tick
@@ -2051,7 +2098,7 @@
2051
2098
  if (a && el.contains(a)) e.preventDefault();
2052
2099
  });
2053
2100
 
2054
- el.addEventListener('focusin', () => { lastFocused = el; sync(); });
2101
+ el.addEventListener('focusin', () => { if (lastFocused && lastFocused !== el) release(lastFocused); lastFocused = el; el.setAttribute('data-text-edit-focused', ''); sync(); });
2055
2102
  // Focus gone somewhere that is neither this area nor one of its controls.
2056
2103
  el.addEventListener('focusout', (e) => {
2057
2104
  if (keeps(el, e.relatedTarget || pointerTarget)) return;
@@ -2060,7 +2107,7 @@
2060
2107
  });
2061
2108
  el.addEventListener('input', (e) => {
2062
2109
  if (!writing && e.data && applyPending(el, e.data)) { commitValue(); }
2063
- if (!writing && e.data && autoformat && convertInlineAtCaret(el)) { commitValue(); }
2110
+ if (!writing && e.data && !e.isComposing && autoformat && convertInlineAtCaret(el)) { commitValue(); }
2064
2111
  settle(); sync();
2065
2112
  });
2066
2113
  el.addEventListener('change', e => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.219",
3
+ "version": "0.5.221",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",