@hyperframes/studio 0.7.90 → 0.7.93
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-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
8
|
+
import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
|
|
9
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
|
|
13
|
+
const roots: Root[] = [];
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const captured: TimelineElement = {
|
|
21
|
+
id: "child",
|
|
22
|
+
key: "parent::child",
|
|
23
|
+
tag: "div",
|
|
24
|
+
start: 1,
|
|
25
|
+
duration: 2,
|
|
26
|
+
track: 3,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("resolveTimelineContextElement", () => {
|
|
30
|
+
it("returns the current expanded model instead of the captured snapshot", () => {
|
|
31
|
+
const current = { ...captured, start: 4, track: 7 };
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
resolveTimelineContextElement({
|
|
35
|
+
capturedElement: captured,
|
|
36
|
+
targetSessionEpoch: 2,
|
|
37
|
+
sessionEpoch: 2,
|
|
38
|
+
selectedElementId: "parent::child",
|
|
39
|
+
elements: [current],
|
|
40
|
+
}),
|
|
41
|
+
).toBe(current);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("resolves synthetic expanded children that are absent from raw store elements", () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveTimelineContextElement({
|
|
47
|
+
capturedElement: captured,
|
|
48
|
+
targetSessionEpoch: 2,
|
|
49
|
+
sessionEpoch: 2,
|
|
50
|
+
selectedElementId: "parent::child",
|
|
51
|
+
elements: [captured],
|
|
52
|
+
}),
|
|
53
|
+
).toBe(captured);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects stale sessions, changed selection, and removed elements", () => {
|
|
57
|
+
const input = {
|
|
58
|
+
capturedElement: captured,
|
|
59
|
+
targetSessionEpoch: 2,
|
|
60
|
+
sessionEpoch: 2,
|
|
61
|
+
selectedElementId: "parent::child",
|
|
62
|
+
elements: [captured],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect(resolveTimelineContextElement({ ...input, sessionEpoch: 3 })).toBeNull();
|
|
66
|
+
expect(resolveTimelineContextElement({ ...input, selectedElementId: "other" })).toBeNull();
|
|
67
|
+
expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function renderKeyframeOverlay(options: {
|
|
72
|
+
capturedElement: TimelineElement;
|
|
73
|
+
currentElement: TimelineElement;
|
|
74
|
+
setKfContextMenu?: ReturnType<typeof vi.fn>;
|
|
75
|
+
onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
|
|
76
|
+
}) {
|
|
77
|
+
const container = document.createElement("div");
|
|
78
|
+
document.body.appendChild(container);
|
|
79
|
+
const root = createRoot(container);
|
|
80
|
+
roots.push(root);
|
|
81
|
+
const elements = [options.currentElement];
|
|
82
|
+
const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
|
|
83
|
+
const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
|
|
84
|
+
const menu: KeyframeDiamondContextMenuState = {
|
|
85
|
+
x: 10,
|
|
86
|
+
y: 10,
|
|
87
|
+
sessionEpoch: 2,
|
|
88
|
+
element: options.capturedElement,
|
|
89
|
+
elementId: options.capturedElement.key ?? options.capturedElement.id,
|
|
90
|
+
percentage: 50,
|
|
91
|
+
animationId: "child-position",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
act(() => {
|
|
95
|
+
usePlayerStore.setState({
|
|
96
|
+
selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
|
|
97
|
+
timelineSessionEpoch: 2,
|
|
98
|
+
});
|
|
99
|
+
root.render(
|
|
100
|
+
createElement(TimelineOverlays, {
|
|
101
|
+
elements,
|
|
102
|
+
elementsRef: { current: elements },
|
|
103
|
+
theme: defaultTimelineTheme,
|
|
104
|
+
showShortcutHint: false,
|
|
105
|
+
showPopover: false,
|
|
106
|
+
rangeSelection: null,
|
|
107
|
+
setShowPopover: vi.fn(),
|
|
108
|
+
setRangeSelection: vi.fn(),
|
|
109
|
+
kfContextMenu: menu,
|
|
110
|
+
setKfContextMenu,
|
|
111
|
+
onDeleteKeyframe: vi.fn(),
|
|
112
|
+
onDeleteAllKeyframes,
|
|
113
|
+
onMoveKeyframeToPlayhead: vi.fn(),
|
|
114
|
+
clipContextMenu: null,
|
|
115
|
+
setClipContextMenu: vi.fn(),
|
|
116
|
+
currentTime: 0,
|
|
117
|
+
onSplitElement: vi.fn(),
|
|
118
|
+
pinZoomBeforeEdit: vi.fn(),
|
|
119
|
+
onDeleteElement: vi.fn(),
|
|
120
|
+
gapContextMenu: null,
|
|
121
|
+
onDismissGapContextMenu: vi.fn(),
|
|
122
|
+
onCloseTrackGap: vi.fn(),
|
|
123
|
+
onCloseAllTrackGaps: vi.fn(),
|
|
124
|
+
onHoverGapAction: vi.fn(),
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
return { setKfContextMenu, onDeleteAllKeyframes };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("TimelineOverlays context lifecycle", () => {
|
|
132
|
+
it("dismisses a keyframe menu when its selected target becomes stale", () => {
|
|
133
|
+
const setKfContextMenu = vi.fn();
|
|
134
|
+
renderKeyframeOverlay({
|
|
135
|
+
capturedElement: captured,
|
|
136
|
+
currentElement: captured,
|
|
137
|
+
setKfContextMenu,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
act(() => usePlayerStore.setState({ selectedElementId: "other" }));
|
|
141
|
+
|
|
142
|
+
expect(setKfContextMenu).toHaveBeenCalledExactlyOnceWith(null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("dispatches a menu action with the current model element", () => {
|
|
146
|
+
const current = { ...captured, start: 4, track: 7 };
|
|
147
|
+
const onDeleteAllKeyframes = vi.fn();
|
|
148
|
+
renderKeyframeOverlay({
|
|
149
|
+
capturedElement: captured,
|
|
150
|
+
currentElement: current,
|
|
151
|
+
onDeleteAllKeyframes,
|
|
152
|
+
});
|
|
153
|
+
const button = Array.from(document.body.querySelectorAll("button")).find(
|
|
154
|
+
(candidate) => candidate.textContent === "Delete All Keyframes",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
act(() => button?.click());
|
|
158
|
+
|
|
159
|
+
expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useEffect, type MutableRefObject } from "react";
|
|
1
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
2
4
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
5
|
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
4
6
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
@@ -11,10 +13,11 @@ import { ClipContextMenu } from "./ClipContextMenu";
|
|
|
11
13
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
|
12
14
|
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
13
15
|
|
|
14
|
-
interface ClipContextMenuState {
|
|
16
|
+
export interface ClipContextMenuState {
|
|
15
17
|
x: number;
|
|
16
18
|
y: number;
|
|
17
19
|
element: TimelineElement;
|
|
20
|
+
sessionEpoch: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** Resolved model for the empty-lane-space (track gap) context menu. */
|
|
@@ -28,6 +31,8 @@ interface TrackGapContextMenuState {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
interface TimelineOverlaysProps {
|
|
34
|
+
elements: readonly TimelineElement[];
|
|
35
|
+
elementsRef: MutableRefObject<readonly TimelineElement[]>;
|
|
31
36
|
theme: TimelineTheme;
|
|
32
37
|
showShortcutHint: boolean;
|
|
33
38
|
showPopover: boolean;
|
|
@@ -52,10 +57,49 @@ interface TimelineOverlaysProps {
|
|
|
52
57
|
onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
interface TimelineContextTargetInput {
|
|
61
|
+
capturedElement: TimelineElement;
|
|
62
|
+
targetSessionEpoch: number | undefined;
|
|
63
|
+
sessionEpoch: number;
|
|
64
|
+
selectedElementId: string | null;
|
|
65
|
+
elements: readonly TimelineElement[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The captured project session and current selection jointly own a context target. */
|
|
69
|
+
export function resolveTimelineContextElement({
|
|
70
|
+
capturedElement,
|
|
71
|
+
targetSessionEpoch,
|
|
72
|
+
sessionEpoch,
|
|
73
|
+
selectedElementId,
|
|
74
|
+
elements,
|
|
75
|
+
}: TimelineContextTargetInput): TimelineElement | null {
|
|
76
|
+
const identity = capturedElement.key ?? capturedElement.id;
|
|
77
|
+
if (targetSessionEpoch !== sessionEpoch) return null;
|
|
78
|
+
if (selectedElementId !== identity) return null;
|
|
79
|
+
return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readTimelineContextElement(
|
|
83
|
+
capturedElement: TimelineElement,
|
|
84
|
+
targetSessionEpoch: number | undefined,
|
|
85
|
+
elements: readonly TimelineElement[],
|
|
86
|
+
): TimelineElement | null {
|
|
87
|
+
const state = usePlayerStore.getState();
|
|
88
|
+
return resolveTimelineContextElement({
|
|
89
|
+
capturedElement,
|
|
90
|
+
targetSessionEpoch,
|
|
91
|
+
sessionEpoch: state.timelineSessionEpoch,
|
|
92
|
+
selectedElementId: state.selectedElementId,
|
|
93
|
+
elements,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
55
97
|
// The timeline's floating overlays, rendered as siblings above the scroll area:
|
|
56
98
|
// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
|
|
57
99
|
// and the clip context menu.
|
|
58
100
|
export function TimelineOverlays({
|
|
101
|
+
elements,
|
|
102
|
+
elementsRef,
|
|
59
103
|
theme,
|
|
60
104
|
showShortcutHint,
|
|
61
105
|
showPopover,
|
|
@@ -79,6 +123,39 @@ export function TimelineOverlays({
|
|
|
79
123
|
onCloseAllTrackGaps,
|
|
80
124
|
onHoverGapAction,
|
|
81
125
|
}: TimelineOverlaysProps) {
|
|
126
|
+
const selectedElementId = usePlayerStore((state) => state.selectedElementId);
|
|
127
|
+
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
|
128
|
+
const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
|
|
129
|
+
const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
|
|
130
|
+
const keyframeElement = kfContextMenu
|
|
131
|
+
? resolveTimelineContextElement({
|
|
132
|
+
capturedElement: kfContextMenu.element,
|
|
133
|
+
targetSessionEpoch: kfTargetSessionEpoch,
|
|
134
|
+
sessionEpoch,
|
|
135
|
+
selectedElementId,
|
|
136
|
+
elements,
|
|
137
|
+
})
|
|
138
|
+
: null;
|
|
139
|
+
const clipElement = clipContextMenu
|
|
140
|
+
? resolveTimelineContextElement({
|
|
141
|
+
capturedElement: clipContextMenu.element,
|
|
142
|
+
targetSessionEpoch: clipTargetSessionEpoch,
|
|
143
|
+
sessionEpoch,
|
|
144
|
+
selectedElementId,
|
|
145
|
+
elements,
|
|
146
|
+
})
|
|
147
|
+
: null;
|
|
148
|
+
const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
|
|
149
|
+
readTimelineContextElement(element, targetSessionEpoch, elementsRef.current);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
|
|
153
|
+
}, [keyframeElement, kfContextMenu, setKfContextMenu]);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (clipContextMenu && !clipElement) setClipContextMenu(null);
|
|
157
|
+
}, [clipContextMenu, clipElement, setClipContextMenu]);
|
|
158
|
+
|
|
82
159
|
return (
|
|
83
160
|
<>
|
|
84
161
|
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
@@ -98,29 +175,45 @@ export function TimelineOverlays({
|
|
|
98
175
|
/>
|
|
99
176
|
)}
|
|
100
177
|
|
|
101
|
-
{kfContextMenu && (
|
|
178
|
+
{kfContextMenu && keyframeElement && (
|
|
102
179
|
<KeyframeDiamondContextMenu
|
|
103
|
-
state={kfContextMenu}
|
|
180
|
+
state={{ ...kfContextMenu, element: keyframeElement }}
|
|
104
181
|
onClose={() => setKfContextMenu(null)}
|
|
105
|
-
onDelete={(
|
|
106
|
-
|
|
182
|
+
onDelete={(...args) => {
|
|
183
|
+
if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
|
|
184
|
+
onDeleteKeyframe?.(...args);
|
|
185
|
+
}}
|
|
186
|
+
onDeleteAll={(_element, animationId) => {
|
|
187
|
+
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
188
|
+
if (element) onDeleteAllKeyframes?.(element, animationId);
|
|
189
|
+
}}
|
|
107
190
|
onMoveToPlayhead={
|
|
108
|
-
onMoveKeyframeToPlayhead
|
|
191
|
+
onMoveKeyframeToPlayhead
|
|
192
|
+
? (_element, ...args) => {
|
|
193
|
+
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
194
|
+
if (element) onMoveKeyframeToPlayhead(element, ...args);
|
|
195
|
+
}
|
|
196
|
+
: undefined
|
|
109
197
|
}
|
|
110
198
|
/>
|
|
111
199
|
)}
|
|
112
200
|
|
|
113
|
-
{clipContextMenu && (
|
|
201
|
+
{clipContextMenu && clipElement && (
|
|
114
202
|
<ClipContextMenu
|
|
115
203
|
x={clipContextMenu.x}
|
|
116
204
|
y={clipContextMenu.y}
|
|
117
|
-
element={
|
|
205
|
+
element={clipElement}
|
|
118
206
|
currentTime={currentTime}
|
|
119
207
|
onClose={() => setClipContextMenu(null)}
|
|
120
|
-
onSplit={(
|
|
121
|
-
|
|
208
|
+
onSplit={(_element, time) => {
|
|
209
|
+
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
210
|
+
if (element) onSplitElement?.(element, time);
|
|
211
|
+
}}
|
|
212
|
+
onDelete={() => {
|
|
213
|
+
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
214
|
+
if (!element) return;
|
|
122
215
|
pinZoomBeforeEdit();
|
|
123
|
-
onDeleteElement?.(
|
|
216
|
+
onDeleteElement?.(element);
|
|
124
217
|
}}
|
|
125
218
|
/>
|
|
126
219
|
)}
|
|
@@ -70,6 +70,7 @@ function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}
|
|
|
70
70
|
act(() => {
|
|
71
71
|
root.render(
|
|
72
72
|
<TimelinePropertyLanes
|
|
73
|
+
id="timeline-property-lanes-test"
|
|
73
74
|
animations={[]}
|
|
74
75
|
clipStart={0}
|
|
75
76
|
clipDuration={1}
|
|
@@ -367,6 +368,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
367
368
|
act(() => {
|
|
368
369
|
root.render(
|
|
369
370
|
<TimelinePropertyLanes
|
|
371
|
+
id="timeline-property-lanes-selected-test"
|
|
370
372
|
animations={animations}
|
|
371
373
|
clipStart={0}
|
|
372
374
|
clipDuration={1}
|
|
@@ -417,6 +419,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
417
419
|
act(() => {
|
|
418
420
|
root.render(
|
|
419
421
|
<TimelinePropertyLanes
|
|
422
|
+
id="timeline-property-lanes-unselected-test"
|
|
420
423
|
animations={animations}
|
|
421
424
|
clipStart={0}
|
|
422
425
|
clipDuration={1}
|
|
@@ -433,7 +436,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
433
436
|
});
|
|
434
437
|
const unselectedSegments = laneEaseSegments(host, "position");
|
|
435
438
|
expect(unselectedSegments).toHaveLength(2);
|
|
436
|
-
expect(
|
|
439
|
+
expect(laneEaseButtons(host, "position")).toHaveLength(2);
|
|
437
440
|
act(() => root.unmount());
|
|
438
441
|
});
|
|
439
442
|
|
|
@@ -461,13 +464,14 @@ describe("TimelinePropertyLanes", () => {
|
|
|
461
464
|
expect(new Set(paths).size).toBe(3);
|
|
462
465
|
// Uniqueness alone passes even when the curves are swapped between segments.
|
|
463
466
|
// Each segment is labelled with the ease it draws, so pin the ORDER: a
|
|
464
|
-
// segment carries the ease of the keyframe it arrives at.
|
|
467
|
+
// segment carries the ease of the keyframe it arrives at. The trailing time
|
|
468
|
+
// is what separates two segments that share an ease name in the same lane.
|
|
465
469
|
expect(
|
|
466
470
|
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
|
|
467
471
|
).toEqual([
|
|
468
|
-
"Edit none easing",
|
|
469
|
-
"Edit power2.out easing",
|
|
470
|
-
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
|
|
472
|
+
"Edit none easing after 0s",
|
|
473
|
+
"Edit power2.out easing after 0.33s",
|
|
474
|
+
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing after 0.66s",
|
|
471
475
|
]);
|
|
472
476
|
act(() => root.unmount());
|
|
473
477
|
});
|
|
@@ -9,6 +9,7 @@ import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
|
|
9
9
|
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
|
10
10
|
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
11
11
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
12
|
+
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
|
12
13
|
|
|
13
14
|
export interface TimelinePropertyLanesProps {
|
|
14
15
|
/**
|
|
@@ -16,7 +17,7 @@ export interface TimelinePropertyLanesProps {
|
|
|
16
17
|
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
|
|
17
18
|
* TimelineLanes, which owns both this subtree and the caret's.
|
|
18
19
|
*/
|
|
19
|
-
id
|
|
20
|
+
id: string;
|
|
20
21
|
animations: readonly GsapAnimation[];
|
|
21
22
|
clipStart: number;
|
|
22
23
|
clipDuration: number;
|
|
@@ -27,6 +28,7 @@ export interface TimelinePropertyLanesProps {
|
|
|
27
28
|
currentPercentage: number;
|
|
28
29
|
elementId: string;
|
|
29
30
|
selectedKeyframes: ReadonlySet<string>;
|
|
31
|
+
rovingTargetId?: string | null;
|
|
30
32
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
31
33
|
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
32
34
|
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
@@ -187,6 +189,7 @@ export function TimelinePropertyLanes({
|
|
|
187
189
|
currentPercentage,
|
|
188
190
|
elementId,
|
|
189
191
|
selectedKeyframes,
|
|
192
|
+
rovingTargetId = null,
|
|
190
193
|
onSelectSegment,
|
|
191
194
|
onClickKeyframe,
|
|
192
195
|
onShiftClickKeyframe,
|
|
@@ -223,9 +226,11 @@ export function TimelinePropertyLanes({
|
|
|
223
226
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
224
227
|
<div
|
|
225
228
|
key={group}
|
|
229
|
+
id={timelineLogicalRowCellId(id, timelinePropertyRowId(elementId, group), "content")}
|
|
226
230
|
role="group"
|
|
227
231
|
aria-label={`${group} keyframes`}
|
|
228
232
|
data-property-group={group}
|
|
233
|
+
data-timeline-element-id={elementId}
|
|
229
234
|
data-timeline-property-lane=""
|
|
230
235
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
231
236
|
className="absolute"
|
|
@@ -240,12 +245,14 @@ export function TimelinePropertyLanes({
|
|
|
240
245
|
keyframesData={keyframesData}
|
|
241
246
|
clipWidthPx={clipWidthPx}
|
|
242
247
|
clipHeightPx={LANE_H}
|
|
243
|
-
clipDuration={clipDuration}
|
|
244
248
|
accentColor={accentColor}
|
|
245
249
|
isSelected={isSelected}
|
|
246
250
|
currentPercentage={currentPercentage}
|
|
247
251
|
elementId={elementId}
|
|
252
|
+
clipStart={clipStart}
|
|
253
|
+
clipDuration={clipDuration}
|
|
248
254
|
selectedKeyframes={selectedKeyframes}
|
|
255
|
+
rovingTargetId={rovingTargetId}
|
|
249
256
|
onSelectSegment={onSelectSegment}
|
|
250
257
|
onClickKeyframe={onClickKeyframe}
|
|
251
258
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./trackHeaderLaneState";
|
|
17
17
|
import { valueReadout } from "./trackHeaderLaneValues";
|
|
18
18
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
19
|
+
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
|
19
20
|
|
|
20
21
|
interface TimelineTrackHeaderProps {
|
|
21
22
|
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
|
|
@@ -41,6 +42,7 @@ interface TimelineTrackHeaderProps {
|
|
|
41
42
|
currentTime: number;
|
|
42
43
|
isTrackHidden: boolean;
|
|
43
44
|
isAudioTrack: boolean;
|
|
45
|
+
rovingTargetId?: string | null;
|
|
44
46
|
theme: TimelineTheme;
|
|
45
47
|
onToggleClipExpanded: () => void;
|
|
46
48
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
@@ -191,6 +193,7 @@ function PropertyGroupNavigation({
|
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
function PropertyGroupHeaderRow({
|
|
196
|
+
lanesId,
|
|
194
197
|
lane,
|
|
195
198
|
laneIndex,
|
|
196
199
|
isLastLane,
|
|
@@ -201,7 +204,9 @@ function PropertyGroupHeaderRow({
|
|
|
201
204
|
columnWidth,
|
|
202
205
|
onTogglePropertyGroupKeyframe,
|
|
203
206
|
onSeek,
|
|
207
|
+
rovingTargetId = null,
|
|
204
208
|
}: {
|
|
209
|
+
lanesId: string;
|
|
205
210
|
lane: TimelinePropertyLane;
|
|
206
211
|
laneIndex: number;
|
|
207
212
|
isLastLane: boolean;
|
|
@@ -212,7 +217,9 @@ function PropertyGroupHeaderRow({
|
|
|
212
217
|
columnWidth: number;
|
|
213
218
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
214
219
|
onSeek?: (time: number) => void;
|
|
220
|
+
rovingTargetId: string | null;
|
|
215
221
|
}) {
|
|
222
|
+
const elementId = expandedElement.key ?? expandedElement.id;
|
|
216
223
|
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
|
217
224
|
lane,
|
|
218
225
|
currentTime,
|
|
@@ -221,6 +228,10 @@ function PropertyGroupHeaderRow({
|
|
|
221
228
|
|
|
222
229
|
return (
|
|
223
230
|
<div
|
|
231
|
+
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
|
|
232
|
+
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
|
|
233
|
+
data-timeline-element-id={elementId}
|
|
234
|
+
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
|
|
224
235
|
data-property-group={lane.group}
|
|
225
236
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
226
237
|
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
|
@@ -298,6 +309,7 @@ export function TimelineTrackHeader({
|
|
|
298
309
|
onToggleTrackHidden,
|
|
299
310
|
onTogglePropertyGroupKeyframe,
|
|
300
311
|
onSeek,
|
|
312
|
+
rovingTargetId = null,
|
|
301
313
|
}: TimelineTrackHeaderProps) {
|
|
302
314
|
const clipPercentage = keyframeClip
|
|
303
315
|
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
|
@@ -314,6 +326,8 @@ export function TimelineTrackHeader({
|
|
|
314
326
|
|
|
315
327
|
return (
|
|
316
328
|
<div
|
|
329
|
+
role="rowheader"
|
|
330
|
+
aria-colindex={1}
|
|
317
331
|
className={`sticky left-0 z-[12] shrink-0 ${
|
|
318
332
|
!isKeyframeLayer
|
|
319
333
|
? showTrackLabel
|
|
@@ -374,6 +388,7 @@ export function TimelineTrackHeader({
|
|
|
374
388
|
lanes.map((lane, laneIndex) => (
|
|
375
389
|
<PropertyGroupHeaderRow
|
|
376
390
|
key={lane.group}
|
|
391
|
+
lanesId={lanesId}
|
|
377
392
|
lane={lane}
|
|
378
393
|
laneIndex={laneIndex}
|
|
379
394
|
isLastLane={laneIndex === lanes.length - 1}
|
|
@@ -384,6 +399,7 @@ export function TimelineTrackHeader({
|
|
|
384
399
|
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
|
385
400
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
386
401
|
onSeek={onSeek}
|
|
402
|
+
rovingTargetId={rovingTargetId}
|
|
387
403
|
/>
|
|
388
404
|
))}
|
|
389
405
|
</>
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { timelineLogicalRowCellId } from "./timelineNavigationIdentity";
|
|
3
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
2
4
|
|
|
3
5
|
interface TimelineTrackRowProps {
|
|
4
6
|
index: number;
|
|
5
7
|
rowKey: number;
|
|
6
|
-
|
|
8
|
+
logicalRow: TimelineLogicalRow;
|
|
9
|
+
propertyRows: readonly TimelineLogicalRow[];
|
|
10
|
+
lanesId: string;
|
|
7
11
|
top: number;
|
|
8
12
|
height: number;
|
|
9
13
|
virtualized: boolean;
|
|
10
14
|
background: string;
|
|
11
15
|
borderColor: string;
|
|
16
|
+
rovingTargetId?: string | null;
|
|
12
17
|
children: ReactNode;
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -16,23 +21,24 @@ interface TimelineTrackRowProps {
|
|
|
16
21
|
export function TimelineTrackRow({
|
|
17
22
|
index,
|
|
18
23
|
rowKey,
|
|
19
|
-
|
|
24
|
+
logicalRow,
|
|
25
|
+
propertyRows,
|
|
26
|
+
lanesId,
|
|
20
27
|
top,
|
|
21
28
|
height,
|
|
22
29
|
virtualized,
|
|
23
30
|
background,
|
|
24
31
|
borderColor,
|
|
32
|
+
rovingTargetId = null,
|
|
25
33
|
children,
|
|
26
34
|
}: TimelineTrackRowProps) {
|
|
27
35
|
return (
|
|
28
36
|
<div
|
|
29
|
-
role="
|
|
30
|
-
aria-posinset={index + 1}
|
|
31
|
-
aria-setsize={rowCount}
|
|
37
|
+
role="rowgroup"
|
|
32
38
|
data-index={index}
|
|
33
39
|
data-timeline-row={index}
|
|
34
40
|
data-timeline-row-key={rowKey}
|
|
35
|
-
className={
|
|
41
|
+
className={virtualized ? "absolute left-0 right-0" : "relative"}
|
|
36
42
|
style={{
|
|
37
43
|
top: virtualized ? top : undefined,
|
|
38
44
|
height,
|
|
@@ -40,7 +46,52 @@ export function TimelineTrackRow({
|
|
|
40
46
|
borderBottom: `1px solid ${borderColor}`,
|
|
41
47
|
}}
|
|
42
48
|
>
|
|
43
|
-
|
|
49
|
+
<div
|
|
50
|
+
role="row"
|
|
51
|
+
aria-rowindex={logicalRow.logicalIndex + 1}
|
|
52
|
+
aria-level={logicalRow.level}
|
|
53
|
+
aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined}
|
|
54
|
+
data-timeline-logical-row-id={logicalRow.id}
|
|
55
|
+
data-timeline-focus-id={logicalRow.id}
|
|
56
|
+
tabIndex={rovingTargetId === logicalRow.id ? 0 : -1}
|
|
57
|
+
className="flex"
|
|
58
|
+
style={{ height }}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
{propertyRows.map((row) => {
|
|
63
|
+
const group = row.propertyGroup;
|
|
64
|
+
const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length;
|
|
65
|
+
const easeCount = row.items.filter((item) => item.kind === "ease").length;
|
|
66
|
+
return (
|
|
67
|
+
// ponytail: aria-owns maps this hidden logical row onto the two visible
|
|
68
|
+
// property-lane cells without duplicating interactive controls.
|
|
69
|
+
<div
|
|
70
|
+
key={row.id}
|
|
71
|
+
role="row"
|
|
72
|
+
aria-rowindex={row.logicalIndex + 1}
|
|
73
|
+
aria-level={row.level}
|
|
74
|
+
data-property-group={group}
|
|
75
|
+
data-timeline-logical-row-id={row.id}
|
|
76
|
+
className="sr-only"
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
role="rowheader"
|
|
80
|
+
aria-colindex={1}
|
|
81
|
+
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
|
|
82
|
+
>
|
|
83
|
+
{group}
|
|
84
|
+
</div>
|
|
85
|
+
<div
|
|
86
|
+
role="gridcell"
|
|
87
|
+
aria-colindex={2}
|
|
88
|
+
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "content")}
|
|
89
|
+
>
|
|
90
|
+
{keyframeCount} keyframes, {easeCount} ease controls
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
44
95
|
</div>
|
|
45
96
|
);
|
|
46
97
|
}
|
|
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
|
|
|
73
73
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
74
74
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
75
75
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
76
|
-
onDeleteAllKeyframes?: (element: TimelineElement) => void;
|
|
76
|
+
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
|
77
77
|
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
78
78
|
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
|
79
79
|
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|