refactor(studio): make timeline rows use manifest elements (#4213)

* refactor(studio): make timeline rows use manifest elements

* fix(studio): preserve timeline consumer prop names

* test(studio): update asset row expectation

* fix(studio): clear preflight lint nit

* chore: allow intentional timeline hook removal

* chore: refresh PR capture checks

* test(studio): align visibility gate with store rows

* test(studio): preserve canEdit literal type
This commit is contained in:
Miguel Ángel
2026-09-20 10:18:03 -04:00
committed by GitHub
parent 84e6531668
commit 41b7202bca
20 changed files with 65 additions and 170 deletions
@@ -24,7 +24,7 @@ import {
afterEach(() => {
document.body.innerHTML = "";
// Wrapped in act: mounted harnesses subscribe to the store via
// useTimelineRowElements, so the reset re-renders them.
// the store timeline elements, so the reset re-renders them.
act(() => usePlayerStore.getState().setElements([]));
});
@@ -1,6 +1,5 @@
import { useCallback, useRef } from "react";
import { usePlayerStore } from "../../player";
import { useTimelineRowElements } from "../../player/hooks/useTimelineRowElements";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import {
displayTrackOrder,
@@ -41,10 +40,8 @@ export interface MirrorZOrderInput {
* commitZMirrorLaneMove) — the shared key is unique per gesture
* (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
*
* Element source: `useTimelineRowElements()` — the same expanded display
* set the Timeline renders and the resolver expects (post-normalizeToZones
* lanes, expanded sub-comp children on their synthetic rows). No new expansion
* is built here.
* Element source: the store's timeline elements, the same set the Timeline
* renders and the resolver expects. No alternate row expansion is built here.
*
* The mirror persists through the SAME machinery as a timeline lane drag
* (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded
@@ -141,7 +138,7 @@ function useMirrorLaneMoveCommit(): (
coalesceKey: string,
resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
) => Promise<boolean> {
const elements = useTimelineRowElements();
const elements = usePlayerStore((state) => state.elements);
const elementsRef = useRef(elements);
elementsRef.current = elements;
const { onMoveElements } = useTimelineEditContextOptional();
@@ -8,7 +8,6 @@
import { useCallback } from "react";
import { usePlayerStore, type TimelineElement } from "../player";
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
@@ -286,7 +285,7 @@ export function useAudioGroupCarveAssignment({
groupId: string,
groupLabel?: string,
) => Promise<void> {
const expandedElements = useTimelineRowElements();
const timelineElements = usePlayerStore((state) => state.elements);
return useCallback(
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
if (isRecordingRef?.current) {
@@ -299,7 +298,7 @@ export function useAudioGroupCarveAssignment({
// timeline's group-pointer button) name clips the way the document does,
// because that is the only space `resolveAudioGroups` reads back.
const wanted = new Set(clipIds);
const elements = expandedElements.filter((item) => {
const elements = timelineElements.filter((item) => {
const domId = runtimeAudioId(item);
return domId !== null && wanted.has(domId);
});
@@ -340,7 +339,7 @@ export function useAudioGroupCarveAssignment({
},
[
activeCompPath,
expandedElements,
timelineElements,
previewIframeRef,
writeProjectFile,
recordEdit,
@@ -1,7 +1,6 @@
import { useCallback } from "react";
import { usePlayerStore, type TimelineElement } from "../player";
import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
import {
timelineTrackOrder,
@@ -295,7 +294,7 @@ export function useTimelineTrackVisibilityEditing({
// virtual sub-comp children carry their own (display.track + idx) track numbers,
// so filtering the raw store list by a virtual track number would hide the wrong
// outer-scene sibling sharing that index.
const expandedElements = useTimelineRowElements();
const timelineElements = usePlayerStore((state) => state.elements);
return useCallback(
async (track: number, hidden: boolean, displayNumber?: number | null) => {
if (isRecordingRef?.current) {
@@ -308,7 +307,7 @@ export function useTimelineTrackVisibilityEditing({
await toggleTimelineTrackHidden({
projectId: pid,
activeCompPath,
timelineElements: expandedElements,
timelineElements,
track,
hidden,
displayNumber,
@@ -327,7 +326,7 @@ export function useTimelineTrackVisibilityEditing({
},
[
activeCompPath,
expandedElements,
timelineElements,
previewIframeRef,
writeProjectFile,
recordEdit,
@@ -354,7 +353,7 @@ export function useTimelineElementVisibilityEditing({
elementKey: string | readonly string[],
hidden: boolean,
) => Promise<void> {
const expandedElements = useTimelineRowElements();
const timelineElements = usePlayerStore((state) => state.elements);
return useCallback(
async (elementKey: string | readonly string[], hidden: boolean) => {
if (isRecordingRef?.current) {
@@ -364,7 +363,7 @@ export function useTimelineElementVisibilityEditing({
const pid = projectIdRef.current;
if (!pid) return;
const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
if (!expandedElements.some((item) => keys.includes(item.key ?? item.id))) {
if (!timelineElements.some((item) => keys.includes(item.key ?? item.id))) {
showToast("This element is inside a sub-composition and has no timeline row to hide.");
return;
}
@@ -372,7 +371,7 @@ export function useTimelineElementVisibilityEditing({
await toggleTimelineElementHidden({
projectId: pid,
activeCompPath,
timelineElements: expandedElements,
timelineElements,
elementKey,
hidden,
previewIframe: previewIframeRef.current,
@@ -390,7 +389,7 @@ export function useTimelineElementVisibilityEditing({
},
[
activeCompPath,
expandedElements,
timelineElements,
previewIframeRef,
writeProjectFile,
recordEdit,
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
});
it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
it("measures the insert row against every manifest timeline row", async () => {
const clip = (id: string, track: number): TimelineElement => ({
id,
key: id,
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
}),
"</main>",
].join("\n");
usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const getDrop = renderDropHook(source, writeProjectFile, [
clip("a", 0),
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
});
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
expect(written).toContain('id="b" data-start="0" data-track-index="2"');
expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
expect(written).toContain('id="b" data-start="0" data-track-index="3"');
expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
});
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
import { extendRootDurationInSource } from "../utils/rootDuration";
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
import { usePlayerStore } from "../player/store/playerStore";
/** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
function fileDropPlacement(
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
const { source: sourceWithRoom, track } = resolveDropTrack({
source: originalContent,
// insertRow counts the rows the timeline shows, so plan against those.
elements: selectTimelineRowElements(
relevantElements,
usePlayerStore.getState().topLevelIds,
),
elements: relevantElements,
placement,
dropped: {
id: newId,
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
unmount();
});
it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
// Regression: canEdit must resolve against the expanded rows
// (timelineTrackVisibility.ts's own invariant), not the raw
// timelineElements prop — proven by making the two disagree below.
it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
// The visibility hook reads the single store-owned row source. The editing
// guard must receive that same source so a blocked row cannot fall through
// to the write path.
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
usePlayerStore.getState().setElements([clip]);
const showToast = vi.fn();
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const canEdit = vi.fn((element: TimelineElement) =>
element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
);
let hook: ReturnType<typeof useTimelineEditing> | null = null;
function Harness() {
hook = useTimelineEditing({
projectId: "p1",
activeCompPath: "index.html",
timelineElements: [], // deliberately does not contain `clip`
timelineElements: [clip],
showToast,
writeProjectFile,
recordEdit: vi.fn(),
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
previewIframeRef: { current: iframe },
pendingTimelineEditPathRef: { current: new Set<string>() },
uploadProjectFiles: vi.fn(),
canEdit: (element) =>
element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
canEdit,
});
return null;
}
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
await flushAsyncWork();
});
expect(canEdit).toHaveBeenCalledWith(clip);
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
expect(writeProjectFile).not.toHaveBeenCalled();
unmount();
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
import { useSetElementAttribute } from "./timelineElementFxAttribute";
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
import {
@@ -77,10 +76,6 @@ export function useTimelineEditing({
const checkEditable = useTimelineEditGate(canEdit, showToast);
const checkEditableRef = useRef(checkEditable);
checkEditableRef.current = checkEditable;
// Same expanded-row source the hide handlers themselves resolve against
// (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
// only exists here, not in the raw store list canEdit would otherwise miss.
const timelineRowElements = useTimelineRowElements();
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
// Refuses (no call, no write, no history entry) when any target is
// blocked; otherwise runs fn as before. Cached by fn identity — like
@@ -514,14 +509,14 @@ export function useTimelineEditing({
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
handleToggleTrackHidden: track(
guard(
(trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
(trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
handleToggleTrackHidden,
),
),
handleToggleElementHidden: track(
guard((elementKey) => {
const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
return timelineElements.filter((el) => keys.has(el.key ?? el.id));
}, handleToggleElementHidden),
),
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
@@ -1,7 +1,6 @@
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { usePublishRangeSelection } from "./usePublishRangeSelection";
@@ -113,7 +112,7 @@ export const Timeline = memo(function Timeline({
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useTimelineRowElements();
const timelineElements = rawElements;
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -123,8 +122,8 @@ export const Timeline = memo(function Timeline({
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const labelMode = useMemo(
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
[gsapAnimations, expandedElements],
() => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
[gsapAnimations, timelineElements],
);
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
@@ -163,13 +162,13 @@ export const Timeline = memo(function Timeline({
groups,
trackGroupOf,
} = useTimelineTrackLayout(
expandedElements,
timelineElements,
gsapAnimations,
selectedElementId,
selectedElementIds,
);
const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
const timelineElementsRef = useRef(timelineElements);
timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
@@ -198,7 +197,7 @@ export const Timeline = memo(function Timeline({
onCompositionDrop,
});
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
expandedElementsRef,
expandedElementsRef: timelineElementsRef,
});
const {
gapMenuModel,
@@ -210,7 +209,7 @@ export const Timeline = memo(function Timeline({
closeAllTrackGaps,
} = useTrackGapMenu({
tracks,
expandedElementsRef,
expandedElementsRef: timelineElementsRef,
trackOrderRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
@@ -259,14 +258,14 @@ export const Timeline = memo(function Timeline({
resizingClip?.groupPreview?.map((change) => change.key) ??
(resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined);
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
totalClipCount: expandedElements.length,
totalClipCount: timelineElements.length,
totalRowCount: displayLayout.displayTrackOrder.length,
zoomMode,
});
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
useTimelineScrollViewport(scrollRef, [
timelineReady,
expandedElements.length,
timelineElements.length,
displayLayout.totalH,
]);
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
@@ -279,7 +278,7 @@ export const Timeline = memo(function Timeline({
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
expandedElements: timelineElements,
isDragging,
scrollRef,
lastScrollLeftRef,
@@ -295,7 +294,7 @@ export const Timeline = memo(function Timeline({
groups,
trackGroupOf,
gsapAnimations,
elements: expandedElements,
elements: timelineElements,
pixelsPerSecond: pps,
contentOrigin,
allowHorizontal: zoomMode === "manual",
@@ -312,7 +311,7 @@ export const Timeline = memo(function Timeline({
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
expandedElements: timelineElements,
keyframeCache,
onSelectElement,
onSeek,
@@ -343,14 +342,14 @@ export const Timeline = memo(function Timeline({
scrollRef,
currentTime,
clipStateVersion: renderTimeRange,
elementStateVersion: expandedElements,
elementStateVersion: timelineElements,
});
const laneGapStrips = useTimelineGapHighlights({
gapHighlight,
tracks,
selectedElementId,
selectedElementIds,
expandedElements,
expandedElements: timelineElements,
dragActive: draggedClip?.started === true || resizingClip != null,
displayDuration,
});
@@ -371,7 +370,7 @@ export const Timeline = memo(function Timeline({
effectiveDuration,
pps,
timelineReady,
elementsLength: expandedElements.length,
elementsLength: timelineElements.length,
setZoomMode,
setManualZoomPercent,
onSeek,
@@ -407,7 +406,7 @@ export const Timeline = memo(function Timeline({
dragScrollRaf,
isDragging,
setShowPopover,
elementsRef: expandedElementsRef,
elementsRef: timelineElementsRef,
clipIndex,
rowGeometryRef,
onSelectElement,
@@ -417,7 +416,7 @@ export const Timeline = memo(function Timeline({
usePublishRangeSelection(rangeSelection, onRangeSelect);
setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
useTimelineSelectionLifecycle(timelineElements, selectedElementId, setShowPopover, () =>
setRangeSelection(null),
);
@@ -433,7 +432,7 @@ export const Timeline = memo(function Timeline({
[resizingClip],
);
if (!timelineReady || expandedElements.length === 0) {
if (!timelineReady || timelineElements.length === 0) {
return (
<TimelineEmptyState
isDragOver={assetDrop.isDragOver}
@@ -449,7 +448,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline track view"
data-timeline-element-count={expandedElements.length}
data-timeline-element-count={timelineElements.length}
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={updateRazorGuide}
onMouseLeave={clearRazorGuide}
@@ -566,8 +565,8 @@ export const Timeline = memo(function Timeline({
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
</div>
<TimelineOverlays
elements={expandedElements}
elementsRef={expandedElementsRef}
elements={timelineElements}
elementsRef={timelineElementsRef}
theme={theme}
showShortcutHint={showShortcutHint}
showPopover={showPopover}
@@ -1,10 +1,7 @@
/**
* The one owner of "what track number does the user see".
*
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
* sub-composition child gets a synthesized fractional key (`host.track + n /
* (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
* announces "Hide track 0.16666666666666666". Every user-visible track number,
* `TimelineElement.track` is a z-order SORT key, not a row number. Every
* whether it is rendered by a component or baked into an undo-history label,
* routes through here; the raw key stays in callbacks and lookups only.
*/
@@ -98,8 +98,8 @@ function buildGroupInfo(
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
// Exactly x.5, which sub-composition child rows are now kept strictly below
// (`useTimelineRowElements`) — they used to be able to land here and
// collide, duplicating the group header.
// the timeline row source — they used to be able to land here and collide,
// duplicating the group header.
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
memberTracks,
memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
@@ -3,7 +3,6 @@
import { describe, expect, it } from "vitest";
import {
collectSubCompositionDomChildren,
collectTopLevelElementIds,
collectSubCompositionHostState,
} from "./timelineSyncHydration";
import type { ClipManifestClip } from "../lib/playbackTypes";
@@ -95,20 +94,3 @@ describe("collectSubCompositionHostState", () => {
expect(state.size).toBe(0);
});
});
describe("collectTopLevelElementIds", () => {
const docOf = (html: string) => new DOMParser().parseFromString(html, "text/html");
it("names the sub-composition host and a timed element, not what is nested inside them", () => {
const doc = docOf(`<div data-composition-id="root">
<div id="wrap"><div id="a" data-start="0" data-duration="2"><div id="inner" data-start="0"></div></div></div>
<div id="scene" data-composition-id="scene" data-composition-src="scene.html"><div id="box" data-start="0"></div></div>
</div>`);
expect([...collectTopLevelElementIds(doc)!].sort()).toEqual(["a", "scene"]);
});
it("is null when the document has no composition root", () => {
expect(collectTopLevelElementIds(docOf("<p>hi</p>"))).toBeNull();
expect(collectTopLevelElementIds(null)).toBeNull();
});
});
@@ -14,7 +14,6 @@ import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
import { topLevelElements, type StructureNode } from "@hyperframes/parsers/top-level-elements";
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
import { groupInfoFor } from "../lib/timelineGroupInfo";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
@@ -137,28 +136,6 @@ export function collectSubCompositionDomChildren(
return out;
}
interface DomStructureNode extends StructureNode<DomStructureNode> {
id: string;
}
function toStructureNode(el: Element): DomStructureNode {
const attrs: Record<string, string> = {};
for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
return {
tag: el.tagName,
attrs,
id: el.id,
children: Array.from(el.children).map(toStructureNode),
};
}
/** DOM ids of the timeline's rows, by the definition the structure lint shares; null when there is no readable root. */
export function collectTopLevelElementIds(doc: Document | null): Set<string> | null {
const root = doc?.querySelector("[data-composition-id]");
if (!root) return null;
return new Set(topLevelElements(toStructureNode(root)).flatMap((n) => (n.id ? [n.id] : [])));
}
/** The host-element `data-*` state one element carries, or null when it has none. */
function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
const state: SubCompositionHostState = {};
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { selectTimelineRowElements } from "./useTimelineRowElements";
const el = (id: string, domId?: string): TimelineElement => ({
id,
domId,
start: 0,
duration: 1,
track: 0,
tag: "div",
});
describe("selectTimelineRowElements", () => {
it("drops an element the shared definition does not call top-level", () => {
const rows = selectTimelineRowElements([el("a", "a"), el("b", "b")], new Set(["a"]));
expect(rows.map((r) => r.id)).toEqual(["a"]);
});
it("keeps everything until the document has been read", () => {
const all = [el("a", "a"), el("b", "b")];
expect(selectTimelineRowElements(all, null)).toBe(all);
});
it("keeps an element with no DOM id", () => {
expect(selectTimelineRowElements([el("k")], new Set())).toHaveLength(1);
});
});
@@ -1,17 +0,0 @@
import { useMemo } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
/** Rows are the ids `topLevelElements` names, as the lint does; id-less ones are kept. */
export function selectTimelineRowElements(
elements: TimelineElement[],
topLevelIds: ReadonlySet<string> | null,
): TimelineElement[] {
if (!topLevelIds) return elements;
return elements.filter((el) => el.domId === undefined || topLevelIds.has(el.domId));
}
export function useTimelineRowElements(): TimelineElement[] {
const elements = usePlayerStore((s) => s.elements);
const topLevelIds = usePlayerStore((s) => s.topLevelIds);
return useMemo(() => selectTimelineRowElements(elements, topLevelIds), [elements, topLevelIds]);
}
@@ -19,7 +19,6 @@ import {
buildTimelineElementsFromClips,
syncManifestTimeline,
clipTreeParentMap,
collectTopLevelElementIds,
collectSubCompositionDomChildren,
collectSubCompositionHostState,
hydrateTimelineFromPreview,
@@ -130,13 +129,11 @@ export function useTimelineSyncCallbacks({
const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
usePlayerStore.getState().setClipParentMap(parentMap);
usePlayerStore.getState().setTopLevelIds(collectTopLevelElementIds(iframeDoc));
usePlayerStore.getState().setDomClipChildren(domClipChildren);
usePlayerStore
.getState()
.setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
} catch {
usePlayerStore.getState().setTopLevelIds(null);
// cross-origin or __clipTree not available — maps stay empty
}
@@ -202,8 +202,6 @@ interface PlayerState extends PlayerStoreSlices {
clipManifest: ClipManifestClip[] | null;
setClipManifest: (clips: ClipManifestClip[] | null) => void;
clipParentMap: Map<string, string>;
topLevelIds: ReadonlySet<string> | null;
setTopLevelIds: (ids: ReadonlySet<string> | null) => void;
setClipParentMap: (map: Map<string, string>) => void;
/**
* Sub-composition DOM descendants (groups + their children) that have no
@@ -394,8 +392,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
setClipManifest: (clips) => set({ clipManifest: clips }),
clipParentMap: new Map(),
setClipParentMap: (map) => set({ clipParentMap: map }),
topLevelIds: null,
setTopLevelIds: (ids) => set({ topLevelIds: ids }),
domClipChildren: [],
setDomClipChildren: (children) => set({ domClipChildren: children }),
subCompositionHostState: new Map(),
@@ -77,10 +77,8 @@ export interface TimelineElement {
audioGroupFxChain?: string;
audioGroupAutomation?: string;
/**
* Set by useTimelineRowElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child
* lives in. Presence marks the element as expanded; edits subtract it to get
* the child's local (sourceFile-relative) time. Works at any nesting depth.
* Legacy source-coordinate marker for an inline sub-composition child. The
* current timeline keeps sub-compositions as single rows.
*/
expandedParentStart?: number;
expandedHostKey?: string;
@@ -44,7 +44,6 @@ export function createTimelineResetState() {
beatPersist: null,
clipManifest: null,
clipParentMap: new Map<string, string>(),
topLevelIds: null,
domClipChildren: [],
subCompositionHostState: new Map<string, SubCompositionHostState>(),
};
+8
View File
@@ -40,6 +40,14 @@ const STORYBOARD_VIEW_REASON =
"owner-directed removal of the Studio storyboard view; its only readers were deleted with it";
export const ALLOWED_DELETIONS = new Map([
[
"packages/studio/src/player/hooks/useTimelineRowElements.ts",
"D-834 removes the duplicate row-source hook; manifest elements are now the single timeline row owner",
],
[
"packages/studio/src/player/hooks/useTimelineRowElements.test.ts",
"D-834 removes tests for the deleted duplicate row-source hook",
],
[
"packages/studio/src/components/StudioGlobalDragOverlay.tsx",
"the full-screen drop-to-add box is replaced by a landing preview on the timeline; no remaining references",