Files
hyperframes/packages/studio/src/player/components/PlayheadIndicator.tsx
T
ukimsanov 19139b91ed fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline
Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.

- timelineMoveAdapter deliberately stripped the track from lane-reorder
  persists ('z-only reorder path' — the old z-driven lane model). Lane =
  authored data-track-index now: lane-reorder and track-insert both persist
  the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
  normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
  fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
  number — silently re-targeting the wrong row in any non-0-contiguous file.
  Elements now record their authoredTrack when remapped; a lane change
  persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
  div) onto separate renumbered tracks at discovery, so authored indices
  never round-tripped ('drop onto an existing track' bounced back). Removed:
  data-track-index is honored verbatim (render never reads it); kind-based
  row presentation belongs in the display layer if ever wanted.

Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
  overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
  back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
  patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
  normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
  ancestor z decides paint order; the sync now partitions by
  stackingContextId and never patches across contexts

Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
  timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
  inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
  (wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
  ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
  steps added for far zoom-out, tick positions computed as exact multiples
  (no float drift)
2026-07-13 16:48:52 -07:00

104 lines
3.8 KiB
TypeScript

// fallow-ignore-file dead-code
/**
* Shared playhead visual used by TimelineCanvas (real playhead) and
* TimelineEditorNotice (animated illustration).
*
* The vertical line + glow span the full track height; the grab-handle HEAD is
* `position: sticky; top: 0` so it pins to the top of the (vertically) scrolling
* track area — the ruler is sticky too, so the head stays visible and grabbable
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
*/
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
interface PlayheadIndicatorProps {
/** CSS color, defaults to the HF accent variable */
color?: string;
/** Glow shadow color, defaults to translucent accent */
glowColor?: string;
/** Whether the playhead is being actively scrubbed — fills the head. */
scrubbing?: boolean;
/**
* When false, the head chip is rendered in normal flow (top:1) instead of the
* sticky pin — used by the static illustration where there is no scroll area.
*/
stickyHead?: boolean;
}
export function PlayheadIndicator({
color = "var(--hf-accent, #3CE6AC)",
glowColor = "rgba(60,230,172,0.14)",
scrubbing = false,
stickyHead = true,
}: PlayheadIndicatorProps) {
// Head chip dimensions — used to compute the centering offset and the
// point where the vertical line starts (so it begins at the head's bottom
// edge rather than running through the hollow diamond center). The width is
// the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
// wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
// wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
const HEAD_W = PLAYHEAD_HEAD_W;
const HEAD_H = 9;
// marginTop(1) + HEAD_H = where the line should start.
const HEAD_TOTAL_H = 1 + HEAD_H;
return (
<>
{/* Glow — spans full height, centered on the line. */}
<div
aria-hidden="true"
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 13,
transform: "translateX(-50%)",
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
}}
/>
{/* Vertical line — starts at the bottom edge of the head chip so nothing
shows through the hollow diamond center. */}
<div
className="absolute bottom-0"
style={{
left: "50%",
top: HEAD_TOTAL_H,
width: 1,
marginLeft: -0.5,
background: color,
boxShadow: `0 0 6px ${glowColor}`,
}}
/>
{/* Head chip — sticky so it pins to the ruler while tracks scroll.
Centering logic: wrapper width = HEAD_W (chip forces it). The line sits
at wrapper.left + HEAD_W/2 (left:"50%" of wrapper). The sticky element's
natural flow position is wrapper.left; so placing it there with no
horizontal translate puts its LEFT edge at wrapper.left and its CENTER
at wrapper.left + HEAD_W/2 — exactly on the line. */}
<div
className={stickyHead ? "sticky" : "absolute"}
style={{
left: 0,
top: stickyHead ? 0 : 1,
// Zero height keeps it from covering rows (sticky strip trick).
height: stickyHead ? 0 : undefined,
}}
>
<div
style={{
width: HEAD_W,
height: HEAD_H,
borderRadius: 2,
marginTop: 1,
// Outline-only at rest, filled while scrubbing.
background: scrubbing ? color : "transparent",
border: `1.5px solid ${color}`,
boxSizing: "border-box",
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
transform: "rotate(45deg)",
}}
/>
</div>
</>
);
}