mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
perf(studio): virtualize timeline clip windows
This commit is contained in:
@@ -6,7 +6,6 @@ import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElement
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { useTimelineZoom } from "./useTimelineZoom";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
@@ -41,8 +40,9 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
||||
import { useTimelineTicks } from "./useTimelineTicks";
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
||||
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
generateTicks,
|
||||
formatTimelineTickLabel,
|
||||
@@ -119,9 +119,8 @@ export const Timeline = memo(function Timeline({
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
||||
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
|
||||
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
|
||||
const hasKeyframedClips = useMemo(
|
||||
() => hasKeyframedTimelineClips(gsapAnimations),
|
||||
[gsapAnimations],
|
||||
@@ -154,7 +153,6 @@ export const Timeline = memo(function Timeline({
|
||||
containerRef.current = el;
|
||||
}, []);
|
||||
|
||||
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
|
||||
const lastScrollLeftRef = useRef(0);
|
||||
|
||||
const effectiveDuration = useMemo(
|
||||
@@ -302,28 +300,46 @@ export const Timeline = memo(function Timeline({
|
||||
toggleSelectedKeyframe,
|
||||
});
|
||||
|
||||
const {
|
||||
pps,
|
||||
fitPps,
|
||||
displayContentWidth,
|
||||
displayDuration,
|
||||
clipStateVersion,
|
||||
zoomModeRef,
|
||||
manualZoomPercentRef,
|
||||
} = useTimelineGeometry({
|
||||
viewportWidth: viewport.clientWidth,
|
||||
effectiveDuration,
|
||||
zoomMode,
|
||||
manualZoomPercent,
|
||||
ppsRef,
|
||||
fitPpsRef,
|
||||
draggedClip,
|
||||
resizingClip,
|
||||
expandedElements,
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
|
||||
useTimelineGeometry({
|
||||
viewportWidth: viewport.clientWidth,
|
||||
effectiveDuration,
|
||||
zoomMode,
|
||||
manualZoomPercent,
|
||||
ppsRef,
|
||||
fitPpsRef,
|
||||
draggedClip,
|
||||
resizingClip,
|
||||
expandedElements,
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
contentOrigin,
|
||||
});
|
||||
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
|
||||
tracks,
|
||||
viewport,
|
||||
pixelsPerSecond: pps,
|
||||
contentOrigin,
|
||||
duration: displayDuration,
|
||||
selectedElementId: selectedElementId ?? undefined,
|
||||
draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id,
|
||||
resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id,
|
||||
revealElementId: clipRevealRequest?.elementId,
|
||||
focusedEaseElementId: focusedEaseSegment?.elementId,
|
||||
clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id,
|
||||
keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id,
|
||||
scrollRef,
|
||||
elements: expandedElements,
|
||||
rowGeometry: displayLayout.rowGeometry,
|
||||
allowHorizontalReveal: zoomMode === "manual",
|
||||
sessionEpoch,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion: renderTimeRange,
|
||||
elementStateVersion: expandedElements,
|
||||
});
|
||||
|
||||
const laneGapStrips = useTimelineGapHighlights({
|
||||
@@ -358,12 +374,6 @@ export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
contentOrigin,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
});
|
||||
|
||||
const {
|
||||
rangeSelection,
|
||||
setRangeSelection,
|
||||
@@ -396,8 +406,12 @@ export const Timeline = memo(function Timeline({
|
||||
setRangeSelection(null),
|
||||
);
|
||||
|
||||
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
|
||||
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
|
||||
const { major, minor, majorTickInterval } = useTimelineTicks(
|
||||
displayDuration,
|
||||
pps,
|
||||
timeDisplayMode,
|
||||
rowVirtualizationActive ? renderTimeRange : undefined,
|
||||
);
|
||||
|
||||
const getPreviewElement = useCallback(
|
||||
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
||||
@@ -488,6 +502,9 @@ export const Timeline = memo(function Timeline({
|
||||
rowGeometry={displayLayout.rowGeometry}
|
||||
virtualRows={virtualRows}
|
||||
rowsVirtualized={rowVirtualizationActive}
|
||||
clipIndex={clipIndex}
|
||||
renderTimeRange={renderTimeRange}
|
||||
pinnedClipIdentities={pinnedClipIdentities}
|
||||
trackOrder={trackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={trackStyles}
|
||||
|
||||
@@ -101,4 +101,73 @@ describe("Timeline row virtualization", () => {
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
import("../lib/timelineViewportBudgets"),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
duration: 1_000,
|
||||
timelineReady: true,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 2_000,
|
||||
selectedElementId: "clip-490",
|
||||
selectedElementIds: new Set(["clip-490"]),
|
||||
elements: Array.from({ length: 500 }, (_, index) => ({
|
||||
id: `clip-${index}`,
|
||||
tag: "div",
|
||||
start: index * 2,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
|
||||
await act(async () => {});
|
||||
|
||||
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
||||
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
|
||||
expect(initialClips.length).toBeGreaterThan(1);
|
||||
expect(initialClips.length).toBeLessThanOrEqual(
|
||||
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
||||
);
|
||||
expect(initialGridCells.length).toBeLessThan(100);
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
|
||||
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
expect(scroller).not.toBeNull();
|
||||
if (scroller) {
|
||||
scroller.scrollLeft = 8_000;
|
||||
await act(async () => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
|
||||
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
|
||||
expect(scrolledClips.length).toBeLessThanOrEqual(
|
||||
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
|
||||
);
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
|
||||
|
||||
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
|
||||
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineLanes } from "./TimelineLanes";
|
||||
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
|
||||
interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
major: number[];
|
||||
@@ -57,8 +57,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
||||
useTimelineRevealClip(scrollRef);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
@@ -121,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
theme={props.theme}
|
||||
beatAnalysis={props.beatAnalysis}
|
||||
contentOrigin={props.contentOrigin}
|
||||
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
||||
/>
|
||||
|
||||
{/* Breathing room between the sticky ruler and the first track lane — the
|
||||
@@ -152,7 +151,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
const rowIndex = displayTrackOrder.indexOf(strip.track);
|
||||
if (rowIndex < 0) return null;
|
||||
const loud = strip.kind === "hover";
|
||||
return strip.intervals.map((gap) => (
|
||||
const visibleIntervals = props.rowsVirtualized
|
||||
? strip.intervals.filter(
|
||||
(gap) =>
|
||||
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
|
||||
)
|
||||
: strip.intervals;
|
||||
return visibleIntervals.map((gap) => (
|
||||
<div
|
||||
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
||||
className="pointer-events-none absolute"
|
||||
@@ -244,6 +249,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
|
||||
hasCustomContent={!!props.renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={props.theme}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TimelineClipProps {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
isActive?: boolean;
|
||||
hasCustomContent: boolean;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
theme?: TimelineTheme;
|
||||
@@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
isActive = false,
|
||||
hasCustomContent,
|
||||
capabilities,
|
||||
theme = defaultTimelineTheme,
|
||||
@@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
data-clip-start={el.start}
|
||||
data-clip-end={el.start + el.duration}
|
||||
data-clip-hidden={el.hidden ? "true" : undefined}
|
||||
data-active={isActive ? "" : undefined}
|
||||
tabIndex={-1}
|
||||
className={clipClassName}
|
||||
style={style}
|
||||
title={
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
||||
|
||||
@@ -21,6 +22,9 @@ export interface TimelineLaneBaseProps {
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
virtualRows: readonly TimelineVirtualRow[];
|
||||
rowsVirtualized: boolean;
|
||||
clipIndex: TimelineClipIndex;
|
||||
renderTimeRange: TimelineTimeRange;
|
||||
pinnedClipIdentities: ReadonlySet<string>;
|
||||
trackOrder: number[];
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
|
||||
@@ -20,6 +20,8 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
|
||||
import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
|
||||
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
||||
@@ -43,6 +45,9 @@ export function TimelineLanes({
|
||||
rowGeometry,
|
||||
virtualRows,
|
||||
rowsVirtualized,
|
||||
clipIndex,
|
||||
renderTimeRange,
|
||||
pinnedClipIdentities,
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
@@ -110,6 +115,29 @@ export function TimelineLanes({
|
||||
if (trackNum === undefined) return null;
|
||||
const rowHeight = rowGeometry.getRowHeight(row);
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const indexedRenderElements = rowsVirtualized
|
||||
? queryTimelineClipIndex(clipIndex, trackNum, renderTimeRange, pinnedClipIdentities)
|
||||
: els;
|
||||
const indexedRenderSet = new Set(indexedRenderElements);
|
||||
const renderElements = rowsVirtualized
|
||||
? els.filter((element) => {
|
||||
if (indexedRenderSet.has(element)) return true;
|
||||
if (
|
||||
!multiDragPreview ||
|
||||
!isMultiDragPassenger(element.key ?? element.id, multiDragPreview)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const previewStart =
|
||||
element.start +
|
||||
multiDragPassengerOffsetPx(element.key ?? element.id, pps, multiDragPreview) /
|
||||
pps;
|
||||
const previewEnd = previewStart + Math.max(0, element.duration);
|
||||
return previewEnd <= previewStart
|
||||
? previewStart >= renderTimeRange.start && previewStart < renderTimeRange.end
|
||||
: previewStart < renderTimeRange.end && previewEnd > renderTimeRange.start;
|
||||
})
|
||||
: els;
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
@@ -202,6 +230,7 @@ export function TimelineLanes({
|
||||
? draggedClip.snapTime
|
||||
: null
|
||||
}
|
||||
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
||||
/>
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
@@ -210,6 +239,7 @@ export function TimelineLanes({
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
@@ -229,7 +259,7 @@ export function TimelineLanes({
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
els.map((el) => {
|
||||
renderElements.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
// Only the track's active keyframe clip shows expanded lanes;
|
||||
@@ -276,6 +306,7 @@ export function TimelineLanes({
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
isActive={isTimelineClipActive(previewElement, currentTime)}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
|
||||
@@ -1,113 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
|
||||
import { liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
isTimelineClipActive,
|
||||
updateTimelineActiveClipClasses,
|
||||
useTimelineActiveClips,
|
||||
} from "./useTimelineActiveClips";
|
||||
|
||||
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
||||
const clip = document.createElement("div");
|
||||
clip.dataset.clip = "true";
|
||||
clip.dataset.elId = id;
|
||||
clip.dataset.clipStart = start;
|
||||
clip.dataset.clipEnd = end;
|
||||
container.append(clip);
|
||||
return clip;
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function clip(id: string, start: number, duration: number, hidden = false): TimelineElement {
|
||||
return { id, tag: "div", start, duration, track: 1, hidden };
|
||||
}
|
||||
|
||||
describe("updateTimelineActiveClipClasses", () => {
|
||||
it("toggles data-active only for clips containing the current time", () => {
|
||||
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.dataset.clip = "true";
|
||||
element.dataset.elId = id;
|
||||
element.dataset.clipStart = start;
|
||||
element.dataset.clipEnd = end;
|
||||
container.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function Harness({ version, heroStart = 2 }: { version: number; heroStart?: number }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime: 0,
|
||||
clipStateVersion: 0,
|
||||
elementStateVersion: version,
|
||||
});
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ref: scrollRef },
|
||||
React.createElement("div", {
|
||||
"data-clip": "true",
|
||||
"data-el-id": "intro",
|
||||
"data-clip-start": "0",
|
||||
"data-clip-end": "1",
|
||||
}),
|
||||
React.createElement("div", {
|
||||
"data-clip": "true",
|
||||
"data-el-id": "hero",
|
||||
"data-clip-start": String(heroStart),
|
||||
"data-clip-end": String(heroStart + 1),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("timeline active clips", () => {
|
||||
it("uses model timing and keeps the end boundary inclusive", () => {
|
||||
const element = clip("hero", 2, 3);
|
||||
expect(isTimelineClipActive(element, 2)).toBe(true);
|
||||
expect(isTimelineClipActive(element, 5)).toBe(true);
|
||||
expect(isTimelineClipActive(element, 5.001)).toBe(false);
|
||||
});
|
||||
|
||||
it("never activates hidden or invalid clips", () => {
|
||||
expect(isTimelineClipActive(clip("hidden", 0, 5, true), 2)).toBe(false);
|
||||
expect(isTimelineClipActive(clip("invalid", Number.NaN, 5), 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("synchronizes mounted clip attributes", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const outro = appendClip(container, "outro", "5", "8");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.25);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(outro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
});
|
||||
|
||||
it("never marks hidden clips active inside their time window", () => {
|
||||
const container = document.createElement("div");
|
||||
const hidden = appendClip(container, "hidden", "0", "5");
|
||||
const visible = appendClip(container, "visible", "0", "5");
|
||||
hidden.dataset.clipHidden = "true";
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(hidden.hasAttribute("data-active")).toBe(false);
|
||||
expect(visible.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["visible"]));
|
||||
});
|
||||
|
||||
it("diffs against the previous active set", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const previous = new Set(["intro"]);
|
||||
intro.toggleAttribute("data-active", true);
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro", "hero"]));
|
||||
});
|
||||
|
||||
it("keeps a clip active through its inclusive end boundary", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 0);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.001);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => {
|
||||
// A clip that moves lanes on a reorder remounts as a new element. It stays
|
||||
// in the previous active set, so the plain diff would skip it and leave the
|
||||
// new node without data-active. syncAll must force the attribute on.
|
||||
it("re-applies active state when a clip remounts inside the current window", () => {
|
||||
const container = document.createElement("div");
|
||||
appendClip(container, "hero", "0", "5");
|
||||
const previous = new Set<string>();
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
|
||||
// Simulate a remount: replace the hero clip's DOM node (no data-active).
|
||||
container.replaceChildren();
|
||||
const heroReborn = appendClip(container, "hero", "0", "5");
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
// Diff-only would skip it (still active → unchanged); syncAll re-applies.
|
||||
const remounted = appendClip(container, "hero", "0", "5");
|
||||
updateTimelineActiveClipClasses(container, previous, 2, true);
|
||||
expect(heroReborn.hasAttribute("data-active")).toBe(true);
|
||||
|
||||
expect(remounted.hasAttribute("data-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores clips with invalid timing data", () => {
|
||||
const container = document.createElement("div");
|
||||
const missingId = appendClip(container, "", "0", "2");
|
||||
const missingTiming = appendClip(container, "bad", "", "2");
|
||||
const previous = new Set<string>();
|
||||
it("moves active state with live playback without changing store time", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => root.render(React.createElement(Harness, { version: 0 })));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 1);
|
||||
const intro = host.querySelector<HTMLElement>('[data-el-id="intro"]');
|
||||
const hero = host.querySelector<HTMLElement>('[data-el-id="hero"]');
|
||||
expect(intro?.hasAttribute("data-active")).toBe(true);
|
||||
expect(hero?.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
expect(missingId.hasAttribute("data-active")).toBe(false);
|
||||
expect(missingTiming.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
act(() => liveTime.notify(2.5));
|
||||
expect(intro?.hasAttribute("data-active")).toBe(false);
|
||||
expect(hero?.hasAttribute("data-active")).toBe(true);
|
||||
|
||||
await act(async () => root.render(React.createElement(Harness, { version: 1, heroStart: 4 })));
|
||||
act(() => liveTime.notify(2.5));
|
||||
expect(host.querySelector('[data-el-id="hero"]')?.hasAttribute("data-active")).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { liveTime } from "../store/playerStore";
|
||||
import { liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface ActiveClipRecord {
|
||||
@@ -14,6 +14,33 @@ interface UseTimelineActiveClipsInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
currentTime: number;
|
||||
clipStateVersion: unknown;
|
||||
elementStateVersion: unknown;
|
||||
}
|
||||
|
||||
function isTimelineIntervalActive(
|
||||
interval: Pick<ActiveClipRecord, "start" | "end" | "hidden">,
|
||||
time: number,
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(time) &&
|
||||
!interval.hidden &&
|
||||
Number.isFinite(interval.start) &&
|
||||
Number.isFinite(interval.end) &&
|
||||
time >= interval.start &&
|
||||
time <= interval.end
|
||||
);
|
||||
}
|
||||
|
||||
/** Model-first active state. Rendered nodes receive this on their first mount. */
|
||||
export function isTimelineClipActive(element: TimelineElement, time: number): boolean {
|
||||
return isTimelineIntervalActive(
|
||||
{
|
||||
start: element.start,
|
||||
end: element.start + Math.max(0, element.duration),
|
||||
hidden: element.hidden === true,
|
||||
},
|
||||
time,
|
||||
);
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: string | undefined): number | null {
|
||||
@@ -27,66 +54,37 @@ function readClipRecord(element: Element): ActiveClipRecord | null {
|
||||
const id = element.dataset.elId;
|
||||
const start = readFiniteNumber(element.dataset.clipStart);
|
||||
const end = readFiniteNumber(element.dataset.clipEnd);
|
||||
const hidden = element.dataset.clipHidden === "true";
|
||||
if (!id || start === null || end === null) return null;
|
||||
return { id, start, end, hidden, element };
|
||||
return { id, start, end, hidden: element.dataset.clipHidden === "true", element };
|
||||
}
|
||||
|
||||
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
|
||||
const records: ActiveClipRecord[] = [];
|
||||
for (const element of container.querySelectorAll('[data-clip="true"]')) {
|
||||
const record = readClipRecord(element);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
|
||||
const recordsById = new Map<string, ActiveClipRecord>();
|
||||
for (const record of records) recordsById.set(record.id, record);
|
||||
return recordsById;
|
||||
return [...container.querySelectorAll('[data-clip="true"]')]
|
||||
.map(readClipRecord)
|
||||
.filter((record): record is ActiveClipRecord => record !== null);
|
||||
}
|
||||
|
||||
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
|
||||
const next = new Set<string>();
|
||||
if (!Number.isFinite(time)) return next;
|
||||
for (const record of records) {
|
||||
if (record.hidden) continue;
|
||||
if (time >= record.start && time <= record.end) next.add(record.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) return false;
|
||||
}
|
||||
return true;
|
||||
return new Set(
|
||||
records.filter((record) => isTimelineIntervalActive(record, time)).map((record) => record.id),
|
||||
);
|
||||
}
|
||||
|
||||
function applyActiveClipDiff(
|
||||
records: ActiveClipRecord[],
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
// Force every record's attribute to match its active state instead of only
|
||||
// touching clips whose active-state changed. Required whenever `records` were
|
||||
// freshly re-queried after a render: a clip that stayed active but got a new
|
||||
// DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
|
||||
// diff and render without `data-active` despite still being at the playhead.
|
||||
syncAll = false,
|
||||
) {
|
||||
): void {
|
||||
const next = getActiveClipIds(records, time);
|
||||
const changed = !setsMatch(previous, next);
|
||||
for (const record of records) {
|
||||
const wasActive = previous.has(record.id);
|
||||
const isActive = next.has(record.id);
|
||||
if (!syncAll && wasActive === isActive) continue;
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
if (syncAll || previous.has(record.id) !== isActive) {
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
}
|
||||
}
|
||||
previous.clear();
|
||||
for (const id of next) previous.add(id);
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function updateTimelineActiveClipClasses(
|
||||
@@ -94,30 +92,28 @@ export function updateTimelineActiveClipClasses(
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
syncAll = false,
|
||||
) {
|
||||
): void {
|
||||
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
|
||||
}
|
||||
|
||||
/** Keeps the currently mounted clip window synchronized with the RAF playback clock. */
|
||||
export function useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
}: UseTimelineActiveClipsInput) {
|
||||
elementStateVersion,
|
||||
}: UseTimelineActiveClipsInput): void {
|
||||
const recordsRef = useRef<ActiveClipRecord[]>([]);
|
||||
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
|
||||
const previousActiveIdsRef = useRef(new Set<string>());
|
||||
|
||||
const refreshRecords = useCallback(
|
||||
(time: number) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) {
|
||||
recordsRef.current = [];
|
||||
recordsByIdRef.current.clear();
|
||||
previousActiveIdsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
recordsRef.current = collectTimelineClipRecords(scroll);
|
||||
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
|
||||
},
|
||||
[scrollRef],
|
||||
@@ -125,12 +121,10 @@ export function useTimelineActiveClips({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
refreshRecords(currentTime);
|
||||
}, [currentTime, clipStateVersion, refreshRecords]);
|
||||
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((time) => {
|
||||
}, [clipStateVersion, currentTime, elementStateVersion, refreshRecords]);
|
||||
useMountEffect(() =>
|
||||
liveTime.subscribe((time) => {
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo, type RefObject } from "react";
|
||||
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
|
||||
interface UseTimelineClipRenderWindowInput {
|
||||
tracks: Parameters<typeof createTimelineClipIndex>[0];
|
||||
viewport: TimelineScrollViewportSnapshot;
|
||||
pixelsPerSecond: number;
|
||||
contentOrigin: number;
|
||||
duration: number;
|
||||
selectedElementId?: string;
|
||||
draggedElementId?: string;
|
||||
resizingElementId?: string;
|
||||
revealElementId?: string;
|
||||
focusedEaseElementId?: string;
|
||||
clipContextMenuElementId?: string;
|
||||
keyframeContextMenuElementId?: string;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
elements: readonly TimelineElement[];
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
allowHorizontalReveal: boolean;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
export function useTimelineClipRenderWindow({
|
||||
tracks,
|
||||
viewport,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
duration,
|
||||
selectedElementId,
|
||||
draggedElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
focusedEaseElementId,
|
||||
clipContextMenuElementId,
|
||||
keyframeContextMenuElementId,
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
allowHorizontalReveal,
|
||||
sessionEpoch,
|
||||
}: UseTimelineClipRenderWindowInput) {
|
||||
const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
|
||||
const renderTimeRange = useMemo(
|
||||
() => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
|
||||
[contentOrigin, duration, pixelsPerSecond, viewport],
|
||||
);
|
||||
const pinnedClipIdentities = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[
|
||||
selectedElementId,
|
||||
draggedElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
focusedEaseElementId,
|
||||
clipContextMenuElementId,
|
||||
keyframeContextMenuElementId,
|
||||
].filter((identity): identity is string => identity !== undefined),
|
||||
),
|
||||
[
|
||||
clipContextMenuElementId,
|
||||
draggedElementId,
|
||||
focusedEaseElementId,
|
||||
keyframeContextMenuElementId,
|
||||
resizingElementId,
|
||||
revealElementId,
|
||||
selectedElementId,
|
||||
],
|
||||
);
|
||||
useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal: allowHorizontalReveal,
|
||||
viewportVersion: viewport,
|
||||
sessionEpoch,
|
||||
});
|
||||
return { clipIndex, renderTimeRange, pinnedClipIdentities };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { createTimelineRowGeometry } from "./timelineLayout";
|
||||
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const element: TimelineElement = {
|
||||
id: "hero",
|
||||
tag: "div",
|
||||
start: 20,
|
||||
duration: 2,
|
||||
track: 1,
|
||||
};
|
||||
const geometry = createTimelineRowGeometry([1], [48]);
|
||||
|
||||
function Harness({ mounted, version }: { mounted: boolean; version: number }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements: [element],
|
||||
rowGeometry: geometry,
|
||||
pixelsPerSecond: 100,
|
||||
contentOrigin: 32,
|
||||
allowHorizontal: true,
|
||||
viewportVersion: version,
|
||||
sessionEpoch: 1,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
scrollRef.current = node;
|
||||
if (node) {
|
||||
Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
|
||||
Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mounted && <div data-el-id="hero" tabIndex={-1} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("useTimelineRevealClip", () => {
|
||||
it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
usePlayerStore.getState().requestClipReveal("hero");
|
||||
|
||||
await act(async () => root.render(<Harness mounted={false} version={0} />));
|
||||
const scroll = host.firstElementChild as HTMLDivElement;
|
||||
expect(scroll.scrollLeft).toBe(1_944);
|
||||
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
|
||||
|
||||
await act(async () => root.render(<Harness mounted version={1} />));
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
|
||||
|
||||
scroll.scrollLeft = 0;
|
||||
await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
|
||||
await act(async () => root.render(<Harness mounted version={2} />));
|
||||
expect(scroll.scrollLeft).toBe(1_944);
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("consumes an invalid target without scrolling", async () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
usePlayerStore.getState().requestClipReveal("missing");
|
||||
await act(async () => root.render(<Harness mounted={false} version={0} />));
|
||||
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,144 @@
|
||||
/**
|
||||
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
|
||||
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
|
||||
* timeline's scroll container so that clip is visible — horizontally to its
|
||||
* time and vertically to its lane.
|
||||
*
|
||||
* The request is consumed (cleared) whether or not the clip node is found, so
|
||||
* a stale request can never replay a scroll later. Respects zoom mode: in
|
||||
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
|
||||
* only the vertical axis is scrolled there.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, RULER_H } from "./timelineLayout";
|
||||
import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
|
||||
import { computeRevealScroll } from "./timelineRevealScroll";
|
||||
|
||||
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
|
||||
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
||||
interface UseTimelineRevealClipInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
elements: readonly TimelineElement[];
|
||||
rowGeometry: TimelineRowGeometry;
|
||||
pixelsPerSecond: number;
|
||||
contentOrigin: number;
|
||||
allowHorizontal: boolean;
|
||||
viewportVersion: unknown;
|
||||
sessionEpoch: number;
|
||||
}
|
||||
|
||||
function escapeSelectorValue(value: string): string {
|
||||
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
||||
? CSS.escape(value)
|
||||
: value.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function scrollToTimelineElement(
|
||||
container: HTMLDivElement,
|
||||
element: TimelineElement,
|
||||
row: number,
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
allowHorizontal: boolean,
|
||||
): void {
|
||||
const clipLeft = contentOrigin + element.start * pixelsPerSecond;
|
||||
const target = computeRevealScroll({
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
viewportWidth: container.clientWidth,
|
||||
viewportHeight: container.clientHeight,
|
||||
clipLeft,
|
||||
clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
|
||||
clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
|
||||
clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
|
||||
stickyLeft: contentOrigin,
|
||||
stickyTop: RULER_H,
|
||||
allowHorizontal,
|
||||
});
|
||||
if (target.left !== null) container.scrollLeft = target.left;
|
||||
if (target.top !== null) container.scrollTop = target.top;
|
||||
if (target.left !== null || target.top !== null) container.dispatchEvent(new Event("scroll"));
|
||||
}
|
||||
|
||||
function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
|
||||
const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
|
||||
if (!(clip instanceof HTMLElement)) return false;
|
||||
clip.setAttribute("data-reveal-highlight", "true");
|
||||
clip.focus({ preventScroll: true });
|
||||
if (document.activeElement !== clip) {
|
||||
clip.removeAttribute("data-reveal-highlight");
|
||||
return false;
|
||||
}
|
||||
clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
|
||||
once: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveRevealTarget(
|
||||
elements: readonly TimelineElement[],
|
||||
rowGeometry: TimelineRowGeometry,
|
||||
elementId: string,
|
||||
): { element: TimelineElement; row: number } | null {
|
||||
const element = elements.find((candidate) => (candidate.key ?? candidate.id) === elementId);
|
||||
if (!element) return null;
|
||||
const row = rowGeometry.getRowIndex(element.track);
|
||||
return row < 0 ? null : { element, row };
|
||||
}
|
||||
|
||||
function shouldScrollReveal(
|
||||
previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
|
||||
request: { elementId: string; nonce: number },
|
||||
sessionEpoch: number,
|
||||
): boolean {
|
||||
return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
|
||||
}
|
||||
|
||||
/** Coordinate-first reveal; the request remains pinned until its clip mounts. */
|
||||
export function useTimelineRevealClip({
|
||||
scrollRef,
|
||||
elements,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal,
|
||||
viewportVersion,
|
||||
sessionEpoch,
|
||||
}: UseTimelineRevealClipInput): void {
|
||||
const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
|
||||
const scrolledRequestRef = useRef<{
|
||||
request: { elementId: string; nonce: number };
|
||||
sessionEpoch: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealRequest) return;
|
||||
// Consume the request first — reveal is one-shot, even when the clip node
|
||||
// isn't currently rendered (e.g. drilled into a different composition).
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
|
||||
if (!revealRequest) {
|
||||
scrolledRequestRef.current = null;
|
||||
return;
|
||||
}
|
||||
const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
|
||||
if (!target) {
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
return;
|
||||
}
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
|
||||
if (!(clip instanceof HTMLElement)) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const clipRect = clip.getBoundingClientRect();
|
||||
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
|
||||
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
|
||||
if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
|
||||
scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
|
||||
scrollToTimelineElement(
|
||||
container,
|
||||
target.element,
|
||||
target.row,
|
||||
rowGeometry,
|
||||
pixelsPerSecond,
|
||||
contentOrigin,
|
||||
allowHorizontal,
|
||||
);
|
||||
}
|
||||
|
||||
const target = computeRevealScroll({
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
viewportWidth: container.clientWidth,
|
||||
viewportHeight: container.clientHeight,
|
||||
clipLeft,
|
||||
clipRight: clipLeft + clipRect.width,
|
||||
clipTop,
|
||||
clipBottom: clipTop + clipRect.height,
|
||||
stickyLeft: GUTTER,
|
||||
stickyTop: RULER_H,
|
||||
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
|
||||
});
|
||||
if (target.left === null && target.top === null) return;
|
||||
container.scrollTo({
|
||||
left: target.left ?? container.scrollLeft,
|
||||
top: target.top ?? container.scrollTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [revealRequest, scrollRef]);
|
||||
if (!focusRevealedElement(container, revealRequest.elementId)) return;
|
||||
if (usePlayerStore.getState().clipRevealRequest === revealRequest) {
|
||||
usePlayerStore.getState().clearClipRevealRequest();
|
||||
}
|
||||
}, [
|
||||
allowHorizontal,
|
||||
contentOrigin,
|
||||
elements,
|
||||
pixelsPerSecond,
|
||||
revealRequest,
|
||||
rowGeometry,
|
||||
scrollRef,
|
||||
sessionEpoch,
|
||||
viewportVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { generateTicks } from "./timelineLayout";
|
||||
import { generateTicks, getTimelineMajorTickInterval } from "./timelineLayout";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
export function useTimelineTicks(
|
||||
duration: number,
|
||||
pixelsPerSecond: number,
|
||||
timeDisplayMode: "time" | "frame",
|
||||
): { major: number[]; minor: number[] } {
|
||||
renderTimeRange?: TimelineTimeRange,
|
||||
) {
|
||||
const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
|
||||
const { major, minor } = useMemo(
|
||||
() => generateTicks(duration, pixelsPerSecond, frameRate),
|
||||
[duration, frameRate, pixelsPerSecond],
|
||||
const ticks = useMemo(
|
||||
() => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
|
||||
[duration, frameRate, pixelsPerSecond, renderTimeRange],
|
||||
);
|
||||
return { major, minor };
|
||||
return {
|
||||
...ticks,
|
||||
majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user