mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
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.
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
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();
|
||
});
|
||
});
|