saccade 0.0.2 → 0.1.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 +6 -6
- package/dist/core.cjs +288 -204
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +43 -13
- package/dist/core.d.ts +43 -13
- package/dist/core.mjs +287 -203
- package/dist/core.mjs.map +1 -1
- package/dist/index.cjs +334 -255
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -12
- package/dist/index.d.ts +14 -12
- package/dist/index.mjs +330 -251
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/core.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// src/core/timing.ts
|
|
2
|
+
var MEDIA_RATE_MIN = 0.0625;
|
|
3
|
+
var MEDIA_RATE_MAX = 16;
|
|
4
|
+
var ZERO_SPEED_DIVISOR = 1e-4;
|
|
2
5
|
var TimingController = class {
|
|
3
6
|
constructor() {
|
|
4
7
|
this.speed = 1;
|
|
5
8
|
this.virtualBaseline = 0;
|
|
6
9
|
this.intervalMap = /* @__PURE__ */ new Map();
|
|
7
10
|
this.nextIntervalId = 1e6;
|
|
8
|
-
this.mediaObserver = null;
|
|
9
|
-
this.animObserver = null;
|
|
10
11
|
this._origAnimate = null;
|
|
11
12
|
this.installed = false;
|
|
13
|
+
this.animPollId = 0;
|
|
14
|
+
// WeakMap tracking for animations and media
|
|
15
|
+
this.trackedAnims = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
this.trackedMedia = /* @__PURE__ */ new WeakMap();
|
|
12
17
|
this._raf = requestAnimationFrame.bind(window);
|
|
13
18
|
this._caf = cancelAnimationFrame.bind(window);
|
|
14
19
|
this._setTimeout = setTimeout.bind(window);
|
|
@@ -18,19 +23,34 @@ var TimingController = class {
|
|
|
18
23
|
this._perfNow = performance.now.bind(performance);
|
|
19
24
|
this._dateNow = Date.now;
|
|
20
25
|
this.realBaseline = this._perfNow();
|
|
26
|
+
this.dateRealBaseline = this._dateNow();
|
|
27
|
+
this.dateVirtualBaseline = this.dateRealBaseline;
|
|
21
28
|
}
|
|
22
29
|
getVirtualTime() {
|
|
23
30
|
const realElapsed = this._perfNow() - this.realBaseline;
|
|
24
31
|
return this.virtualBaseline + realElapsed * this.speed;
|
|
25
32
|
}
|
|
33
|
+
getVirtualDateNow() {
|
|
34
|
+
const realElapsed = this._dateNow() - this.dateRealBaseline;
|
|
35
|
+
return this.dateVirtualBaseline + realElapsed * this.speed;
|
|
36
|
+
}
|
|
26
37
|
reanchor() {
|
|
27
38
|
const virtualNow = this.getVirtualTime();
|
|
28
39
|
this.realBaseline = this._perfNow();
|
|
29
40
|
this.virtualBaseline = virtualNow;
|
|
41
|
+
const virtualDateNow = this.getVirtualDateNow();
|
|
42
|
+
this.dateRealBaseline = this._dateNow();
|
|
43
|
+
this.dateVirtualBaseline = virtualDateNow;
|
|
44
|
+
}
|
|
45
|
+
/** Effective speed divisor — avoids division by zero at speed=0. */
|
|
46
|
+
get speedDivisor() {
|
|
47
|
+
return this.speed || ZERO_SPEED_DIVISOR;
|
|
30
48
|
}
|
|
31
49
|
/** Install timing patches. Safe to call multiple times. */
|
|
32
50
|
install() {
|
|
33
51
|
if (this.installed) return;
|
|
52
|
+
if (window.__saccadeInstalled) return;
|
|
53
|
+
window.__saccadeInstalled = true;
|
|
34
54
|
this.installed = true;
|
|
35
55
|
const self = this;
|
|
36
56
|
window.__LAPSE_ORIGINAL_RAF__ = this._raf;
|
|
@@ -39,21 +59,27 @@ var TimingController = class {
|
|
|
39
59
|
Element.prototype.animate = function(...args) {
|
|
40
60
|
const anim = origAnimate.apply(this, args);
|
|
41
61
|
if (self.speed !== 1) {
|
|
42
|
-
|
|
62
|
+
const originalRate = anim.playbackRate;
|
|
63
|
+
const applied = originalRate * (self.speed || 1e-3);
|
|
64
|
+
anim.playbackRate = applied;
|
|
65
|
+
self.trackedAnims.set(anim, { original: originalRate, applied });
|
|
43
66
|
}
|
|
44
67
|
return anim;
|
|
45
68
|
};
|
|
46
69
|
performance.now = () => self.getVirtualTime();
|
|
47
|
-
|
|
48
|
-
Date.now = () => dateBaseline + self.getVirtualTime();
|
|
70
|
+
Date.now = () => self.getVirtualDateNow();
|
|
49
71
|
window.requestAnimationFrame = (callback) => {
|
|
50
72
|
return self._raf(() => {
|
|
73
|
+
if (self.speed === 0) {
|
|
74
|
+
window.requestAnimationFrame(callback);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
51
77
|
callback(self.getVirtualTime());
|
|
52
78
|
});
|
|
53
79
|
};
|
|
54
80
|
window.cancelAnimationFrame = this._caf;
|
|
55
81
|
window.setTimeout = ((handler, delay, ...args) => {
|
|
56
|
-
const scaledDelay = (delay ?? 0) /
|
|
82
|
+
const scaledDelay = (delay ?? 0) / self.speedDivisor;
|
|
57
83
|
return self._setTimeout(handler, scaledDelay, ...args);
|
|
58
84
|
});
|
|
59
85
|
window.clearTimeout = this._clearTimeout;
|
|
@@ -61,7 +87,7 @@ var TimingController = class {
|
|
|
61
87
|
const id = self.nextIntervalId++;
|
|
62
88
|
const baseDelay = delay ?? 0;
|
|
63
89
|
function tick() {
|
|
64
|
-
const scaledDelay = baseDelay /
|
|
90
|
+
const scaledDelay = baseDelay / self.speedDivisor;
|
|
65
91
|
const realId = self._setTimeout(() => {
|
|
66
92
|
if (typeof handler === "function") {
|
|
67
93
|
;
|
|
@@ -86,61 +112,93 @@ var TimingController = class {
|
|
|
86
112
|
self._clearInterval(id);
|
|
87
113
|
}
|
|
88
114
|
});
|
|
89
|
-
this.
|
|
90
|
-
for (const mutation of mutations) {
|
|
91
|
-
for (const node of mutation.addedNodes) {
|
|
92
|
-
if (node instanceof HTMLVideoElement || node instanceof HTMLAudioElement) {
|
|
93
|
-
node.playbackRate = self.speed;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
if (document.body) {
|
|
99
|
-
this.mediaObserver.observe(document.body, { childList: true, subtree: true });
|
|
100
|
-
}
|
|
115
|
+
this.startAnimationPoll();
|
|
101
116
|
}
|
|
102
117
|
/** Set playback speed. Requires install() first. */
|
|
103
118
|
setSpeed(newSpeed) {
|
|
104
119
|
if (!this.installed) this.install();
|
|
105
120
|
this.reanchor();
|
|
106
121
|
this.speed = newSpeed;
|
|
107
|
-
document.querySelectorAll("video, audio").forEach((el) => {
|
|
108
|
-
;
|
|
109
|
-
el.playbackRate = newSpeed;
|
|
110
|
-
});
|
|
111
122
|
this.patchAnimations();
|
|
123
|
+
this.patchMedia();
|
|
124
|
+
this.patchGSAP();
|
|
125
|
+
}
|
|
126
|
+
getSpeed() {
|
|
127
|
+
return this.speed;
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Animation polling — per-frame via original rAF
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
startAnimationPoll() {
|
|
133
|
+
const poll = () => {
|
|
134
|
+
if (!this.installed) return;
|
|
135
|
+
this.patchAnimations();
|
|
136
|
+
this.patchMedia();
|
|
137
|
+
this.animPollId = this._raf(poll);
|
|
138
|
+
};
|
|
139
|
+
this.animPollId = this._raf(poll);
|
|
112
140
|
}
|
|
113
|
-
/** Patch playbackRate on all active
|
|
141
|
+
/** Patch playbackRate on all active animations via WAAPI. */
|
|
114
142
|
patchAnimations() {
|
|
115
143
|
try {
|
|
116
144
|
const anims = document.getAnimations();
|
|
117
145
|
for (const anim of anims) {
|
|
118
146
|
const target = anim.effect?.target;
|
|
119
147
|
if (target?.closest?.("[data-lapse-panel]")) continue;
|
|
120
|
-
|
|
148
|
+
if (target?.closest?.("[data-saccade-exclude]")) continue;
|
|
149
|
+
const effectiveSpeed = this.speed || 1e-3;
|
|
150
|
+
let tracked = this.trackedAnims.get(anim);
|
|
151
|
+
if (!tracked) {
|
|
152
|
+
tracked = { original: anim.playbackRate, applied: anim.playbackRate };
|
|
153
|
+
this.trackedAnims.set(anim, tracked);
|
|
154
|
+
} else if (anim.playbackRate !== tracked.applied) {
|
|
155
|
+
tracked.original = anim.playbackRate;
|
|
156
|
+
}
|
|
157
|
+
const desired = tracked.original * effectiveSpeed;
|
|
158
|
+
if (anim.playbackRate !== desired) {
|
|
159
|
+
anim.playbackRate = desired;
|
|
160
|
+
tracked.applied = desired;
|
|
161
|
+
}
|
|
121
162
|
}
|
|
122
163
|
} catch {
|
|
123
164
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
165
|
+
}
|
|
166
|
+
/** Patch playbackRate on all video/audio elements. */
|
|
167
|
+
patchMedia() {
|
|
168
|
+
try {
|
|
169
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
170
|
+
const el = node;
|
|
171
|
+
if (el.closest?.("[data-lapse-panel]")) return;
|
|
172
|
+
if (el.closest?.("[data-saccade-exclude]")) return;
|
|
173
|
+
let tracked = this.trackedMedia.get(el);
|
|
174
|
+
if (!tracked) {
|
|
175
|
+
tracked = { original: el.playbackRate, applied: el.playbackRate };
|
|
176
|
+
this.trackedMedia.set(el, tracked);
|
|
177
|
+
} else if (el.playbackRate !== tracked.applied) {
|
|
178
|
+
tracked.original = el.playbackRate;
|
|
137
179
|
}
|
|
138
|
-
|
|
180
|
+
const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
|
|
181
|
+
if (el.playbackRate !== desired) {
|
|
182
|
+
el.playbackRate = desired;
|
|
183
|
+
tracked.applied = desired;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
139
187
|
}
|
|
140
188
|
}
|
|
141
|
-
|
|
142
|
-
|
|
189
|
+
/** Sync GSAP's global timeline if present. */
|
|
190
|
+
patchGSAP() {
|
|
191
|
+
try {
|
|
192
|
+
const gsap = window.gsap;
|
|
193
|
+
if (gsap?.globalTimeline) {
|
|
194
|
+
gsap.globalTimeline.timeScale(this.speed || 1e-3);
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
143
198
|
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Cleanup
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
144
202
|
/** Restore all patched APIs to originals. */
|
|
145
203
|
destroy() {
|
|
146
204
|
if (!this.installed) return;
|
|
@@ -160,19 +218,32 @@ var TimingController = class {
|
|
|
160
218
|
Element.prototype.animate = this._origAnimate;
|
|
161
219
|
this._origAnimate = null;
|
|
162
220
|
}
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
this._clearInterval(this.animObserver);
|
|
167
|
-
this.animObserver = null;
|
|
221
|
+
if (this.animPollId) {
|
|
222
|
+
this._caf(this.animPollId);
|
|
223
|
+
this.animPollId = 0;
|
|
168
224
|
}
|
|
169
225
|
try {
|
|
170
226
|
for (const anim of document.getAnimations()) {
|
|
171
|
-
|
|
227
|
+
const tracked = this.trackedAnims.get(anim);
|
|
228
|
+
anim.playbackRate = tracked?.original ?? 1;
|
|
172
229
|
}
|
|
173
230
|
} catch {
|
|
174
231
|
}
|
|
232
|
+
try {
|
|
233
|
+
document.querySelectorAll("video, audio").forEach((node) => {
|
|
234
|
+
const el = node;
|
|
235
|
+
const tracked = this.trackedMedia.get(el);
|
|
236
|
+
el.playbackRate = tracked?.original ?? 1;
|
|
237
|
+
});
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const gsap = window.gsap;
|
|
242
|
+
if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
175
245
|
delete window.__LAPSE_ORIGINAL_RAF__;
|
|
246
|
+
delete window.__saccadeInstalled;
|
|
176
247
|
this.installed = false;
|
|
177
248
|
}
|
|
178
249
|
};
|
|
@@ -242,6 +313,10 @@ var SNAPSHOT_ATTRS = [
|
|
|
242
313
|
"data-hover",
|
|
243
314
|
"data-at-boundary",
|
|
244
315
|
"data-scrubbing",
|
|
316
|
+
"data-starting-style",
|
|
317
|
+
"data-ending-style",
|
|
318
|
+
"data-panel-open",
|
|
319
|
+
"data-hidden",
|
|
245
320
|
"aria-checked",
|
|
246
321
|
"aria-selected",
|
|
247
322
|
"aria-expanded",
|
|
@@ -255,6 +330,7 @@ var SNAPSHOT_ATTRS = [
|
|
|
255
330
|
"checked",
|
|
256
331
|
"disabled",
|
|
257
332
|
"hidden",
|
|
333
|
+
"inert",
|
|
258
334
|
"value",
|
|
259
335
|
"class",
|
|
260
336
|
"style"
|
|
@@ -386,6 +462,8 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
386
462
|
// ---- WAAPI interception --------------------------------------------------
|
|
387
463
|
/** Animations captured via Element.prototype.animate monkey-patch. */
|
|
388
464
|
this.interceptedAnimations = [];
|
|
465
|
+
// ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
|
|
466
|
+
this.seekableClones = /* @__PURE__ */ new Map();
|
|
389
467
|
this.hiddenSince = null;
|
|
390
468
|
this.onVisibilityChange = null;
|
|
391
469
|
/** Set to true when the capture loop self-terminates due to limits. */
|
|
@@ -436,6 +514,7 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
436
514
|
this.portalIdCounter = 0;
|
|
437
515
|
this.currentPortalIds.clear();
|
|
438
516
|
this.capturedPortals.clear();
|
|
517
|
+
this.seekableClones.clear();
|
|
439
518
|
this.prevInlineStyles.clear();
|
|
440
519
|
this.jsAnimStartTimes.clear();
|
|
441
520
|
this.jsAnimLastSeen.clear();
|
|
@@ -789,6 +868,13 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
789
868
|
} catch (_) {
|
|
790
869
|
}
|
|
791
870
|
}
|
|
871
|
+
const cleanedKeyframes = keyframes2.map((kf) => {
|
|
872
|
+
const clean = {};
|
|
873
|
+
for (const [k, v] of Object.entries(kf)) {
|
|
874
|
+
if (k !== "computedOffset" && k !== "composite") clean[k] = v;
|
|
875
|
+
}
|
|
876
|
+
return clean;
|
|
877
|
+
});
|
|
792
878
|
this.animations.set(id, {
|
|
793
879
|
id,
|
|
794
880
|
name,
|
|
@@ -800,7 +886,9 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
800
886
|
type,
|
|
801
887
|
source,
|
|
802
888
|
resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
|
|
803
|
-
conflicts
|
|
889
|
+
conflicts,
|
|
890
|
+
rawKeyframes: cleanedKeyframes,
|
|
891
|
+
rawTiming: { ...timing, fill: "both" }
|
|
804
892
|
});
|
|
805
893
|
}
|
|
806
894
|
const keyframes = a.effect?.getKeyframes?.() || [];
|
|
@@ -1021,96 +1109,83 @@ var _TimelineRecorder = class _TimelineRecorder {
|
|
|
1021
1109
|
blocker.title = "Clear the timeline to interact with the page";
|
|
1022
1110
|
document.body.appendChild(blocker);
|
|
1023
1111
|
this.blockerEl = blocker;
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if (t && (t.includes(":hover") || t.includes(":focus"))) {
|
|
1042
|
-
allCss += t + "\n";
|
|
1112
|
+
setTimeout(() => {
|
|
1113
|
+
try {
|
|
1114
|
+
const lapseStyle = document.createElement("style");
|
|
1115
|
+
lapseStyle.id = "__lapse-state-rules";
|
|
1116
|
+
const hoverFocusRules = [];
|
|
1117
|
+
for (const sheet of document.styleSheets) {
|
|
1118
|
+
try {
|
|
1119
|
+
const walk = (rules) => {
|
|
1120
|
+
for (const rule of rules) {
|
|
1121
|
+
if (rule.cssRules) {
|
|
1122
|
+
walk(rule.cssRules);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const t = rule.cssText;
|
|
1126
|
+
if (t && (t.includes(":hover") || t.includes(":focus"))) {
|
|
1127
|
+
hoverFocusRules.push(t);
|
|
1128
|
+
}
|
|
1043
1129
|
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
walk2(sheet.cssRules);
|
|
1048
|
-
} catch (_) {
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
const stateRegex = /([^{}]*(?::hover|:focus-visible|:focus-within|:focus(?!-))[^{}]*)\{([^{}]*)\}/g;
|
|
1052
|
-
let match;
|
|
1053
|
-
while ((match = stateRegex.exec(allCss)) !== null) {
|
|
1054
|
-
const selector = match[1].trim();
|
|
1055
|
-
const body = match[2].trim();
|
|
1056
|
-
if (!body) continue;
|
|
1057
|
-
const newBody = body.replace(
|
|
1058
|
-
/([^;:]+):\s*([^;]+)(;|$)/g,
|
|
1059
|
-
(m, prop, val, end) => {
|
|
1060
|
-
if (val.includes("!important")) return m;
|
|
1061
|
-
return prop + ": " + val.trim() + " !important" + end;
|
|
1130
|
+
};
|
|
1131
|
+
walk(sheet.cssRules);
|
|
1132
|
+
} catch (_) {
|
|
1062
1133
|
}
|
|
1063
|
-
);
|
|
1064
|
-
if (selector.includes(":hover")) {
|
|
1065
|
-
lapseStyle.textContent += selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }\n";
|
|
1066
1134
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
} catch (_) {
|
|
1081
|
-
}
|
|
1082
|
-
if (this.frames.length > 0) {
|
|
1083
|
-
const frame0 = this.frames[0];
|
|
1084
|
-
if (frame0.elementSnapshots) {
|
|
1085
|
-
for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
|
|
1086
|
-
const el = this.elements.get(sel);
|
|
1087
|
-
if (!el || !el.isConnected) continue;
|
|
1088
|
-
if (snap.__styles) {
|
|
1089
|
-
for (const [prop, value] of Object.entries(snap.__styles)) {
|
|
1090
|
-
if (SAFE_PROPS_SET.has(prop)) {
|
|
1091
|
-
el.style.setProperty(prop, value, "important");
|
|
1092
|
-
}
|
|
1135
|
+
const parts = [];
|
|
1136
|
+
for (const ruleText of hoverFocusRules) {
|
|
1137
|
+
const braceIdx = ruleText.indexOf("{");
|
|
1138
|
+
if (braceIdx === -1) continue;
|
|
1139
|
+
const selector = ruleText.slice(0, braceIdx).trim();
|
|
1140
|
+
const bodyEnd = ruleText.lastIndexOf("}");
|
|
1141
|
+
const body = ruleText.slice(braceIdx + 1, bodyEnd).trim();
|
|
1142
|
+
if (!body) continue;
|
|
1143
|
+
const newBody = body.replace(
|
|
1144
|
+
/([^;:]+):\s*([^;]+)(;|$)/g,
|
|
1145
|
+
(m, prop, val, end) => {
|
|
1146
|
+
if (val.includes("!important")) return m;
|
|
1147
|
+
return prop + ": " + val.trim() + " !important" + end;
|
|
1093
1148
|
}
|
|
1149
|
+
);
|
|
1150
|
+
if (selector.includes(":hover")) {
|
|
1151
|
+
parts.push(selector.replace(/:hover/g, "[data-lapse-hover]") + " { " + newBody + " }");
|
|
1094
1152
|
}
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
el.className = value;
|
|
1102
|
-
} else if (attr === "style") {
|
|
1103
|
-
} else if (attr === "value" && value != null) {
|
|
1104
|
-
;
|
|
1105
|
-
el.value = value;
|
|
1106
|
-
} else if (value == null) {
|
|
1107
|
-
el.removeAttribute(attr);
|
|
1108
|
-
} else {
|
|
1109
|
-
el.setAttribute(attr, value);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1153
|
+
if (selector.includes(":focus-visible")) {
|
|
1154
|
+
parts.push(selector.replace(/:focus-visible/g, "[data-lapse-focus]") + " { " + newBody + " }");
|
|
1155
|
+
} else if (selector.includes(":focus-within")) {
|
|
1156
|
+
parts.push(selector.replace(/:focus-within/g, ":has([data-lapse-focus])") + " { " + newBody + " }");
|
|
1157
|
+
} else if (selector.includes(":focus")) {
|
|
1158
|
+
parts.push(selector.replace(/:focus(?!-)/g, "[data-lapse-focus]") + " { " + newBody + " }");
|
|
1112
1159
|
}
|
|
1113
1160
|
}
|
|
1161
|
+
lapseStyle.textContent = parts.join("\n");
|
|
1162
|
+
document.head.appendChild(lapseStyle);
|
|
1163
|
+
this.lapseStyleEl = lapseStyle;
|
|
1164
|
+
} catch (_) {
|
|
1165
|
+
}
|
|
1166
|
+
}, 0);
|
|
1167
|
+
this.seekableClones.clear();
|
|
1168
|
+
for (const [animId, animInfo] of this.animations) {
|
|
1169
|
+
if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
|
|
1170
|
+
if (animInfo.type === "JSAnimation") continue;
|
|
1171
|
+
const firstColon = animId.indexOf(":");
|
|
1172
|
+
const secondColon = animId.indexOf(":", firstColon + 1);
|
|
1173
|
+
const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
|
|
1174
|
+
const el = this.elements.get(elSelector);
|
|
1175
|
+
if (!el?.isConnected) continue;
|
|
1176
|
+
try {
|
|
1177
|
+
const clone = el.animate(animInfo.rawKeyframes, {
|
|
1178
|
+
...animInfo.rawTiming,
|
|
1179
|
+
fill: "both"
|
|
1180
|
+
});
|
|
1181
|
+
clone.pause();
|
|
1182
|
+
clone.currentTime = 0;
|
|
1183
|
+
this.seekableClones.set(animId, {
|
|
1184
|
+
animation: clone,
|
|
1185
|
+
element: el,
|
|
1186
|
+
effect: clone.effect
|
|
1187
|
+
});
|
|
1188
|
+
} catch (_) {
|
|
1114
1189
|
}
|
|
1115
1190
|
}
|
|
1116
1191
|
const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
|
|
@@ -1133,6 +1208,8 @@ var TimelineRecorder = _TimelineRecorder;
|
|
|
1133
1208
|
// src/core/scrubber.ts
|
|
1134
1209
|
var TimelineScrubber = class {
|
|
1135
1210
|
constructor(state) {
|
|
1211
|
+
/** Precomputed frame range per animation for O(1) before/after lookup. */
|
|
1212
|
+
this.animFrameRanges = /* @__PURE__ */ new Map();
|
|
1136
1213
|
/** Saved originals for restore on release */
|
|
1137
1214
|
this._originalAnimate = null;
|
|
1138
1215
|
this._originalRaf = null;
|
|
@@ -1142,36 +1219,24 @@ var TimelineScrubber = class {
|
|
|
1142
1219
|
this.frames = state.frames;
|
|
1143
1220
|
this.capturedPortals = state.capturedPortals;
|
|
1144
1221
|
this.interceptedAnimations = state.interceptedAnimations;
|
|
1145
|
-
this.
|
|
1222
|
+
this.seekableClones = state.seekableClones;
|
|
1146
1223
|
this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
|
|
1147
1224
|
this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
|
|
1148
1225
|
this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
|
|
1149
1226
|
this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
let current = el;
|
|
1159
|
-
for (let i = 0; i < 5 && current && current.tagName && current.tagName !== "HTML"; i++) {
|
|
1160
|
-
const tag = current.tagName.toLowerCase();
|
|
1161
|
-
const parent = current.parentElement;
|
|
1162
|
-
if (parent) {
|
|
1163
|
-
const siblings = Array.from(parent.children);
|
|
1164
|
-
const idx = siblings.indexOf(current) + 1;
|
|
1165
|
-
parts.unshift(`${tag}:nth-child(${idx})`);
|
|
1166
|
-
} else {
|
|
1167
|
-
parts.unshift(tag);
|
|
1227
|
+
for (let i = 0; i < this.frames.length; i++) {
|
|
1228
|
+
for (const fa of this.frames[i].animations) {
|
|
1229
|
+
const range = this.animFrameRanges.get(fa.animationId);
|
|
1230
|
+
if (!range) {
|
|
1231
|
+
this.animFrameRanges.set(fa.animationId, { first: i, last: i });
|
|
1232
|
+
} else {
|
|
1233
|
+
range.last = i;
|
|
1234
|
+
}
|
|
1168
1235
|
}
|
|
1169
|
-
current = parent;
|
|
1170
1236
|
}
|
|
1171
|
-
return parts.join(" > ");
|
|
1172
1237
|
}
|
|
1173
1238
|
// ---------------------------------------------------------------------------
|
|
1174
|
-
// seekTo — scrub
|
|
1239
|
+
// seekTo — scrub to a specific timestamp using WAAPI-native seeking
|
|
1175
1240
|
// ---------------------------------------------------------------------------
|
|
1176
1241
|
seekTo(timeMs) {
|
|
1177
1242
|
if (!this.frames.length) return;
|
|
@@ -1219,6 +1284,34 @@ var TimelineScrubber = class {
|
|
|
1219
1284
|
}
|
|
1220
1285
|
}
|
|
1221
1286
|
}
|
|
1287
|
+
const activeAnimIds = /* @__PURE__ */ new Map();
|
|
1288
|
+
for (const fa of frame.animations || []) {
|
|
1289
|
+
activeAnimIds.set(fa.animationId, fa);
|
|
1290
|
+
}
|
|
1291
|
+
for (const [animId, clone] of this.seekableClones) {
|
|
1292
|
+
const frameAnim = activeAnimIds.get(animId);
|
|
1293
|
+
try {
|
|
1294
|
+
if (frameAnim) {
|
|
1295
|
+
if (!clone.animation.effect) {
|
|
1296
|
+
clone.animation.effect = clone.effect;
|
|
1297
|
+
}
|
|
1298
|
+
clone.animation.currentTime = frameAnim.currentTime;
|
|
1299
|
+
} else {
|
|
1300
|
+
const range = this.animFrameRanges.get(animId);
|
|
1301
|
+
if (!range || lo < range.first) {
|
|
1302
|
+
clone.animation.effect = null;
|
|
1303
|
+
} else {
|
|
1304
|
+
if (!clone.animation.effect) {
|
|
1305
|
+
clone.animation.effect = clone.effect;
|
|
1306
|
+
}
|
|
1307
|
+
const timing = clone.effect.getTiming();
|
|
1308
|
+
const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
|
|
1309
|
+
clone.animation.currentTime = endTime;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
} catch {
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1222
1315
|
for (const entry of this.interceptedAnimations) {
|
|
1223
1316
|
try {
|
|
1224
1317
|
const anim = entry.animation;
|
|
@@ -1233,34 +1326,13 @@ var TimelineScrubber = class {
|
|
|
1233
1326
|
const el = this.elements.get(sel);
|
|
1234
1327
|
if (!el || !el.isConnected) continue;
|
|
1235
1328
|
if (el.closest?.("[data-lapse-panel]")) continue;
|
|
1236
|
-
const hasAnimation = (frame.animations || []).some(
|
|
1237
|
-
(a) => a.animationId.endsWith(":" + sel) || a.animationId.includes(":" + sel.split(" > ").pop())
|
|
1238
|
-
);
|
|
1239
1329
|
const snapTyped = snap;
|
|
1240
|
-
if (snapTyped.__styles) {
|
|
1241
|
-
for (const [prop, value] of Object.entries(snapTyped.__styles)) {
|
|
1242
|
-
if (this.SAFE_PROPS_SET.has(prop) || hasAnimation) {
|
|
1243
|
-
el.style.setProperty(prop, value, "important");
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
1330
|
if (snapTyped.__attrs) {
|
|
1248
1331
|
for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
|
|
1332
|
+
if (attr === "class" || attr === "style") continue;
|
|
1249
1333
|
if (attr === "checked") {
|
|
1250
1334
|
;
|
|
1251
1335
|
el.checked = value === "true";
|
|
1252
|
-
} else if (attr === "class") {
|
|
1253
|
-
if (value != null) el.className = value;
|
|
1254
|
-
} else if (attr === "style") {
|
|
1255
|
-
if (value) {
|
|
1256
|
-
el.setAttribute("style", value);
|
|
1257
|
-
el.style.transition = "none";
|
|
1258
|
-
if (snapTyped.__styles) {
|
|
1259
|
-
for (const [prop, val] of Object.entries(snapTyped.__styles)) {
|
|
1260
|
-
el.style.setProperty(prop, val, "important");
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
1336
|
} else if (attr === "value") {
|
|
1265
1337
|
if (value != null) el.value = value;
|
|
1266
1338
|
} else if (value == null) {
|
|
@@ -1271,39 +1343,31 @@ var TimelineScrubber = class {
|
|
|
1271
1343
|
}
|
|
1272
1344
|
}
|
|
1273
1345
|
}
|
|
1274
|
-
for (const
|
|
1275
|
-
|
|
1276
|
-
const
|
|
1277
|
-
const
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1346
|
+
for (const fa of frame.animations || []) {
|
|
1347
|
+
if (!fa.animationId.startsWith("JSAnimation:")) continue;
|
|
1348
|
+
const firstColon = fa.animationId.indexOf(":");
|
|
1349
|
+
const secondColon = fa.animationId.indexOf(":", firstColon + 1);
|
|
1350
|
+
const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
|
|
1351
|
+
const el = this.elements.get(elSel);
|
|
1352
|
+
if (!el || !el.isConnected) continue;
|
|
1353
|
+
for (const prop of fa.properties) {
|
|
1281
1354
|
if (prop.value) {
|
|
1282
|
-
|
|
1355
|
+
el.style.setProperty(prop.property, prop.value, "important");
|
|
1283
1356
|
}
|
|
1284
1357
|
}
|
|
1285
1358
|
}
|
|
1286
|
-
const animatedSels = /* @__PURE__ */ new Set();
|
|
1287
|
-
for (const anim of frame.animations || []) {
|
|
1288
|
-
const fc = anim.animationId.indexOf(":");
|
|
1289
|
-
const sc = anim.animationId.indexOf(":", fc + 1);
|
|
1290
|
-
if (sc >= 0) animatedSels.add(anim.animationId.substring(sc + 1));
|
|
1291
|
-
}
|
|
1292
|
-
document.querySelectorAll(".checkbox-indicator, .radio-indicator").forEach((rawEl) => {
|
|
1293
|
-
const el = rawEl;
|
|
1294
|
-
const sel = this.getSelector(el);
|
|
1295
|
-
if (sel && !animatedSels.has(sel)) {
|
|
1296
|
-
el.style.removeProperty("opacity");
|
|
1297
|
-
el.style.removeProperty("transform");
|
|
1298
|
-
el.style.removeProperty("filter");
|
|
1299
|
-
el.style.removeProperty("stroke-dashoffset");
|
|
1300
|
-
}
|
|
1301
|
-
});
|
|
1302
1359
|
}
|
|
1303
1360
|
// ---------------------------------------------------------------------------
|
|
1304
1361
|
// release — tear down all scrub state and restore the page to normal
|
|
1305
1362
|
// ---------------------------------------------------------------------------
|
|
1306
1363
|
release() {
|
|
1364
|
+
for (const [, clone] of this.seekableClones) {
|
|
1365
|
+
try {
|
|
1366
|
+
clone.animation.cancel();
|
|
1367
|
+
} catch {
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
this.seekableClones.clear();
|
|
1307
1371
|
for (const entry of this.interceptedAnimations) {
|
|
1308
1372
|
try {
|
|
1309
1373
|
entry.animation.cancel();
|
|
@@ -1365,6 +1429,7 @@ var TimelineScrubber = class {
|
|
|
1365
1429
|
this.elements.clear();
|
|
1366
1430
|
this.frames.length = 0;
|
|
1367
1431
|
this.capturedPortals.clear();
|
|
1432
|
+
this.animFrameRanges.clear();
|
|
1368
1433
|
}
|
|
1369
1434
|
};
|
|
1370
1435
|
|
|
@@ -1591,7 +1656,7 @@ function formatExportForLLM(exp, detail = "standard") {
|
|
|
1591
1656
|
}
|
|
1592
1657
|
|
|
1593
1658
|
// src/core/engine.ts
|
|
1594
|
-
var
|
|
1659
|
+
var SaccadeEngine = class {
|
|
1595
1660
|
constructor() {
|
|
1596
1661
|
this.timing = new TimingController();
|
|
1597
1662
|
this.recorder = new TimelineRecorder();
|
|
@@ -1633,14 +1698,33 @@ var LapseEngine = class {
|
|
|
1633
1698
|
boundingBox: null
|
|
1634
1699
|
};
|
|
1635
1700
|
}
|
|
1636
|
-
|
|
1701
|
+
let capture;
|
|
1702
|
+
try {
|
|
1703
|
+
capture = this.recorder.stopRecording();
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
|
|
1706
|
+
document.getElementById("__lapse-scrub-blocker")?.remove();
|
|
1707
|
+
document.getElementById("__lapse-no-transitions")?.remove();
|
|
1708
|
+
document.getElementById("__lapse-state-rules")?.remove();
|
|
1709
|
+
this._state = "idle";
|
|
1710
|
+
this.notify();
|
|
1711
|
+
return {
|
|
1712
|
+
startTime: 0,
|
|
1713
|
+
endTime: 0,
|
|
1714
|
+
duration: 0,
|
|
1715
|
+
animations: [],
|
|
1716
|
+
frames: [],
|
|
1717
|
+
boundingBox: null
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1637
1720
|
this.capture = capture;
|
|
1638
1721
|
const scrubberState = {
|
|
1639
1722
|
elements: this.recorder.elements,
|
|
1640
1723
|
frames: capture.frames,
|
|
1641
1724
|
capturedPortals: this.recorder.capturedPortalIds,
|
|
1642
1725
|
interceptedAnimations: this.recorder.interceptedAnimations,
|
|
1643
|
-
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
|
|
1726
|
+
SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
|
|
1727
|
+
seekableClones: this.recorder.seekableClones
|
|
1644
1728
|
};
|
|
1645
1729
|
this.scrubber = new TimelineScrubber(scrubberState);
|
|
1646
1730
|
this._state = "scrubbing";
|
|
@@ -1693,7 +1777,7 @@ var LapseEngine = class {
|
|
|
1693
1777
|
}
|
|
1694
1778
|
};
|
|
1695
1779
|
export {
|
|
1696
|
-
|
|
1780
|
+
SaccadeEngine,
|
|
1697
1781
|
TimelineRecorder,
|
|
1698
1782
|
TimelineScrubber,
|
|
1699
1783
|
TimingController,
|