Initial commit
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
// Initializes the shared capture bucket that every other hook writes into.
|
||||
// Runs first because hooks load in lexicographic order.
|
||||
(() => {
|
||||
if (window.__CLONE_CAPTURE__) return;
|
||||
window.__CLONE_CAPTURE__ = {
|
||||
shaders: [],
|
||||
gsap: [],
|
||||
framer: [],
|
||||
lottie: [],
|
||||
threejs: [],
|
||||
cssVars: {},
|
||||
fonts: [],
|
||||
};
|
||||
// Sentinel the capture script waits on after DOMContentLoaded + a tick.
|
||||
// Hooks that dump state on demand (gsap, framer) set __CLONE_READY__ when they've run.
|
||||
window.__CLONE_READY__ = false;
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (typeof window.__CLONE_FINALIZE__ === 'function') window.__CLONE_FINALIZE__();
|
||||
} catch {}
|
||||
window.__CLONE_READY__ = true;
|
||||
}, 800);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,193 @@
|
||||
// Exposes window.__CLONE_DUMP_COMPUTED__() which walks the DOM and returns a minimal tree
|
||||
// of resolved computed styles (only non-default values). The capture script calls this on demand
|
||||
// after each scroll step — cheap to call repeatedly, much richer than the inline per-step snapshot
|
||||
// the Playwright script does as a fallback.
|
||||
(() => {
|
||||
const PROPS = [
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'backgroundImage',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'backgroundRepeat',
|
||||
'backgroundAttachment',
|
||||
'backgroundClip',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'textTransform',
|
||||
'textAlign',
|
||||
'textDecoration',
|
||||
'textShadow',
|
||||
'whiteSpace',
|
||||
'display',
|
||||
'flexDirection',
|
||||
'flexWrap',
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'alignContent',
|
||||
'alignSelf',
|
||||
'gap',
|
||||
'rowGap',
|
||||
'columnGap',
|
||||
'gridTemplateColumns',
|
||||
'gridTemplateRows',
|
||||
'gridTemplateAreas',
|
||||
'gridColumn',
|
||||
'gridRow',
|
||||
'gridAutoFlow',
|
||||
'padding',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'margin',
|
||||
'marginTop',
|
||||
'marginRight',
|
||||
'marginBottom',
|
||||
'marginLeft',
|
||||
'width',
|
||||
'height',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'aspectRatio',
|
||||
'position',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'zIndex',
|
||||
'inset',
|
||||
'border',
|
||||
'borderTop',
|
||||
'borderRight',
|
||||
'borderBottom',
|
||||
'borderLeft',
|
||||
'borderRadius',
|
||||
'borderColor',
|
||||
'borderWidth',
|
||||
'borderStyle',
|
||||
'boxShadow',
|
||||
'opacity',
|
||||
'transform',
|
||||
'transformOrigin',
|
||||
'filter',
|
||||
'backdropFilter',
|
||||
'clipPath',
|
||||
'mask',
|
||||
'maskImage',
|
||||
'mixBlendMode',
|
||||
'isolation',
|
||||
'overflow',
|
||||
'overflowX',
|
||||
'overflowY',
|
||||
'scrollBehavior',
|
||||
'scrollSnapType',
|
||||
'cursor',
|
||||
'pointerEvents',
|
||||
'userSelect',
|
||||
'willChange',
|
||||
'contain',
|
||||
];
|
||||
|
||||
// Compute defaults once per tag by creating a hidden iframe so we don't treat
|
||||
// inherited defaults as "used styles". The iframe is created lazily — at
|
||||
// init-script time, document.documentElement may still be null (Playwright
|
||||
// fires add_init_script before the HTML parser materialises <html>).
|
||||
let iframe = null;
|
||||
function ensureIframe() {
|
||||
if (iframe) return iframe;
|
||||
if (!document.documentElement) return null;
|
||||
try {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
|
||||
iframe.srcdoc = '<!doctype html><html><body></body></html>';
|
||||
document.documentElement.appendChild(iframe);
|
||||
} catch {
|
||||
iframe = null;
|
||||
}
|
||||
return iframe;
|
||||
}
|
||||
|
||||
const defaultsByTag = new Map();
|
||||
function defaultsFor(tag) {
|
||||
if (defaultsByTag.has(tag)) return defaultsByTag.get(tag);
|
||||
const ifr = ensureIframe();
|
||||
const doc = ifr ? ifr.contentDocument : null;
|
||||
if (!doc || !doc.body) return {};
|
||||
const el = doc.createElement(tag);
|
||||
doc.body.appendChild(el);
|
||||
const cs = doc.defaultView.getComputedStyle(el);
|
||||
const d = {};
|
||||
for (const p of PROPS) d[p] = cs[p];
|
||||
doc.body.removeChild(el);
|
||||
defaultsByTag.set(tag, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function nonDefault(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
const defaults = defaultsFor(el.tagName.toLowerCase());
|
||||
const out = {};
|
||||
for (const p of PROPS) {
|
||||
const v = cs[p];
|
||||
if (v == null || v === '') continue;
|
||||
if (v === defaults[p]) continue;
|
||||
out[p] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pseudoStyles(el) {
|
||||
const out = {};
|
||||
for (const pseudo of ['::before', '::after']) {
|
||||
try {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
const content = cs.content;
|
||||
if (content && content !== 'normal' && content !== 'none') {
|
||||
const block = {};
|
||||
for (const p of PROPS) {
|
||||
const v = cs[p];
|
||||
if (v && v !== 'none' && v !== 'normal' && v !== 'auto') block[p] = v;
|
||||
}
|
||||
block.content = content;
|
||||
out[pseudo] = block;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function serialize(el) {
|
||||
if (el.nodeType === Node.TEXT_NODE) {
|
||||
const t = el.textContent;
|
||||
return t && t.trim() ? { text: t } : null;
|
||||
}
|
||||
if (el.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'NOSCRIPT') return null;
|
||||
const attrs = {};
|
||||
for (const a of el.attributes) attrs[a.name] = a.value;
|
||||
const computed = nonDefault(el);
|
||||
const pseudo = pseudoStyles(el);
|
||||
if (pseudo) computed.pseudo = pseudo;
|
||||
const r = el.getBoundingClientRect();
|
||||
const children = [];
|
||||
for (const c of el.childNodes) {
|
||||
const s = serialize(c);
|
||||
if (s) children.push(s);
|
||||
}
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
attrs,
|
||||
computed,
|
||||
bbox: { x: r.x, y: r.y + window.scrollY, width: r.width, height: r.height },
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
window.__CLONE_DUMP_COMPUTED__ = () => serialize(document.body);
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
// Dumps every same-origin CSS rule as { selector, cssText } into __CLONE_CAPTURE__.cssRules,
|
||||
// and extracts every url(...) asset reference (resolved to absolute URLs) into
|
||||
// __CLONE_CAPTURE__.cssAssets. Catches background-images set in stylesheets that the
|
||||
// response listener missed (e.g. @media-gated, ::before, mask-image, list-style-image, cursor).
|
||||
(() => {
|
||||
function isSameOrigin(href) {
|
||||
if (!href) return true;
|
||||
try {
|
||||
const u = new URL(href, location.href);
|
||||
return u.origin === location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(raw, baseHref) {
|
||||
if (!raw) return null;
|
||||
let s = raw.trim();
|
||||
if (s.startsWith('"') || s.startsWith("'")) s = s.slice(1, -1);
|
||||
if (s.startsWith('data:') || s.startsWith('#')) return null;
|
||||
try {
|
||||
return new URL(s, baseHref || location.href).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromRules(rules, baseHref, out) {
|
||||
if (!rules) return;
|
||||
for (const rule of rules) {
|
||||
const type = rule.constructor?.name || '';
|
||||
if (type === 'CSSStyleRule') {
|
||||
const text = rule.cssText || '';
|
||||
out.rules.push({ selector: rule.selectorText, cssText: text, baseHref });
|
||||
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
|
||||
for (const m of urls) {
|
||||
const inner = m
|
||||
.slice(4, -1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
const abs = resolveUrl(inner, baseHref);
|
||||
if (abs && !out.assetSet.has(abs)) {
|
||||
out.assetSet.add(abs);
|
||||
out.assets.push({ source_url: abs, selector: rule.selectorText });
|
||||
}
|
||||
}
|
||||
} else if (type === 'CSSMediaRule' || type === 'CSSSupportsRule' || type === 'CSSContainerRule') {
|
||||
const condition = rule.conditionText || rule.media?.mediaText;
|
||||
out.media.push({ condition, type });
|
||||
collectFromRules(rule.cssRules, baseHref, out);
|
||||
} else if (type === 'CSSImportRule') {
|
||||
// Recurse into imported stylesheet if same-origin and accessible
|
||||
try {
|
||||
const importedHref = rule.styleSheet?.href || baseHref;
|
||||
if (isSameOrigin(importedHref)) {
|
||||
collectFromRules(rule.styleSheet?.cssRules, importedHref, out);
|
||||
}
|
||||
} catch {}
|
||||
} else if (type === 'CSSFontFaceRule') {
|
||||
const text = rule.cssText || '';
|
||||
out.fontFaces.push({ cssText: text, baseHref });
|
||||
const urls = text.match(/url\(\s*([^)]+?)\s*\)/g) || [];
|
||||
for (const m of urls) {
|
||||
const inner = m
|
||||
.slice(4, -1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
const abs = resolveUrl(inner, baseHref);
|
||||
if (abs && !out.assetSet.has(abs)) {
|
||||
out.assetSet.add(abs);
|
||||
out.assets.push({ source_url: abs, selector: '@font-face' });
|
||||
}
|
||||
}
|
||||
} else if (type === 'CSSKeyframesRule') {
|
||||
const frames = [];
|
||||
for (const k of rule.cssRules || []) frames.push({ key: k.keyText, cssText: k.style?.cssText || '' });
|
||||
out.keyframes.push({ name: rule.name, frames, baseHref });
|
||||
} else if (rule.cssRules) {
|
||||
collectFromRules(rule.cssRules, baseHref, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const out = {
|
||||
rules: [],
|
||||
media: [],
|
||||
fontFaces: [],
|
||||
keyframes: [],
|
||||
assets: [],
|
||||
assetSet: new Set(),
|
||||
crossOrigin: 0,
|
||||
};
|
||||
for (const sheet of document.styleSheets) {
|
||||
const baseHref = sheet.href || location.href;
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
out.crossOrigin += 1;
|
||||
continue;
|
||||
}
|
||||
collectFromRules(rules, baseHref, out);
|
||||
}
|
||||
delete out.assetSet;
|
||||
window.__CLONE_CAPTURE__.cssRules = out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
collect();
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.cssRules = { error: String(e) };
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,51 @@
|
||||
// Dumps every :root --custom-property and every @property registration into
|
||||
// window.__CLONE_CAPTURE__.cssVars, keyed under { custom, registered }.
|
||||
(() => {
|
||||
function collect() {
|
||||
const custom = {};
|
||||
try {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
for (let i = 0; i < cs.length; i++) {
|
||||
const name = cs[i];
|
||||
if (!name.startsWith('--')) continue;
|
||||
try {
|
||||
custom[name] = cs.getPropertyValue(name).trim();
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const registered = [];
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try {
|
||||
rules = sheet.cssRules;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!rules) continue;
|
||||
for (const rule of rules) {
|
||||
if (rule.constructor?.name === 'CSSPropertyRule' || rule.type === 18 /* @property */) {
|
||||
registered.push({
|
||||
name: rule.name,
|
||||
syntax: rule.syntax,
|
||||
initialValue: rule.initialValue,
|
||||
inherits: rule.inherits,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
window.__CLONE_CAPTURE__.cssVars = { custom, registered };
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
collect();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,107 @@
|
||||
// Walks the React fiber tree (via __REACT_DEVTOOLS_GLOBAL_HOOK__) to find framer-motion
|
||||
// components and dump their animate/variants/transition/whileInView/whileHover props.
|
||||
// Called from __CLONE_FINALIZE__.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function isMotionComponent(fiber) {
|
||||
const type = fiber?.type;
|
||||
if (!type) return false;
|
||||
if (type.$$typeof && type.render && type.render.displayName?.startsWith('motion.')) return true;
|
||||
const dn = type.displayName || type.name;
|
||||
if (typeof dn === 'string' && (dn.startsWith('motion.') || dn === 'MotionComponent')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function serializeProp(v) {
|
||||
try {
|
||||
if (v == null) return v;
|
||||
if (typeof v === 'function') return null;
|
||||
if (typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v)) {
|
||||
const val = v[k];
|
||||
if (typeof val === 'function') continue;
|
||||
if (val && typeof val === 'object' && val.nodeType) continue;
|
||||
out[k] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findFibers(fiber, out) {
|
||||
if (!fiber) return;
|
||||
if (isMotionComponent(fiber)) {
|
||||
const props = fiber.memoizedProps || {};
|
||||
let node = fiber.stateNode;
|
||||
if (!(node instanceof Element)) {
|
||||
// Walk down to the first DOM host for selector extraction.
|
||||
let cursor = fiber.child;
|
||||
while (cursor) {
|
||||
if (cursor.stateNode instanceof Element) {
|
||||
node = cursor.stateNode;
|
||||
break;
|
||||
}
|
||||
cursor = cursor.child;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
selector: node instanceof Element ? cssPath(node) : null,
|
||||
initial: serializeProp(props.initial),
|
||||
animate: serializeProp(props.animate),
|
||||
exit: serializeProp(props.exit),
|
||||
variants: serializeProp(props.variants),
|
||||
transition: serializeProp(props.transition),
|
||||
whileHover: serializeProp(props.whileHover),
|
||||
whileTap: serializeProp(props.whileTap),
|
||||
whileInView: serializeProp(props.whileInView),
|
||||
viewport: serializeProp(props.viewport),
|
||||
});
|
||||
}
|
||||
if (fiber.child) findFibers(fiber.child, out);
|
||||
if (fiber.sibling) findFibers(fiber.sibling, out);
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook || !hook.renderers) return;
|
||||
for (const [, renderer] of hook.renderers) {
|
||||
if (!renderer) continue;
|
||||
const roots = renderer.findFiberByHostInstance ? null : renderer.roots || null;
|
||||
const scanFrom = [];
|
||||
if (hook.getFiberRoots) {
|
||||
const rootSet = hook.getFiberRoots(1) || new Set();
|
||||
for (const r of rootSet) scanFrom.push(r.current);
|
||||
}
|
||||
for (const fiber of scanFrom) findFibers(fiber, window.__CLONE_CAPTURE__.framer);
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.framer.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
// Dumps GSAP timelines + tweens into window.__CLONE_CAPTURE__.gsap after load.
|
||||
// Runs on __CLONE_FINALIZE__ so that page-load animations have had time to register.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function targetsToSelectors(targets) {
|
||||
if (!targets) return [];
|
||||
if (targets instanceof Element) return [cssPath(targets)];
|
||||
if (Array.isArray(targets) || targets.length != null) {
|
||||
const out = [];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t = targets[i];
|
||||
if (t instanceof Element) out.push(cssPath(t));
|
||||
else if (typeof t === 'string') out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof targets === 'string') return [targets];
|
||||
return [];
|
||||
}
|
||||
|
||||
function serializeEase(ease) {
|
||||
if (!ease) return null;
|
||||
if (typeof ease === 'string') return ease;
|
||||
if (typeof ease === 'function') return ease.name || ease.toString().slice(0, 40);
|
||||
return String(ease);
|
||||
}
|
||||
|
||||
function serializeTween(t) {
|
||||
const vars = {};
|
||||
if (t.vars) {
|
||||
for (const k of Object.keys(t.vars)) {
|
||||
if (['onComplete', 'onStart', 'onUpdate', 'onRepeat', 'scrollTrigger'].includes(k)) continue;
|
||||
try {
|
||||
const v = t.vars[k];
|
||||
if (typeof v === 'function') continue;
|
||||
if (v && typeof v === 'object' && v.nodeType) continue;
|
||||
vars[k] = v;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'tween',
|
||||
targets: targetsToSelectors(t._targets || t.targets?.()),
|
||||
duration: t.duration?.() ?? t._duration,
|
||||
ease: serializeEase(t.vars?.ease),
|
||||
vars,
|
||||
scrollTrigger: t.vars?.scrollTrigger
|
||||
? {
|
||||
trigger:
|
||||
t.vars.scrollTrigger.trigger instanceof Element
|
||||
? cssPath(t.vars.scrollTrigger.trigger)
|
||||
: t.vars.scrollTrigger.trigger,
|
||||
start: t.vars.scrollTrigger.start,
|
||||
end: t.vars.scrollTrigger.end,
|
||||
scrub: t.vars.scrollTrigger.scrub,
|
||||
pin: t.vars.scrollTrigger.pin,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function walk(tl) {
|
||||
const out = { type: 'timeline', duration: tl.duration?.(), children: [] };
|
||||
const kids = tl.getChildren ? tl.getChildren(true, true, true) : [];
|
||||
for (const c of kids) {
|
||||
if (c.getChildren) out.children.push(walk(c));
|
||||
else out.children.push(serializeTween(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
if (!window.gsap) return;
|
||||
const root = window.gsap.globalTimeline;
|
||||
if (!root) return;
|
||||
window.__CLONE_CAPTURE__.gsap.push(walk(root));
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.gsap.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pulls registered Lottie animations + their animationData into window.__CLONE_CAPTURE__.lottie.
|
||||
// Called from __CLONE_FINALIZE__. Supports lottie-web directly; lottie-react uses lottie-web internally.
|
||||
(() => {
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
const lottie = window.lottie || window.bodymovin;
|
||||
if (!lottie || typeof lottie.getRegisteredAnimations !== 'function') return;
|
||||
const anims = lottie.getRegisteredAnimations();
|
||||
for (const a of anims) {
|
||||
try {
|
||||
window.__CLONE_CAPTURE__.lottie.push({
|
||||
selector: a.wrapper instanceof Element ? cssPath(a.wrapper) : null,
|
||||
renderer: a.renderer?.name || 'unknown',
|
||||
animationData: a.animationData ? JSON.parse(JSON.stringify(a.animationData)) : null,
|
||||
loop: a.loop,
|
||||
autoplay: a.autoplay,
|
||||
name: a.name,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.lottie.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,147 @@
|
||||
// Exposes window.__CLONE_LIST_SECTIONS__() which returns a list of candidate top-level
|
||||
// section bounding boxes for the capture script's scroll planner. We emit one entry per
|
||||
// large flex/block child of <body>, <main>, or any element classed as section/container/etc.
|
||||
// The capture script uses these to scroll-to-section instead of fixed-pixel stepping.
|
||||
(() => {
|
||||
const SECTIONLIKE_TAGS = new Set(['section', 'header', 'footer', 'nav', 'main', 'article', 'aside']);
|
||||
const SECTIONLIKE_CLASS_RE =
|
||||
/(^|\s)(section|hero|container|banner|elementor-section|elementor-element-[^\s]+|e-con|e-parent|wp-block-[^\s]+)(\s|$)/i;
|
||||
|
||||
function isViewportWide(el, vw) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width >= vw * 0.5 && r.height >= 80;
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||||
if (parseFloat(cs.opacity) === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeSection(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SECTIONLIKE_TAGS.has(tag)) return true;
|
||||
if (el.id && /(hero|banner|section|footer|nav|header)/i.test(el.id)) return true;
|
||||
if (typeof el.className === 'string' && SECTIONLIKE_CLASS_RE.test(el.className)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 8) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p && p.nodeType === 1) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
const SECTION_QUERY =
|
||||
'section, header, footer, nav, main, article, ' +
|
||||
'[class*="section"], [class*="elementor-element"], [class*="e-parent"], [class*="e-con"], ' +
|
||||
'[class*="hero"], [class*="banner"], [id*="section"], [id*="hero"], [id*="banner"]';
|
||||
|
||||
function snapshotEntry(el, vw) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
element: el,
|
||||
selector: cssPath(el),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
className: typeof el.className === 'string' ? el.className.slice(0, 200) : null,
|
||||
x: r.x,
|
||||
y: r.y + window.scrollY,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
looksLikeSection: looksLikeSection(el),
|
||||
};
|
||||
}
|
||||
|
||||
function collectInScope(scope, vw, out, candidates) {
|
||||
for (const el of candidates) {
|
||||
if (!scope.contains(el) || el === scope) continue;
|
||||
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
|
||||
// Outermost-wins inside this scope
|
||||
let skip = false;
|
||||
for (const o of out) {
|
||||
if (o.element.contains(el)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
for (let i = out.length - 1; i >= 0; i--) {
|
||||
if (el.contains(out[i].element)) out.splice(i, 1);
|
||||
}
|
||||
out.push(snapshotEntry(el, vw));
|
||||
}
|
||||
}
|
||||
|
||||
window.__CLONE_LIST_SECTIONS__ = () => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const docHeight = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
||||
|
||||
// Pass 1: known section-like elements
|
||||
const candidates = Array.from(document.querySelectorAll(SECTION_QUERY));
|
||||
|
||||
// Pass 2: large direct children of body/main as a fallback
|
||||
const root = document.querySelector('main') || document.body;
|
||||
if (root) {
|
||||
for (const child of root.children) {
|
||||
if (!candidates.includes(child)) candidates.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// First-pass dedup with outermost-wins
|
||||
const out = [];
|
||||
for (const el of candidates) {
|
||||
if (!isVisible(el) || !isViewportWide(el, vw)) continue;
|
||||
let skip = false;
|
||||
for (const o of out) {
|
||||
if (o.element.contains(el)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
for (let i = out.length - 1; i >= 0; i--) {
|
||||
if (el.contains(out[i].element)) out.splice(i, 1);
|
||||
}
|
||||
out.push(snapshotEntry(el, vw));
|
||||
}
|
||||
|
||||
// Pass 3: oversized-wrapper expansion. WordPress / Elementor sites commonly
|
||||
// wrap the whole page in <main id="main">. Without this step the outer
|
||||
// wrapper wins and individual sections are dropped, so the smart-scroll
|
||||
// loop never visits them and lazy backgrounds never fire.
|
||||
const oversizedThreshold = Math.max(vh * 2, docHeight * 0.5);
|
||||
const expanded = [];
|
||||
for (const sec of out) {
|
||||
if (sec.height < oversizedThreshold) {
|
||||
expanded.push(sec);
|
||||
continue;
|
||||
}
|
||||
const inner = [];
|
||||
const innerCandidates = Array.from(sec.element.querySelectorAll(SECTION_QUERY));
|
||||
collectInScope(sec.element, vw, inner, innerCandidates);
|
||||
// Only replace the wrapper if recursion produced enough sections to be
|
||||
// meaningful — otherwise this is a genuine large section, not a wrapper.
|
||||
if (inner.length >= 3) expanded.push(...inner);
|
||||
else expanded.push(sec);
|
||||
}
|
||||
|
||||
return expanded
|
||||
.filter((s) => s.height > 80)
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map(({ element, ...rest }) => rest);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
// Extract GLSL from WebGL1 + WebGL2 by intercepting shaderSource / compileShader / linkProgram / useProgram.
|
||||
// Stashes into window.__CLONE_CAPTURE__.shaders as
|
||||
// { programId, vertex, fragment, attributes, uniforms, canvasSelector }
|
||||
(() => {
|
||||
const CAPTURE = () => window.__CLONE_CAPTURE__;
|
||||
|
||||
const shaderMap = new WeakMap(); // WebGLShader -> { type, source }
|
||||
const programMap = new WeakMap(); // WebGLProgram -> { vertex, fragment }
|
||||
let nextProgramId = 1;
|
||||
const contextCanvas = new WeakMap(); // WebGL(2)RenderingContext -> canvas
|
||||
|
||||
function patch(ctxProto, isGL2) {
|
||||
const origShaderSource = ctxProto.shaderSource;
|
||||
ctxProto.shaderSource = function (shader, source) {
|
||||
shaderMap.set(shader, { source, type: this.getShaderParameter?.(shader, this.SHADER_TYPE) });
|
||||
return origShaderSource.call(this, shader, source);
|
||||
};
|
||||
|
||||
const origAttach = ctxProto.attachShader;
|
||||
ctxProto.attachShader = function (program, shader) {
|
||||
const info = shaderMap.get(shader);
|
||||
const t = this.getShaderParameter(shader, this.SHADER_TYPE);
|
||||
const entry = programMap.get(program) || {};
|
||||
if (t === this.VERTEX_SHADER) entry.vertex = info?.source || '';
|
||||
else if (t === this.FRAGMENT_SHADER) entry.fragment = info?.source || '';
|
||||
programMap.set(program, entry);
|
||||
return origAttach.call(this, program, shader);
|
||||
};
|
||||
|
||||
const origLink = ctxProto.linkProgram;
|
||||
ctxProto.linkProgram = function (program) {
|
||||
const res = origLink.call(this, program);
|
||||
const entry = programMap.get(program) || {};
|
||||
if (!entry.id) entry.id = nextProgramId++;
|
||||
const canvas = contextCanvas.get(this);
|
||||
const sel = canvas ? cssPath(canvas) : null;
|
||||
const attributes = [];
|
||||
const uniforms = [];
|
||||
try {
|
||||
const aCount = this.getProgramParameter(program, this.ACTIVE_ATTRIBUTES);
|
||||
for (let i = 0; i < aCount; i++) {
|
||||
const info = this.getActiveAttrib(program, i);
|
||||
if (info) attributes.push({ name: info.name, type: info.type, size: info.size });
|
||||
}
|
||||
const uCount = this.getProgramParameter(program, this.ACTIVE_UNIFORMS);
|
||||
for (let i = 0; i < uCount; i++) {
|
||||
const info = this.getActiveUniform(program, i);
|
||||
if (info) uniforms.push({ name: info.name, type: info.type, size: info.size });
|
||||
}
|
||||
} catch {}
|
||||
CAPTURE().shaders.push({
|
||||
programId: entry.id,
|
||||
vertex: entry.vertex || '',
|
||||
fragment: entry.fragment || '',
|
||||
attributes,
|
||||
uniforms,
|
||||
canvasSelector: sel,
|
||||
isWebGL2: isGL2,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// Intercept getContext to remember which canvas owns which WebGL context.
|
||||
const origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
|
||||
const ctx = origGetContext.call(this, type, ...rest);
|
||||
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
|
||||
contextCanvas.set(ctx, this);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
if (typeof WebGLRenderingContext !== 'undefined') patch(WebGLRenderingContext.prototype, false);
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') patch(WebGL2RenderingContext.prototype, true);
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el) return null;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
if (el.id) {
|
||||
s += '#' + el.id;
|
||||
parts.unshift(s);
|
||||
break;
|
||||
}
|
||||
const parent = el.parentNode;
|
||||
if (parent) {
|
||||
const sibs = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
// Patches THREE.WebGLRenderer to track its canvas + scene root, then serializes the scene graph
|
||||
// at __CLONE_FINALIZE__. Records geometry types, material types + uniforms, lights, cameras,
|
||||
// and per-object position/rotation/scale.
|
||||
(() => {
|
||||
const renderers = new Set();
|
||||
const rendererToScene = new WeakMap();
|
||||
|
||||
function cssPath(el) {
|
||||
if (!el || el.nodeType !== 1) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const parts = [];
|
||||
while (el && el.nodeType === 1 && parts.length < 10) {
|
||||
let s = el.tagName.toLowerCase();
|
||||
const p = el.parentNode;
|
||||
if (p) {
|
||||
const sibs = Array.from(p.children).filter((c) => c.tagName === el.tagName);
|
||||
if (sibs.length > 1) s += `:nth-of-type(${sibs.indexOf(el) + 1})`;
|
||||
}
|
||||
parts.unshift(s);
|
||||
el = el.parentNode;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function patchWhenReady() {
|
||||
if (!window.THREE || !window.THREE.WebGLRenderer) return false;
|
||||
const WR = window.THREE.WebGLRenderer;
|
||||
const origRender = WR.prototype.render;
|
||||
WR.prototype.render = function (scene, camera) {
|
||||
renderers.add(this);
|
||||
if (scene) rendererToScene.set(this, { scene, camera });
|
||||
return origRender.call(this, scene, camera);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!patchWhenReady()) {
|
||||
const timer = setInterval(() => {
|
||||
if (patchWhenReady()) clearInterval(timer);
|
||||
}, 200);
|
||||
setTimeout(() => clearInterval(timer), 10_000);
|
||||
}
|
||||
|
||||
function serializeObject(obj) {
|
||||
const out = {
|
||||
type: obj.type || obj.constructor?.name || 'Object3D',
|
||||
name: obj.name || undefined,
|
||||
position: obj.position ? [obj.position.x, obj.position.y, obj.position.z] : undefined,
|
||||
rotation: obj.rotation ? [obj.rotation.x, obj.rotation.y, obj.rotation.z] : undefined,
|
||||
scale: obj.scale ? [obj.scale.x, obj.scale.y, obj.scale.z] : undefined,
|
||||
visible: obj.visible,
|
||||
children: [],
|
||||
};
|
||||
if (obj.geometry) {
|
||||
out.geometry = {
|
||||
type: obj.geometry.type,
|
||||
parameters: obj.geometry.parameters ? { ...obj.geometry.parameters } : undefined,
|
||||
};
|
||||
}
|
||||
if (obj.material) {
|
||||
const m = Array.isArray(obj.material) ? obj.material[0] : obj.material;
|
||||
out.material = {
|
||||
type: m.type,
|
||||
color: m.color ? m.color.getHex?.().toString(16) : undefined,
|
||||
opacity: m.opacity,
|
||||
transparent: m.transparent,
|
||||
uniforms: m.uniforms ? summarizeUniforms(m.uniforms) : undefined,
|
||||
vertexShader: m.vertexShader?.slice(0, 4000),
|
||||
fragmentShader: m.fragmentShader?.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
if (obj.isLight) {
|
||||
out.light = {
|
||||
type: obj.type,
|
||||
color: obj.color?.getHex?.()?.toString(16),
|
||||
intensity: obj.intensity,
|
||||
};
|
||||
}
|
||||
if (obj.isCamera) {
|
||||
out.camera = {
|
||||
type: obj.type,
|
||||
fov: obj.fov,
|
||||
aspect: obj.aspect,
|
||||
near: obj.near,
|
||||
far: obj.far,
|
||||
};
|
||||
}
|
||||
if (obj.children?.length) {
|
||||
out.children = obj.children.map(serializeObject);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeUniforms(u) {
|
||||
const out = {};
|
||||
for (const k of Object.keys(u)) {
|
||||
try {
|
||||
const v = u[k]?.value;
|
||||
if (v == null) continue;
|
||||
if (typeof v === 'number') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.slice(0, 16);
|
||||
else if (v.x != null && v.y != null) out[k] = [v.x, v.y, v.z ?? 0, v.w ?? 0];
|
||||
else out[k] = String(v).slice(0, 80);
|
||||
} catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const prevFinalize = window.__CLONE_FINALIZE__;
|
||||
window.__CLONE_FINALIZE__ = function () {
|
||||
if (prevFinalize)
|
||||
try {
|
||||
prevFinalize();
|
||||
} catch {}
|
||||
try {
|
||||
for (const r of renderers) {
|
||||
const { scene, camera } = rendererToScene.get(r) || {};
|
||||
const dom = r.domElement;
|
||||
window.__CLONE_CAPTURE__.threejs.push({
|
||||
canvasSelector: dom instanceof Element ? cssPath(dom) : null,
|
||||
size: dom ? [dom.width, dom.height] : null,
|
||||
scene: scene ? serializeObject(scene) : null,
|
||||
camera: camera ? serializeObject(camera) : null,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
window.__CLONE_CAPTURE__.threejs.push({ error: String(e) });
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user