mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): group rows in the timeline, and a split disclosure
A group renders as its own row with member rows beneath it, and disclosure
splits into two independent controls: caret shows/hides a group's member
rows (structural), `∿` shows/hides any row's automation-lane rows. Plain
tracks lose their caret (nothing to disclose structurally) and keep only
`∿`. `expandedClipIds` keeps its existing keyframe-lane-state job;
`expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets.
Groups get a real position in the row/geometry pipeline rather than a
visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member
tracks contiguously under a synthetic fractional anchor key
(firstMember - 0.5, the same fractional-key convention sub-composition
expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat
a group row as a first-class row without widening their key type away from
number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group /
member-under-group / lane), lanes always `owner.level + 1`.
All of it — grouped row emission, the header, the new expansion state — is
gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves
empty and every new code path no-ops. `TimelineElement.audioGroup` (+
`audioGroupLabel`, resolved once per document via `resolveAudioGroups` from
B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already
flow DOM → manifest → TimelineElement — inert without the canary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e42185582b
commit
dd3212d11b
@@ -4,6 +4,8 @@ import { resolveBeatSourceTrack } from "../utils/timelineInspector";
|
||||
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
|
||||
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
|
||||
import { mergeUserBeats } from "../utils/beatEditing";
|
||||
import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
|
||||
import { remapBeatAnalysisToComposition } from "../utils/beatEditActions";
|
||||
import {
|
||||
audioRelPathForSrc,
|
||||
beatFilePathForSrc,
|
||||
@@ -90,6 +92,17 @@ async function loadBeatAnalysis(
|
||||
}
|
||||
}
|
||||
|
||||
/** The current beat analysis, remapped onto the composition's edited beat grid. */
|
||||
export function useAdjustedBeatAnalysis() {
|
||||
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
||||
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
return useMemo(
|
||||
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
||||
[beatAnalysis, musicElement, beatEdits],
|
||||
);
|
||||
}
|
||||
|
||||
export function useMusicBeatAnalysis(): void {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CaretRight } from "@phosphor-icons/react";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
|
||||
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
|
||||
// here, not on the clip bar, and re-expands a collapsed layer.
|
||||
// Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
|
||||
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
|
||||
// caret) because a group's own row keeps the caret for its structural
|
||||
// disclosure (member rows) — this button only ever means "show this row's
|
||||
// lanes", so it needs its own distinct glyph.
|
||||
export function LayerDisclosureRow({
|
||||
name,
|
||||
clipCount,
|
||||
@@ -49,23 +51,20 @@ export function LayerDisclosureRow({
|
||||
tabIndex={-1}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={lanesId}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
||||
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
|
||||
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
||||
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
||||
// only the hit box grows.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
||||
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleClipExpanded();
|
||||
}}
|
||||
>
|
||||
<CaretRight
|
||||
size={11}
|
||||
weight="bold"
|
||||
aria-hidden="true"
|
||||
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
||||
/>
|
||||
<span aria-hidden="true">∿</span>
|
||||
</button>
|
||||
{/* Decorative: the disclosure button above already names the row's keyframe
|
||||
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
||||
|
||||
@@ -556,11 +556,11 @@ describe("Timeline provider boundary", () => {
|
||||
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
|
||||
// lives in the left column. clip-2 has no keyframes so it never shows one.
|
||||
const collapseButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Collapse clip-1 keyframes"]',
|
||||
'button[aria-label="Hide clip-1 lanes"]',
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();
|
||||
|
||||
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
|
||||
const row = clip?.parentElement?.parentElement;
|
||||
@@ -571,7 +571,7 @@ describe("Timeline provider boundary", () => {
|
||||
expectTrackExpansion(row, [], TRACK_H);
|
||||
|
||||
const expandButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand clip-1 keyframes"]',
|
||||
'button[aria-label="Show clip-1 lanes"]',
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
act(() => expandButton?.click());
|
||||
@@ -603,8 +603,8 @@ describe("Timeline provider boundary", () => {
|
||||
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
|
||||
?.parentElement;
|
||||
// A row of several clips is named for the track, so the caret is too.
|
||||
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]');
|
||||
expect(caret()?.getAttribute("aria-label")).toBe("Expand Track 1 keyframes");
|
||||
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
|
||||
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");
|
||||
|
||||
act(() => caret()?.click());
|
||||
// One shared volume row, and BOTH clips hold it open.
|
||||
@@ -647,7 +647,7 @@ describe("Timeline provider boundary", () => {
|
||||
});
|
||||
const root = createRoot(host);
|
||||
act(() => root.render(React.createElement(Timeline)));
|
||||
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]')?.click());
|
||||
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
|
||||
|
||||
const before = [...host.querySelectorAll(".hf-automation-lane")];
|
||||
expect(before).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
||||
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
||||
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
||||
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
||||
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
||||
import { useTimelineTicks } from "./useTimelineTicks";
|
||||
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
@@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
|
||||
useMusicBeatAnalysis();
|
||||
const rawElements = usePlayerStore((s) => s.elements);
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
||||
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
||||
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
||||
const adjustedBeatAnalysis = useMemo(
|
||||
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
||||
[beatAnalysis, musicElement, beatEdits],
|
||||
);
|
||||
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
@@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
|
||||
laneCounts,
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
} = useTimelineTrackLayout(
|
||||
expandedElements,
|
||||
gsapAnimations,
|
||||
@@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
elements: expandedElements,
|
||||
pixelsPerSecond: pps,
|
||||
@@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
|
||||
trackOrder={trackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={trackStyles}
|
||||
groups={groups}
|
||||
laneCounts={laneCounts}
|
||||
selectedElementId={selectedElementId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
|
||||
interface TimelineGroupHeaderProps {
|
||||
label: string;
|
||||
memberCount: number;
|
||||
/** Caret: shows/hides the member rows beneath this group (structural). */
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
/** `∿`: shows/hides the group's own automation-lane rows. */
|
||||
laneCount: number;
|
||||
isLaneOpen: boolean;
|
||||
onToggleLanes: () => void;
|
||||
columnWidth: number;
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's own row header: caret (member disclosure) + `▤` + label + `∿ n`
|
||||
* (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as
|
||||
* siblings once those steps exist — nothing to reserve for them yet.
|
||||
*/
|
||||
export function TimelineGroupHeader({
|
||||
label,
|
||||
memberCount,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
laneCount,
|
||||
isLaneOpen,
|
||||
onToggleLanes,
|
||||
columnWidth,
|
||||
theme,
|
||||
}: TimelineGroupHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
role="rowheader"
|
||||
aria-colindex={1}
|
||||
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
||||
style={{
|
||||
width: columnWidth,
|
||||
height: TRACK_H,
|
||||
color: "#ffffff",
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
|
||||
title={`${isExpanded ? "Hide" : "Show"} tracks`}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
||||
isExpanded ? "text-white" : "text-white/55 hover:text-white"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpanded();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
|
||||
▸
|
||||
</span>
|
||||
</button>
|
||||
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
||||
▤
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
||||
aria-hidden="true"
|
||||
title={`${memberCount} tracks`}
|
||||
>
|
||||
{memberCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-expanded={isLaneOpen}
|
||||
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
|
||||
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
|
||||
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
||||
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleLanes();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">∿</span>
|
||||
{laneCount > 0 && (
|
||||
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import { LABEL_COL_W } from "./timelineLayout";
|
||||
|
||||
interface TimelineGroupRowProps {
|
||||
index: number;
|
||||
rowKey: number;
|
||||
group: TimelineTrackGroupInfo;
|
||||
logicalRow: TimelineLogicalRow;
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
||||
top: number;
|
||||
height: number;
|
||||
virtualized: boolean;
|
||||
contentOrigin: number;
|
||||
theme: TimelineTheme;
|
||||
rovingTargetId?: string | null;
|
||||
expandedGroupIds: ReadonlySet<string>;
|
||||
expandedLaneOwnerIds: ReadonlySet<string>;
|
||||
toggleGroupExpanded: (id: string) => void;
|
||||
toggleLaneOwnerExpanded: (id: string) => void;
|
||||
}
|
||||
|
||||
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
|
||||
export function TimelineGroupRow({
|
||||
index,
|
||||
rowKey,
|
||||
group,
|
||||
logicalRow,
|
||||
tracks,
|
||||
top,
|
||||
height,
|
||||
virtualized,
|
||||
contentOrigin,
|
||||
theme,
|
||||
rovingTargetId = null,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
toggleGroupExpanded,
|
||||
toggleLaneOwnerExpanded,
|
||||
}: TimelineGroupRowProps) {
|
||||
const memberElements = group.memberTracks.flatMap(
|
||||
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
|
||||
);
|
||||
return (
|
||||
<TimelineTrackRow
|
||||
index={index}
|
||||
rowKey={rowKey}
|
||||
logicalRow={logicalRow}
|
||||
propertyRows={[]}
|
||||
lanesId=""
|
||||
headerLanesId=""
|
||||
top={top}
|
||||
height={height}
|
||||
virtualized={virtualized}
|
||||
background={theme.rowBackground}
|
||||
borderColor={theme.rowBorder}
|
||||
rovingTargetId={rovingTargetId}
|
||||
>
|
||||
<TimelineGroupHeader
|
||||
label={group.label}
|
||||
memberCount={group.memberTracks.length}
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||
laneCount={groupAutomationLanes(memberElements).length}
|
||||
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
</TimelineTrackRow>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
||||
selectedElementId: null,
|
||||
selectedElementIds: next.selectedElementIds ?? new Set(),
|
||||
expandedClipIds: new Set(next.expandedClipIds ?? []),
|
||||
expandedGroupIds: new Set(),
|
||||
expandedLaneOwnerIds: new Set(),
|
||||
groups: [],
|
||||
trackGroupOf: new Map(),
|
||||
gsapAnimations,
|
||||
})}
|
||||
clipIndex={createTimelineClipIndex(tracks)}
|
||||
@@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
||||
trackOrder={displayTrackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={new Map()}
|
||||
groups={[]}
|
||||
laneCounts={laneCounts}
|
||||
selectedElementId={null}
|
||||
selectedElementIds={next.selectedElementIds ?? new Set()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useId, useMemo } from "react";
|
||||
import { Fragment, useId } from "react";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
||||
@@ -7,6 +7,8 @@ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
|
||||
import { useAutomationLanes } from "./useAutomationLanes";
|
||||
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { TimelineGroupRow } from "./TimelineGroupRow";
|
||||
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
|
||||
import {
|
||||
isTrackRowExpanded,
|
||||
resolveTrackKeyframeClip,
|
||||
@@ -17,12 +19,8 @@ import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import {
|
||||
isMultiDragActive,
|
||||
isMultiDragPassenger,
|
||||
multiDragDeltaSeconds,
|
||||
multiDragPassengerOffsetPx,
|
||||
} from "./timelineMultiDragPreview";
|
||||
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
||||
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
|
||||
import type { TimelineLanesProps } from "./timelineLaneProps";
|
||||
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
||||
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
||||
@@ -32,7 +30,6 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
||||
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
||||
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
||||
|
||||
@@ -55,6 +52,7 @@ export function TimelineLanes({
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
groups,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
@@ -102,20 +100,14 @@ export function TimelineLanes({
|
||||
// from resolving into a second timeline that renders the same logical rows.
|
||||
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
||||
useTimelineGroupDisclosure();
|
||||
const automationLanes = useAutomationLanes();
|
||||
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
||||
const expandClips = usePlayerStore((s) => s.expandClips);
|
||||
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
||||
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
||||
const logicalRowsByTrack = useMemo(() => {
|
||||
const byTrack = new Map<number, TimelineLogicalRow[]>();
|
||||
for (const logicalRow of logicalRows) {
|
||||
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
|
||||
trackRows.push(logicalRow);
|
||||
byTrack.set(logicalRow.physicalTrackKey, trackRows);
|
||||
}
|
||||
return byTrack;
|
||||
}, [logicalRows]);
|
||||
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
|
||||
// The caret belongs to the ROW, so it opens and closes every clip on it at
|
||||
// once. Toggling only the active clip left the row's state depending on which
|
||||
// sibling happened to be selected: expand one, click another, and the row
|
||||
@@ -131,22 +123,11 @@ export function TimelineLanes({
|
||||
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
||||
toggleClipExpanded(key);
|
||||
};
|
||||
const multiDragDelta =
|
||||
multiDragPreview && isMultiDragActive(multiDragPreview)
|
||||
? multiDragDeltaSeconds(multiDragPreview)
|
||||
: 0;
|
||||
const actorWindows =
|
||||
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
||||
? [
|
||||
{
|
||||
range: {
|
||||
start: renderTimeRange.start - multiDragDelta,
|
||||
end: renderTimeRange.end - multiDragDelta,
|
||||
},
|
||||
identities: multiDragPreview.selectedKeys,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const actorWindows = useTimelineMultiDragActorWindows(
|
||||
multiDragPreview,
|
||||
rowsVirtualized,
|
||||
renderTimeRange,
|
||||
);
|
||||
const keyboard = useTimelineKeyboardActor({
|
||||
logicalRows,
|
||||
focusedTargetId,
|
||||
@@ -171,6 +152,31 @@ export function TimelineLanes({
|
||||
virtualRows.map(({ index: row, rowKey }) => {
|
||||
const trackNum = displayTrackOrder[row];
|
||||
if (trackNum === undefined) return null;
|
||||
const group = groupByAnchor.get(trackNum);
|
||||
if (group) {
|
||||
const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
|
||||
if (!groupLogicalRow) return null;
|
||||
return (
|
||||
<TimelineGroupRow
|
||||
key={rowKey}
|
||||
index={row}
|
||||
rowKey={rowKey}
|
||||
group={group}
|
||||
logicalRow={groupLogicalRow}
|
||||
tracks={tracks}
|
||||
top={rowGeometry.getRowTop(row)}
|
||||
height={rowGeometry.getRowHeight(row)}
|
||||
virtualized={rowsVirtualized}
|
||||
contentOrigin={contentOrigin}
|
||||
theme={theme}
|
||||
rovingTargetId={keyboard.rovingTargetId}
|
||||
expandedGroupIds={expandedGroupIds}
|
||||
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
||||
toggleGroupExpanded={toggleGroupExpanded}
|
||||
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
||||
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
|
||||
const logicalRow = trackLogicalRows[0];
|
||||
|
||||
@@ -58,6 +58,10 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
|
||||
selectedElementId: "active",
|
||||
selectedElementIds: new Set(),
|
||||
expandedClipIds: new Set(["active"]),
|
||||
expandedGroupIds: new Set(),
|
||||
expandedLaneOwnerIds: new Set(),
|
||||
groups: [],
|
||||
trackGroupOf: new Map(),
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"active",
|
||||
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
|
||||
selectedElementId: null,
|
||||
selectedElementIds: new Set(),
|
||||
expandedClipIds: new Set(),
|
||||
expandedGroupIds: new Set(),
|
||||
expandedLaneOwnerIds: new Set(),
|
||||
groups: [],
|
||||
trackGroupOf: new Map(),
|
||||
gsapAnimations: new Map(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import {
|
||||
timelineKeyframeSelectionKey,
|
||||
type TimelineKeyframeTarget,
|
||||
@@ -8,11 +9,13 @@ import {
|
||||
import {
|
||||
timelineClipFocusId,
|
||||
timelineEaseFocusId,
|
||||
timelineGroupRowId,
|
||||
timelineKeyframeFocusId,
|
||||
timelinePropertyRowId,
|
||||
timelineTrackRowId,
|
||||
} from "./timelineNavigationIdentity";
|
||||
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
|
||||
export type TimelineNavigationKey =
|
||||
| "ArrowLeft"
|
||||
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
|
||||
kind: "row";
|
||||
physicalTrackKey: number;
|
||||
logicalIndex: number;
|
||||
level: 1 | 2;
|
||||
level: 1 | 2 | 3;
|
||||
parentId: string | null;
|
||||
elementId: string | null;
|
||||
/** Set only on a group's own row (level 1, no clips of its own). */
|
||||
groupId?: string;
|
||||
expandable: boolean;
|
||||
expanded: boolean;
|
||||
propertyGroup?: PropertyGroupName;
|
||||
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
|
||||
|
||||
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
|
||||
|
||||
interface BuildTimelineLogicalRowsInput {
|
||||
export interface BuildTimelineLogicalRowsInput {
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
||||
displayTrackOrder: readonly number[];
|
||||
laneCounts: ReadonlyMap<string, number>;
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: ReadonlySet<string>;
|
||||
expandedClipIds: ReadonlySet<string>;
|
||||
/** Groups whose member rows the caret has shown (structural, not lanes). */
|
||||
expandedGroupIds: ReadonlySet<string>;
|
||||
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
|
||||
expandedLaneOwnerIds: ReadonlySet<string>;
|
||||
groups: readonly TimelineTrackGroupInfo[];
|
||||
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
|
||||
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
||||
}
|
||||
|
||||
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
|
||||
});
|
||||
}
|
||||
|
||||
/** A track's active clip (if any), its element id, and its automation lanes. */
|
||||
function resolveActiveTrackClip(
|
||||
elements: readonly TimelineElement[],
|
||||
laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
|
||||
): {
|
||||
activeClip: TimelineElement | null;
|
||||
activeId: string | null;
|
||||
lanes: ReturnType<typeof getTimelinePropertyLanes>;
|
||||
} {
|
||||
const activeClip = resolveTrackKeyframeClip(
|
||||
elements,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
);
|
||||
const activeId = activeClip ? elementId(activeClip) : null;
|
||||
const lanes = activeClip
|
||||
? getTimelinePropertyLanes(
|
||||
gsapAnimations.get(elementId(activeClip)) ?? [],
|
||||
activeClip.start,
|
||||
activeClip.duration,
|
||||
)
|
||||
: [];
|
||||
return { activeClip, activeId, lanes };
|
||||
}
|
||||
|
||||
function keyframeTarget(
|
||||
keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
|
||||
): TimelineKeyframeTarget {
|
||||
@@ -167,6 +207,42 @@ function propertyItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
|
||||
function isRowOpen(
|
||||
activeId: string | null,
|
||||
expandedClipIds: ReadonlySet<string>,
|
||||
expandedLaneOwnerIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (activeId === null) return false;
|
||||
return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
|
||||
}
|
||||
|
||||
/** A single automation-lane row, one level deeper than the track/group row that owns it. */
|
||||
function buildLaneRow(
|
||||
track: number,
|
||||
logicalIndex: number,
|
||||
activeId: string,
|
||||
activeClip: TimelineElement,
|
||||
lane: ReturnType<typeof getTimelinePropertyLanes>[number],
|
||||
level: 2 | 3,
|
||||
parentId: string,
|
||||
): TimelineLogicalRow {
|
||||
const laneRowId = timelinePropertyRowId(activeId, lane.group);
|
||||
return {
|
||||
id: laneRowId,
|
||||
kind: "row",
|
||||
physicalTrackKey: track,
|
||||
logicalIndex,
|
||||
level,
|
||||
parentId,
|
||||
elementId: activeId,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
propertyGroup: lane.group,
|
||||
items: propertyItems(laneRowId, activeClip, lane.keyframes),
|
||||
};
|
||||
}
|
||||
|
||||
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
|
||||
export function buildTimelineLogicalRows({
|
||||
tracks,
|
||||
@@ -175,58 +251,99 @@ export function buildTimelineLogicalRows({
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
|
||||
const trackMap = new Map(tracks);
|
||||
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
|
||||
const rows: TimelineLogicalRow[] = [];
|
||||
for (const track of displayTrackOrder) {
|
||||
|
||||
// A real track's own row (level 1 ungrouped, level 2 under a group) plus,
|
||||
// when its clip's lanes are open, the lane rows one level deeper.
|
||||
function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
|
||||
const elements = trackMap.get(track) ?? [];
|
||||
const trackId = timelineTrackRowId(track);
|
||||
const activeClip = resolveTrackKeyframeClip(
|
||||
const { activeClip, activeId, lanes } = resolveActiveTrackClip(
|
||||
elements,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
gsapAnimations,
|
||||
);
|
||||
const activeId = activeClip ? elementId(activeClip) : null;
|
||||
const lanes = activeClip
|
||||
? getTimelinePropertyLanes(
|
||||
gsapAnimations.get(elementId(activeClip)) ?? [],
|
||||
activeClip.start,
|
||||
activeClip.duration,
|
||||
)
|
||||
: [];
|
||||
const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
|
||||
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
|
||||
rows.push({
|
||||
id: trackId,
|
||||
kind: "row",
|
||||
physicalTrackKey: track,
|
||||
logicalIndex: rows.length,
|
||||
level: 1,
|
||||
parentId: null,
|
||||
level,
|
||||
parentId,
|
||||
elementId: activeId,
|
||||
expandable: lanes.length > 0,
|
||||
expanded,
|
||||
items: clipItems(trackId, elements),
|
||||
});
|
||||
if (!expanded || !activeClip) continue;
|
||||
if (!expanded || !activeClip || !activeId) return;
|
||||
for (const lane of lanes) {
|
||||
const rowId = timelinePropertyRowId(activeId, lane.group);
|
||||
rows.push({
|
||||
id: rowId,
|
||||
kind: "row",
|
||||
physicalTrackKey: track,
|
||||
logicalIndex: rows.length,
|
||||
level: 2,
|
||||
parentId: trackId,
|
||||
elementId: activeId,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
propertyGroup: lane.group,
|
||||
items: propertyItems(rowId, activeClip, lane.keyframes),
|
||||
});
|
||||
rows.push(
|
||||
buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A group's own row (level 1) plus, when its `∿` is open, its own
|
||||
// automation-lane rows (level 2) — structural content deferred to whatever
|
||||
// step wires group automation editing; this reserves the rows and their
|
||||
// count.
|
||||
function emitGroup(group: TimelineTrackGroupInfo): void {
|
||||
const groupRowId = timelineGroupRowId(group.id);
|
||||
const groupExpanded = expandedGroupIds.has(group.id);
|
||||
rows.push({
|
||||
id: groupRowId,
|
||||
kind: "row",
|
||||
physicalTrackKey: group.anchorKey,
|
||||
logicalIndex: rows.length,
|
||||
level: 1,
|
||||
parentId: null,
|
||||
elementId: null,
|
||||
groupId: group.id,
|
||||
expandable: group.memberTracks.length > 0,
|
||||
expanded: groupExpanded,
|
||||
items: [],
|
||||
});
|
||||
if (expandedLaneOwnerIds.has(group.id)) {
|
||||
const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
|
||||
for (const laneGroup of groupAutomationLanes(memberElements)) {
|
||||
rows.push({
|
||||
id: `${groupRowId}::${laneGroup.key}`,
|
||||
kind: "row",
|
||||
physicalTrackKey: group.anchorKey,
|
||||
logicalIndex: rows.length,
|
||||
level: 2,
|
||||
parentId: groupRowId,
|
||||
elementId: null,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!groupExpanded) return;
|
||||
for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
|
||||
}
|
||||
|
||||
for (const key of displayTrackOrder) {
|
||||
const group = groupByAnchor.get(key);
|
||||
if (group) {
|
||||
emitGroup(group);
|
||||
continue;
|
||||
}
|
||||
if (trackGroupOf.has(key)) continue; // emitted above, under its group
|
||||
emitTrack(key, 1, null);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
|
||||
/**
|
||||
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
||||
@@ -99,6 +100,8 @@ export interface TimelineLaneBaseProps {
|
||||
*/
|
||||
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
/** Resolved audio groups, positioned in row order — see useTimelineTrackDerivations. */
|
||||
groups: readonly TimelineTrackGroupInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,10 @@ export function timelineTrackRowId(track: number): string {
|
||||
return stableId("track", track);
|
||||
}
|
||||
|
||||
export function timelineGroupRowId(groupId: string): string {
|
||||
return stableId("group", groupId);
|
||||
}
|
||||
|
||||
export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
|
||||
return stableId("property", elementId, group);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
|
||||
/** The four pieces of group-disclosure state a group row's header reads and writes. */
|
||||
export function useTimelineGroupDisclosure() {
|
||||
return {
|
||||
expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
|
||||
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
|
||||
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
|
||||
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
|
||||
};
|
||||
}
|
||||
|
||||
/** Two lookups TimelineLanes needs once per render: a row's logical rows by
|
||||
* physical key, and which physical key is a group's own anchor row. */
|
||||
export function useTimelineLaneRowIndexes(
|
||||
logicalRows: readonly TimelineLogicalRow[],
|
||||
groups: readonly TimelineTrackGroupInfo[],
|
||||
) {
|
||||
const logicalRowsByTrack = useMemo(() => {
|
||||
const byTrack = new Map<number, TimelineLogicalRow[]>();
|
||||
for (const logicalRow of logicalRows) {
|
||||
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
|
||||
trackRows.push(logicalRow);
|
||||
byTrack.set(logicalRow.physicalTrackKey, trackRows);
|
||||
}
|
||||
return byTrack;
|
||||
}, [logicalRows]);
|
||||
|
||||
const groupByAnchor = useMemo(
|
||||
() => new Map(groups.map((group) => [group.anchorKey, group])),
|
||||
[groups],
|
||||
);
|
||||
|
||||
return { logicalRowsByTrack, groupByAnchor };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineRowGeometry } from "./timelineLayout";
|
||||
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
|
||||
@@ -15,6 +16,8 @@ interface TimelineLogicalFocusInput {
|
||||
laneCounts: ReadonlyMap<string, number>;
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: ReadonlySet<string>;
|
||||
groups: readonly TimelineTrackGroupInfo[];
|
||||
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
|
||||
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
||||
elements: readonly TimelineElement[];
|
||||
pixelsPerSecond: number;
|
||||
@@ -32,6 +35,8 @@ interface TimelineLogicalFocusInput {
|
||||
|
||||
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
||||
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
|
||||
const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
|
||||
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
|
||||
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
||||
const logicalRows = useTimelineLogicalRows({
|
||||
tracks: input.tracks,
|
||||
@@ -40,6 +45,10 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
||||
selectedElementId: input.selectedElementId,
|
||||
selectedElementIds: input.selectedElementIds,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups: input.groups,
|
||||
trackGroupOf: input.trackGroupOf,
|
||||
gsapAnimations: input.gsapAnimations,
|
||||
});
|
||||
const focus = useTimelineFocusCoordinator({
|
||||
|
||||
@@ -22,6 +22,10 @@ const displayTrackOrder = tracks.map(([track]) => track);
|
||||
const laneCounts = new Map<string, number>();
|
||||
const selectedElementIds = new Set<string>();
|
||||
const expandedClipIds = new Set<string>();
|
||||
const expandedGroupIds = new Set<string>();
|
||||
const expandedLaneOwnerIds = new Set<string>();
|
||||
const groups: never[] = [];
|
||||
const trackGroupOf = new Map();
|
||||
const gsapAnimations = new Map();
|
||||
|
||||
function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
|
||||
@@ -33,6 +37,10 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
|
||||
selectedElementId: null,
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
});
|
||||
snapshots.push(logicalRows);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
|
||||
import {
|
||||
buildTimelineLogicalRows,
|
||||
type BuildTimelineLogicalRowsInput,
|
||||
} from "./timelineKeyboardNavigation";
|
||||
|
||||
interface TimelineLogicalRowsInput {
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
||||
displayTrackOrder: readonly number[];
|
||||
laneCounts: ReadonlyMap<string, number>;
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: ReadonlySet<string>;
|
||||
expandedClipIds: ReadonlySet<string>;
|
||||
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
||||
}
|
||||
type TimelineLogicalRowsInput = BuildTimelineLogicalRowsInput;
|
||||
|
||||
/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
|
||||
export function useTimelineLogicalRows({
|
||||
@@ -21,6 +14,10 @@ export function useTimelineLogicalRows({
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
}: TimelineLogicalRowsInput) {
|
||||
return useMemo(
|
||||
@@ -32,11 +29,19 @@ export function useTimelineLogicalRows({
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
}),
|
||||
[
|
||||
displayTrackOrder,
|
||||
expandedClipIds,
|
||||
expandedGroupIds,
|
||||
expandedLaneOwnerIds,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
gsapAnimations,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isMultiDragActive, multiDragDeltaSeconds } from "./timelineMultiDragPreview";
|
||||
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
|
||||
/** The extra render window a live multi-drag needs: its passengers still draw
|
||||
* at their ORIGIN position, offset by the drag delta, outside the normal
|
||||
* virtualized range. Empty when nothing is dragging. */
|
||||
export function useTimelineMultiDragActorWindows(
|
||||
multiDragPreview: MultiDragPreviewInput | null,
|
||||
rowsVirtualized: boolean,
|
||||
renderTimeRange: TimelineTimeRange,
|
||||
) {
|
||||
const multiDragDelta =
|
||||
multiDragPreview && isMultiDragActive(multiDragPreview)
|
||||
? multiDragDeltaSeconds(multiDragPreview)
|
||||
: 0;
|
||||
const actorWindows =
|
||||
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
||||
? [
|
||||
{
|
||||
range: {
|
||||
start: renderTimeRange.start - multiDragDelta,
|
||||
end: renderTimeRange.end - multiDragDelta,
|
||||
},
|
||||
identities: multiDragPreview.selectedKeys,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return actorWindows;
|
||||
}
|
||||
@@ -1,20 +1,106 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
||||
|
||||
/** One resolved audio group, positioned in the row order. */
|
||||
export interface TimelineTrackGroupInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
/**
|
||||
* Synthetic sort key for the group's own row — the same fractional-key
|
||||
* convention sub-composition expansion already uses (see
|
||||
* timelineTrackDisplay.ts): the first (lowest) member track's number minus
|
||||
* 0.5, so it slots in immediately above that member.
|
||||
*/
|
||||
anchorKey: number;
|
||||
/** Member track numbers, ascending. */
|
||||
memberTracks: number[];
|
||||
}
|
||||
|
||||
interface GroupMembership {
|
||||
trackToGroupId: Map<number, string>;
|
||||
memberTracksByGroup: Map<string, number[]>;
|
||||
labelByGroup: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
|
||||
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
||||
const trackToGroupId = new Map<number, string>();
|
||||
const memberTracksByGroup = new Map<string, number[]>();
|
||||
const labelByGroup = new Map<string, string>();
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const owner = elements.find((el) => el.audioGroup);
|
||||
if (!owner?.audioGroup) continue;
|
||||
trackToGroupId.set(trackNum, owner.audioGroup);
|
||||
if (!labelByGroup.has(owner.audioGroup)) {
|
||||
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
||||
}
|
||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||
members.push(trackNum);
|
||||
memberTracksByGroup.set(owner.audioGroup, members);
|
||||
}
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull each group's members out of raw ascending track order and re-emit them
|
||||
* contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
|
||||
* their position; a group's members move up to sit under its anchor even when
|
||||
* other (ungrouped) tracks were interleaved between them.
|
||||
*/
|
||||
function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
||||
tracks: [number, TimelineElement[]][];
|
||||
groups: TimelineTrackGroupInfo[];
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
||||
} {
|
||||
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
|
||||
const rawByTrack = new Map(rawTracks);
|
||||
const groups: TimelineTrackGroupInfo[] = [];
|
||||
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
|
||||
const emitted = new Set<string>();
|
||||
const tracks: [number, TimelineElement[]][] = [];
|
||||
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const groupId = trackToGroupId.get(trackNum);
|
||||
if (!groupId) {
|
||||
tracks.push([trackNum, elements]);
|
||||
continue;
|
||||
}
|
||||
if (emitted.has(groupId)) continue;
|
||||
emitted.add(groupId);
|
||||
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
|
||||
const info: TimelineTrackGroupInfo = {
|
||||
id: groupId,
|
||||
label: labelByGroup.get(groupId) ?? groupId,
|
||||
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
|
||||
memberTracks,
|
||||
};
|
||||
groups.push(info);
|
||||
tracks.push([info.anchorKey, []]);
|
||||
for (const member of memberTracks) {
|
||||
trackGroupOf.set(member, info);
|
||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
||||
}
|
||||
}
|
||||
return { tracks, groups, trackGroupOf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
|
||||
* clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
|
||||
* `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
|
||||
* cohesive unit (600-line studio cap); each memo keys on the expanded display
|
||||
* element set exactly as before.
|
||||
* clip grouping (`tracks`, group-aware order), per-lane visual styles, the
|
||||
* matching `trackOrder`, and audio-group membership. Extracted from
|
||||
* Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
|
||||
* expanded display element set exactly as before.
|
||||
*/
|
||||
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
trackOrder: number[];
|
||||
groups: TimelineTrackGroupInfo[];
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
||||
} {
|
||||
const tracks = useMemo(() => {
|
||||
const rawTracks = useMemo(() => {
|
||||
const map = new Map<number, TimelineElement[]>();
|
||||
for (const el of expandedElements) {
|
||||
const list = map.get(el.track) ?? [];
|
||||
@@ -24,6 +110,17 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||
}, [expandedElements]);
|
||||
|
||||
const { tracks, groups, trackGroupOf } = useMemo(() => {
|
||||
if (!isCanaryEnabled("audio-groups")) {
|
||||
return {
|
||||
tracks: rawTracks,
|
||||
groups: [],
|
||||
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
|
||||
};
|
||||
}
|
||||
return groupTimelineTracks(rawTracks);
|
||||
}, [rawTracks]);
|
||||
|
||||
const trackStyles = useMemo(() => {
|
||||
const map = new Map<number, TrackVisualStyle>();
|
||||
for (const [trackNum, els] of tracks) {
|
||||
@@ -34,5 +131,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
||||
|
||||
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
||||
|
||||
return { tracks, trackStyles, trackOrder };
|
||||
return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
|
||||
}
|
||||
|
||||
@@ -188,7 +188,8 @@ export function useTimelineTrackLayout(
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
) {
|
||||
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
||||
const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
|
||||
useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
|
||||
@@ -207,6 +208,8 @@ export function useTimelineTrackLayout(
|
||||
rowGeometry,
|
||||
rowGeometryRef,
|
||||
rowHeights,
|
||||
groups,
|
||||
trackGroupOf,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +230,23 @@ function useDisplayRowHeights(
|
||||
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
|
||||
return useMemo(() => {
|
||||
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||
// A group's members sit out of raw numeric order (pulled under their
|
||||
// anchor row), so a plain numeric sort here would undo that grouping the
|
||||
// moment a clip drags onto a brand-new track. Insert the new preview
|
||||
// track only relative to other REAL (integer) tracks, leaving any
|
||||
// fractional group-anchor keys exactly where grouping placed them.
|
||||
const preview = draggedClip.previewTrack;
|
||||
const result: number[] = [];
|
||||
let inserted = false;
|
||||
for (const key of trackOrder) {
|
||||
if (!inserted && Number.isInteger(key) && key > preview) {
|
||||
result.push(preview);
|
||||
inserted = true;
|
||||
}
|
||||
result.push(key);
|
||||
}
|
||||
if (!inserted) result.push(preview);
|
||||
return result;
|
||||
}, [draggedClip, trackOrder]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
||||
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
||||
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
import {
|
||||
resolveMediaElement,
|
||||
applyMediaMetadataFromElement,
|
||||
@@ -67,6 +68,20 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
return clip.tagName || clip.kind || "div";
|
||||
}
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
const groupLabelCache = new WeakMap<Document, Map<string, string>>();
|
||||
|
||||
function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
|
||||
if (!doc) return groupId;
|
||||
let labels = groupLabelCache.get(doc);
|
||||
if (!labels) {
|
||||
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
|
||||
groupLabelCache.set(doc, labels);
|
||||
}
|
||||
return labels.get(groupId) ?? groupId;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function createTimelineElementFromManifestClip(params: {
|
||||
clip: ClipManifestClip;
|
||||
@@ -138,6 +153,11 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
||||
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
||||
if (timelineRole) entry.timelineRole = timelineRole;
|
||||
const audioGroup = hostEl.getAttribute("data-audio-group");
|
||||
if (audioGroup) {
|
||||
entry.audioGroup = audioGroup;
|
||||
entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
|
||||
}
|
||||
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||
if (fxChain) entry.fxChain = fxChain;
|
||||
const automation = hostEl.getAttribute("data-automation");
|
||||
@@ -356,6 +376,12 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
const timelineRole = el.getAttribute("data-timeline-role");
|
||||
if (timelineRole) entry.timelineRole = timelineRole;
|
||||
|
||||
const domAudioGroup = el.getAttribute("data-audio-group");
|
||||
if (domAudioGroup) {
|
||||
entry.audioGroup = domAudioGroup;
|
||||
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
|
||||
@@ -63,6 +63,14 @@ export interface KeyframeSlice {
|
||||
/** Union-expand clips (keyframed clips are expanded by default on load). */
|
||||
expandClips: (ids: readonly string[]) => void;
|
||||
|
||||
/** Groups whose member rows the caret has shown (structural, not lanes). */
|
||||
expandedGroupIds: Set<string>;
|
||||
toggleGroupExpanded: (id: string) => void;
|
||||
|
||||
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
|
||||
expandedLaneOwnerIds: Set<string>;
|
||||
toggleLaneOwnerExpanded: (id: string) => void;
|
||||
|
||||
/**
|
||||
* Project/session/element-scoped request. Its nonce is monotonic across store
|
||||
* resets so a stale consumer can never collide with a later request.
|
||||
@@ -119,6 +127,24 @@ export function createKeyframeSlice(
|
||||
return { expandedClipIds: next };
|
||||
}),
|
||||
|
||||
expandedGroupIds: new Set(),
|
||||
toggleGroupExpanded: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedGroupIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { expandedGroupIds: next };
|
||||
}),
|
||||
|
||||
expandedLaneOwnerIds: new Set(),
|
||||
toggleLaneOwnerExpanded: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedLaneOwnerIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { expandedLaneOwnerIds: next };
|
||||
}),
|
||||
|
||||
focusedEaseSegment: null,
|
||||
focusedEaseRequestNonce: 0,
|
||||
setFocusedEaseSegment: (target) =>
|
||||
|
||||
@@ -270,6 +270,8 @@ export function createTimelineResetState() {
|
||||
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
|
||||
automationSelection: null,
|
||||
expandedClipIds: new Set<string>(),
|
||||
expandedGroupIds: new Set<string>(),
|
||||
expandedLaneOwnerIds: new Set<string>(),
|
||||
focusedEaseSegment: null,
|
||||
selectedElementIds: new Set<string>(),
|
||||
requestedSeekTime: null,
|
||||
|
||||
@@ -67,6 +67,10 @@ export interface TimelineElement {
|
||||
hidden?: boolean;
|
||||
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
||||
timelineRole?: string;
|
||||
/** Verbatim `data-audio-group` — the id of the `<hf-audio-group>` this clip belongs to, when any. */
|
||||
audioGroup?: string;
|
||||
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
|
||||
audioGroupLabel?: string;
|
||||
/**
|
||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||
* child: the absolute master-timeline start of the sub-comp host the child
|
||||
|
||||
Reference in New Issue
Block a user