Files
hyperframes/packages/studio/src/player/components/timelineRevealScroll.ts
T
ukimsanov a33b3f35e1 fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit):
- asset reveal now clears any open preview overlay (stuck-overlay repro:
  preview on A, click already-added B — A stayed open over the reveal)
- duration readout rolls back on failed persist: captureDurationRollback
  snapshots store + live root data-duration before the optimistic sync and
  restores both in every move/resize/delete/group catch (golden's
  previousDuration pattern)
- asset preview opened during running playback dismisses immediately (the
  RAF loop bypasses the store, so the subscription alone never fired)
- persistTimelineBatchEdit resolves the target (findTagByTarget) before
  treating identical output as a no-op — a mistargeted member now throws
  like the single-element path instead of being silently dropped
- a post-mutation history-fold failure no longer suppresses the preview
  sync: fold errors are surfaced separately and the rewritten script still
  syncs (previously the preview kept stale GSAP positions with no recovery)
- timelineRevealScroll guards degenerate viewports (windowSize <= 0)
- CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches

Review 2 (single-source-of-truth pass):
- createTimelineElementFromManifestClip — the one manifest->element
  boundary — now carries authoredTrack and stackingContextId; expanded
  sub-comp children preserve both (authoredTrack in their OWN file's space)
- authoredTrackForLane scopes occupants to the dragged clip's sourceFile
  (a foreign file's authored values are a different coordinate space);
  nearest-same-file-lane offset fallback
- optimistic store updates mirror the persisted track into authoredTrack
  (and roll it back on failure), so consecutive drags before a reload
  resolve from fresh data
- spill sub-lanes: documented decision — dropping onto a spill lane is a
  legitimate same-track join (occupants share the authored track by
  construction); false 'never a lane-move target' docstring rewritten
- single-element fallback persists vertical-only moves (early return now
  requires neither start nor track changed; live DOM patch includes
  data-track-index)
- canonical contextKey helper for stacking-context normalization
- new pipeline test crosses the REAL factory boundary (sparse authored
  tracks -> factory -> expansion -> normalize -> drag commit -> persisted
  attribute), no injected fields
2026-07-13 16:48:52 -07:00

92 lines
3.3 KiB
TypeScript

/**
* Pure scroll-target math for revealing a timeline clip inside the timeline's
* scroll container (the overflow div in Timeline.tsx).
*
* Coordinates are content-space: a clip edge measured from the scroll
* container's content origin (rect delta + current scroll offset). The visible
* window on each axis is reduced by the sticky chrome that occludes it — the
* track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
* "hidden" under the sticky gutter still counts as off-screen.
*
* Scrolls minimally: an axis already fully visible returns null for that axis;
* otherwise the nearest edge is brought just inside the window (plus padding).
* A clip larger than the window aligns its start edge. Pure — unit-tested.
*/
export interface RevealScrollInput {
scrollLeft: number;
scrollTop: number;
/** Scroll container clientWidth / clientHeight. */
viewportWidth: number;
viewportHeight: number;
/** Clip bounds in content-space (relative to the scroll content origin). */
clipLeft: number;
clipRight: number;
clipTop: number;
clipBottom: number;
/** Width of the sticky left gutter occluding the viewport's left edge. */
stickyLeft: number;
/** Height of the sticky ruler occluding the viewport's top edge. */
stickyTop: number;
/** False in "fit" zoom mode, where horizontal scrolling is disabled. */
allowHorizontal: boolean;
}
export interface RevealScrollTarget {
/** Target scrollLeft, or null when the horizontal axis needs no scroll. */
left: number | null;
/** Target scrollTop, or null when the vertical axis needs no scroll. */
top: number | null;
}
/** Breathing room between the revealed clip edge and the window edge. */
export const REVEAL_SCROLL_PADDING_PX = 12;
/**
* Minimal scroll on one axis to bring [start, end] inside the visible window
* [scroll + stickyStart, scroll + viewport], with padding. Returns null when
* the range is already fully visible.
*/
function revealAxis(
scroll: number,
viewport: number,
stickyStart: number,
start: number,
end: number,
): number | null {
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
const windowSize = windowEnd - windowStart;
// Degenerate viewport (container smaller than the sticky chrome + padding):
// there is no visible window to reveal into, so never scroll on this axis.
if (windowSize <= 0) return null;
if (start >= windowStart && end <= windowEnd) return null;
// Oversized range (or start hidden): align the start edge to the window start.
if (end - start > windowSize || start < windowStart) {
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
}
// Only the end is clipped: pull it just inside the window's far edge.
return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
}
export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
return {
left: input.allowHorizontal
? revealAxis(
input.scrollLeft,
input.viewportWidth,
input.stickyLeft,
input.clipLeft,
input.clipRight,
)
: null,
top: revealAxis(
input.scrollTop,
input.viewportHeight,
input.stickyTop,
input.clipTop,
input.clipBottom,
),
};
}