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.
This commit is contained in:
ukimsanov
2026-07-13 16:48:51 -07:00
parent d04bcbf7c4
commit 54f41b41b6
25 changed files with 1752 additions and 1394 deletions
@@ -0,0 +1,94 @@
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();
});
});