saccade 0.0.3 → 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/dist/index.mjs CHANGED
@@ -8,16 +8,21 @@ 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
+ // WeakMap tracking for animations and media
24
+ this.trackedAnims = /* @__PURE__ */ new WeakMap();
25
+ this.trackedMedia = /* @__PURE__ */ new WeakMap();
21
26
  this._raf = requestAnimationFrame.bind(window);
22
27
  this._caf = cancelAnimationFrame.bind(window);
23
28
  this._setTimeout = setTimeout.bind(window);
@@ -27,19 +32,34 @@ var TimingController = class {
27
32
  this._perfNow = performance.now.bind(performance);
28
33
  this._dateNow = Date.now;
29
34
  this.realBaseline = this._perfNow();
35
+ this.dateRealBaseline = this._dateNow();
36
+ this.dateVirtualBaseline = this.dateRealBaseline;
30
37
  }
31
38
  getVirtualTime() {
32
39
  const realElapsed = this._perfNow() - this.realBaseline;
33
40
  return this.virtualBaseline + realElapsed * this.speed;
34
41
  }
42
+ getVirtualDateNow() {
43
+ const realElapsed = this._dateNow() - this.dateRealBaseline;
44
+ return this.dateVirtualBaseline + realElapsed * this.speed;
45
+ }
35
46
  reanchor() {
36
47
  const virtualNow = this.getVirtualTime();
37
48
  this.realBaseline = this._perfNow();
38
49
  this.virtualBaseline = virtualNow;
50
+ const virtualDateNow = this.getVirtualDateNow();
51
+ this.dateRealBaseline = this._dateNow();
52
+ this.dateVirtualBaseline = virtualDateNow;
53
+ }
54
+ /** Effective speed divisor — avoids division by zero at speed=0. */
55
+ get speedDivisor() {
56
+ return this.speed || ZERO_SPEED_DIVISOR;
39
57
  }
40
58
  /** Install timing patches. Safe to call multiple times. */
