mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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.
126 lines
3.9 KiB
TypeScript
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;
|
|
});
|
|
}
|