Files
hyperframes/packages/studio/src/player/components/timelineRevealScroll.test.ts
T
ukimsanov 54f41b41b6 fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild
The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped
or regressed several final-wave behaviors from the reviewed studio-dnd stack,
and never repaired the stale timelineZones.ts that #2279 introduced. Restores:

- TimelineRuler: sticky under vertical scroll, full-height gridlines removed
  (beat lines only), frame-number tick labels via a persisted timeDisplayMode
  store preference (PlayerControls toggle now store-backed)
- timelineZones: stable track lanes — lane = authored data-track-index
  ascending; z is paint order only (replaces the stale z-driven lane pack,
  which broke track insert-band commits that contractually depend on it)
- persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes
  already at target values, e.g. in a track-insert renumber) is skipped
  instead of aborting and rolling back the whole batch — this alone made
  new-track creation (incl. the top insert band) fail silently
- useTimelineStackingSync: unresolvable clips read as NaN again so
  timelineStackingSync's Number.isFinite exclusion contract holds (z=0
  fabrications skewed stacking boundaries)
- timelineAssetDrop: drops land on the drop track (no overlap bump to
  max-track+1), data-hf-id stamped, audio gets data-volume
- timing edits: soft-reload the server's rewritten GSAP script instead of a
  full iframe remount (no all-clips flash on move/resize); full reload only
  when no scriptText or the soft path can't apply, and one full reload when a
  group edit touches non-active files (new hooks/timelineTimingSync.ts)
- duration: content-driven grow-AND-shrink on move/resize/delete, synced
  optimistically to the store and the live root data-duration at release
  (was a grow-only ratchet; shrink never updated the readout)

New UX: sidebar asset click opens a compact non-modal preview over the canvas
(dismiss on outside click, Escape, playback, or seek), and clicking an
already-added asset reveals its clip in the timeline (smooth minimal scroll
to its time and lane; vertical-only in fit zoom).

Verified by pointer-driving a real project: sticky ruler + gridline removal,
no iframe remount on move/resize (marker survives, GSAP tween positions
rewritten in place), duration readout 40->37->40 on shrink/stretch, and
top-insert-band track creation renumbering lanes correctly on disk.
2026-07-13 16:48:51 -07:00

95 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
computeRevealScroll,
REVEAL_SCROLL_PADDING_PX,
type RevealScrollInput,
} from "./timelineRevealScroll";
/** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
return {
scrollLeft: 0,
scrollTop: 0,
viewportWidth: 1000,
viewportHeight: 400,
clipLeft: 100,
clipRight: 200,
clipTop: 100,
clipBottom: 148,
stickyLeft: 32,
stickyTop: 24,
allowHorizontal: true,
...overrides,
};
}
describe("computeRevealScroll", () => {
it("returns null on both axes when the clip is fully visible", () => {
expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
});
it("scrolls right minimally when the clip end is past the right edge", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
// Clip end lands padding px inside the right edge.
expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
expect(result.top).toBeNull();
});
it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
const result = computeRevealScroll(
makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
);
// clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("aligns the start edge when the clip is wider than the viewport", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("never returns a negative scroll target", () => {
const result = computeRevealScroll(
makeInput({
scrollLeft: 300,
clipLeft: 10,
clipRight: 60,
scrollTop: 200,
clipTop: 4,
clipBottom: 20,
}),
);
expect(result.left).toBe(0);
expect(result.top).toBe(0);
});
it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
const result = computeRevealScroll(
makeInput({
allowHorizontal: false,
clipLeft: 1500,
clipRight: 1600,
clipTop: 700,
clipBottom: 748,
}),
);
expect(result.left).toBeNull();
// Vertical reveal still happens.
expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
});
it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
const result = computeRevealScroll(
makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
);
// clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
});
it("scrolls down minimally when the clip lane is below the viewport", () => {
const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
expect(result.left).toBeNull();
});
});