41
59
  install() {
42
60
  if (this.installed) return;
61
+ if (window.__saccadeInstalled) return;
62
+ window.__saccadeInstalled = true;
43
63
  this.installed = true;
44
64
  const self = this;
45
65
  window.__LAPSE_ORIGINAL_RAF__ = this._raf;
@@ -48,21 +68,27 @@ var TimingController = class {
48
68
  Element.prototype.animate = function(...args) {
49
69
  const anim = origAnimate.apply(this, args);
50
70
  if (self.speed !== 1) {
51
- anim.playbackRate = self.speed || 1e-3;
71
+ const originalRate = anim.playbackRate;
72
+ const applied = originalRate * (self.speed || 1e-3);
73
+ anim.playbackRate = applied;
74
+ self.trackedAnims.set(anim, { original: originalRate, applied });
52
75
  }
53
76
  return anim;
54
77
  };
55
78
  performance.now = () => self.getVirtualTime();
56
- const dateBaseline = this._dateNow();
57
- Date.now = () => dateBaseline + self.getVirtualTime();
79
+ Date.now = () => self.getVirtualDateNow();
58
80
  window.requestAnimationFrame = (callback) => {
59
81
  return self._raf(() => {
82
+ if (self.speed === 0) {
83
+ window.requestAnimationFrame(callback);
84
+ return;
85
+ }
60
86
  callback(self.getVirtualTime());
61
87
  });
62
88
  };
63
89
  window.cancelAnimationFrame = this._caf;
64
90
  window.setTimeout = ((handler, delay, ...args) => {
65
- const scaledDelay = (delay ?? 0) / (self.speed || 1);
91
+ const scaledDelay = (delay ?? 0) / self.speedDivisor;
66
92
  return self._setTimeout(handler, scaledDelay, ...args);
67
93
  });
68
94
  window.clearTimeout = this._clearTimeout;
@@ -70,7 +96,7 @@ var TimingController = class {
70
96
  const id = self.nextIntervalId++;
71
97
  const baseDelay = delay ?? 0;
72
98
  function tick() {
73
- const scaledDelay = baseDelay / (self.speed || 1);
99
+ const scaledDelay = baseDelay / self.speedDivisor;
74
100
  const realId = self._setTimeout(() => {
75
101
  if (typeof handler === "function") {
76
102
  ;
@@ -95,61 +121,93 @@ var TimingController = class {
95
121
  self._clearInterval(id);
96
122
  }
97
123
  });
98
- this.mediaObserver = new MutationObserver((mutations) => {
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
- }
124
+ this.startAnimationPoll();
110
125
  }
111
126
  /** Set playback speed. Requires install() first. */
112
127
  setSpeed(newSpeed) {
113
128
  if (!this.installed) this.install();
114
129
  this.reanchor();
115
130
  this.speed = newSpeed;
116
- document.querySelectorAll("video, audio").forEach((el) => {
117
- ;
118
- el.playbackRate = newSpeed;
119
- });
120
131
  this.patchAnimations();
132
+ this.patchMedia();
133
+ this.patchGSAP();
134
+ }
135
+ getSpeed() {
136
+ return this.speed;
121
137
  }
122
- /** Patch playbackRate on all active CSS transitions/animations via WAAPI. */
138
+ // ---------------------------------------------------------------------------
139
+ // Animation polling — per-frame via original rAF
140
+ // ---------------------------------------------------------------------------
141
+ startAnimationPoll() {
142
+ const poll = () => {
143
+ if (!this.installed) return;
144
+ this.patchAnimations();
145
+ this.patchMedia();
146
+ this.animPollId = this._raf(poll);
147
+ };
148
+ this.animPollId = this._raf(poll);
149
+ }
150
+ /** Patch playbackRate on all active animations via WAAPI. */
123
151
  patchAnimations() {
124
152
  try {
125
153
  const anims = document.getAnimations();
126
154
  for (const anim of anims) {
127
155
  const target = anim.effect?.target;
128
156
  if (target?.closest?.("[data-lapse-panel]")) continue;
129
- anim.playbackRate = this.speed || 1e-3;
157
+ if (target?.closest?.("[data-saccade-exclude]")) continue;
158
+ const effectiveSpeed = this.speed || 1e-3;
159
+ let tracked = this.trackedAnims.get(anim);
160
+ if (!tracked) {
161
+ tracked = { original: anim.playbackRate, applied: anim.playbackRate };
162
+ this.trackedAnims.set(anim, tracked);
163
+ } else if (anim.playbackRate !== tracked.applied) {
164
+ tracked.original = anim.playbackRate;
165
+ }
166
+ const desired = tracked.original * effectiveSpeed;
167
+ if (anim.playbackRate !== desired) {
168
+ anim.playbackRate = desired;
169
+ tracked.applied = desired;
170
+ }
130
171
  }
131
172
  } catch {
132
173
  }
133
- if (!this.animObserver) {
134
- this.animObserver = this._setInterval(() => {
135
- if (!this.installed) return;
136
- try {
137
- const anims = document.getAnimations();
138
- for (const anim of anims) {
139
- const target = anim.effect?.target;
140
- if (target?.closest?.("[data-lapse-panel]")) continue;
141
- if (anim.playbackRate !== this.speed) {
142
- anim.playbackRate = this.speed || 1e-3;
143
- }
144
- }
145
- } catch {
174
+ }
175
+ /** Patch playbackRate on all video/audio elements. */
176
+ patchMedia() {
177
+ try {
178
+ document.querySelectorAll("video, audio").forEach((node) => {
179
+ const el = node;
180
+ if (el.closest?.("[data-lapse-panel]")) return;
181
+ if (el.closest?.("[data-saccade-exclude]")) return;
182
+ let tracked = this.trackedMedia.get(el);
183
+ if (!tracked) {
184
+ tracked = { original: el.playbackRate, applied: el.playbackRate };
185
+ this.trackedMedia.set(el, tracked);
186
+ } else if (el.playbackRate !== tracked.applied) {
187
+ tracked.original = el.playbackRate;
188
+ }
189
+ const desired = Math.min(MEDIA_RATE_MAX, Math.max(MEDIA_RATE_MIN, tracked.original * (this.speed || MEDIA_RATE_MIN)));
190
+ if (el.playbackRate !== desired) {
191
+ el.playbackRate = desired;
192
+ tracked.applied = desired;
146
193
  }
147
- }, 100);
194
+ });
195
+ } catch {
148
196
  }
149
197
  }
150
- getSpeed() {
151
- return this.speed;
198
+ /** Sync GSAP's global timeline if present. */
199
+ patchGSAP() {
200
+ try {
201
+ const gsap = window.gsap;
202
+ if (gsap?.globalTimeline) {
203
+ gsap.globalTimeline.timeScale(this.speed || 1e-3);
204
+ }
205
+ } catch {
206
+ }
152
207
  }
208
+ // ---------------------------------------------------------------------------
209
+ // Cleanup
210
+ // ---------------------------------------------------------------------------
153
211
  /** Restore all patched APIs to originals. */
154
212
  destroy() {
155
213
  if (!this.installed) return;
@@ -169,19 +227,32 @@ var TimingController = class {
169
227
  Element.prototype.animate = this._origAnimate;
170
228
  this._origAnimate = null;
171
229
  }
172
- this.mediaObserver?.disconnect();
173
- this.mediaObserver = null;
174
- if (this.animObserver != null) {
175
- this._clearInterval(this.animObserver);
176
- this.animObserver = null;
230
+ if (this.animPollId) {
231
+ this._caf(this.animPollId);
232
+ this.animPollId = 0;
177
233
  }
178
234
  try {
179
235
  for (const anim of document.getAnimations()) {
180
- anim.playbackRate = 1;
236
+ const tracked = this.trackedAnims.get(anim);
237
+ anim.playbackRate = tracked?.original ?? 1;
181
238
  }
182
239
  } catch {
183
240
  }
241
+ try {
242
+ document.querySelectorAll("video, audio").forEach((node) => {
243
+ const el = node;
244
+ const tracked = this.trackedMedia.get(el);
245
+ el.playbackRate = tracked?.original ?? 1;
246
+ });
247
+ } catch {
248
+ }
249
+ try {
250
+ const gsap = window.gsap;
251
+ if (gsap?.globalTimeline) gsap.globalTimeline.timeScale(1);
252
+ } catch {
253
+ }
184
254
  delete window.__LAPSE_ORIGINAL_RAF__;
255
+ delete window.__saccadeInstalled;
185
256
  this.installed = false;
186
257
  }
187
258
  };
@@ -251,6 +322,10 @@ var SNAPSHOT_ATTRS = [
251
322
  "data-hover",
252
323
  "data-at-boundary",
253
324
  "data-scrubbing",
325
+ "data-starting-style",
326
+ "data-ending-style",
327
+ "data-panel-open",
328
+ "data-hidden",
254
329
  "aria-checked",
255
330
  "aria-selected",
256
331
  "aria-expanded",
@@ -264,6 +339,7 @@ var SNAPSHOT_ATTRS = [
264
339
  "checked",
265
340
  "disabled",
266
341
  "hidden",
342
+ "inert",
267
343
  "value",
268
344
  "class",
269
345
  "style"
@@ -395,6 +471,8 @@ var _TimelineRecorder = class _TimelineRecorder {
395
471
  // ---- WAAPI interception --------------------------------------------------
396
472
  /** Animations captured via Element.prototype.animate monkey-patch. */
397
473
  this.interceptedAnimations = [];
474
+ // ---- Seekable WAAPI clones (created in stopRecording for scrubbing) -----
475
+ this.seekableClones = /* @__PURE__ */ new Map();
398
476
  this.hiddenSince = null;
399
477
  this.onVisibilityChange = null;
400
478
  /** Set to true when the capture loop self-terminates due to limits. */
@@ -445,6 +523,7 @@ var _TimelineRecorder = class _TimelineRecorder {
445
523
  this.portalIdCounter = 0;
446
524
  this.currentPortalIds.clear();
447
525
  this.capturedPortals.clear();
526
+ this.seekableClones.clear();
448
527
  this.prevInlineStyles.clear();
449
528
  this.jsAnimStartTimes.clear();
450
529
  this.jsAnimLastSeen.clear();
@@ -798,6 +877,13 @@ var _TimelineRecorder = class _TimelineRecorder {
798
877
  } catch (_) {
799
878
  }
800
879
  }
880
+ const cleanedKeyframes = keyframes2.map((kf) => {
881
+ const clean = {};
882
+ for (const [k, v] of Object.entries(kf)) {
883
+ if (k !== "computedOffset" && k !== "composite") clean[k] = v;
884
+ }
885
+ return clean;
886
+ });
801
887
  this.animations.set(id, {
802
888
  id,
803
889
  name,
@@ -809,7 +895,9 @@ var _TimelineRecorder = class _TimelineRecorder {
809
895
  type,
810
896
  source,
811
897
  resolvedVars: Object.keys(resolvedVars).length > 0 ? resolvedVars : null,
812
- conflicts
898
+ conflicts,
899
+ rawKeyframes: cleanedKeyframes,
900
+ rawTiming: { ...timing, fill: "both" }
813
901
  });
814
902
  }
815
903
  const keyframes = a.effect?.getKeyframes?.() || [];
@@ -1085,38 +1173,28 @@ var _TimelineRecorder = class _TimelineRecorder {
1085
1173
  } catch (_) {
1086
1174
  }
1087
1175
  }, 0);
1088
- if (this.frames.length > 0) {
1089
- const frame0 = this.frames[0];
1090
- if (frame0.elementSnapshots) {
1091
- for (const [sel, snap] of Object.entries(frame0.elementSnapshots)) {
1092
- const el = this.elements.get(sel);
1093
- if (!el || !el.isConnected) continue;
1094
- if (snap.__styles) {
1095
- for (const [prop, value] of Object.entries(snap.__styles)) {
1096
- if (SAFE_PROPS_SET.has(prop)) {
1097
- el.style.setProperty(prop, value, "important");
1098
- }
1099
- }
1100
- }
1101
- if (snap.__attrs) {
1102
- for (const [attr, value] of Object.entries(snap.__attrs)) {
1103
- if (attr === "checked") {
1104
- ;
1105
- el.checked = value === "true";
1106
- } else if (attr === "class" && value != null) {
1107
- el.className = value;
1108
- } else if (attr === "style") {
1109
- } else if (attr === "value" && value != null) {
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
- }
1176
+ this.seekableClones.clear();
1177
+ for (const [animId, animInfo] of this.animations) {
1178
+ if (!animInfo.rawKeyframes?.length || !animInfo.rawTiming) continue;
1179
+ if (animInfo.type === "JSAnimation") continue;
1180
+ const firstColon = animId.indexOf(":");
1181
+ const secondColon = animId.indexOf(":", firstColon + 1);
1182
+ const elSelector = secondColon >= 0 ? animId.substring(secondColon + 1) : "";
1183
+ const el = this.elements.get(elSelector);
1184
+ if (!el?.isConnected) continue;
1185
+ try {
1186
+ const clone = el.animate(animInfo.rawKeyframes, {
1187
+ ...animInfo.rawTiming,
1188
+ fill: "both"
1189
+ });
1190
+ clone.pause();
1191
+ clone.currentTime = 0;
1192
+ this.seekableClones.set(animId, {
1193
+ animation: clone,
1194
+ element: el,
1195
+ effect: clone.effect
1196
+ });
1197
+ } catch (_) {
1120
1198
  }
1121
1199
  }
1122
1200
  const duration = this.frames.length > 0 ? this.frames[this.frames.length - 1].time : 0;
@@ -1139,6 +1217,8 @@ var TimelineRecorder = _TimelineRecorder;
1139
1217
  // src/core/scrubber.ts
1140
1218
  var TimelineScrubber = class {
1141
1219
  constructor(state) {
1220
+ /** Precomputed frame range per animation for O(1) before/after lookup. */
1221
+ this.animFrameRanges = /* @__PURE__ */ new Map();
1142
1222
  /** Saved originals for restore on release */
1143
1223
  this._originalAnimate = null;
1144
1224
  this._originalRaf = null;
@@ -1148,36 +1228,24 @@ var TimelineScrubber = class {
1148
1228
  this.frames = state.frames;
1149
1229
  this.capturedPortals = state.capturedPortals;
1150
1230
  this.interceptedAnimations = state.interceptedAnimations;
1151
- this.SAFE_PROPS_SET = state.SAFE_PROPS_SET;
1231
+ this.seekableClones = state.seekableClones;
1152
1232
  this._originalAnimate = window.__LAPSE_ORIGINAL_ANIMATE__ ?? null;
1153
1233
  this._originalRaf = window.__LAPSE_ORIGINAL_RAF__ ?? null;
1154
1234
  this._originalRemoveChild = window.__LAPSE_TIMELINE__?._removeChild ?? null;
1155
1235
  this._originalRemove = window.__LAPSE_TIMELINE__?._remove ?? null;
1156
- }
1157
- // ---------------------------------------------------------------------------
1158
- // Selector helper mirrors the recorder's getSelector so we can look up
1159
- // elements by the same key the recorder used in frame.animations[].animationId
1160
- // ---------------------------------------------------------------------------
1161
- getSelector(el) {
1162
- if (!el || !el.tagName) return null;
1163
- const parts = [];
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);
1236
+ for (let i = 0; i < this.frames.length; i++) {
1237
+ for (const fa of this.frames[i].animations) {
1238
+ const range = this.animFrameRanges.get(fa.animationId);
1239
+ if (!range) {
1240
+ this.animFrameRanges.set(fa.animationId, { first: i, last: i });
1241
+ } else {
1242
+ range.last = i;
1243
+ }
1174
1244
  }
1175
- current = parent;
1176
1245
  }
1177
- return parts.join(" > ");
1178
1246
  }
1179
1247
  // ---------------------------------------------------------------------------
1180
- // seekTo — scrub the DOM to match a specific timestamp
1248
+ // seekTo — scrub to a specific timestamp using WAAPI-native seeking
1181
1249
  // ---------------------------------------------------------------------------
1182
1250
  seekTo(timeMs) {
1183
1251
  if (!this.frames.length) return;
@@ -1225,6 +1293,34 @@ var TimelineScrubber = class {
1225
1293
  }
1226
1294
  }
1227
1295
  }
1296
+ const activeAnimIds = /* @__PURE__ */ new Map();
1297
+ for (const fa of frame.animations || []) {
1298
+ activeAnimIds.set(fa.animationId, fa);
1299
+ }
1300
+ for (const [animId, clone] of this.seekableClones) {
1301
+ const frameAnim = activeAnimIds.get(animId);
1302
+ try {
1303
+ if (frameAnim) {
1304
+ if (!clone.animation.effect) {
1305
+ clone.animation.effect = clone.effect;
1306
+ }
1307
+ clone.animation.currentTime = frameAnim.currentTime;
1308
+ } else {
1309
+ const range = this.animFrameRanges.get(animId);
1310
+ if (!range || lo < range.first) {
1311
+ clone.animation.effect = null;
1312
+ } else {
1313
+ if (!clone.animation.effect) {
1314
+ clone.animation.effect = clone.effect;
1315
+ }
1316
+ const timing = clone.effect.getTiming();
1317
+ const endTime = (typeof timing.duration === "number" ? timing.duration : 0) + (timing.delay || 0);
1318
+ clone.animation.currentTime = endTime;
1319
+ }
1320
+ }
1321
+ } catch {
1322
+ }
1323
+ }
1228
1324
  for (const entry of this.interceptedAnimations) {
1229
1325
  try {
1230
1326
  const anim = entry.animation;
@@ -1239,34 +1335,13 @@ var TimelineScrubber = class {
1239
1335
  const el = this.elements.get(sel);
1240
1336
  if (!el || !el.isConnected) continue;
1241
1337
  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
1338
  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
1339
  if (snapTyped.__attrs) {
1254
1340
  for (const [attr, value] of Object.entries(snapTyped.__attrs)) {
1341
+ if (attr === "class" || attr === "style") continue;
1255
1342
  if (attr === "checked") {
1256
1343
  ;
1257
1344
  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
1345
  } else if (attr === "value") {
1271
1346
  if (value != null) el.value = value;
1272
1347
  } else if (value == null) {
@@ -1277,39 +1352,31 @@ var TimelineScrubber = class {
1277
1352
  }
1278
1353
  }
1279
1354
  }
1280
- for (const anim of frame.animations || []) {
1281
- const firstColon = anim.animationId.indexOf(":");
1282
- const secondColon = anim.animationId.indexOf(":", firstColon + 1);
1283
- const animSel = secondColon >= 0 ? anim.animationId.substring(secondColon + 1) : "";
1284
- const animEl = this.elements.get(animSel);
1285
- if (!animEl || !animEl.isConnected) continue;
1286
- for (const prop of anim.properties || []) {
1355
+ for (const fa of frame.animations || []) {
1356
+ if (!fa.animationId.startsWith("JSAnimation:")) continue;
1357
+ const firstColon = fa.animationId.indexOf(":");
1358
+ const secondColon = fa.animationId.indexOf(":", firstColon + 1);
1359
+ const elSel = secondColon >= 0 ? fa.animationId.substring(secondColon + 1) : "";
1360
+ const el = this.elements.get(elSel);
1361
+ if (!el || !el.isConnected) continue;
1362
+ for (const prop of fa.properties) {
1287
1363
  if (prop.value) {
1288
- animEl.style.setProperty(prop.property, prop.value, "important");
1364
+ el.style.setProperty(prop.property, prop.value, "important");
1289
1365
  }
1290
1366
  }
1291
1367
  }
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
1368
  }
1309
1369
  // ---------------------------------------------------------------------------
1310
1370
  // release — tear down all scrub state and restore the page to normal
1311
1371
  // ---------------------------------------------------------------------------
1312
1372
  release() {
1373
+ for (const [, clone] of this.seekableClones) {
1374
+ try {
1375
+ clone.animation.cancel();
1376
+ } catch {
1377
+ }
1378
+ }
1379
+ this.seekableClones.clear();
1313
1380
  for (const entry of this.interceptedAnimations) {
1314
1381
  try {
1315
1382
  entry.animation.cancel();
@@ -1371,6 +1438,7 @@ var TimelineScrubber = class {
1371
1438
  this.elements.clear();
1372
1439
  this.frames.length = 0;
1373
1440
  this.capturedPortals.clear();
1441
+ this.animFrameRanges.clear();
1374
1442
  }
1375
1443
  };
1376
1444
 
@@ -1643,7 +1711,7 @@ var SaccadeEngine = class {
1643
1711
  try {
1644
1712
  capture = this.recorder.stopRecording();
1645
1713
  } catch (e) {
1646
- console.error("[Saccade] stopRecording failed:", e);
1714
+ if (typeof console !== "undefined") console.error("[Saccade] stopRecording failed:", e);
1647
1715
  document.getElementById("__lapse-scrub-blocker")?.remove();
1648
1716
  document.getElementById("__lapse-no-transitions")?.remove();
1649
1717
  document.getElementById("__lapse-state-rules")?.remove();
@@ -1664,7 +1732,8 @@ var SaccadeEngine = class {
1664
1732
  frames: capture.frames,
1665
1733
  capturedPortals: this.recorder.capturedPortalIds,
1666
1734
  interceptedAnimations: this.recorder.interceptedAnimations,
1667
- SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET
1735
+ SAFE_PROPS_SET: this.recorder.SAFE_PROPS_SET,
1736
+ seekableClones: this.recorder.seekableClones
1668
1737
  };
1669
1738
  this.scrubber = new TimelineScrubber(scrubberState);
1670
1739
  this._state = "scrubbing";
@@ -2764,14 +2833,15 @@ var PANEL_STYLES = (
2764
2833
  // src/react/Saccade.tsx
2765
2834
  import { jsx as jsx5 } from "react/jsx-runtime";
2766
2835
  function Saccade({ position = "bottom-left" }) {
2767
- const hostRef = useRef6(null);
2768
2836
  const [shadowRoot, setShadowRoot] = useState4(null);
2837
+ const hostRef = useRef6(null);
2769
2838
  useEffect4(() => {
2770
- const host = hostRef.current;
2771
- if (!host || host.shadowRoot) {
2772
- if (host?.shadowRoot) setShadowRoot(host.shadowRoot);
2773
- return;
2774
- }
2839
+ const host = document.createElement("div");
2840
+ host.setAttribute("data-lapse-panel", "");
2841
+ 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";
2842
+ host.style.cssText = `position:fixed;z-index:2147483647;pointer-events:auto;${positionOffset}`;
2843
+ document.body.appendChild(host);
2844
+ hostRef.current = host;
2775
2845
  const shadow = host.attachShadow({ mode: "open" });
2776
2846
  const style = document.createElement("style");
2777
2847
  style.textContent = PANEL_STYLES;
@@ -2779,25 +2849,15 @@ function Saccade({ position = "bottom-left" }) {
2779
2849
  const mount = document.createElement("div");
2780
2850
  shadow.appendChild(mount);
2781
2851
  setShadowRoot(shadow);
2782
- }, []);
2783
- const positionOffset = position === "top-left" ? { top: 20, left: 20 } : position === "top-right" ? { top: 20, right: 20 } : position === "bottom-left" ? { bottom: 20, left: 20 } : { bottom: 20, right: 20 };
2784
- return /* @__PURE__ */ jsx5(
2785
- "div",
2786
- {
2787
- ref: hostRef,
2788
- "data-lapse-panel": "",
2789
- style: {
2790
- position: "fixed",
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
- }
2852
+ return () => {
2853
+ host.remove();
2854
+ hostRef.current = null;
2855
+ };
2856
+ }, [position]);
2857
+ if (!shadowRoot) return null;
2858
+ return createPortal(
2859
+ /* @__PURE__ */ jsx5(SaccadeProvider, { children: /* @__PURE__ */ jsx5(SaccadePanel, {}) }),
2860
+ shadowRoot.lastElementChild || shadowRoot
2801
2861
  );
2802
2862
  }
2803
2863
  export {