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.
- package/lib/manifest.appwrite.data.js +1 -1
- package/lib/manifest.code.js +126 -46
- package/lib/manifest.css +94 -222
- package/lib/manifest.d.ts +2 -0
- package/lib/manifest.data.js +26 -3
- package/lib/manifest.defer.js +145 -34
- package/lib/manifest.edit.css +65 -27
- package/lib/manifest.edit.js +213 -28
- package/lib/manifest.integrity.json +11 -11
- package/lib/manifest.js +43 -2
- package/lib/manifest.markdown.js +22 -12
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.router.js +58 -69
- package/lib/manifest.sw.js +1 -1
- package/lib/manifest.text.edit.css +29 -0
- package/lib/manifest.text.edit.js +164 -25
- package/lib/manifest.utilities.js +246 -100
- package/lib/manifest.utilities.node.mjs +195 -35
- package/package.json +1 -1
package/lib/manifest.router.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
});
|
package/lib/manifest.sw.js
CHANGED
|
@@ -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.
|
|
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
|
|
1018
|
-
if (!
|
|
1019
|
-
|
|
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 —  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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 :
|
|
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.
|
|
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'
|
|
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
|
|