saccade 0.0.3 → 0.2.0
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/README.md +65 -2
- package/dist/core.cjs +267 -157
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +63 -11
- package/dist/core.d.ts +63 -11
- package/dist/core.mjs +264 -156
- package/dist/core.mjs.map +1 -1
- package/dist/index.cjs +299 -193
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -11
- package/dist/index.d.ts +37 -11
- package/dist/index.mjs +297 -193
- package/dist/index.mjs.map +1 -1
- package/dist/install.cjs +1846 -0
- package/dist/install.cjs.map +1 -0
- package/dist/install.d.cts +129 -0
- package/dist/install.d.ts +129 -0
- package/dist/install.mjs +1819 -0
- package/dist/install.mjs.map +1 -0
- package/package.json +12 -1
package/dist/index.mjs
CHANGED
|
@@ -8,16 +8,22 @@ import { createPortal } from "react-dom";
|
|
|
8
8
|
import { createContext, useContext, useRef } from "react";
|
|
9
9
|
|
|
10
10
|
// src/core/timing.ts
|
|
11
|
+
var MEDIA_RATE_MIN = 0.0625;
|
|
12
|
+
var MEDIA_RATE_MAX = 16;
|
|
13
|
+
var ZERO_SPEED_DIVISOR = 1e-4;
|
|
11
14
|
var TimingController = class {
|
|
12
15
|
constructor() {
|
|
13
16
|
this.speed = 1;
|
|
14
17
|
this.virtualBaseline = 0;
|
|
15
18
|
this.intervalMap = /* @__PURE__ */ new Map();
|
|
16
19
|
this.nextIntervalId = 1e6;
|
|
17
|
-
this.mediaObserver = null;
|
|
18
|
-
this.animObserver = null;
|
|
19
20
|
this._origAnimate = null;
|
|
20
21
|
this.installed = false;
|
|
22
|
+
this.animPollId = 0;
|
|
23
|
+
this.gsapInstance = null;
|
|
24
|
+
// WeakMap tracking for animations and media
|
|
25
|
+
this.trackedAnims = /* @__PURE__ */ new WeakMap();
|
|
26
|
+
this.trackedMedia = /* @__PURE__ */ new WeakMap();
|
|
21
27
|
this._raf = requestAnimationFrame.bind(window);
|
|
22
28
|
this._caf = cancelAnimationFrame.bind(window);
|
|
23
29
|
this._setTimeout = setTimeout.bind(window);
|
|
@@ -27,19 +33,34 @@ var TimingController = class {
|
|
|
27
33
|
this._perfNow = performance.now.bind(performance);
|
|
28
34
|
this._dateNow = Date.now;
|
|
29
35
|
this.realBaseline = this._perfNow();
|
|
36
|
+
this.dateRealBaseline = this._dateNow();
|
|
37
|
+
this.dateVirtualBaseline = this.dateRealBaseline;
|
|
30
38
|
}
|
|
31
39
|
getVirtualTime() {
|
|
32
40
|
const realElapsed = this._perfNow() - this.realBaseline;
|
|
33
41
|
return this.virtualBaseline + realElapsed * this.speed;
|
|
34
42
|
}
|
|
43
|
+
getVirtualDateNow() {
|
|
44
|
+
const realElapsed = this._dateNow() - this.dateRealBaseline;
|
|
45
|
+
return this.dateVirtualBaseline + realElapsed * this.speed;
|
|
46
|
+
}
|
|
35
47
|
reanchor() {
|
|
36
48
|
const virtualNow = this.getVirtualTime();
|
|
37
49
|
this.realBaseline = this._perfNow();
|
|
38
50
|
this.virtualBaseline = virtualNow;
|
|
51
|
+
const virtualDateNow = this.getVirtualDateNow();
|
|
52
|
+
this.dateRealBaseline = this._dateNow();
|
|
53
|
+
this.dateVirtualBaseline = virtualDateNow;
|
|
54
|
+
}
|
|
55
|
+
/** Effective speed divisor — avoids division by zero at speed=0. */
|
|
56
|
+
get speedDivisor() {
|
|
57
|
+
return this.speed || ZERO_SPEED_DIVISOR;
|
|
39
58
|
}
|
|
40
59
|
/** Install timing patches. Safe to call multiple times. */
|
|
41
60
|
install() {
|
|
42
61
|
if (this.installed) return;
|
|
62
|
+
if (window.__saccadeInstalled) return;
|
|
63
|
+
window.__saccadeInstalled = true;
|
|
43
64
|
this.installed = true;
|
|
44
65
|
const self = this;
|
|
45
66
|
window.__LAPSE_ORIGINAL_RAF__ = this._raf;
|
|
@@ -48,21 +69,27 @@ var TimingController = class {
|
|
|
48
69
|
Element.prototype.animate = function(...args) {
|
|
49
70
|
const anim = origAnimate.apply(this, args);
|
|
50
71
|
if (self.speed !== 1) {
|
|
51
|
-
|
|
72
|
+
const originalRate = anim.playbackRate;
|
|
73
|
+
const applied = originalRate * (self.speed || 1e-3);
|
|
74
|
+
anim.playbackRate = applied;
|
|
75
|
+
self.trackedAnims.set(anim, { original: originalRate, applied });
|
|
52
76
|
}
|
|
53
77
|
return anim;
|
|
54
78
|
};
|
|
55
79
|
performance.now = () => self.getVirtualTime();
|
|
56
|
-
|
|
57
|
-
Date.now = () => dateBaseline + self.getVirtualTime();
|
|
80
|
+
Date.now = () => self.getVirtualDateNow();
|
|
58
81
|
window.requestAnimationFrame = (callback) => {
|
|
59
82
|
return self._raf(() => {
|
|
83
|
+
if (self.speed === 0) {
|
|
84
|
+
window.requestAnimationFrame(callback);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
60
87
|
callback(self.getVirtualTime());
|
|
61
88
|
});
|
|
62
89
|
};
|
|
63
90
|
window.cancelAnimationFrame = this._caf;
|
|
64
91
|
window.setTimeout = ((handler, delay, ...args) => {
|
|
65
|
-
const scaledDelay = (delay ?? 0) /
|
|
92
|
+
const scaledDelay = (delay ?? 0) / self.speedDivisor;
|
|
66
93
|
return self._setTimeout(handler, scaledDelay, ...args);
|
|
67
94
|
});
|
|
68
95
|
window.clearTimeout = this._clearTimeout;
|
|
@@ -70,7 +97,7 @@ var TimingController = class {
|
|
|
70
97
|
const id = self.nextIntervalId++;
|
|
71
98
|
const baseDelay = delay ?? 0;
|
|
72
99
|
function tick() {
|
|
73
|
-
const scaledDelay = baseDelay /
|
|
100
|
+
const scaledDelay = baseDelay / self.speedDivisor;
|
|
74
101
|
const realId = self._setTimeout(() => {
|
|
75
102
|
if (typeof handler === "function") {
|
|
76
103
|
;
|
|
@@ -95,61 +122,99 @@ var TimingController = class {
|
|
|
95
122
|
self._clearInterval(id);
|
|
96
123
|
}
|
|
97
124
|
});
|
|
98
|
-
this.
|
|
99
|
-
for (const mutation of mutations) {
|
|
100
|
-
for (const node of mutation.addedNodes) {
|
|
101
|
-
if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
|
|
102
|
-
node.playbackRate = self.speed;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
if (document.body) {
|
|
108
|
-
this.mediaObserver.observe(document.body, { childList: true, subtree: true });
|
|
109
|
-
}
|
|
125
|
+
this.startAnimationPoll();
|
|
110
126
|
}
|
|
111
127
|
/** Set playback speed. Requires install() first. */
|
|
112
128
|
setSpeed(newSpeed) {
|
|
113
129
|
if (!this.installed) this.install();
|
|
114
130
|
this.reanchor();
|
|
115
131
|
this.speed = newSpeed;
|
|
116
|
-
document.querySelectorAll("video, audio").forEach((el) => {
|
|
117
|
-
;
|
|
118
|
-
el.playbackRate = newSpeed;
|
|
119
|
-
});
|
|
120
132
|
this.patchAnimations();
|
|
133
|
+
this.patchMedia();
|
|
134
|
+
this.patchGSAP();
|
|
121
135
|
}
|
|
122
|
-
|
|
136
|
+
getSpeed() {
|
|
137
|
+
return this.speed;
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Animation polling — per-frame via original rAF
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
startAnimationPoll() {
|
|
143
|
+
const poll = () => {
|
|
144
|
+
if (!this.installed) return;
|
|
145
|
+
this.patchAnimations();
|
|
146
|
+
this.patchMedia();
|
|
147
|
+
this.animPollId = this._raf(poll);
|
|
148
|
+
};
|
|
149
|
+
this.animPollId = this._raf(poll);
|
|
150
|
+
}
|
|
151
|
+
/** Patch playbackRate on all active animations via WAAPI. */
|
|
123
152
|
patchAnimations() {
|
|
124
153
|
try {
|
|
125
154
|
const anims = document.getAnimations();
|
|
126
155
|
for (const anim of anims) {
|
|
127
156
|
const target = anim.effect?.target;
|
|
128
157
|
if (target?.closest?.("[data-lapse-panel]")) continue;
|
|
129
|
-
|
|
158
|
+
if (target?.closest?.("[data-saccade-exclude]")) continue;
|
|
159
|
+
const effectiveSpeed = this.speed || 1e-3;
|
|
160
|
+
let tracked = this.trackedAnims.get(anim);
|
|
161
|
+
if (!tracked) {
|
|
162
|
+
tracked = { original: anim.playbackRate, applied: anim.playbackRate };
|
|
163
|
+
this.trackedAnims.set(anim, tracked);
|
|
164
|
+
} else if (anim.playbackRate !== tracked.applied) {
|
|
165
|
+
tracked.original = anim.playbackRate;
|
|
166
|
+
}
|
|
167
|
+
const desired = tracked.original * effectiveSpeed;
|
|
168
|
+
if (anim.playbackRate !== desired) {
|
|
169
|
+
anim.playbackRate = desired;
|
|
170
|
+
tracked.applied = desired;
|
|
171
|
+
}
|
|
130
172
|
}
|
|
131
173
|
} catch {
|
|
132
174
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
175
|
+
}
|
|
176
|
+
/** Patch playbackRate on all video/audio elements. */
|
|
177
|
+
patchMedia() {
|
|
178
|
+
try {
|
|
179
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
180
|
+
const el = node;
|
|
181
|
+
if (el.closest?.("[data-lapse-panel]")) return;
|
|
182
|
+
if (el.closest?.("[data-saccade-exclude]")) return;
|
|
183
|
+
let tracked = this.trackedMedia.get(el);
|
|
184
|
+
if (!tracked) {
|
|
185
|
+
tracked = { original: el.playbackRate, applied: el.playbackRate };
|
|
186
|
+
this.trackedMedia.set(el, tracked);
|
|
187
|
+
} else if (el.playbackRate !== tracked.applied) {
|
|
188
|
+
tracked.original = el.playbackRate;
|
|
189
|
+
}
|
|
190
|
+
const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
|
|
191
|
+
if (el.playbackRate !== desired) {
|
|
192
|
+
el.playbackRate = desired;
|
|
193
|
+
tracked.applied = desired;
|
|
146
194
|
}
|
|
147
|
-
}
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
148
197
|
}
|
|
149
198
|
}
|
|
150
|
-
|
|
151
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Register a GSAP instance (for ES-module imports where window.gsap is
|
|
201
|
+
* undefined). Applies the current timeScale immediately if speed !== 1.
|
|
202
|
+
*/
|
|
203
|
+
registerGSAP(gsap) {
|
|
204
|
+
this.gsapInstance = gsap;
|
|
205
|
+
if (this.speed !== 1) this.patchGSAP();
|
|
152
206
|
}
|
|
207
|
+
/** Sync GSAP's global timeline if present. */
|
|
208
|
+
patchGSAP() {
|
|
209
|
+
try {
|
|
210
|
+
const gsap = this.gsapInstance ?? window.gsap;
|
|
211
|
+
gsap?.globalTimeline?.timeScale(this.speed || 1e-3);
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Cleanup
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
153
218
|
/** Restore all patched APIs to originals. */
|
|
154
219
|
destroy() {
|
|
155
220
|
if (!this.installed) return;
|
|
@@ -169,19 +234,33 @@ var TimingController = class {
|
|
|
169
234
|
Element.prototype.animate = this._origAnimate;
|
|
170
235
|
this._origAnimate = null;
|
|
171
236
|
}
|
|
172
|
-
this.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this._clearInterval(this.animObserver);
|
|
176
|
-
this.animObserver = null;
|
|
237
|
+
if (this.animPollId) {
|
|
238
|
+
this._caf(this.animPollId);
|
|
239
|
+
this.animPollId = 0;
|
|
177
240
|
}
|
|
178
241
|
try {
|
|
179
242
|
for (const anim of document.getAnimations()) {
|
|
180
|
-
|
|
243
|
+
const tracked = this.trackedAnims.get(anim);
|
|
244
|
+
anim.playbackRate = tracked?.original ?? 1;
|
|
181
245
|
}
|
|
182
246
|
} catch {
|
|
183
247
|
}
|
|
248
|
+
try {
|
|
249
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
250
|
+
const el = node;
|
|
251
|
+
const tracked = this.trackedMedia.get(el);
|
|
252
|
+
el.playbackRate = tracked?.original ?? 1;
|
|
253
|
+
});
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const gsap = this.gsapInstance ?? window.gsap;
|
|
258
|
+
gsap?.globalTimeline?.timeScale(1);
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
this.gsapInstance = null;
|
|
184
262
|
delete window.__LAPSE_ORIGINAL_RAF__;
|
|
263
|
+
delete window.__saccadeInstalled;
|
|
185
264
|
this.installed = false;
|
|
186
265
|
}
|
|
187
266
|
};
|
|
@@ -251,6 +330,10 @@ var SNAPSHOT_ATTRS = [
|
|
|
251
330
|
"data-hover",
|
|
252
331
|
"data-at-boundary",
|
|
253
332
|
"data-scrubbing",
|
|
333
|
+
"data-starting-style",
|
|
334
|
+
"data-ending-style",
|
|
335
|
+
"data-panel-open",
|
|
336
|
+
"data-hidden",
|
|
254
337
|
"aria-checked",
|
|
255
338
|
"aria-selected",
|
|
256
339
|
"aria-expanded",
|
|
@@ -264,6 +347,7 @@ var SNAPSHOT_ATTRS = [
|
|
|
264
347
|
"checked",
|
|
265
348
|
"disabled",
|
|
266
349
|
"hidden",
|
|
350
|
+
"inert",
|
|
267
351
|
"value",
|
|
268
352
|
"class",
|
|
269
353
|
"style"
|
|
@@ -395,6 +479,8 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
395
479
|
// ---- WAAPI interception --------------------------------------------------
|
|
396
480
|
/** Animations captured via Element.prototype.animate monkey-patch. */
|
|
397
481
|
this.interceptedAnimations = [];
|
|
482
|
+
// ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
|
|
483
|
+
this.seekableClones = /* @__PURE__ */ new Map();
|
|
398
484
|
this.hiddenSince = null;
|
|
399
485
|
this.onVisibilityChange = null;
|
|
400
486
|
/** Set to true when the capture loop self-terminates due to limits. */
|
|
@@ -445,6 +531,7 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
445
531
|
this.portalIdCounter = 0;
|
|
446
532
|
this.currentPortalIds.clear();
|
|
447
533
|
this.capturedPortals.clear();
|
|
534
|
+
this.seekableClones.clear();
|
|
448
535
|
this.prevInlineStyles.clear();
|
|
449
536
|
this.jsAnimStartTimes.clear();
|
|
450
537
|
this.jsAnimLastSeen.clear();
|
|
@@ -798,6 +885,13 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
798
885
|
} catch (_) {
|
|
799
886
|
}
|
|
800
887
|
}
|
|
888
|
+
const cleanedKeyframes = keyframes2.map((kf) => {
|
|
889
|
+
const clean = {};
|
|
890
|
+
for (const [k, v] of Object.entries(kf)) {
|
|
891
|
+
if (k !== "computedOffset" && k !== "composite") clean[k] = v;
|
|
892
|
+
}
|
|
893
|
+
return clean;
|
|
894
|
+
});
|
|
801
895
|
this.animations.set(id, {
|
|
802
896
|
id,
|
|
803
897
|
name,
|
|
@@ -809,7 +903,9 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
809
903
|
type,
|
|
810
904
|
source,
|
|
811
905
|
resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
|
|
812
|
-
conflicts
|
|
906
|
+
conflicts,
|
|
907
|
+
rawKeyframes: cleanedKeyframes,
|
|
908
|
+
rawTiming: { ...timing, fill: "both" }
|
|
813
909
|
});
|
|
814
910
|
}
|
|
815
911
|
const keyframes = a.effect?.getKeyframes?.() || [];
|
|
@@ -1085,38 +1181,28 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
1085
1181
|
} catch (_) {
|
|
1086
1182
|
}
|
|
1087
1183
|
}, 0);
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
if (
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
;
|
|
1111
|
-
el.value = value;
|
|
1112
|
-
} else if (value == null) {
|
|
1113
|
-
el.removeAttribute(attr);
|
|
1114
|
-
} else {
|
|
1115
|
-
el.setAttribute(attr, value);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1184
|
+
this.seekableClones.clear();
|
|
1185
|
+
for (const [animId, animInfo] of this.animations) {
|
|
1186
|
+
if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
|
|
1187
|
+
if (animInfo.type === "JSAnimation") continue;
|
|
1188
|
+
const firstColon = animId.indexOf(":");
|
|
1189
|
+
const secondColon = animId.indexOf(":", firstColon + 1);
|
|
1190
|
+
const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
|
|
1191
|
+
const el = this.elements.get(elSelector);
|
|
1192
|
+
if (!el?.isConnected) continue;
|
|
1193
|
+
try {
|
|
1194
|
+
const clone = el.animate(animInfo.rawKeyframes, {
|
|
1195
|
+
...animInfo.rawTiming,
|
|
1196
|
+
fill: "both"
|
|
1197
|
+
});
|
|
1198
|
+
clone.pause();
|
|
1199
|
+
clone.currentTime = 0;
|
|
1200
|
+
this.seekableClones.set(animId, {
|
|
1201
|
+
animation: clone,
|
|
1202
|
+
element: el,
|
|
1203
|
+
effect: clone.effect
|
|
1204
|
+
});
|
|
1205
|
+
} catch (_) {
|
|
1120
1206
|
}
|
|
1121
1207
|
}
|
|
1122
1208
|
const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
|
|
@@ -1139,6 +1225,8 @@ var TimelineRecorder = _TimelineRecorder;
|
|
|
1139
1225
|
// src/core/scrubber.ts
|
|
1140
1226
|
var TimelineScrubber = class {
|
|
1141
1227
|
constructor(state) {
|
|
1228
|
+
/** Precomputed frame range per animation for O(1) before/after lookup. */
|
|
1229
|
+
this.animFrameRanges = /* @__PURE__ */ new Map();
|
|
1142
1230
|
/** Saved originals for restore on release */
|
|
1143
1231
|
this._originalAnimate = null;
|
|
1144
1232
|
this._originalRaf = null;
|
|
@@ -1148,36 +1236,24 @@ var TimelineScrubber = class {
|
|
|
1148
1236
|
this.frames = state.frames;
|
|
1149
1237
|
this.capturedPortals = state.capturedPortals;
|
|
1150
1238
|
this.interceptedAnimations = state.interceptedAnimations;
|
|
1151
|
-
this.
|
|
1239
|
+
this.seekableClones = state.seekableClones;
|
|
1152
1240
|
this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
|
|
1153
1241
|
this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
|
|
1154
1242
|
this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
|
|
1155
1243
|
this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
let current = el;
|
|
1165
|
-
for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
|
|
1166
|
-
const tag = current.tagName.toLowerCase();
|
|
1167
|
-
const parent = current.parentElement;
|
|
1168
|
-
if (parent) {
|
|
1169
|
-
const siblings = Array.from(parent.children);
|
|
1170
|
-
const idx = siblings.indexOf(current) + 1;
|
|
1171
|
-
parts.unshift(`${tag}:nth-child(${idx})`);
|
|
1172
|
-
} else {
|
|
1173
|
-
parts.unshift(tag);
|
|
1244
|
+
for (let i = 0; i < this.frames.length; i++) {
|
|
1245
|
+
for (const fa of this.frames[i].animations) {
|
|
1246
|
+
const range = this.animFrameRanges.get(fa.animationId);
|
|
1247
|
+
if (!range) {
|
|
1248
|
+
this.animFrameRanges.set(fa.animationId, { first: i, last: i });
|
|
1249
|
+
} else {
|
|
1250
|
+
range.last = i;
|
|
1251
|
+
}
|
|
1174
1252
|
}
|
|
1175
|
-
current = parent;
|
|
1176
1253
|
}
|
|
1177
|
-
return parts.join(" > ");
|
|
1178
1254
|
}
|
|
1179
1255
|
// ---------------------------------------------------------------------------
|
|
1180
|
-
// seekTo — scrub
|
|
1256
|
+
// seekTo — scrub to a specific timestamp using WAAPI-native seeking
|
|
1181
1257
|
// ---------------------------------------------------------------------------
|
|
1182
1258
|
seekTo(timeMs) {
|
|
1183
1259
|
if (!this.frames.length) return;
|
|
@@ -1225,6 +1301,34 @@ var TimelineScrubber = class {
|
|
|
1225
1301
|
}
|
|
1226
1302
|
}
|
|
1227
1303
|
}
|
|
1304
|
+
const activeAnimIds = /* @__PURE__ */ new Map();
|
|
1305
|
+
for (const fa of frame.animations || []) {
|
|
1306
|
+
activeAnimIds.set(fa.animationId, fa);
|
|
1307
|
+
}
|
|
1308
|
+
for (const [animId, clone] of this.seekableClones) {
|
|
1309
|
+
const frameAnim = activeAnimIds.get(animId);
|
|
1310
|
+
try {
|
|
1311
|
+
if (frameAnim) {
|
|
1312
|
+
if (!clone.animation.effect) {
|
|
1313
|
+
clone.animation.effect = clone.effect;
|
|
1314
|
+
}
|
|
1315
|
+
clone.animation.currentTime = frameAnim.currentTime;
|
|
1316
|
+
} else {
|
|
1317
|
+
const range = this.animFrameRanges.get(animId);
|
|
1318
|
+
if (!range || lo < range.first) {
|
|
1319
|
+
clone.animation.effect = null;
|
|
1320
|
+
} else {
|
|
1321
|
+
if (!clone.animation.effect) {
|
|
1322
|
+
clone.animation.effect = clone.effect;
|
|
1323
|
+
}
|
|
1324
|
+
const timing = clone.effect.getTiming();
|
|
1325
|
+
const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
|
|
1326
|
+
clone.animation.currentTime = endTime;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
} catch {
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1228
1332
|
for (const entry of this.interceptedAnimations) {
|
|
1229
1333
|
try {
|
|
1230
1334
|
const anim = entry.animation;
|
|
@@ -1239,34 +1343,13 @@ var TimelineScrubber = class {
|
|
|
1239
1343
|
const el = this.elements.get(sel);
|
|
1240
1344
|
if (!el || !el.isConnected) continue;
|
|
1241
1345
|
if (el.closest?.("[data-lapse-panel]")) continue;
|
|
1242
|
-
const hasAnimation = (frame.animations || []).some(
|
|
1243
|
-
(a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
|
|
1244
|
-
);
|
|
1245
1346
|
const snapTyped = snap;
|
|
1246
|
-
if (snapTyped.__styles) {
|
|
1247
|
-
for (const [prop, value] of Object.entries(snapTyped.__styles)) {
|
|
1248
|
-
if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
|
|
1249
|
-
el.style.setProperty(prop, value, "important");
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
1347
|
if (snapTyped.__attrs) {
|
|
1254
1348
|
for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
|
|
1349
|
+
if (attr === "class" || attr === "style") continue;
|
|
1255
1350
|
if (attr === "checked") {
|
|
1256
1351
|
;
|
|
1257
1352
|
el.checked = value === "true";
|
|
1258
|
-
} else if (attr === "class") {
|
|
1259
|
-
if (value != null) el.className = value;
|
|
1260
|
-
} else if (attr === "style") {
|
|
1261
|
-
if (value) {
|
|
1262
|
-
el.setAttribute("style", value);
|
|
1263
|
-
el.style.transition = "none";
|
|
1264
|
-
if (snapTyped.__styles) {
|
|
1265
|
-
for (const [prop, val] of Object.entries(snapTyped.__styles)) {
|
|
1266
|
-
el.style.setProperty(prop, val, "important");
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
1353
|
} else if (attr === "value") {
|
|
1271
1354
|
if (value != null) el.value = value;
|
|
1272
1355
|
} else if (value == null) {
|
|
@@ -1277,39 +1360,31 @@ var TimelineScrubber = class {
|
|
|
1277
1360
|
}
|
|
1278
1361
|
}
|
|
1279
1362
|
}
|
|
1280
|
-
for (const
|
|
1281
|
-
|
|
1282
|
-
const
|
|
1283
|
-
const
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1363
|
+
for (const fa of frame.animations || []) {
|
|
1364
|
+
if (!fa.animationId.startsWith("JSAnimation:")) continue;
|
|
1365
|
+
const firstColon = fa.animationId.indexOf(":");
|
|
1366
|
+
const secondColon = fa.animationId.indexOf(":", firstColon + 1);
|
|
1367
|
+
const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
|
|
1368
|
+
const el = this.elements.get(elSel);
|
|
1369
|
+
if (!el || !el.isConnected) continue;
|
|
1370
|
+
for (const prop of fa.properties) {
|
|
1287
1371
|
if (prop.value) {
|
|
1288
|
-
|
|
1372
|
+
el.style.setProperty(prop.property, prop.value, "important");
|
|
1289
1373
|
}
|
|
1290
1374
|
}
|
|
1291
1375
|
}
|
|
1292
|
-
const animatedSels = /* @__PURE__ */ new Set();
|
|
1293
|
-
for (const anim of frame.animations || []) {
|
|
1294
|
-
const fc = anim.animationId.indexOf(":");
|
|
1295
|
-
const sc = anim.animationId.indexOf(":", fc + 1);
|
|
1296
|
-
if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
|
|
1297
|
-
}
|
|
1298
|
-
document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
|
|
1299
|
-
const el = rawEl;
|
|
1300
|
-
const sel = this.getSelector(el);
|
|
1301
|
-
if (sel && !animatedSels.has(sel)) {
|
|
1302
|
-
el.style.removeProperty("opacity");
|
|
1303
|
-
el.style.removeProperty("transform");
|
|
1304
|
-
el.style.removeProperty("filter");
|
|
1305
|
-
el.style.removeProperty("stroke-dashoffset");
|
|
1306
|
-
}
|
|
1307
|
-
});
|
|
1308
1376
|
}
|
|
1309
1377
|
// ---------------------------------------------------------------------------
|
|
1310
1378
|
// release — tear down all scrub state and restore the page to normal
|
|
1311
1379
|
// ---------------------------------------------------------------------------
|
|
1312
1380
|
release() {
|
|
1381
|
+
for (const [, clone] of this.seekableClones) {
|
|
1382
|
+
try {
|
|
1383
|
+
clone.animation.cancel();
|
|
1384
|
+
} catch {
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
this.seekableClones.clear();
|
|
1313
1388
|
for (const entry of this.interceptedAnimations) {
|
|
1314
1389
|
try {
|
|
1315
1390
|
entry.animation.cancel();
|
|
@@ -1371,6 +1446,7 @@ var TimelineScrubber = class {
|
|
|
1371
1446
|
this.elements.clear();
|
|
1372
1447
|
this.frames.length = 0;
|
|
1373
1448
|
this.capturedPortals.clear();
|
|
1449
|
+
this.animFrameRanges.clear();
|
|
1374
1450
|
}
|
|
1375
1451
|
};
|
|
1376
1452
|
|
|
@@ -1457,7 +1533,7 @@ function generateExport(animations, frames, timeMs, filter = "active") {
|
|
|
1457
1533
|
animations: animExports
|
|
1458
1534
|
};
|
|
1459
1535
|
}
|
|
1460
|
-
function formatExportForLLM(exp, detail = "
|
|
1536
|
+
function formatExportForLLM(exp, detail = "moderate") {
|
|
1461
1537
|
const lines = [];
|
|
1462
1538
|
const grouped = /* @__PURE__ */ new Map();
|
|
1463
1539
|
for (const anim of exp.animations) {
|
|
@@ -1472,7 +1548,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1472
1548
|
const [from, to] = prop.range.split(" \u2192 ");
|
|
1473
1549
|
return !(from && to && from.trim() === to.trim());
|
|
1474
1550
|
}
|
|
1475
|
-
if (detail === "
|
|
1551
|
+
if (detail === "brief") {
|
|
1476
1552
|
lines.push(`# Animation State at ${exp.timestamp}`);
|
|
1477
1553
|
lines.push("");
|
|
1478
1554
|
for (const [, group] of grouped) {
|
|
@@ -1497,7 +1573,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1497
1573
|
}
|
|
1498
1574
|
lines.push(`# Animation State at ${exp.timestamp}`);
|
|
1499
1575
|
lines.push("");
|
|
1500
|
-
if (detail === "
|
|
1576
|
+
if (detail === "granular") {
|
|
1501
1577
|
lines.push("**Environment:**");
|
|
1502
1578
|
lines.push(`- Viewport: ${window.innerWidth}\xD7${window.innerHeight}`);
|
|
1503
1579
|
lines.push(`- URL: ${window.location.href}`);
|
|
@@ -1564,7 +1640,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1564
1640
|
lines.push(`Transitions: ${[...transitionSet].join(", ")}`);
|
|
1565
1641
|
lines.push("");
|
|
1566
1642
|
for (const line of cssPropLines) lines.push(line);
|
|
1567
|
-
if (detail === "detailed" || detail === "
|
|
1643
|
+
if (detail === "detailed" || detail === "granular") {
|
|
1568
1644
|
const allVars = {};
|
|
1569
1645
|
for (const anim of cssAnims) {
|
|
1570
1646
|
if (anim.resolvedVars) Object.assign(allVars, anim.resolvedVars);
|
|
@@ -1619,6 +1695,22 @@ var SaccadeEngine = class {
|
|
|
1619
1695
|
getSpeed() {
|
|
1620
1696
|
return this.timing.getSpeed();
|
|
1621
1697
|
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Install the timing patches immediately, without changing speed.
|
|
1700
|
+
*
|
|
1701
|
+
* Call this as early as possible (before app code, GSAP, or Framer Motion
|
|
1702
|
+
* run) so they capture the patched time functions rather than the originals.
|
|
1703
|
+
* Idempotent and harmless to call more than once. `setSpeed` and
|
|
1704
|
+
* `startRecording` also install on demand, so this is only needed to win the
|
|
1705
|
+
* early-load race against libraries that cache `Date.now`/`performance.now`.
|
|
1706
|
+
*/
|
|
1707
|
+
install() {
|
|
1708
|
+
this.timing.install();
|
|
1709
|
+
}
|
|
1710
|
+
/** Register a module-imported GSAP instance so saccade can slow it. */
|
|
1711
|
+
registerGSAP(gsap) {
|
|
1712
|
+
this.timing.registerGSAP(gsap);
|
|
1713
|
+
}
|
|
1622
1714
|
// -- Timeline recording ---------------------------------------------------
|
|
1623
1715
|
startRecording(boundingBox) {
|
|
1624
1716
|
if (this._state !== "idle") return;
|
|
@@ -1643,7 +1735,7 @@ var SaccadeEngine = class {
|
|
|
1643
1735
|
try {
|
|
1644
1736
|
capture = this.recorder.stopRecording();
|
|
1645
1737
|
} catch (e) {
|
|
1646
|
-
console.error("[Saccade] stopRecording failed:", e);
|
|
1738
|
+
if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
|
|
1647
1739
|
document.getElementById("__lapse-scrub-blocker")?.remove();
|
|
1648
1740
|
document.getElementById("__lapse-no-transitions")?.remove();
|
|
1649
1741
|
document.getElementById("__lapse-state-rules")?.remove();
|
|
@@ -1664,7 +1756,8 @@ var SaccadeEngine = class {
|
|
|
1664
1756
|
frames: capture.frames,
|
|
1665
1757
|
capturedPortals: this.recorder.capturedPortalIds,
|
|
1666
1758
|
interceptedAnimations: this.recorder.interceptedAnimations,
|
|
1667
|
-
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
|
|
1759
|
+
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
|
|
1760
|
+
seekableClones: this.recorder.seekableClones
|
|
1668
1761
|
};
|
|
1669
1762
|
this.scrubber = new TimelineScrubber(scrubberState);
|
|
1670
1763
|
this._state = "scrubbing";
|
|
@@ -1692,7 +1785,7 @@ var SaccadeEngine = class {
|
|
|
1692
1785
|
filter
|
|
1693
1786
|
);
|
|
1694
1787
|
}
|
|
1695
|
-
exportForLLM(timeMs, filter = "active", detail = "
|
|
1788
|
+
exportForLLM(timeMs, filter = "active", detail = "moderate") {
|
|
1696
1789
|
const exp = this.generateExport(timeMs, filter);
|
|
1697
1790
|
if (!exp) return "";
|
|
1698
1791
|
return formatExportForLLM(exp, detail);
|
|
@@ -1717,13 +1810,29 @@ var SaccadeEngine = class {
|
|
|
1717
1810
|
}
|
|
1718
1811
|
};
|
|
1719
1812
|
|
|
1813
|
+
// src/core/shared.ts
|
|
1814
|
+
var KEY = "__saccadeSharedEngine__";
|
|
1815
|
+
function getSharedEngine() {
|
|
1816
|
+
const g = globalThis;
|
|
1817
|
+
if (!g[KEY]) g[KEY] = new SaccadeEngine();
|
|
1818
|
+
return g[KEY];
|
|
1819
|
+
}
|
|
1820
|
+
function resetSharedEngine() {
|
|
1821
|
+
const g = globalThis;
|
|
1822
|
+
g[KEY]?.destroy();
|
|
1823
|
+
g[KEY] = null;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1720
1826
|
// src/react/SaccadeContext.tsx
|
|
1721
1827
|
import { jsx } from "react/jsx-runtime";
|
|
1722
1828
|
var SaccadeContext = createContext(null);
|
|
1723
|
-
function SaccadeProvider({
|
|
1829
|
+
function SaccadeProvider({
|
|
1830
|
+
children,
|
|
1831
|
+
engine
|
|
1832
|
+
}) {
|
|
1724
1833
|
const engineRef = useRef(null);
|
|
1725
1834
|
if (!engineRef.current) {
|
|
1726
|
-
engineRef.current =
|
|
1835
|
+
engineRef.current = engine ?? getSharedEngine();
|
|
1727
1836
|
}
|
|
1728
1837
|
return /* @__PURE__ */ jsx(SaccadeContext.Provider, { value: engineRef.current, children });
|
|
1729
1838
|
}
|
|
@@ -1740,10 +1849,10 @@ import { useRef as useRef5, useCallback as useCallback5 } from "react";
|
|
|
1740
1849
|
import { useCallback, useRef as useRef2, useState, useEffect } from "react";
|
|
1741
1850
|
import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
1742
1851
|
var DETAIL_LABELS = {
|
|
1743
|
-
|
|
1744
|
-
|
|
1852
|
+
brief: "Brief",
|
|
1853
|
+
moderate: "Moderate",
|
|
1745
1854
|
detailed: "Detailed",
|
|
1746
|
-
|
|
1855
|
+
granular: "Granular"
|
|
1747
1856
|
};
|
|
1748
1857
|
function CopyCheckIcon({ copied }) {
|
|
1749
1858
|
const spring = "cubic-bezier(0.34, 1.15, 0.64, 1)";
|
|
@@ -1797,10 +1906,10 @@ function CopyCheckIcon({ copied }) {
|
|
|
1797
1906
|
] });
|
|
1798
1907
|
}
|
|
1799
1908
|
var DETAIL_BRIGHT_COUNT = {
|
|
1800
|
-
|
|
1801
|
-
|
|
1909
|
+
brief: 1,
|
|
1910
|
+
moderate: 2,
|
|
1802
1911
|
detailed: 3,
|
|
1803
|
-
|
|
1912
|
+
granular: 4
|
|
1804
1913
|
};
|
|
1805
1914
|
function DetailIcon({ level }) {
|
|
1806
1915
|
const bright = DETAIL_BRIGHT_COUNT[level];
|
|
@@ -2125,7 +2234,9 @@ function SpeedControl({ speed, isPaused, onSetSpeed, onTogglePause }) {
|
|
|
2125
2234
|
|
|
2126
2235
|
// src/react/useTimeline.ts
|
|
2127
2236
|
import { useState as useState2, useCallback as useCallback3, useEffect as useEffect2, useRef as useRef4, useSyncExternalStore } from "react";
|
|
2128
|
-
var
|
|
2237
|
+
var _realSetTimeout = setTimeout.bind(window);
|
|
2238
|
+
var _realClearTimeout = clearTimeout.bind(window);
|
|
2239
|
+
var DETAIL_LEVELS = ["brief", "moderate", "detailed", "granular"];
|
|
2129
2240
|
function useTimeline() {
|
|
2130
2241
|
const engine = useSaccadeEngine();
|
|
2131
2242
|
const state = useSyncExternalStore(
|
|
@@ -2136,7 +2247,7 @@ function useTimeline() {
|
|
|
2136
2247
|
const [scrubTime, setScrubTime] = useState2(0);
|
|
2137
2248
|
const [copied, setCopied] = useState2(false);
|
|
2138
2249
|
const [exportFilter, setExportFilter] = useState2("all-animations");
|
|
2139
|
-
const [detailLevel, setDetailLevel] = useState2("
|
|
2250
|
+
const [detailLevel, setDetailLevel] = useState2("moderate");
|
|
2140
2251
|
const copiedTimeout = useRef4(null);
|
|
2141
2252
|
const pendingSeek = useRef4(null);
|
|
2142
2253
|
const rafId = useRef4(0);
|
|
@@ -2210,8 +2321,8 @@ function useTimeline() {
|
|
|
2210
2321
|
navigator.clipboard.writeText(text).catch(() => {
|
|
2211
2322
|
});
|
|
2212
2323
|
setCopied(true);
|
|
2213
|
-
if (copiedTimeout.current)
|
|
2214
|
-
copiedTimeout.current =
|
|
2324
|
+
if (copiedTimeout.current) _realClearTimeout(copiedTimeout.current);
|
|
2325
|
+
copiedTimeout.current = _realSetTimeout(() => setCopied(false), 1800);
|
|
2215
2326
|
return text;
|
|
2216
2327
|
},
|
|
2217
2328
|
[engine, capture, scrubTime, exportFilter, detailLevel]
|
|
@@ -2763,15 +2874,16 @@ var PANEL_STYLES = (
|
|
|
2763
2874
|
|
|
2764
2875
|
// src/react/Saccade.tsx
|
|
2765
2876
|
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
2766
|
-
function Saccade({ position = "bottom-left" }) {
|
|
2767
|
-
const hostRef = useRef6(null);
|
|
2877
|
+
function Saccade({ position = "bottom-left", engine }) {
|
|
2768
2878
|
const [shadowRoot, setShadowRoot] = useState4(null);
|
|
2879
|
+
const hostRef = useRef6(null);
|
|
2769
2880
|
useEffect4(() => {
|
|
2770
|
-
const host =
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2881
|
+
const host = document.createElement("div");
|
|
2882
|
+
host.setAttribute("data-lapse-panel", "");
|
|
2883
|
+
const positionOffset = position === "top-left" ? "top:20px;left:20px" : position === "top-right" ? "top:20px;right:20px" : position === "bottom-left" ? "bottom:20px;left:20px" : "bottom:20px;right:20px";
|
|
2884
|
+
host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
|
|
2885
|
+
document.body.appendChild(host);
|
|
2886
|
+
hostRef.current = host;
|
|
2775
2887
|
const shadow = host.attachShadow({ mode: "open" });
|
|
2776
2888
|
const style = document.createElement("style");
|
|
2777
2889
|
style.textContent = PANEL_STYLES;
|
|
@@ -2779,31 +2891,23 @@ function Saccade({ position = "bottom-left" }) {
|
|
|
2779
2891
|
const mount = document.createElement("div");
|
|
2780
2892
|
shadow.appendChild(mount);
|
|
2781
2893
|
setShadowRoot(shadow);
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
zIndex: 2147483647,
|
|
2792
|
-
// max int — must sit above the scrub blocker (z-index: 999999)
|
|
2793
|
-
pointerEvents: "auto",
|
|
2794
|
-
...positionOffset
|
|
2795
|
-
},
|
|
2796
|
-
children: shadowRoot && createPortal(
|
|
2797
|
-
/* @__PURE__ */ jsx5(SaccadeProvider, { children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
|
|
2798
|
-
shadowRoot.lastElementChild || shadowRoot
|
|
2799
|
-
)
|
|
2800
|
-
}
|
|
2894
|
+
return () => {
|
|
2895
|
+
host.remove();
|
|
2896
|
+
hostRef.current = null;
|
|
2897
|
+
};
|
|
2898
|
+
}, [position]);
|
|
2899
|
+
if (!shadowRoot) return null;
|
|
2900
|
+
return createPortal(
|
|
2901
|
+
/* @__PURE__ */ jsx5(SaccadeProvider, { engine, children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
|
|
2902
|
+
shadowRoot.lastElementChild || shadowRoot
|
|
2801
2903
|
);
|
|
2802
2904
|
}
|
|
2803
2905
|
export {
|
|
2804
2906
|
Saccade,
|
|
2805
2907
|
SaccadeEngine,
|
|
2806
2908
|
SaccadeProvider,
|
|
2909
|
+
getSharedEngine,
|
|
2910
|
+
resetSharedEngine,
|
|
2807
2911
|
useSaccadeEngine,
|
|
2808
2912
|
useSpeed,
|
|
2809
2913
|
useTimeline
|