mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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>(),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user