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.
- package/lib/manifest.appwrite.data.js +1 -1
- package/lib/manifest.code.js +120 -46
- package/lib/manifest.css +83 -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 +63 -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 +11 -7
- 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 +20 -0
- package/lib/manifest.text.edit.js +151 -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.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
|
|
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,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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 :
|
|
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.
|
|
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'
|
|
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
|
|