@hyperframes/studio 0.8.124 → 0.8.126

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.
Files changed (69) hide show
  1. package/dist/assets/{hyperframes-player-ColOZ_78.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-BuSTrK-Z.js → index-B8kU2xXl.js} +177 -177
  3. package/dist/assets/{index-CVuUfURE.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-jQEqFKMk.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.d.ts +43 -35
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +4458 -4072
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -8
  10. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  11. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  12. package/src/components/editor/MotionPathNode.tsx +56 -0
  13. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
  14. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  15. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  16. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
  19. package/src/components/editor/dragUndoPaint.test.ts +170 -2
  20. package/src/components/editor/gestureUndoRevert.ts +104 -0
  21. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  22. package/src/components/editor/manualOffsetDrag.ts +3 -15
  23. package/src/components/editor/motionPathGeometry.test.ts +48 -1
  24. package/src/components/editor/motionPathGeometry.ts +46 -8
  25. package/src/components/editor/motionPathHome.ts +75 -0
  26. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  27. package/src/components/editor/motionPathLayerNode.ts +20 -16
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  29. package/src/components/editor/studioMotion.ts +7 -19
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
  32. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  33. package/src/components/editor/useMotionPathData.ts +23 -61
  34. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  35. package/src/hooks/gestureTransaction.test.ts +60 -0
  36. package/src/hooks/gestureTransaction.ts +5 -2
  37. package/src/hooks/gsapGestureOutcome.ts +9 -2
  38. package/src/hooks/gsapParsedTween.ts +16 -0
  39. package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
  40. package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
  41. package/src/hooks/gsapResizeIntercept.ts +55 -68
  42. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  43. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  44. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  45. package/src/hooks/gsapShared.ts +2 -1
  46. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  47. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  48. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  49. package/src/hooks/timelineEditingHelpers.ts +2 -9
  50. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  52. package/src/hooks/useGsapAwareEditing.ts +21 -25
  53. package/src/hooks/useGsapAwareGroupMove.ts +4 -6
  54. package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
  55. package/src/hooks/useGsapScriptCommits.ts +12 -3
  56. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  57. package/src/hooks/useThumbnailStripSize.ts +206 -17
  58. package/src/noWindowBoundDomChecks.test.ts +31 -0
  59. package/src/player/components/CompositionThumbnail.tsx +10 -6
  60. package/src/player/components/ImageThumbnail.tsx +11 -5
  61. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  62. package/src/player/components/ThumbnailTiles.tsx +41 -0
  63. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  64. package/src/player/components/VideoThumbnail.tsx +11 -5
  65. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  66. package/src/player/lib/timelineGroupInfo.ts +2 -1
  67. package/src/utils/studioPendingEdits.test.ts +52 -0
  68. package/src/utils/studioPendingEdits.ts +55 -10
  69. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
5
+ import type { StudioEditInFlight } from "../utils/studioPendingEdits";
5
6
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
7
  import type { KeyframeUsageAction } from "../utils/keyframeUsage";
7
8
 
@@ -47,6 +48,7 @@ export interface CommitMutationOptions {
47
48
  /** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
48
49
  previewFallbackLatch?: { pending: boolean };
49
50
  beforeReload?: () => void;
51
+ pendingEdit?: StudioEditInFlight;
50
52
  /**
51
53
  * Serialize this commit against others sharing the same key. Used to chain
52
54
  * per-animationId GSAP meta updates. Every commit independently takes the
@@ -3,6 +3,7 @@
3
3
  * Centralises duplicated interfaces, constants, and small utilities
4
4
  * to reduce drift risk.
5
5
  */
6
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
6
7
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
9
  import {
@@ -223,7 +224,7 @@ function structuralSelector(element: Element): string | null {
223
224
  const parts: string[] = [];
224
225
  for (let node: Element | null = element; node; node = node.parentElement) {
225
226
  if (node !== element) {
226
- const id = node instanceof HTMLElement ? node.id : "";
227
+ const id = isHtmlElement(node) ? node.id : "";
227
228
  const hfId = node.getAttribute("data-hf-id");
228
229
  if (id) {
229
230
  parts.unshift(idSelector(id));
@@ -110,6 +110,16 @@ describe("writeTargetSelector", () => {
110
110
  expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
111
111
  });
112
112
 
113
+ it("anchors on an ancestor's id in the preview, whichever window built its nodes", () => {
114
+ const frame = document.body.appendChild(document.createElement("iframe"));
115
+ const doc = frame.contentDocument!;
116
+ doc.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
117
+ const el = doc.querySelectorAll<HTMLElement>("#scene > div")[1]!;
118
+ const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
119
+
120
+ expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
121
+ });
122
+
113
123
  it("returns null when a live DOM is present and no rung addresses one element", () => {
114
124
  document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
115
125
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
@@ -0,0 +1,16 @@
1
+ // Test-only IntersectionObserver stand-in: everything observed is reported near the screen, asynchronously.
2
+ export function NearScreenIntersectionObserver(callback: IntersectionObserverCallback) {
3
+ const observer = {
4
+ observe(target: Element) {
5
+ queueMicrotask(() =>
6
+ callback(
7
+ [{ isIntersecting: true, target } as IntersectionObserverEntry],
8
+ observer as unknown as IntersectionObserver,
9
+ ),
10
+ );
11
+ },
12
+ unobserve() {},
13
+ disconnect() {},
14
+ };
15
+ return observer;
16
+ }
@@ -1,14 +1,21 @@
1
1
  // Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
2
2
  let resizeCallback: ResizeObserverCallback | null = null;
3
+ let observed: Element | null = null;
3
4
 
4
5
  export function MockResizeObserver(callback: ResizeObserverCallback) {
5
6
  resizeCallback = callback;
6
- return { observe() {}, disconnect() {}, unobserve() {} };
7
+ return {
8
+ observe(target: Element) {
9
+ observed = target;
10
+ },
11
+ disconnect() {},
12
+ unobserve() {},
13
+ };
7
14
  }
8
15
 
9
16
  export function reportResize(width: number, height: number): void {
10
17
  resizeCallback!(
11
- [{ contentRect: { width, height } } as ResizeObserverEntry],
18
+ [{ contentRect: { width, height }, target: observed } as ResizeObserverEntry],
12
19
  {} as ResizeObserver,
13
20
  );
14
21
  }
@@ -1,3 +1,4 @@
1
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
1
2
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
3
  import { toAuthoredStart } from "../player/store/timelineElement";
3
4
  import {
@@ -35,14 +36,6 @@ import {
35
36
  } from "../components/editor/domEditingElement";
36
37
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
37
38
  export { readFileContent };
38
- function isHTMLElement(element: Element | null): element is HTMLElement {
39
- if (!element) return false;
40
- // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
41
- // iframe, and cross-realm `element instanceof HTMLElement` (main window) is
42
- // always false — which silently dropped every timeline z-index commit.
43
- const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
44
- return element instanceof Ctor;
45
- }
46
39
  /**
47
40
  * Resolve a timeline vertical move to a z-index stacking reorder and commit it
48
41
  * through the shared layers-panel reorder path. Reads live sibling z-index from
@@ -96,7 +89,7 @@ export function applyTimelineStackingReorder(input: {
96
89
  input.activeCompPath,
97
90
  )
98
91
  : null;
99
- if (!isHTMLElement(element)) return Promise.resolve();
92
+ if (!isHtmlElement(element)) return Promise.resolve();
100
93
  if (getElementZIndex(element) === change.zIndex) continue;
101
94
  commitEntries.push({
102
95
  element,
@@ -6,6 +6,10 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
8
8
  import { usePlayerStore } from "../player/store/playerStore";
9
+ import {
10
+ beginStudioPendingEdit,
11
+ paintBackNewestStudioPendingEdit,
12
+ } from "../utils/studioPendingEdits";
9
13
  import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
10
14
  import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
11
15
  import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
@@ -65,7 +69,10 @@ function mountGroup(animations: GsapAnimation[]) {
65
69
  batch: vi.fn().mockResolvedValue(undefined),
66
70
  });
67
71
  const showToast = vi.fn();
68
- const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
72
+ const stageElementPositionOffset = vi.fn((selection: DomEditSelection) => {
73
+ selection.element.style.setProperty("translate", "30px 0px");
74
+ return { save: vi.fn().mockResolvedValue(undefined), rollback: vi.fn() };
75
+ });
69
76
  const { editing, root } = mountGsapAwareEditing({
70
77
  gsapCommitMutation: commitMutation,
71
78
  activeCompPath: "index.html",
@@ -158,3 +165,24 @@ describe("a group drag plans every member before its first write", () => {
158
165
  act(() => h.root.unmount());
159
166
  });
160
167
  });
168
+
169
+ it("keeps a member it moves on its own CSS undone when undo painted the group back", async () => {
170
+ const h = mountGroup(["b", "c"].map((id) => positionTween(id)));
171
+ const plain = h.elements[0]!;
172
+ const edit = beginStudioPendingEdit(() => {
173
+ const shown = plain.getAttribute("style") ?? "";
174
+ plain.setAttribute("style", "");
175
+ return () => plain.setAttribute("style", shown);
176
+ });
177
+ const saved = edit.adopt(() =>
178
+ h.groupCommit(h.updates.map((u, i) => (i === 0 ? { ...u, plainTranslate: true } : u))),
179
+ );
180
+ edit.settle(saved);
181
+ const shown = paintBackNewestStudioPendingEdit();
182
+
183
+ await act(() => saved);
184
+ expect(plain.getAttribute("style")).toBe("");
185
+ shown!.showAgain();
186
+ expect(plain.getAttribute("style")).toContain("translate: 30px 0px");
187
+ act(() => h.root.unmount());
188
+ });
@@ -423,6 +423,7 @@ describe("useGsapAwareEditing anchored resize", () => {
423
423
  expect.any(Function),
424
424
  expect.any(Function),
425
425
  undefined,
426
+ expect.any(Function),
426
427
  );
427
428
  act(() => h.root.unmount());
428
429
  });
@@ -169,8 +169,10 @@ export function useGsapAwareEditing({
169
169
  modifiers,
170
170
  );
171
171
  await saveMove(outcome, async () => {
172
- const result = await stageElementPositionOffset(selection, next, false).save();
173
- writes.recordDomResult(result);
172
+ const staged = writes.drawKeepingUndone(() =>
173
+ stageElementPositionOffset(selection, next, false),
174
+ );
175
+ writes.recordDomResult(await staged.save());
174
176
  });
175
177
  return writes.finish();
176
178
  } catch (error) {
@@ -258,11 +260,8 @@ export function useGsapAwareEditing({
258
260
  // Saved after the size, under its undo key, so the two are one step.
259
261
  await saveMove(dragOutcome, async () => {
260
262
  const plainAfterSettle = editsPlainCss(selection.element, "move");
261
- anchorMove = stageElementPositionOffset(
262
- selection,
263
- offset,
264
- plainAfterSettle,
265
- coalesceKey,
263
+ anchorMove = writes.drawKeepingUndone(() =>
264
+ stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
266
265
  );
267
266
  });
268
267
  }
@@ -270,17 +269,20 @@ export function useGsapAwareEditing({
270
269
  await runGestureTransaction({
271
270
  element: selection.element,
272
271
  label: "Resize layer",
272
+ draw: writes.drawKeepingUndone,
273
273
  settle: () => {
274
274
  // Scale resize settles its center-scale residual after the scale commit
275
275
  // renders. Width/height can settle its anchored position immediately.
276
276
  if (!offset || scaleRoute || !selector) return;
277
- const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
278
- x: 0,
279
- y: 0,
280
- };
281
- const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
282
- logResize("sync-settle", { gsapPos, offset, newX, newY });
283
- setElementGsapPosition(selection.element, newX, newY);
277
+ writes.drawKeepingUndone(() => {
278
+ const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
279
+ x: 0,
280
+ y: 0,
281
+ };
282
+ const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
283
+ logResize("sync-settle", { gsapPos, offset, newX, newY });
284
+ setElementGsapPosition(selection.element, newX, newY);
285
+ });
284
286
  },
285
287
  persist: async (commit, coalesceKey) => {
286
288
  if (writes.commit) {
@@ -294,16 +296,13 @@ export function useGsapAwareEditing({
294
296
  commitMutation,
295
297
  makeFetchFallback(selection),
296
298
  offset,
299
+ writes.drawKeepingUndone,
297
300
  );
298
301
  assertGsapEditPersisted(outcome);
299
302
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
300
303
  if (outcome.status === "element-size") {
301
- const result = await handleDomBoxSizeCommit(
302
- selection,
303
- next,
304
- undefined,
305
- undefined,
306
- coalesceKey,
304
+ const result = await writes.drawKeepingUndone(() =>
305
+ handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
307
306
  );
308
307
  writes.recordDomResult(result);
309
308
  } else cropUndoKey = coalesceKey;
@@ -332,11 +331,8 @@ export function useGsapAwareEditing({
332
331
  writes.recordDomResult(anchorResult);
333
332
  // Only now is the size live for every caller, drag or not.
334
333
  if (cropUndoKey) {
335
- const cropResult = await saveCropResize(
336
- stageCrop,
337
- selection,
338
- commitPositionPatchToHtml,
339
- cropUndoKey,
334
+ const cropResult = await writes.drawKeepingUndone(() =>
335
+ saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
340
336
  );
341
337
  writes.recordDomResult(cropResult);
342
338
  }
@@ -123,12 +123,10 @@ export function useGsapAwareGroupMove({
123
123
  renderOnCommit = index === lastScriptWrite;
124
124
  const plain = offsetMembers.get(selection);
125
125
  if (plain !== undefined) {
126
- const result = await stageElementPositionOffset(
127
- selection,
128
- next,
129
- plain,
130
- coalesceKey,
131
- ).save();
126
+ const staged = writes.drawKeepingUndone(() =>
127
+ stageElementPositionOffset(selection, next, plain, coalesceKey),
128
+ );
129
+ const result = await staged.save();
132
130
  writes.recordDomResult(result);
133
131
  continue;
134
132
  }
@@ -38,7 +38,12 @@ import type {
38
38
  import { persistSdkSerialize } from "../utils/sdkCutover";
39
39
  import { jsonResponse } from "./fetchStubTestUtils";
40
40
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
41
- import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
41
+ import {
42
+ beginStudioPendingEdit,
43
+ hasStudioPendingEdits,
44
+ paintBackNewestStudioPendingEdit,
45
+ } from "../utils/studioPendingEdits";
46
+ import { observeGsapGesture } from "./gsapGestureOutcome";
42
47
 
43
48
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
44
49
 
@@ -490,6 +495,42 @@ describe("a GSAP script commit", () => {
490
495
  await act(async () => void (await committed));
491
496
  expect(hasStudioPendingEdits()).toBe(false);
492
497
  });
498
+
499
+ it("a gesture's write after an await is still its edit, and once undo paints that edit back it saves without drawing it", async () => {
500
+ applySoftReload.mockReturnValue("applied");
501
+ let respond!: () => void;
502
+ const body: MutationResult = { ok: true, changed: true, after: "AFTER", scriptText: "SCRIPT" };
503
+ vi.stubGlobal(
504
+ "fetch",
505
+ vi.fn(
506
+ () =>
507
+ new Promise((resolve) => {
508
+ respond = () => resolve({ ok: true, json: async () => body } as unknown as Response);
509
+ }),
510
+ ),
511
+ );
512
+ const deps = renderCommitHook();
513
+ const edit = beginStudioPendingEdit(() => () => undefined);
514
+ const saved = edit.adopt(async () => {
515
+ const writes = observeGsapGesture(deps.api.commitMutation);
516
+ await Promise.resolve();
517
+ return writes.commit!(selection, { x: 10 }, { label: "Move layer", softReload: true });
518
+ });
519
+ edit.settle(saved);
520
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
521
+
522
+ const shown = paintBackNewestStudioPendingEdit();
523
+ expect(shown).not.toBeNull();
524
+ await act(async () => {
525
+ respond();
526
+ await saved;
527
+ });
528
+ expect(deps.onFileContentChanged).toHaveBeenCalledWith("index.html", "AFTER");
529
+ expect(applySoftReload).not.toHaveBeenCalled();
530
+
531
+ shown!.showAgain();
532
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
533
+ });
493
534
  });
494
535
 
495
536
  describe("runCommit — instantPatch wiring", () => {
@@ -113,11 +113,18 @@ function finishUnchangedMutation(
113
113
  !options.skipReload &&
114
114
  (instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
115
115
  ) {
116
- applyPreviewSync(iframe, result, options, reloadPreview);
116
+ redrawUnlessPaintedBack(options, () =>
117
+ applyPreviewSync(iframe, result, options, reloadPreview),
118
+ );
117
119
  }
118
120
  return true;
119
121
  }
120
122
 
123
+ function redrawUnlessPaintedBack(options: CommitMutationOptions, redraw: () => void): void {
124
+ if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw);
125
+ else redraw();
126
+ }
127
+
121
128
  function refreshMutationPreview(
122
129
  iframe: HTMLIFrameElement | null,
123
130
  result: MutationResult,
@@ -126,8 +133,10 @@ function refreshMutationPreview(
126
133
  onCacheInvalidate: () => void,
127
134
  nestedFiles?: Map<string, string> | null,
128
135
  ): void {
129
- options.beforeReload?.();
130
- applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
136
+ redrawUnlessPaintedBack(options, () => {
137
+ options.beforeReload?.();
138
+ applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
139
+ });
131
140
  onCacheInvalidate();
132
141
  }
133
142
 
@@ -1,7 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
- import { expect, it } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { createHappyDomRootHarness } from "../player/components/testRootHarness";
6
+ import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils";
5
7
  import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
6
8
  import { useThumbnailStripSize } from "./useThumbnailStripSize";
7
9
 
@@ -45,3 +47,236 @@ it("does not re-render the strip when the observer reports the size it already h
45
47
  globalThis.ResizeObserver = originalResizeObserver;
46
48
  }
47
49
  });
50
+
51
+ describe("on a scroll", () => {
52
+ const originalResizeObserver = globalThis.ResizeObserver;
53
+ const originalIntersectionObserver = globalThis.IntersectionObserver;
54
+ const frames: FrameRequestCallback[] = [];
55
+ const harness = createHappyDomRootHarness();
56
+ let host: HTMLDivElement;
57
+ let root: ReturnType<typeof createRoot>;
58
+ let left = 0;
59
+ let top = 0;
60
+ let scrolled = 0;
61
+ let scrolledDown = 0;
62
+ let stripWidth = 1_000_000;
63
+ let renders = 0;
64
+
65
+ function Strip() {
66
+ renders += 1;
67
+ const [size, ref] = useThumbnailStripSize();
68
+ return (
69
+ <div>
70
+ <div ref={ref} data-in-view={`${size.inViewStart}-${size.inViewEnd}`} />
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const mountStrips = (count: number) =>
76
+ act(async () =>
77
+ root.render(Array.from({ length: count }, (_, index) => <Strip key={index} />)),
78
+ );
79
+
80
+ const spans = () =>
81
+ [...host.querySelectorAll("[data-in-view]")].map((strip) => strip.getAttribute("data-in-view"));
82
+
83
+ /** The timeline scrolls so the strips' left edge lands at `nextLeft`, then one frame runs. */
84
+ const scrollTo = (nextLeft: number) => {
85
+ scrolled += left - nextLeft;
86
+ left = nextLeft;
87
+ act(() => host.dispatchEvent(new Event("scroll")));
88
+ expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
89
+ act(() => frames.splice(0).forEach((frame) => frame(0)));
90
+ };
91
+
92
+ const neverReportedNear = () => {
93
+ globalThis.IntersectionObserver = class {
94
+ observe() {}
95
+ unobserve() {}
96
+ disconnect() {}
97
+ } as unknown as typeof IntersectionObserver;
98
+ };
99
+
100
+ beforeEach(() => {
101
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
102
+ globalThis.IntersectionObserver =
103
+ NearScreenIntersectionObserver as unknown as typeof IntersectionObserver;
104
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame));
105
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
106
+ () => ({ left, top, width: stripWidth, height: 40 }) as DOMRect,
107
+ );
108
+ vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => stripWidth);
109
+ left = 0;
110
+ top = 0;
111
+ scrolled = 0;
112
+ scrolledDown = 0;
113
+ stripWidth = 1_000_000;
114
+ renders = 0;
115
+ host = document.body.appendChild(document.createElement("div"));
116
+ host.setAttribute("data-timeline-scroll-viewport", "");
117
+ Object.defineProperty(host, "scrollLeft", { configurable: true, get: () => scrolled });
118
+ Object.defineProperty(host, "scrollTop", { configurable: true, get: () => scrolledDown });
119
+ root = harness.mount(host);
120
+ });
121
+
122
+ afterEach(() => {
123
+ frames.length = 0;
124
+ vi.restoreAllMocks();
125
+ globalThis.ResizeObserver = originalResizeObserver;
126
+ globalThis.IntersectionObserver = originalIntersectionObserver;
127
+ });
128
+
129
+ it("re-measures every near strip in one shared frame, so a full timeline lays out once", async () => {
130
+ await mountStrips(50);
131
+
132
+ scrollTo(-10_000);
133
+
134
+ expect(spans()).toEqual(Array(50).fill("9216-11776"));
135
+ });
136
+
137
+ it("keeps a visible strip measured when it is read after a scroll but before that scroll's frame", async () => {
138
+ stripWidth = 5_000;
139
+ left = -1_000;
140
+ await mountStrips(1);
141
+
142
+ scrolled += 3_000;
143
+ left = -4_000;
144
+ act(() => reportResize(stripWidth, 40));
145
+ act(() => host.dispatchEvent(new Event("scroll")));
146
+ act(() => frames.splice(0).forEach((frame) => frame(0)));
147
+
148
+ expect(spans()).toEqual(["3072-5000"]);
149
+ });
150
+
151
+ it("keeps a strip on screen measured after a move without a scroll, then a scroll", async () => {
152
+ await mountStrips(1);
153
+ expect(spans()).toEqual(["0-1536"]);
154
+
155
+ left = -3_000;
156
+ scrollTo(-1_000);
157
+ expect(spans()).toEqual(["0-2560"]);
158
+ });
159
+
160
+ it("measures a strip a vertical scroll brings on screen in that same frame", async () => {
161
+ neverReportedNear();
162
+ top = 5_000;
163
+ await mountStrips(1);
164
+ expect(spans()).toEqual(["0-0"]);
165
+
166
+ scrolledDown += 4_900;
167
+ top = 100;
168
+ act(() => host.dispatchEvent(new Event("scroll")));
169
+ act(() => frames.splice(0).forEach((frame) => frame(0)));
170
+
171
+ expect(spans()).toEqual(["0-1536"]);
172
+ });
173
+
174
+ it("keeps the tiles of a strip that ends just off screen", async () => {
175
+ await mountStrips(1);
176
+
177
+ scrollTo(-(stripWidth + 100));
178
+
179
+ expect(spans()).toEqual(["999424-1000000"]);
180
+ });
181
+
182
+ it("measures the strips a jump brings on screen in that same frame", async () => {
183
+ neverReportedNear();
184
+ left = 50_000;
185
+ await mountStrips(50);
186
+ expect(spans()).toEqual(Array(50).fill("0-0"));
187
+
188
+ scrollTo(0);
189
+
190
+ expect(spans()).toEqual(Array(50).fill("0-1536"));
191
+ });
192
+
193
+ it("does not re-render a strip wholly on screen when it moves", async () => {
194
+ stripWidth = 300;
195
+ left = 100;
196
+ await mountStrips(1);
197
+ const settled = renders;
198
+
199
+ scrollTo(700);
200
+
201
+ expect(renders).toBe(settled);
202
+ });
203
+
204
+ it("does not re-render strips that a jump carries from one side of the screen to the other", async () => {
205
+ left = 50_000;
206
+ await mountStrips(50);
207
+ const settled = renders;
208
+
209
+ scrollTo(-2_000_000);
210
+
211
+ expect(renders).toBe(settled);
212
+ });
213
+
214
+ it("keeps every tile of a short clip, even far from the screen", async () => {
215
+ stripWidth = 4_096;
216
+ top = 5_000;
217
+ await mountStrips(1);
218
+
219
+ expect(spans()).toEqual(["0-4096"]);
220
+ });
221
+
222
+ it("never re-measures a short clip on a scroll", async () => {
223
+ stripWidth = 300;
224
+ left = 100;
225
+ await mountStrips(1);
226
+ const reads = vi.mocked(Element.prototype.getBoundingClientRect);
227
+ reads.mockClear();
228
+
229
+ scrollTo(50);
230
+
231
+ expect(reads).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it("mounts nothing for a strip far above or below the screen", async () => {
235
+ top = 5_000;
236
+ await mountStrips(1);
237
+
238
+ expect(spans()).toEqual(["0-0"]);
239
+ });
240
+
241
+ it("measures a strip as it comes near the screen, without waiting for a frame", async () => {
242
+ const reports: IntersectionObserverCallback[] = [];
243
+ globalThis.IntersectionObserver = class {
244
+ constructor(callback: IntersectionObserverCallback) {
245
+ reports.push(callback);
246
+ }
247
+ observe() {}
248
+ unobserve() {}
249
+ disconnect() {}
250
+ } as unknown as typeof IntersectionObserver;
251
+ await mountStrips(1);
252
+ const strip = host.querySelector("[data-in-view]")!;
253
+
254
+ left = -10_000;
255
+ const [presence] = reports;
256
+ act(() =>
257
+ presence!(
258
+ [
259
+ {
260
+ isIntersecting: true,
261
+ target: strip.parentElement!,
262
+ } as unknown as IntersectionObserverEntry,
263
+ ],
264
+ {} as IntersectionObserver,
265
+ ),
266
+ );
267
+
268
+ expect(frames).toHaveLength(0);
269
+ expect(strip.getAttribute("data-in-view")).toBe("9216-11776");
270
+ });
271
+
272
+ it("reads no strip far from the screen, however many clips the timeline mounts", async () => {
273
+ left = 50_000;
274
+ await mountStrips(50);
275
+ const reads = vi.mocked(Element.prototype.getBoundingClientRect);
276
+ reads.mockClear();
277
+
278
+ scrollTo(49_900);
279
+
280
+ expect(reads).not.toHaveBeenCalled();
281
+ });
282
+ });