mnfst 0.5.71 → 0.5.74
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.components.js +152 -0
- package/lib/manifest.export.js +453 -0
- package/lib/manifest.js +3 -1
- package/lib/manifest.virtual.js +319 -0
- package/package.json +5 -3
|
@@ -776,6 +776,158 @@ window.ManifestComponentsMutation = {
|
|
|
776
776
|
}
|
|
777
777
|
};
|
|
778
778
|
|
|
779
|
+
// Components — route-level prefetch.
|
|
780
|
+
//
|
|
781
|
+
// Two enhancements that run on top of the existing on-encounter loader:
|
|
782
|
+
//
|
|
783
|
+
// 1. Parallel batch on route change. When manifest:route-change fires,
|
|
784
|
+
// scan the [x-route] subtrees that match the new route and call
|
|
785
|
+
// loadComponent() on every <x-*> tag inside them. The loader
|
|
786
|
+
// deduplicates fetches, so calling it for components that the
|
|
787
|
+
// regular swapping logic is already mounting is harmless — but
|
|
788
|
+
// pre-issuing in parallel saves 50–200 ms vs. one-by-one fetches.
|
|
789
|
+
//
|
|
790
|
+
// 2. Prefetch on hover. When the pointer enters an internal <a href>,
|
|
791
|
+
// derive the target pathname, find the [x-route] subtree(s) that
|
|
792
|
+
// would match it, and prefetch their components. By the time the
|
|
793
|
+
// user clicks the link, the components are warm in the loader's
|
|
794
|
+
// cache and navigation feels instant.
|
|
795
|
+
//
|
|
796
|
+
// Both phases require zero author configuration. Manifest auto-discovers
|
|
797
|
+
// what to prefetch from the existing [x-route] DOM structure.
|
|
798
|
+
|
|
799
|
+
(function () {
|
|
800
|
+
'use strict';
|
|
801
|
+
|
|
802
|
+
// <x-*> tag pattern — lowercase, hyphenated.
|
|
803
|
+
const TAG_RE = /^x-[a-z][a-z0-9-]*$/;
|
|
804
|
+
|
|
805
|
+
// Framework-provided web components (registered by Manifest plugins
|
|
806
|
+
// themselves, not as project components in manifest.json). Skip these
|
|
807
|
+
// when scanning for project components to prefetch.
|
|
808
|
+
const FRAMEWORK_TAGS = new Set(['code', 'code-group']);
|
|
809
|
+
|
|
810
|
+
// Anchors we've already issued a hover-prefetch for. WeakSet so DOM
|
|
811
|
+
// garbage-collects naturally as elements leave the tree.
|
|
812
|
+
const prefetchedAnchors = new WeakSet();
|
|
813
|
+
|
|
814
|
+
function loader() { return window.ManifestComponentsLoader; }
|
|
815
|
+
|
|
816
|
+
// Match a single route pattern against a normalized pathname (no
|
|
817
|
+
// leading/trailing slashes, '/' represented as '/'). Mirrors the
|
|
818
|
+
// router visibility logic so prefetch targets the same subtrees.
|
|
819
|
+
function routeMatches(routeValue, pathname) {
|
|
820
|
+
const pieces = String(routeValue || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
821
|
+
let matched = false;
|
|
822
|
+
let negated = false;
|
|
823
|
+
for (const piece of pieces) {
|
|
824
|
+
if (piece === '!*') continue; // catch-all only handled by visibility plugin
|
|
825
|
+
if (piece.startsWith('!')) {
|
|
826
|
+
if (piece.slice(1) === pathname) negated = true;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (piece.startsWith('=')) {
|
|
830
|
+
if (piece.slice(1) === pathname) matched = true;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (piece.endsWith('/*')) {
|
|
834
|
+
const prefix = piece.slice(0, -2);
|
|
835
|
+
if (pathname === prefix || pathname.startsWith(prefix + '/')) matched = true;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (piece === pathname) { matched = true; continue; }
|
|
839
|
+
if (pathname.startsWith(piece + '/')) matched = true;
|
|
840
|
+
}
|
|
841
|
+
return matched && !negated;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function findRouteSubtrees(pathname) {
|
|
845
|
+
const normalized = (pathname || '/') === '/' ? '/' : pathname.replace(/^\/|\/$/g, '');
|
|
846
|
+
const out = [];
|
|
847
|
+
document.querySelectorAll('[x-route]').forEach((el) => {
|
|
848
|
+
const value = el.getAttribute('x-route') || '';
|
|
849
|
+
if (routeMatches(value, normalized)) out.push(el);
|
|
850
|
+
});
|
|
851
|
+
return out;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function discoverComponentNames(root) {
|
|
855
|
+
const names = new Set();
|
|
856
|
+
if (!root || !root.querySelectorAll) return names;
|
|
857
|
+
// querySelectorAll('*') is the fastest path for "every descendant".
|
|
858
|
+
// We filter by tag name in JS — there's no CSS selector for "tag
|
|
859
|
+
// name starts with x-". A page typically has a few thousand nodes,
|
|
860
|
+
// which scans in well under a millisecond.
|
|
861
|
+
root.querySelectorAll('*').forEach((el) => {
|
|
862
|
+
const tag = el.tagName.toLowerCase();
|
|
863
|
+
if (!tag.startsWith('x-') || !TAG_RE.test(tag)) return;
|
|
864
|
+
const name = tag.slice(2);
|
|
865
|
+
if (!FRAMEWORK_TAGS.has(name)) names.add(name);
|
|
866
|
+
});
|
|
867
|
+
return names;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function prefetchForRoute(pathname) {
|
|
871
|
+
const L = loader();
|
|
872
|
+
if (!L || typeof L.loadComponent !== 'function') return;
|
|
873
|
+
const subtrees = findRouteSubtrees(pathname);
|
|
874
|
+
if (!subtrees.length) return;
|
|
875
|
+
const names = new Set();
|
|
876
|
+
for (const subtree of subtrees) {
|
|
877
|
+
discoverComponentNames(subtree).forEach((n) => names.add(n));
|
|
878
|
+
}
|
|
879
|
+
names.forEach((name) => {
|
|
880
|
+
try { L.loadComponent(name); } catch { /* swallow — dedup is internal */ }
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function hrefToPathname(href) {
|
|
885
|
+
if (!href) return null;
|
|
886
|
+
if (/^(#|mailto:|tel:|javascript:)/i.test(href)) return null;
|
|
887
|
+
try {
|
|
888
|
+
const url = new URL(href, window.location.href);
|
|
889
|
+
if (url.origin !== window.location.origin) return null;
|
|
890
|
+
return url.pathname || '/';
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function initialize() {
|
|
897
|
+
// 1) Parallel batch on route change.
|
|
898
|
+
window.addEventListener('manifest:route-change', (event) => {
|
|
899
|
+
const detail = (event && event.detail) || {};
|
|
900
|
+
const path = detail.normalizedPath || detail.to || '/';
|
|
901
|
+
const pathname = String(path).startsWith('/') ? String(path) : '/' + String(path);
|
|
902
|
+
prefetchForRoute(pathname);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// 2) Hover prefetch. Use pointerover (bubbles) and check the closest
|
|
906
|
+
// anchor on each event so we get a single trigger per anchor entry
|
|
907
|
+
// without needing pointerenter (which doesn't bubble). Dedup via
|
|
908
|
+
// a WeakSet so repeat moves within the anchor don't re-scan.
|
|
909
|
+
document.addEventListener('pointerover', (e) => {
|
|
910
|
+
if (!e.target || !e.target.closest) return;
|
|
911
|
+
const a = e.target.closest('a[href]');
|
|
912
|
+
if (!a || prefetchedAnchors.has(a)) return;
|
|
913
|
+
// Author opt-out: `data-no-prefetch` skips this anchor.
|
|
914
|
+
if (a.hasAttribute('data-no-prefetch')) return;
|
|
915
|
+
const href = a.getAttribute('href');
|
|
916
|
+
const pathname = hrefToPathname(href);
|
|
917
|
+
if (!pathname) return;
|
|
918
|
+
prefetchedAnchors.add(a);
|
|
919
|
+
prefetchForRoute(pathname);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (document.readyState === 'loading') {
|
|
924
|
+
document.addEventListener('DOMContentLoaded', initialize);
|
|
925
|
+
} else {
|
|
926
|
+
initialize();
|
|
927
|
+
}
|
|
928
|
+
})();
|
|
929
|
+
|
|
930
|
+
|
|
779
931
|
// Main initialization for Manifest Components
|
|
780
932
|
function initializeComponents() {
|
|
781
933
|
if (window.ManifestComponentsRegistry) window.ManifestComponentsRegistry.initialize();
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/* Manifest Export — runtime download of pages, regions, or data sources.
|
|
2
|
+
*
|
|
3
|
+
* The `x-export` directive turns its host element into a download action.
|
|
4
|
+
* What gets downloaded depends on the host element type:
|
|
5
|
+
*
|
|
6
|
+
* <button x-export> → click downloads the whole page
|
|
7
|
+
* <button x-export="{ target: '#x' }"> → click downloads the element with id="x"
|
|
8
|
+
* <a x-export href="#section"> → click downloads #section (no scroll-to)
|
|
9
|
+
* <a x-export href="/other-page"> → appends ?export=<format> to the href
|
|
10
|
+
* so the destination page auto-exports
|
|
11
|
+
* itself after navigation
|
|
12
|
+
*
|
|
13
|
+
* For the destination of a cross-page export, declare what's exportable:
|
|
14
|
+
*
|
|
15
|
+
* <div x-export="{ trigger: 'url', target: '#report' }"></div>
|
|
16
|
+
*
|
|
17
|
+
* On page load, this checks `?export=<format>` in the URL. If present and a
|
|
18
|
+
* known format, it fires the export against the configured target. If the
|
|
19
|
+
* URL has no param, the page renders normally — random visitors never
|
|
20
|
+
* trigger downloads.
|
|
21
|
+
*
|
|
22
|
+
* For programmatic use, the `$export` magic runs an export from any
|
|
23
|
+
* Alpine expression and returns a promise:
|
|
24
|
+
*
|
|
25
|
+
* <form @submit.prevent="if (valid) await $export({ format: 'csv', source: 'rows' })">
|
|
26
|
+
*
|
|
27
|
+
* Supported formats: pdf (default), png, jpeg, webp, csv, json.
|
|
28
|
+
*
|
|
29
|
+
* Library dependencies (html-to-image, jsPDF) are loaded lazily on first
|
|
30
|
+
* use from jsDelivr; pages that never export pay nothing.
|
|
31
|
+
*
|
|
32
|
+
* Elements with `data-no-export` are excluded from visual snapshots.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
function initializeExportPlugin() {
|
|
36
|
+
|
|
37
|
+
Alpine.directive('export', (el, { modifiers, expression }, { evaluate, cleanup }) => {
|
|
38
|
+
|
|
39
|
+
const opts = resolveOptions(expression, modifiers, evaluate);
|
|
40
|
+
const format = (opts.format || 'pdf').toLowerCase();
|
|
41
|
+
const isAnchor = el.tagName === 'A';
|
|
42
|
+
const href = isAnchor ? el.getAttribute('href') : null;
|
|
43
|
+
|
|
44
|
+
// ----- URL-trigger destination behavior -----
|
|
45
|
+
// The page declares "I'm exportable; fire if the URL says so." Runs
|
|
46
|
+
// once per page load, regardless of how many elements declare it.
|
|
47
|
+
if (opts.trigger === 'url') {
|
|
48
|
+
if (urlTriggerFired) return;
|
|
49
|
+
const paramName = opts.urlParam || 'export';
|
|
50
|
+
const paramValue = new URLSearchParams(window.location.search).get(paramName);
|
|
51
|
+
if (paramValue) {
|
|
52
|
+
urlTriggerFired = true;
|
|
53
|
+
const fmt = isKnownFormat(paramValue) ? paramValue : format;
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
runExport(fmt, opts, opts.filename || defaultFilename(fmt))
|
|
56
|
+
.catch((err) => emitError(fmt, err));
|
|
57
|
+
}, Number(opts.delay) > 0 ? Number(opts.delay) : 0);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ----- Anchor with cross-page href -----
|
|
63
|
+
// Pre-arm the href with ?export=<format> so default browser navigation
|
|
64
|
+
// delivers the user to the destination URL with the export signal.
|
|
65
|
+
// Hover-prefetch, middle-click, right-click-copy all see the same URL.
|
|
66
|
+
if (isAnchor && href && !href.startsWith('#') && !href.startsWith('javascript:')
|
|
67
|
+
&& !/^(mailto|tel):/i.test(href)) {
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(href, window.location.href);
|
|
70
|
+
const paramName = opts.urlParam || 'export';
|
|
71
|
+
url.searchParams.set(paramName, format);
|
|
72
|
+
// Preserve the original href shape: relative stays relative.
|
|
73
|
+
if (url.origin === window.location.origin && !href.startsWith('http')) {
|
|
74
|
+
el.setAttribute('href', url.pathname + url.search + url.hash);
|
|
75
|
+
} else {
|
|
76
|
+
el.setAttribute('href', url.toString());
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.warn('[x-export] could not parse href for cross-page export:', err.message);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ----- Anchor with same-page fragment href -----
|
|
85
|
+
// The href IS the target. Click downloads the matched element
|
|
86
|
+
// instead of jumping the page.
|
|
87
|
+
if (isAnchor && href && href.startsWith('#')) {
|
|
88
|
+
const onClick = async (e) => {
|
|
89
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
90
|
+
const filename = opts.filename || defaultFilename(format);
|
|
91
|
+
try {
|
|
92
|
+
await runExport(format, { ...opts, target: href }, filename);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
emitError(format, err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
el.addEventListener('click', onClick);
|
|
98
|
+
cleanup(() => el.removeEventListener('click', onClick));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ----- Default: click anywhere else triggers export -----
|
|
103
|
+
const onClick = async (e) => {
|
|
104
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
|
105
|
+
const filename = opts.filename || defaultFilename(format);
|
|
106
|
+
try {
|
|
107
|
+
await runExport(format, opts, filename);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
emitError(format, err);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
el.addEventListener('click', onClick);
|
|
113
|
+
cleanup(() => el.removeEventListener('click', onClick));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ----- $export magic — programmatic trigger from any expression -----
|
|
117
|
+
Alpine.magic('export', () => async (opts = {}) => {
|
|
118
|
+
const format = String(opts.format || 'pdf').toLowerCase();
|
|
119
|
+
const filename = opts.filename || defaultFilename(format);
|
|
120
|
+
return runExport(format, opts, filename);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ------- Options + format helpers -----------------------------------
|
|
124
|
+
|
|
125
|
+
function resolveOptions(expression, modifiers, evaluate) {
|
|
126
|
+
let opts = {};
|
|
127
|
+
if (expression && expression.trim()) {
|
|
128
|
+
try {
|
|
129
|
+
const v = evaluate(expression);
|
|
130
|
+
if (v && typeof v === 'object') opts = { ...v };
|
|
131
|
+
else if (typeof v === 'string') opts.format = v;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn('[x-export] could not evaluate options expression:', err.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!opts.format && Array.isArray(modifiers) && modifiers.length) {
|
|
137
|
+
const found = modifiers.find((m) => isKnownFormat(String(m).toLowerCase()));
|
|
138
|
+
if (found) opts.format = String(found).toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
return opts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isKnownFormat(f) {
|
|
144
|
+
return f === 'pdf' || f === 'png' || f === 'jpeg' || f === 'jpg' || f === 'webp' || f === 'csv' || f === 'json';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function defaultFilename(format) {
|
|
148
|
+
const ext = format === 'jpeg' || format === 'jpg' ? 'jpg' : format;
|
|
149
|
+
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
150
|
+
return `export-${ts}.${ext}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Transparent 1×1 PNG, used as a fallback when an inline image fails to fetch.
|
|
154
|
+
// html-to-image would otherwise reject the whole snapshot on a single CORS or 404.
|
|
155
|
+
const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=';
|
|
156
|
+
|
|
157
|
+
// html-to-image rejects with the raw `image.onerror` Event when the assembled
|
|
158
|
+
// SVG fails to decode (oversized clones, malformed inline assets, etc.).
|
|
159
|
+
// Translate those into something readable before logging.
|
|
160
|
+
function describeExportError(err) {
|
|
161
|
+
if (err && err.message) return err;
|
|
162
|
+
if (err && typeof Event !== 'undefined' && err instanceof Event) {
|
|
163
|
+
const tag = err.target && err.target.tagName ? err.target.tagName.toLowerCase() : 'image';
|
|
164
|
+
return new Error(
|
|
165
|
+
`failed to render ${tag} during export. ` +
|
|
166
|
+
`Common causes: cross-origin images without CORS headers, ` +
|
|
167
|
+
`an oversized target element, or a no-target snapshot of a complex page. ` +
|
|
168
|
+
`Pass a "target" option to scope the snapshot.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return new Error(String(err));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function emitError(format, err) {
|
|
175
|
+
const e = describeExportError(err);
|
|
176
|
+
console.error('[x-export] export failed:', e.message);
|
|
177
|
+
try {
|
|
178
|
+
window.dispatchEvent(new CustomEvent('manifest:export-error', {
|
|
179
|
+
detail: { format, error: e.message }
|
|
180
|
+
}));
|
|
181
|
+
} catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runExport(format, opts, filename) {
|
|
185
|
+
switch (format) {
|
|
186
|
+
case 'pdf': return exportPdf(opts, filename);
|
|
187
|
+
case 'png': return exportImage(opts, filename, 'png');
|
|
188
|
+
case 'jpeg':
|
|
189
|
+
case 'jpg': return exportImage(opts, filename, 'jpeg');
|
|
190
|
+
case 'webp': return exportImage(opts, filename, 'webp');
|
|
191
|
+
case 'csv': return exportCsv(opts, filename);
|
|
192
|
+
case 'json': return exportJson(opts, filename);
|
|
193
|
+
default: throw new Error(`Unknown format "${format}". Supported: pdf, png, jpeg, webp, csv, json.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ------- Visual exports (PDF, PNG, JPEG, WebP) ----------------------
|
|
198
|
+
|
|
199
|
+
function resolveTarget(opts) {
|
|
200
|
+
if (opts.target) {
|
|
201
|
+
const t = typeof opts.target === 'string'
|
|
202
|
+
? document.querySelector(opts.target)
|
|
203
|
+
: opts.target;
|
|
204
|
+
if (!t) throw new Error(`target "${opts.target}" matched no element`);
|
|
205
|
+
return t;
|
|
206
|
+
}
|
|
207
|
+
return document.body;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function snapshotOptions(opts) {
|
|
211
|
+
// Filter callback for html-to-image — exclude opt-out elements.
|
|
212
|
+
const filter = (node) => {
|
|
213
|
+
if (!node || node.nodeType !== 1) return true;
|
|
214
|
+
return !(node.hasAttribute && node.hasAttribute('data-no-export'));
|
|
215
|
+
};
|
|
216
|
+
const out = {
|
|
217
|
+
pixelRatio: Number(opts.resolution) > 0 ? Number(opts.resolution) : 2,
|
|
218
|
+
cacheBust: true,
|
|
219
|
+
// A single cross-origin image without CORS headers would otherwise
|
|
220
|
+
// reject the whole snapshot. Substitute a transparent pixel so the
|
|
221
|
+
// export still produces a usable file.
|
|
222
|
+
imagePlaceholder: TRANSPARENT_PIXEL,
|
|
223
|
+
filter
|
|
224
|
+
};
|
|
225
|
+
if (opts.backgroundColor) out.backgroundColor = opts.backgroundColor;
|
|
226
|
+
if (opts.width) out.canvasWidth = Number(opts.width);
|
|
227
|
+
if (opts.height) out.canvasHeight = Number(opts.height);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Run an html-to-image call once. If it rejects with a raw Event (the SVG
|
|
232
|
+
// failed to decode — usually a font or oversized clone issue), retry once
|
|
233
|
+
// with safer options before propagating the error. Most real-world failures
|
|
234
|
+
// recover on the second attempt.
|
|
235
|
+
async function snapshotWithFallback(lib, fn, target, so) {
|
|
236
|
+
try {
|
|
237
|
+
return await lib[fn](target, so);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err && err.message) throw err; // a real Error — don't mask it
|
|
240
|
+
const safer = { ...so, skipFonts: true, pixelRatio: Math.min(so.pixelRatio || 2, 1) };
|
|
241
|
+
return await lib[fn](target, safer);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function exportImage(opts, filename, ext) {
|
|
246
|
+
const lib = await loadHtmlToImage();
|
|
247
|
+
const target = resolveTarget(opts);
|
|
248
|
+
const so = snapshotOptions(opts);
|
|
249
|
+
let dataUrl;
|
|
250
|
+
if (ext === 'png') dataUrl = await snapshotWithFallback(lib, 'toPng', target, so);
|
|
251
|
+
else if (ext === 'jpeg') {
|
|
252
|
+
if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
|
|
253
|
+
so.quality = Number(opts.quality) > 0 && Number(opts.quality) <= 1
|
|
254
|
+
? Number(opts.quality)
|
|
255
|
+
: 0.95;
|
|
256
|
+
dataUrl = await snapshotWithFallback(lib, 'toJpeg', target, so);
|
|
257
|
+
} else if (ext === 'webp') {
|
|
258
|
+
const webpOpts = { ...so, type: 'image/webp', quality: opts.quality || 0.95 };
|
|
259
|
+
const blob = await snapshotWithFallback(lib, 'toBlob', target, webpOpts);
|
|
260
|
+
dataUrl = URL.createObjectURL(blob);
|
|
261
|
+
}
|
|
262
|
+
triggerDownload(dataUrl, filename);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function exportPdf(opts, filename) {
|
|
266
|
+
const [imgLib, jsPDFCtor] = await Promise.all([loadHtmlToImage(), loadJsPDF()]);
|
|
267
|
+
const target = resolveTarget(opts);
|
|
268
|
+
const so = snapshotOptions(opts);
|
|
269
|
+
if (!so.backgroundColor) so.backgroundColor = effectivePageBackground();
|
|
270
|
+
const dataUrl = await snapshotWithFallback(imgLib, 'toPng', target, so);
|
|
271
|
+
const img = await loadImage(dataUrl);
|
|
272
|
+
const orientation = img.width > img.height ? 'landscape' : 'portrait';
|
|
273
|
+
const pdf = new jsPDFCtor({ orientation, unit: 'pt', format: opts.pageSize || 'a4' });
|
|
274
|
+
const pageW = pdf.internal.pageSize.getWidth();
|
|
275
|
+
const pageH = pdf.internal.pageSize.getHeight();
|
|
276
|
+
const ratio = Math.min(pageW / img.width, pageH / img.height);
|
|
277
|
+
const renderW = img.width * ratio;
|
|
278
|
+
const renderH = img.height * ratio;
|
|
279
|
+
const offsetX = (pageW - renderW) / 2;
|
|
280
|
+
const offsetY = (pageH - renderH) / 2;
|
|
281
|
+
pdf.addImage(dataUrl, 'PNG', offsetX, offsetY, renderW, renderH);
|
|
282
|
+
pdf.save(filename);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function effectivePageBackground() {
|
|
286
|
+
let el = document.body;
|
|
287
|
+
while (el && el !== document.documentElement.parentElement) {
|
|
288
|
+
const cs = getComputedStyle(el);
|
|
289
|
+
const bg = cs.backgroundColor;
|
|
290
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
|
|
291
|
+
el = el.parentElement;
|
|
292
|
+
}
|
|
293
|
+
return '#ffffff';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function loadImage(dataUrl) {
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
const img = new Image();
|
|
299
|
+
img.onload = () => resolve(img);
|
|
300
|
+
img.onerror = reject;
|
|
301
|
+
img.src = dataUrl;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ------- Tabular exports (CSV, JSON) --------------------------------
|
|
306
|
+
|
|
307
|
+
function resolveDataset(opts) {
|
|
308
|
+
if (Array.isArray(opts.data)) return opts.data;
|
|
309
|
+
if (opts.data && typeof opts.data === 'object') return opts.data;
|
|
310
|
+
if (opts.source) {
|
|
311
|
+
const x = window.$x || (window.Alpine && window.Alpine.magic && window.Alpine.magic('x'));
|
|
312
|
+
if (x && x[opts.source] != null) return x[opts.source];
|
|
313
|
+
}
|
|
314
|
+
throw new Error('csv/json export needs `source: "<name>"` or `data: <value>`');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function exportCsv(opts, filename) {
|
|
318
|
+
const data = resolveDataset(opts);
|
|
319
|
+
if (!Array.isArray(data)) throw new Error('csv export expects an array data source');
|
|
320
|
+
if (data.length === 0) {
|
|
321
|
+
triggerDownload(blobUrl('', 'text/csv'), filename);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const headers = [];
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
for (const row of data) {
|
|
327
|
+
if (!row || typeof row !== 'object') continue;
|
|
328
|
+
for (const k of Object.keys(row)) {
|
|
329
|
+
if (!seen.has(k)) { seen.add(k); headers.push(k); }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const lines = [headers.map(csvCell).join(',')];
|
|
333
|
+
for (const row of data) {
|
|
334
|
+
lines.push(headers.map((h) => csvCell(row && row[h])).join(','));
|
|
335
|
+
}
|
|
336
|
+
triggerDownload(blobUrl(lines.join('\n') + '\n', 'text/csv;charset=utf-8'), filename);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function exportJson(opts, filename) {
|
|
340
|
+
const data = resolveDataset(opts);
|
|
341
|
+
const serializable = sanitize(data);
|
|
342
|
+
triggerDownload(blobUrl(JSON.stringify(serializable, null, 2), 'application/json'), filename);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function sanitize(value) {
|
|
346
|
+
if (Array.isArray(value)) return value.map(sanitize);
|
|
347
|
+
if (value && typeof value === 'object') {
|
|
348
|
+
const out = {};
|
|
349
|
+
for (const k of Object.keys(value)) {
|
|
350
|
+
if (k.startsWith('$') || k === '_loading' || k === '_error') continue;
|
|
351
|
+
out[k] = sanitize(value[k]);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function csvCell(v) {
|
|
359
|
+
if (v == null) return '';
|
|
360
|
+
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
361
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ------- Helpers ----------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function blobUrl(content, type) {
|
|
367
|
+
return URL.createObjectURL(new Blob([content], { type }));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function triggerDownload(url, filename) {
|
|
371
|
+
const a = document.createElement('a');
|
|
372
|
+
a.href = url;
|
|
373
|
+
a.download = filename;
|
|
374
|
+
document.body.appendChild(a);
|
|
375
|
+
a.click();
|
|
376
|
+
a.remove();
|
|
377
|
+
if (url.startsWith('blob:')) setTimeout(() => { try { URL.revokeObjectURL(url); } catch { /* ignore */ } }, 2000);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Lazy library loaders — cached promises.
|
|
381
|
+
|
|
382
|
+
let htmlToImagePromise = null;
|
|
383
|
+
function loadHtmlToImage() {
|
|
384
|
+
if (htmlToImagePromise) return htmlToImagePromise;
|
|
385
|
+
htmlToImagePromise = loadScript('https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js')
|
|
386
|
+
.then(() => {
|
|
387
|
+
if (!window.htmlToImage) throw new Error('html-to-image failed to load');
|
|
388
|
+
return window.htmlToImage;
|
|
389
|
+
});
|
|
390
|
+
return htmlToImagePromise;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let jsPDFPromise = null;
|
|
394
|
+
function loadJsPDF() {
|
|
395
|
+
if (jsPDFPromise) return jsPDFPromise;
|
|
396
|
+
jsPDFPromise = loadScript('https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js')
|
|
397
|
+
.then(() => {
|
|
398
|
+
const ctor = window.jspdf && window.jspdf.jsPDF;
|
|
399
|
+
if (!ctor) throw new Error('jsPDF failed to load');
|
|
400
|
+
return ctor;
|
|
401
|
+
});
|
|
402
|
+
return jsPDFPromise;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function loadScript(src) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
408
|
+
if (existing) {
|
|
409
|
+
if (existing._loaded) resolve();
|
|
410
|
+
else {
|
|
411
|
+
existing.addEventListener('load', () => resolve());
|
|
412
|
+
existing.addEventListener('error', reject);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const s = document.createElement('script');
|
|
417
|
+
s.src = src;
|
|
418
|
+
s.async = true;
|
|
419
|
+
s.addEventListener('load', () => { s._loaded = true; resolve(); });
|
|
420
|
+
s.addEventListener('error', () => reject(new Error('Failed to load ' + src)));
|
|
421
|
+
document.head.appendChild(s);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Module-level guard: URL-triggered exports fire at most once per page load,
|
|
427
|
+
// regardless of how many elements declare `trigger: 'url'`.
|
|
428
|
+
let urlTriggerFired = false;
|
|
429
|
+
|
|
430
|
+
// Standard plugin init lifecycle.
|
|
431
|
+
let exportPluginInitialized = false;
|
|
432
|
+
function ensureExportPluginInitialized() {
|
|
433
|
+
if (exportPluginInitialized) return;
|
|
434
|
+
if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
|
|
435
|
+
exportPluginInitialized = true;
|
|
436
|
+
initializeExportPlugin();
|
|
437
|
+
}
|
|
438
|
+
window.ensureExportPluginInitialized = ensureExportPluginInitialized;
|
|
439
|
+
if (document.readyState === 'loading') {
|
|
440
|
+
document.addEventListener('DOMContentLoaded', ensureExportPluginInitialized);
|
|
441
|
+
}
|
|
442
|
+
document.addEventListener('alpine:init', ensureExportPluginInitialized);
|
|
443
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') {
|
|
444
|
+
setTimeout(ensureExportPluginInitialized, 0);
|
|
445
|
+
} else {
|
|
446
|
+
const checkAlpine = setInterval(() => {
|
|
447
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') {
|
|
448
|
+
clearInterval(checkAlpine);
|
|
449
|
+
ensureExportPluginInitialized();
|
|
450
|
+
}
|
|
451
|
+
}, 10);
|
|
452
|
+
setTimeout(() => clearInterval(checkAlpine), 5000);
|
|
453
|
+
}
|
package/lib/manifest.js
CHANGED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/* Manifest Virtual — variable-height list virtualization for Alpine.
|
|
2
|
+
*
|
|
3
|
+
* Renders only the rows visible in the scroll viewport (plus an overscan
|
|
4
|
+
* buffer), so a list of tens of thousands of rows can scroll smoothly with
|
|
5
|
+
* a low DOM count. Row heights are measured on render and the spacer
|
|
6
|
+
* recalculates, so authors aren't bound to a fixed row height.
|
|
7
|
+
*
|
|
8
|
+
* Usage — wrap an x-for template with x-virtual on the scrolling container:
|
|
9
|
+
*
|
|
10
|
+
* <div x-virtual style="height: 600px; overflow: auto">
|
|
11
|
+
* <template x-for="row in $x.customers" :key="row.id">
|
|
12
|
+
* <div class="row">
|
|
13
|
+
* <span x-text="row.name"></span>
|
|
14
|
+
* </div>
|
|
15
|
+
* </template>
|
|
16
|
+
* </div>
|
|
17
|
+
*
|
|
18
|
+
* Options (object expression on the directive):
|
|
19
|
+
*
|
|
20
|
+
* <div x-virtual="{ estimate: 48, overscan: 5 }" style="height: 600px">
|
|
21
|
+
*
|
|
22
|
+
* estimate Initial per-row height in px (default 50). Used for rows
|
|
23
|
+
* that haven't been measured yet. Closer estimates mean
|
|
24
|
+
* less scroll-position drift on first render.
|
|
25
|
+
* overscan Rows to render above/below the visible window (default 3).
|
|
26
|
+
* Higher = smoother scroll, more DOM.
|
|
27
|
+
*
|
|
28
|
+
* Notes:
|
|
29
|
+
*
|
|
30
|
+
* - Only one template child is supported. It must have x-for and :key.
|
|
31
|
+
* - The container element must have a bounded height (CSS height /
|
|
32
|
+
* max-height) and scroll. The plugin sets overflow: auto + position:
|
|
33
|
+
* relative if not already set.
|
|
34
|
+
* - Heights are remeasured automatically if a row's content changes.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
function initializeVirtualPlugin() {
|
|
38
|
+
|
|
39
|
+
Alpine.directive('virtual', (el, { expression }, { effect, evaluate, evaluateLater, cleanup }) => {
|
|
40
|
+
|
|
41
|
+
// --- Find and parse the template ---
|
|
42
|
+
const template = el.querySelector(':scope > template');
|
|
43
|
+
if (!template) {
|
|
44
|
+
console.warn('[x-virtual] expects a child <template> with x-for, e.g. <template x-for="row in $x.items" :key="row.id">…');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const forExpr = template.getAttribute('x-for');
|
|
48
|
+
if (!forExpr) {
|
|
49
|
+
console.warn('[x-virtual] child <template> must have x-for');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const m = /^\s*(\S+|\(\s*\S+\s*,\s*\S+\s*\))\s+(?:in|of)\s+(.+?)\s*$/.exec(forExpr);
|
|
53
|
+
if (!m) {
|
|
54
|
+
console.warn('[x-virtual] could not parse x-for expression: ' + forExpr);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const itemName = m[1].trim();
|
|
58
|
+
const sourceExpr = m[2].trim();
|
|
59
|
+
const keyExpr =
|
|
60
|
+
template.getAttribute(':key') ||
|
|
61
|
+
template.getAttribute('x-bind:key') ||
|
|
62
|
+
`${itemName}.id`;
|
|
63
|
+
|
|
64
|
+
// Remove x-for/:key so Alpine doesn't try to render the full list, but
|
|
65
|
+
// KEEP the template in the DOM as our render source. We'll clone its
|
|
66
|
+
// contents per visible row.
|
|
67
|
+
template.removeAttribute('x-for');
|
|
68
|
+
template.removeAttribute(':key');
|
|
69
|
+
template.removeAttribute('x-bind:key');
|
|
70
|
+
|
|
71
|
+
// --- Options ---
|
|
72
|
+
const options = expression ? evaluate(expression) || {} : {};
|
|
73
|
+
const initialEstimate = Number(options.estimate) > 0 ? Number(options.estimate) : 50;
|
|
74
|
+
const overscan = Number.isFinite(options.overscan) && options.overscan >= 0 ? Number(options.overscan) : 3;
|
|
75
|
+
|
|
76
|
+
// --- Container setup ---
|
|
77
|
+
const cs = getComputedStyle(el);
|
|
78
|
+
if (cs.overflow === 'visible' && cs.overflowY === 'visible') el.style.overflow = 'auto';
|
|
79
|
+
if (cs.position === 'static') el.style.position = 'relative';
|
|
80
|
+
|
|
81
|
+
// The spacer holds the rendered (absolutely positioned) rows and sizes
|
|
82
|
+
// itself to the total virtual height so the scrollbar is correct.
|
|
83
|
+
const spacer = document.createElement('div');
|
|
84
|
+
spacer.dataset.virtualSpacer = '';
|
|
85
|
+
spacer.style.position = 'relative';
|
|
86
|
+
spacer.style.width = '100%';
|
|
87
|
+
spacer.style.height = '0px';
|
|
88
|
+
el.appendChild(spacer);
|
|
89
|
+
|
|
90
|
+
// --- State ---
|
|
91
|
+
// heights: key -> measured pixel height (only for rows that have been
|
|
92
|
+
// mounted at least once and measured).
|
|
93
|
+
const heights = new Map();
|
|
94
|
+
let measuredSum = 0;
|
|
95
|
+
let measuredCount = 0;
|
|
96
|
+
// rendered: key -> wrapper element currently in the DOM
|
|
97
|
+
const rendered = new Map();
|
|
98
|
+
// data: latest snapshot of the source array
|
|
99
|
+
let data = [];
|
|
100
|
+
// Cached cumulative offsets — index i holds the sum of heights of rows
|
|
101
|
+
// 0..(i-1). Length is data.length + 1; final entry is total height.
|
|
102
|
+
let cumulative = new Float64Array(1);
|
|
103
|
+
|
|
104
|
+
const getAvg = () => (measuredCount > 0 ? measuredSum / measuredCount : initialEstimate);
|
|
105
|
+
const rowHeightFor = (key) => heights.get(key) ?? getAvg();
|
|
106
|
+
|
|
107
|
+
// Evaluate the key expression against an item without going through
|
|
108
|
+
// Alpine — `new Function` is fast and isolates from the surrounding
|
|
109
|
+
// scope. Expression usually looks like `row.id` or `row.$id`.
|
|
110
|
+
const keyFn = buildKeyFn(itemName, keyExpr);
|
|
111
|
+
|
|
112
|
+
function rebuildCumulative() {
|
|
113
|
+
const n = data.length;
|
|
114
|
+
cumulative = new Float64Array(n + 1);
|
|
115
|
+
let y = 0;
|
|
116
|
+
for (let i = 0; i < n; i++) {
|
|
117
|
+
cumulative[i] = y;
|
|
118
|
+
const k = keyFn(data[i]);
|
|
119
|
+
y += rowHeightFor(k);
|
|
120
|
+
}
|
|
121
|
+
cumulative[n] = y;
|
|
122
|
+
spacer.style.height = y + 'px';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Find the first index whose offset is >= scrollTop. Cumulative is
|
|
126
|
+
// monotonic so binary search works.
|
|
127
|
+
function findStartIndex(scrollTop) {
|
|
128
|
+
let lo = 0, hi = data.length;
|
|
129
|
+
while (lo < hi) {
|
|
130
|
+
const mid = (lo + hi) >>> 1;
|
|
131
|
+
if (cumulative[mid + 1] <= scrollTop) lo = mid + 1;
|
|
132
|
+
else hi = mid;
|
|
133
|
+
}
|
|
134
|
+
return Math.max(0, lo - overscan);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findEndIndex(scrollBottom, startHint) {
|
|
138
|
+
let i = startHint;
|
|
139
|
+
const n = data.length;
|
|
140
|
+
while (i < n && cumulative[i] < scrollBottom) i++;
|
|
141
|
+
return Math.min(n, i + overscan);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderVisible() {
|
|
145
|
+
if (!data.length) {
|
|
146
|
+
for (const [, node] of rendered) node.remove();
|
|
147
|
+
rendered.clear();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const scrollTop = el.scrollTop;
|
|
151
|
+
const viewportHeight = el.clientHeight;
|
|
152
|
+
const start = findStartIndex(scrollTop);
|
|
153
|
+
const end = findEndIndex(scrollTop + viewportHeight, start);
|
|
154
|
+
|
|
155
|
+
// Track which keys remain visible
|
|
156
|
+
const stillVisible = new Set();
|
|
157
|
+
for (let i = start; i < end; i++) {
|
|
158
|
+
const item = data[i];
|
|
159
|
+
if (item == null) continue;
|
|
160
|
+
const key = keyFn(item);
|
|
161
|
+
if (key == null) continue; // skip un-keyable rows
|
|
162
|
+
stillVisible.add(key);
|
|
163
|
+
|
|
164
|
+
let node = rendered.get(key);
|
|
165
|
+
if (!node) {
|
|
166
|
+
node = mountRow(i);
|
|
167
|
+
if (!node) continue;
|
|
168
|
+
rendered.set(key, node);
|
|
169
|
+
spacer.appendChild(node);
|
|
170
|
+
// x-data on the row needs the parent scope (where the
|
|
171
|
+
// source array lives) to resolve, so we MUST init after
|
|
172
|
+
// append, not before.
|
|
173
|
+
Alpine.initTree(node);
|
|
174
|
+
// Measure on next frame so Alpine has bound everything.
|
|
175
|
+
requestAnimationFrame(() => measureRow(key, node));
|
|
176
|
+
}
|
|
177
|
+
node.style.top = cumulative[i] + 'px';
|
|
178
|
+
node.dataset.virtualIndex = i;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Remove rows no longer in the window
|
|
182
|
+
for (const [key, node] of rendered) {
|
|
183
|
+
if (!stillVisible.has(key)) {
|
|
184
|
+
node.remove();
|
|
185
|
+
rendered.delete(key);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function mountRow(index) {
|
|
191
|
+
const tplChild = template.content.firstElementChild;
|
|
192
|
+
if (!tplChild) return null;
|
|
193
|
+
const node = tplChild.cloneNode(true);
|
|
194
|
+
node.style.position = 'absolute';
|
|
195
|
+
node.style.left = '0';
|
|
196
|
+
node.style.right = '0';
|
|
197
|
+
// Inject a per-row Alpine scope. Because we reference the source
|
|
198
|
+
// expression with the index baked in via a getter, Alpine tracks
|
|
199
|
+
// the dependency and re-renders this row when its data updates.
|
|
200
|
+
const scopeExpr = `{ get ${itemName}() { return (${sourceExpr})[${index}]; } }`;
|
|
201
|
+
// Merge with any existing x-data on the cloned root.
|
|
202
|
+
const existing = node.getAttribute('x-data');
|
|
203
|
+
node.setAttribute('x-data', existing ? `Object.assign({}, ${scopeExpr}, ${existing})` : scopeExpr);
|
|
204
|
+
// Note: caller must Alpine.initTree(node) AFTER appending to the
|
|
205
|
+
// DOM, otherwise the scope can't resolve identifiers (e.g. the
|
|
206
|
+
// source array) from outer x-data contexts.
|
|
207
|
+
return node;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function measureRow(key, node) {
|
|
211
|
+
if (!node.isConnected) return;
|
|
212
|
+
const h = node.offsetHeight;
|
|
213
|
+
if (!h) return;
|
|
214
|
+
const prev = heights.get(key);
|
|
215
|
+
if (prev === h) return;
|
|
216
|
+
if (prev !== undefined) measuredSum -= prev;
|
|
217
|
+
else measuredCount++;
|
|
218
|
+
measuredSum += h;
|
|
219
|
+
heights.set(key, h);
|
|
220
|
+
// Recompute cumulative offsets and re-render so positions reflect
|
|
221
|
+
// the new heights AND any rows now in/out of the visible window.
|
|
222
|
+
rebuildCumulative();
|
|
223
|
+
renderVisible();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Reactive data source subscription ---
|
|
227
|
+
const sourceGetter = evaluateLater(sourceExpr);
|
|
228
|
+
effect(() => {
|
|
229
|
+
sourceGetter((value) => {
|
|
230
|
+
data = Array.isArray(value) ? value : (value ? Array.from(value) : []);
|
|
231
|
+
// When the data identity or length changes, drop any rendered
|
|
232
|
+
// rows whose keys no longer exist in the new data.
|
|
233
|
+
const validKeys = new Set();
|
|
234
|
+
for (const item of data) {
|
|
235
|
+
if (item != null) validKeys.add(keyFn(item));
|
|
236
|
+
}
|
|
237
|
+
for (const [key, node] of rendered) {
|
|
238
|
+
if (!validKeys.has(key)) {
|
|
239
|
+
node.remove();
|
|
240
|
+
rendered.delete(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
rebuildCumulative();
|
|
244
|
+
renderVisible();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- Scroll + resize handlers ---
|
|
249
|
+
let scrollScheduled = false;
|
|
250
|
+
const onScroll = () => {
|
|
251
|
+
if (scrollScheduled) return;
|
|
252
|
+
scrollScheduled = true;
|
|
253
|
+
requestAnimationFrame(() => {
|
|
254
|
+
scrollScheduled = false;
|
|
255
|
+
renderVisible();
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
259
|
+
|
|
260
|
+
const ro = new ResizeObserver(() => renderVisible());
|
|
261
|
+
ro.observe(el);
|
|
262
|
+
|
|
263
|
+
cleanup(() => {
|
|
264
|
+
el.removeEventListener('scroll', onScroll);
|
|
265
|
+
ro.disconnect();
|
|
266
|
+
for (const [, node] of rendered) node.remove();
|
|
267
|
+
rendered.clear();
|
|
268
|
+
spacer.remove();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Build a key-evaluator function for a given itemName + keyExpr.
|
|
275
|
+
// `keyFn(item)` returns the row's key. Falls back to identity if it fails.
|
|
276
|
+
function buildKeyFn(itemName, keyExpr) {
|
|
277
|
+
try {
|
|
278
|
+
// eslint-disable-next-line no-new-func
|
|
279
|
+
const fn = new Function(itemName, `return (${keyExpr});`);
|
|
280
|
+
return (item) => {
|
|
281
|
+
try { return fn(item); } catch { return item; }
|
|
282
|
+
};
|
|
283
|
+
} catch {
|
|
284
|
+
return (item) => item;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Track initialization to prevent duplicates
|
|
289
|
+
let virtualPluginInitialized = false;
|
|
290
|
+
|
|
291
|
+
function ensureVirtualPluginInitialized() {
|
|
292
|
+
if (virtualPluginInitialized) return;
|
|
293
|
+
if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
|
|
294
|
+
virtualPluginInitialized = true;
|
|
295
|
+
initializeVirtualPlugin();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Expose on window for loader to call if needed
|
|
299
|
+
window.ensureVirtualPluginInitialized = ensureVirtualPluginInitialized;
|
|
300
|
+
|
|
301
|
+
// Handle both DOMContentLoaded and alpine:init
|
|
302
|
+
if (document.readyState === 'loading') {
|
|
303
|
+
document.addEventListener('DOMContentLoaded', ensureVirtualPluginInitialized);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
document.addEventListener('alpine:init', ensureVirtualPluginInitialized);
|
|
307
|
+
|
|
308
|
+
// If Alpine is already initialized when this script loads, initialize immediately
|
|
309
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') {
|
|
310
|
+
setTimeout(ensureVirtualPluginInitialized, 0);
|
|
311
|
+
} else {
|
|
312
|
+
const checkAlpine = setInterval(() => {
|
|
313
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') {
|
|
314
|
+
clearInterval(checkAlpine);
|
|
315
|
+
ensureVirtualPluginInitialized();
|
|
316
|
+
}
|
|
317
|
+
}, 10);
|
|
318
|
+
setTimeout(() => clearInterval(checkAlpine), 5000);
|
|
319
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnfst",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.74",
|
|
4
4
|
"private": false,
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"templates/starter",
|
|
7
7
|
"packages/render",
|
|
8
8
|
"packages/types",
|
|
9
|
-
"packages/test"
|
|
9
|
+
"packages/test",
|
|
10
|
+
"packages/export"
|
|
10
11
|
],
|
|
11
12
|
"main": "lib/manifest.js",
|
|
12
13
|
"style": "lib/manifest.css",
|
|
@@ -32,8 +33,9 @@
|
|
|
32
33
|
"release:render": "cd packages/render && npm version patch --no-git-tag-version && npm publish --auth-type=web",
|
|
33
34
|
"release:types": "cd packages/types && npm version patch --no-git-tag-version && npm publish --auth-type=web",
|
|
34
35
|
"release:test": "cd packages/test && npm version patch --no-git-tag-version && npm publish --auth-type=web",
|
|
36
|
+
"release:export": "cd packages/export && npm version patch --no-git-tag-version && npm publish --auth-type=web",
|
|
35
37
|
"release:starter": "cd packages/create-starter && npm version patch --no-git-tag-version && npm publish --auth-type=web",
|
|
36
|
-
"release:all": "npm run release:run && npm run release:render && npm run release:types && npm run release:test && npm run release:starter && npm run release",
|
|
38
|
+
"release:all": "npm run release:run && npm run release:render && npm run release:types && npm run release:test && npm run release:export && npm run release:starter && npm run release",
|
|
37
39
|
"prepublishOnly": "npm run build",
|
|
38
40
|
"test": "vitest run",
|
|
39
41
|
"lint": "echo 'No linting configured'"
|