Files
hyperframes/packages/studio/src/player/components/useTimelineActiveClips.ts
T
Miguel Ángel 037266e72b feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling

WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload

Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
2026-07-07 04:26:56 -04:00

126 lines
3.9 KiB
TypeScript

import { useCallback, useLayoutEffect, useRef } from "react";
import { liveTime } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
interface ActiveClipRecord {
id: string;
start: number;
end: number;
hidden: boolean;
element: HTMLElement;
}
interface UseTimelineActiveClipsInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
currentTime: number;
clipStateVersion: string;
}
function readFiniteNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function readClipRecord(element: Element): ActiveClipRecord | null {
if (!(element instanceof HTMLElement)) return 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 };
}
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;
}
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;
}
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
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 (wasActive === isActive) continue;
record.element.toggleAttribute("data-active", isActive);
}
previous.clear();
for (const id of next) previous.add(id);
return changed;
}
export function updateTimelineActiveClipClasses(
container: HTMLElement,
previous: Set<string>,
time: number,
) {
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time);
}
export function useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
}: UseTimelineActiveClipsInput) {
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);
},
[scrollRef],
);
useLayoutEffect(() => {
refreshRecords(currentTime);
}, [currentTime, clipStateVersion, refreshRecords]);
useMountEffect(() => {
const unsub = liveTime.subscribe((time) => {
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
});
return unsub;
});
}