@hyperframes/studio 0.8.57 → 0.8.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +577 -8
- package/dist/index.html +2 -2
- package/dist/index.js +22537 -21600
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +25 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { TimelineContextValue } from "./TimelineProvider";
|
|
5
|
+
import { TimelineContextProvider, useTimelineContext } from "./TimelineProvider";
|
|
6
|
+
import { TimelineClipMenu, TimelineLanes, TimelinePlayhead, TimelineRuler } from "./TimelineParts";
|
|
7
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
8
|
+
|
|
9
|
+
vi.mock("./TimelineRuler", () => ({
|
|
10
|
+
TimelineRuler: () => <div data-testid="timeline-ruler" />,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("./TimelineLanes", () => ({
|
|
13
|
+
TimelineLanes: ({ renderClipContent }: { renderClipContent?: unknown }) => {
|
|
14
|
+
const { state } = useTimelineContext();
|
|
15
|
+
return (
|
|
16
|
+
<div data-testid="timeline-clip">
|
|
17
|
+
{state.elements[0]?.id}
|
|
18
|
+
{renderClipContent ? <span data-testid="lane-render-bridge" /> : null}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { mount } = createHappyDomRootHarness();
|
|
25
|
+
|
|
26
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Timeline parts composition", () => {
|
|
33
|
+
it("reads a shared provider context when composed without Timeline or Frame", () => {
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
document.body.append(host);
|
|
36
|
+
const root = mount(host);
|
|
37
|
+
const onSplitElement = vi.fn();
|
|
38
|
+
const element = { id: "clip-a", key: "clip-a", tag: "video", start: 0, duration: 2, track: 0 };
|
|
39
|
+
const value = {
|
|
40
|
+
state: {
|
|
41
|
+
timelineReady: true,
|
|
42
|
+
elements: [element],
|
|
43
|
+
selectedElementId: "clip-a",
|
|
44
|
+
sessionEpoch: 1,
|
|
45
|
+
keyframeCache: new Map(),
|
|
46
|
+
canvas: { isScrubbing: false } as TimelineContextValue["state"]["canvas"],
|
|
47
|
+
overlays: {
|
|
48
|
+
elements: [element],
|
|
49
|
+
elementsRef: { current: [element] },
|
|
50
|
+
theme: {} as TimelineContextValue["state"]["overlays"]["theme"],
|
|
51
|
+
showShortcutHint: false,
|
|
52
|
+
showPopover: false,
|
|
53
|
+
rangeSelection: null,
|
|
54
|
+
setShowPopover: vi.fn(),
|
|
55
|
+
setRangeSelection: vi.fn(),
|
|
56
|
+
kfContextMenu: null,
|
|
57
|
+
setKfContextMenu: vi.fn(),
|
|
58
|
+
onDeleteKeyframe: vi.fn(),
|
|
59
|
+
onDeleteAllKeyframes: vi.fn(),
|
|
60
|
+
onMoveKeyframeToPlayhead: vi.fn(),
|
|
61
|
+
clipContextMenu: { x: 10, y: 10, element, sessionEpoch: 1 },
|
|
62
|
+
setClipContextMenu: vi.fn(),
|
|
63
|
+
currentTime: 1,
|
|
64
|
+
onSplitElement,
|
|
65
|
+
pinZoomBeforeEdit: vi.fn(),
|
|
66
|
+
gapContextMenu: null,
|
|
67
|
+
onDismissGapContextMenu: vi.fn(),
|
|
68
|
+
onCloseTrackGap: vi.fn(),
|
|
69
|
+
onCloseAllTrackGaps: vi.fn(),
|
|
70
|
+
onHoverGapAction: vi.fn(),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
actions: {
|
|
74
|
+
renderClipContent: vi.fn(),
|
|
75
|
+
renderClipOverlay: undefined,
|
|
76
|
+
setFocusedEaseSegment: vi.fn(),
|
|
77
|
+
},
|
|
78
|
+
meta: { razorGuide: null } as TimelineContextValue["meta"],
|
|
79
|
+
} satisfies TimelineContextValue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
act(() => {
|
|
83
|
+
root.render(
|
|
84
|
+
<TimelineContextProvider value={value}>
|
|
85
|
+
<TimelineRuler />
|
|
86
|
+
<TimelineLanes />
|
|
87
|
+
<TimelinePlayhead />
|
|
88
|
+
<TimelineClipMenu />
|
|
89
|
+
</TimelineContextProvider>,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(host.querySelector("[data-testid='timeline-ruler']")).not.toBeNull();
|
|
94
|
+
expect(host.querySelector("[data-testid='timeline-clip']")?.textContent).toBe("clip-a");
|
|
95
|
+
expect(host.querySelector("[data-testid='lane-render-bridge']")).not.toBeNull();
|
|
96
|
+
expect(document.querySelector("[role='menu'][aria-label='Clip actions']")).not.toBeNull();
|
|
97
|
+
} finally {
|
|
98
|
+
act(() => root.unmount());
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { TimelineCanvas as TimelineCanvasImpl } from "./TimelineCanvas";
|
|
2
|
+
import {
|
|
3
|
+
TimelineClipMenuOverlay as TimelineClipMenuImpl,
|
|
4
|
+
TimelineEditPopoverOverlay as TimelineEditPopoverImpl,
|
|
5
|
+
TimelineGapMenuOverlay as TimelineGapMenuImpl,
|
|
6
|
+
TimelineKeyframeMenuOverlay as TimelineKeyframeMenuImpl,
|
|
7
|
+
TimelineShortcutHintOverlay as TimelineShortcutHintImpl,
|
|
8
|
+
} from "./TimelineOverlays";
|
|
9
|
+
import { TimelineLanes as TimelineLanesImpl } from "./TimelineLanes";
|
|
10
|
+
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
11
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
12
|
+
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
|
+
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
14
|
+
|
|
15
|
+
/** The provider-backed canvas shell. */
|
|
16
|
+
export function TimelineFrame() {
|
|
17
|
+
return <TimelineCanvasImpl />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The provider-backed ruler. */
|
|
21
|
+
export const TimelineRuler = TimelineRulerPart;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The provider-backed lane renderer. TimelineFrame remains the composed Studio
|
|
25
|
+
* canvas; this part is exported for hosts that own the canvas arrangement.
|
|
26
|
+
*/
|
|
27
|
+
export function TimelineLanes() {
|
|
28
|
+
const { state, actions } = useTimelineContext();
|
|
29
|
+
const props = state.canvas;
|
|
30
|
+
return (
|
|
31
|
+
<TimelineLanesImpl
|
|
32
|
+
{...props}
|
|
33
|
+
renderClipContent={actions.renderClipContent}
|
|
34
|
+
renderClipOverlay={actions.renderClipOverlay}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The provider-backed playhead indicator. */
|
|
40
|
+
export function TimelinePlayhead() {
|
|
41
|
+
const { state } = useTimelineContext();
|
|
42
|
+
const props = state.canvas;
|
|
43
|
+
return <PlayheadIndicator scrubbing={props.isScrubbing} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The provider-backed razor guide. */
|
|
47
|
+
export function TimelineRazorGuide() {
|
|
48
|
+
const { meta } = useTimelineContext();
|
|
49
|
+
return meta.razorGuide;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The composed overlay surface used by the Studio variant. */
|
|
53
|
+
export { TimelineOverlays } from "./TimelineOverlays";
|
|
54
|
+
|
|
55
|
+
export function TimelineEmptyStatePart() {
|
|
56
|
+
const { meta } = useTimelineContext();
|
|
57
|
+
return <TimelineEmptyState {...meta.emptyState} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TimelineShortcutHint() {
|
|
61
|
+
return <TimelineShortcutHintImpl />;
|
|
62
|
+
}
|
|
63
|
+
export function TimelineEditPopover() {
|
|
64
|
+
return <TimelineEditPopoverImpl />;
|
|
65
|
+
}
|
|
66
|
+
export function TimelineClipMenu() {
|
|
67
|
+
return <TimelineClipMenuImpl />;
|
|
68
|
+
}
|
|
69
|
+
export function TimelineKeyframeMenu() {
|
|
70
|
+
return <TimelineKeyframeMenuImpl />;
|
|
71
|
+
}
|
|
72
|
+
export function TimelineGapMenu() {
|
|
73
|
+
return <TimelineGapMenuImpl />;
|
|
74
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function TimelinePartsVariant() {
|
|
18
|
+
const { meta } = useTimelineContext();
|
|
19
|
+
return React.createElement(
|
|
20
|
+
"div",
|
|
21
|
+
meta.containerProps,
|
|
22
|
+
React.createElement(
|
|
23
|
+
"div",
|
|
24
|
+
meta.viewportProps,
|
|
25
|
+
React.createElement(Timeline.Frame),
|
|
26
|
+
React.createElement(Timeline.RazorGuide),
|
|
27
|
+
),
|
|
28
|
+
React.createElement(Timeline.Overlays),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Timeline provider boundary", () => {
|
|
33
|
+
it("keeps the composed Timeline markup equal to its provider parts", () => {
|
|
34
|
+
usePlayerStore.setState({
|
|
35
|
+
duration: 4,
|
|
36
|
+
timelineReady: true,
|
|
37
|
+
elements: [{ id: "parity-clip", tag: "div", start: 0, duration: 2, track: 0 }],
|
|
38
|
+
});
|
|
39
|
+
const composedHost = document.createElement("div");
|
|
40
|
+
const partsHost = document.createElement("div");
|
|
41
|
+
document.body.append(composedHost, partsHost);
|
|
42
|
+
const composedRoot = createRoot(composedHost);
|
|
43
|
+
const partsRoot = createRoot(partsHost);
|
|
44
|
+
act(() => {
|
|
45
|
+
composedRoot.render(React.createElement(Timeline));
|
|
46
|
+
partsRoot.render(
|
|
47
|
+
React.createElement(Timeline.Provider, null, React.createElement(TimelinePartsVariant)),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const normalizeMarkup = (markup: string) =>
|
|
51
|
+
markup.replaceAll(/timeline-lanes_[^"]+/g, "timeline-lanes");
|
|
52
|
+
expect(normalizeMarkup(partsHost.innerHTML)).toBe(normalizeMarkup(composedHost.innerHTML));
|
|
53
|
+
act(() => {
|
|
54
|
+
composedRoot.unmount();
|
|
55
|
+
partsRoot.unmount();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -17,6 +17,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
|
|
|
17
17
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
18
18
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
19
19
|
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
20
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
21
|
+
import type { TimelineSnapTarget } from "./timelineSnapping";
|
|
20
22
|
import type { TimelineElement } from "../store/playerStore";
|
|
21
23
|
import type { KeyframeCacheEntry } from "../store/keyframeSlice";
|
|
22
24
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
@@ -24,6 +26,10 @@ import type { TimelineTheme } from "./timelineTheme";
|
|
|
24
26
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
25
27
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
26
28
|
import { useTimelineProviderState } from "./useTimelineProviderState";
|
|
29
|
+
import {
|
|
30
|
+
TimelineEditProvider,
|
|
31
|
+
useTimelineEditContextValue,
|
|
32
|
+
} from "../../contexts/TimelineEditContext";
|
|
27
33
|
|
|
28
34
|
export type TimelineCanvasState = Omit<
|
|
29
35
|
TimelineLaneBaseProps,
|
|
@@ -51,6 +57,13 @@ export type TimelineCanvasState = Omit<
|
|
|
51
57
|
beatDragging: boolean;
|
|
52
58
|
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
|
53
59
|
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
|
60
|
+
draggedElement: TimelineElement | null;
|
|
61
|
+
snapGuide: TimelineSnapTarget | null;
|
|
62
|
+
multiDragPreview: MultiDragPreviewInput | null;
|
|
63
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
64
|
+
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
65
|
+
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
|
66
|
+
onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
|
|
54
67
|
};
|
|
55
68
|
|
|
56
69
|
export interface ClipContextMenuState {
|
|
@@ -166,6 +179,18 @@ export interface TimelineContextValue {
|
|
|
166
179
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
167
180
|
|
|
168
181
|
export function TimelineProvider({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
182
|
+
const editContext = useTimelineEditContextValue();
|
|
183
|
+
if (!editContext) {
|
|
184
|
+
return (
|
|
185
|
+
<TimelineEditProvider value={props}>
|
|
186
|
+
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
|
187
|
+
</TimelineEditProvider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
169
194
|
const value = useTimelineProviderState(props);
|
|
170
195
|
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
171
196
|
}
|
|
@@ -50,7 +50,7 @@ export function useTimelineRazorInteraction({
|
|
|
50
50
|
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function
|
|
53
|
+
export function TimelineRazorGuideOverlay({ x }: { x: number }) {
|
|
54
54
|
return (
|
|
55
55
|
<div
|
|
56
56
|
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TimelineRuler as TimelineRulerImpl } from "./TimelineRuler";
|
|
2
|
+
import { useTimelineContext } from "./TimelineProvider";
|
|
3
|
+
|
|
4
|
+
export function TimelineRulerPart() {
|
|
5
|
+
const { state } = useTimelineContext();
|
|
6
|
+
const props = state.canvas;
|
|
7
|
+
return (
|
|
8
|
+
<TimelineRulerImpl
|
|
9
|
+
major={props.major}
|
|
10
|
+
minor={props.minor}
|
|
11
|
+
pps={props.pps}
|
|
12
|
+
trackContentWidth={props.trackContentWidth}
|
|
13
|
+
totalH={props.totalH}
|
|
14
|
+
effectiveDuration={props.effectiveDuration}
|
|
15
|
+
majorTickInterval={props.majorTickInterval}
|
|
16
|
+
theme={props.theme}
|
|
17
|
+
beatAnalysis={props.beatAnalysis}
|
|
18
|
+
contentOrigin={props.contentOrigin}
|
|
19
|
+
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -136,4 +136,14 @@ describe("PlainTrackHeader", () => {
|
|
|
136
136
|
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
137
137
|
view.unmount();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it("truncates long names and keeps the full name in the tooltip", () => {
|
|
141
|
+
const name = "A-very-long-unbroken-clip-name-for-the-timeline";
|
|
142
|
+
const view = renderHeader({ trackLabel: name });
|
|
143
|
+
const label = view.host.querySelector("span[title]");
|
|
144
|
+
expect(label?.className).toContain("truncate");
|
|
145
|
+
expect(label?.className).not.toContain("wrap-break-word");
|
|
146
|
+
expect(label?.getAttribute("title")).toBe(name);
|
|
147
|
+
view.unmount();
|
|
148
|
+
});
|
|
139
149
|
});
|
|
@@ -93,14 +93,11 @@ export function PlainTrackHeader({
|
|
|
93
93
|
)}
|
|
94
94
|
{/* No `flex-1`: the name takes only the width it needs, so the clip
|
|
95
95
|
count sits against it rather than being pushed out to meet the
|
|
96
|
-
controls. The slack goes to the `ml-auto` group below instead.
|
|
97
|
-
|
|
98
|
-
Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
-
read at all, and a tooltip is no use to a name you are scanning a
|
|
100
|
-
column of. `wrap-break-word` so a long single token breaks instead of
|
|
101
|
-
forcing the column wider. */}
|
|
96
|
+
controls. The slack goes to the `ml-auto` group below instead. */}
|
|
102
97
|
{showTrackLabel && (
|
|
103
|
-
<span className="min-w-0
|
|
98
|
+
<span title={trackLabel} className="min-w-0 truncate text-[11px] leading-tight">
|
|
99
|
+
{trackLabel}
|
|
100
|
+
</span>
|
|
104
101
|
)}
|
|
105
102
|
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
103
|
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
|
|
@@ -5,6 +5,26 @@ import type {
|
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
7
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
8
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
10
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
11
|
+
|
|
12
|
+
export function resolveMultiDragPreview(
|
|
13
|
+
draggedClip: DraggedClipState | null,
|
|
14
|
+
selectedKeys: ReadonlySet<string>,
|
|
15
|
+
): MultiDragPreviewInput | null {
|
|
16
|
+
// The dragged clip is a free ghost; selected companions follow the same
|
|
17
|
+
// clamped delta so the formation stays rigid at the lane boundary.
|
|
18
|
+
if (!draggedClip?.started) return null;
|
|
19
|
+
const draggedKey = getTimelineElementIdentity(draggedClip.element);
|
|
20
|
+
return {
|
|
21
|
+
dragStarted: true,
|
|
22
|
+
draggedKey,
|
|
23
|
+
draggedOriginStart: draggedClip.element.start,
|
|
24
|
+
draggedPreviewStart: draggedClip.previewStart,
|
|
25
|
+
selectedKeys,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
export function resolveResizingElementIds(
|
|
10
30
|
resizingClip: ResizingClipState | null,
|
|
@@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
|
10
10
|
import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
|
|
11
11
|
import {
|
|
12
12
|
buildTimelineMeta,
|
|
13
|
+
resolveMultiDragPreview,
|
|
13
14
|
resolveRenderClipContent,
|
|
14
15
|
resolveResizingElementIds,
|
|
15
16
|
shouldIgnoreTimelinePointerDown,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
} from "./useTimelineTrackLayout";
|
|
31
32
|
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
32
33
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
33
|
-
import {
|
|
34
|
+
import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
34
35
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
35
36
|
import {
|
|
36
37
|
getEffectiveTimelineDuration,
|
|
@@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
|
44
45
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
45
46
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
46
47
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
48
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
49
|
+
import { resolveSnapGuide } from "./timelineSnapping";
|
|
47
50
|
export function useTimelineProviderState({
|
|
48
51
|
onSeek,
|
|
49
52
|
onDrillDown,
|
|
@@ -90,10 +93,10 @@ export function useTimelineProviderState({
|
|
|
90
93
|
onSplitElement: onSplitElementOverride,
|
|
91
94
|
});
|
|
92
95
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
96
|
+
const editContext = useTimelineEditContextOptional();
|
|
93
97
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
94
98
|
useMusicBeatAnalysis();
|
|
95
|
-
const
|
|
96
|
-
const timelineElements = rawElements;
|
|
99
|
+
const timelineElements = usePlayerStore((s) => s.elements);
|
|
97
100
|
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
98
101
|
const duration = usePlayerStore((s) => s.duration);
|
|
99
102
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
@@ -108,7 +111,6 @@ export function useTimelineProviderState({
|
|
|
108
111
|
);
|
|
109
112
|
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
110
113
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
111
|
-
const contentGutter = labelMode ? GUTTER : 0;
|
|
112
114
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
113
115
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
114
116
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -130,8 +132,8 @@ export function useTimelineProviderState({
|
|
|
130
132
|
}, []);
|
|
131
133
|
const lastScrollLeftRef = useRef(0);
|
|
132
134
|
const effectiveDuration = useMemo(
|
|
133
|
-
() => getEffectiveTimelineDuration(duration,
|
|
134
|
-
[duration,
|
|
135
|
+
() => getEffectiveTimelineDuration(duration, timelineElements),
|
|
136
|
+
[duration, timelineElements],
|
|
135
137
|
);
|
|
136
138
|
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
137
139
|
useAutoExpandKeyframedClips(gsapAnimations);
|
|
@@ -406,7 +408,6 @@ export function useTimelineProviderState({
|
|
|
406
408
|
handlePointerCancel,
|
|
407
409
|
} = overlaysProps;
|
|
408
410
|
const { rangeSelection, setRangeSelection } = overlays;
|
|
409
|
-
|
|
410
411
|
const laneGapStrips = useTimelineGapHighlights({
|
|
411
412
|
gapHighlight,
|
|
412
413
|
tracks,
|
|
@@ -416,25 +417,24 @@ export function useTimelineProviderState({
|
|
|
416
417
|
dragActive: draggedClip?.started === true || resizingClip != null,
|
|
417
418
|
displayDuration,
|
|
418
419
|
});
|
|
419
|
-
|
|
420
420
|
const { major, minor, majorTickInterval } = useTimelineTicks(
|
|
421
421
|
displayDuration,
|
|
422
422
|
pps,
|
|
423
423
|
timeDisplayMode,
|
|
424
424
|
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
|
|
425
425
|
);
|
|
426
|
-
|
|
427
426
|
const getPreviewElement = useCallback(
|
|
428
427
|
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
|
429
428
|
[resizingClip],
|
|
430
429
|
);
|
|
431
|
-
|
|
430
|
+
const draggedElement = draggedClip?.element ?? null;
|
|
431
|
+
const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
|
|
432
432
|
const canvasProps = {
|
|
433
433
|
major,
|
|
434
434
|
minor,
|
|
435
435
|
pps,
|
|
436
436
|
contentOrigin,
|
|
437
|
-
contentGutter,
|
|
437
|
+
contentGutter: labelMode ? GUTTER : 0,
|
|
438
438
|
trackContentWidth: displayContentWidth,
|
|
439
439
|
totalH: displayLayout.totalH,
|
|
440
440
|
effectiveDuration,
|
|
@@ -501,6 +501,13 @@ export function useTimelineProviderState({
|
|
|
501
501
|
onResizeElement,
|
|
502
502
|
onMoveElement,
|
|
503
503
|
beatDragging,
|
|
504
|
+
draggedElement,
|
|
505
|
+
snapGuide: resolveSnapGuide(draggedClip, resizingClip),
|
|
506
|
+
multiDragPreview,
|
|
507
|
+
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
|
508
|
+
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
|
509
|
+
onRazorSplit: editContext.onRazorSplit,
|
|
510
|
+
onRazorSplitAll: editContext.onRazorSplitAll,
|
|
504
511
|
};
|
|
505
512
|
const timelineRenderClipContent = resolveRenderClipContent(
|
|
506
513
|
timelineFocus.rowVirtualizationActive,
|
|
@@ -559,7 +566,7 @@ export function useTimelineProviderState({
|
|
|
559
566
|
labelColumnWidth: LABEL_COL_W,
|
|
560
567
|
razorGuide:
|
|
561
568
|
activeTool === "razor" && razorGuideX !== null ? (
|
|
562
|
-
<
|
|
569
|
+
<TimelineRazorGuideOverlay x={razorGuideX} />
|
|
563
570
|
) : null,
|
|
564
571
|
});
|
|
565
572
|
const contextValue: TimelineContextValue = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
|
|
5
|
+
|
|
6
|
+
function timeline(duration: number) {
|
|
7
|
+
return {
|
|
8
|
+
duration: vi.fn(() => duration),
|
|
9
|
+
time: vi.fn(() => 0),
|
|
10
|
+
seek: vi.fn(),
|
|
11
|
+
play: vi.fn(),
|
|
12
|
+
pause: vi.fn(),
|
|
13
|
+
isActive: vi.fn(() => false),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("resolvePlaybackAdapter", () => {
|
|
18
|
+
it("uses the __timelines key matching the document root, not the first key", () => {
|
|
19
|
+
const first = timeline(12);
|
|
20
|
+
const second = timeline(12);
|
|
21
|
+
const third = timeline(12);
|
|
22
|
+
const iframe = document.createElement("iframe");
|
|
23
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
24
|
+
doc.body.innerHTML = '<div data-composition-id="second" data-duration="10"></div>';
|
|
25
|
+
Object.defineProperty(iframe, "contentDocument", { configurable: true, value: doc });
|
|
26
|
+
const win = {
|
|
27
|
+
__timelines: { first, second, third },
|
|
28
|
+
} as never;
|
|
29
|
+
|
|
30
|
+
const adapter = resolvePlaybackAdapter(iframe, win, {
|
|
31
|
+
cache: { current: null },
|
|
32
|
+
warned: { current: false },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(adapter).not.toBeNull();
|
|
36
|
+
adapter?.seek(7);
|
|
37
|
+
expect(second.seek).toHaveBeenCalledWith(7);
|
|
38
|
+
expect(first.seek).not.toHaveBeenCalled();
|
|
39
|
+
expect(third.seek).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|