@hyperframes/studio 0.8.96 → 0.8.98
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-ClfxdXeC.js → hyperframes-player-C6muFtfk.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B6vU39NM.js} +1 -1
- package/dist/assets/{index-BNQLP5_v.js → index-BxnoLgxG.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-itc1XziP.js} +198 -192
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +23 -6
- package/dist/index.html +2 -2
- package/dist/index.js +2153 -1969
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +9 -8
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/index.ts +4 -1
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +24 -7
- package/src/player/components/TimelineTypes.ts +12 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +44 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { describe, expect, it, vi, type Mock } from "vitest";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
|
6
|
+
import { TimelineReadOnlyContext } from "./timelineReadOnly";
|
|
6
7
|
import { PAD_X } from "./automationLaneGeometry";
|
|
7
8
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
8
9
|
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
@@ -1965,3 +1966,25 @@ describe("TimelineAutomationLane — a read-only lane offers nothing to grab", (
|
|
|
1965
1966
|
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
|
|
1966
1967
|
});
|
|
1967
1968
|
});
|
|
1969
|
+
|
|
1970
|
+
describe("TimelineAutomationLane inside a read-only timeline", () => {
|
|
1971
|
+
it("reports a point drag and a double-click once each, and writes nothing", () => {
|
|
1972
|
+
const onReadOnlyPress = vi.fn();
|
|
1973
|
+
const props = laneProps({ automation: ramp, readOnly: true });
|
|
1974
|
+
const { container } = render(
|
|
1975
|
+
<TimelineReadOnlyContext.Provider value={onReadOnlyPress}>
|
|
1976
|
+
<TimelineAutomationLane {...props} />
|
|
1977
|
+
</TimelineReadOnlyContext.Provider>,
|
|
1978
|
+
);
|
|
1979
|
+
const svg = container.querySelector("svg")!;
|
|
1980
|
+
stubBox(svg, BOX);
|
|
1981
|
+
fire(svg, "pointerdown", at(0, 1));
|
|
1982
|
+
fire(svg, "pointermove", at(1, 0.6));
|
|
1983
|
+
fire(svg, "pointermove", at(2, 0.4));
|
|
1984
|
+
fire(svg, "pointerup", at(2, 0.4));
|
|
1985
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
1986
|
+
fire(svg, "dblclick", at(2, 0.4));
|
|
1987
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(2);
|
|
1988
|
+
expect(props.onCommit).not.toHaveBeenCalled();
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type TimelineKeyframeRetimeHandle,
|
|
12
12
|
} from "./useTimelineKeyframeHandlers";
|
|
13
13
|
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
|
|
14
|
+
import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly";
|
|
14
15
|
import {
|
|
15
16
|
DIAMOND_RATIO,
|
|
16
17
|
keyframeTimeLabel,
|
|
@@ -92,6 +93,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
92
93
|
// This lane only arms it and renders the preview it publishes.
|
|
93
94
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
94
95
|
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
|
96
|
+
const readOnlyPress = useTimelineReadOnlyPress();
|
|
95
97
|
// Retime destinations already dispatched but not yet in the keyframe cache, so
|
|
96
98
|
// a rapid second drag composes from where the first move left the keyframe
|
|
97
99
|
// instead of the stale rendered value.
|
|
@@ -227,7 +229,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
227
229
|
: null;
|
|
228
230
|
const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
|
|
229
231
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
230
|
-
const canDrag = isSelected && !!onMoveKeyframe;
|
|
232
|
+
const canDrag = isSelected && !!onMoveKeyframe && !readOnlyPress;
|
|
231
233
|
|
|
232
234
|
return (
|
|
233
235
|
<div
|
|
@@ -285,7 +287,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
285
287
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
286
288
|
if (e.button !== 0) return;
|
|
287
289
|
e.stopPropagation();
|
|
288
|
-
if (!canDrag)
|
|
290
|
+
if (!canDrag) {
|
|
291
|
+
if (isSelected) reportPressOnTravel(e, readOnlyPress);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
289
294
|
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
|
290
295
|
event: e,
|
|
291
296
|
elementId,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { useEffect, useRef, useState } from "react";
|
|
13
13
|
import { createPortal } from "react-dom";
|
|
14
|
+
import { useTimelineReadOnlyPress } from "./timelineReadOnly";
|
|
14
15
|
import {
|
|
15
16
|
enabledAudioFxNodes,
|
|
16
17
|
parseAudioFxChain,
|
|
@@ -197,8 +198,10 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
197
198
|
const transport = useAuditionTransport();
|
|
198
199
|
/** Whether THIS audition lifted a mute, so only it puts one back. */
|
|
199
200
|
const borrowedMute = useRef(false);
|
|
201
|
+
const readOnlyPress = useTimelineReadOnlyPress();
|
|
200
202
|
|
|
201
203
|
const openAt = () => {
|
|
204
|
+
if (readOnlyPress) return readOnlyPress();
|
|
202
205
|
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
|
203
206
|
setOpen(true);
|
|
204
207
|
};
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./useTimelineTrackLayout";
|
|
18
18
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
19
19
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
20
|
-
import {
|
|
20
|
+
import { useTimelineClipCapabilities } from "./timelineReadOnly";
|
|
21
21
|
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
|
22
22
|
import { usePlayerStore } from "../store/playerStore";
|
|
23
23
|
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
|
@@ -107,6 +107,7 @@ export function TimelineLanes({
|
|
|
107
107
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
108
108
|
useTimelineGroupDisclosure();
|
|
109
109
|
const automationLanes = useAutomationLanes();
|
|
110
|
+
const getClipCapabilities = useTimelineClipCapabilities();
|
|
110
111
|
const transitionSeamsByTrack = useMemo(
|
|
111
112
|
() =>
|
|
112
113
|
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
|
|
@@ -368,7 +369,7 @@ export function TimelineLanes({
|
|
|
368
369
|
// diamonds on their own bar instead.
|
|
369
370
|
const isTrackKeyframeClip = elementKey === keyframeClipKey;
|
|
370
371
|
const showsLanes = isTrackKeyframeClip && rowExpanded;
|
|
371
|
-
const capabilities =
|
|
372
|
+
const capabilities = getClipCapabilities(el);
|
|
372
373
|
const isSelected =
|
|
373
374
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
374
375
|
const isComposition = !!el.compositionSrc;
|
|
@@ -71,11 +71,12 @@ describe("resolveTimelineContextElement", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
function
|
|
74
|
+
function renderOverlay(options: {
|
|
75
75
|
capturedElement: TimelineElement;
|
|
76
76
|
currentElement: TimelineElement;
|
|
77
77
|
setKfContextMenu?: ReturnType<typeof vi.fn>;
|
|
78
78
|
onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
|
|
79
|
+
overlays?: Partial<TimelineOverlaysState>;
|
|
79
80
|
}) {
|
|
80
81
|
const container = document.createElement("div");
|
|
81
82
|
document.body.appendChild(container);
|
|
@@ -117,6 +118,7 @@ function renderKeyframeOverlay(options: {
|
|
|
117
118
|
onCloseTrackGap: vi.fn(),
|
|
118
119
|
onCloseAllTrackGaps: vi.fn(),
|
|
119
120
|
onHoverGapAction: vi.fn(),
|
|
121
|
+
...options.overlays,
|
|
120
122
|
} satisfies TimelineOverlaysState;
|
|
121
123
|
const contextValue = {
|
|
122
124
|
state: {
|
|
@@ -151,22 +153,24 @@ function renderKeyframeOverlay(options: {
|
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
const render = () =>
|
|
157
|
+
root.render(
|
|
158
|
+
createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
|
|
159
|
+
);
|
|
154
160
|
act(() => {
|
|
155
161
|
usePlayerStore.setState({
|
|
156
162
|
selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
|
|
157
163
|
timelineSessionEpoch: 2,
|
|
158
164
|
});
|
|
159
|
-
|
|
160
|
-
createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
|
|
161
|
-
);
|
|
165
|
+
render();
|
|
162
166
|
});
|
|
163
|
-
return { setKfContextMenu, onDeleteAllKeyframes };
|
|
167
|
+
return { setKfContextMenu, onDeleteAllKeyframes, rerender: () => act(render) };
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
describe("TimelineOverlays context lifecycle", () => {
|
|
167
171
|
it("dismisses a keyframe menu when its selected target becomes stale", () => {
|
|
168
172
|
const setKfContextMenu = vi.fn();
|
|
169
|
-
|
|
173
|
+
renderOverlay({
|
|
170
174
|
capturedElement: captured,
|
|
171
175
|
currentElement: captured,
|
|
172
176
|
setKfContextMenu,
|
|
@@ -180,7 +184,7 @@ describe("TimelineOverlays context lifecycle", () => {
|
|
|
180
184
|
it("dispatches a menu action with the current model element", () => {
|
|
181
185
|
const current = { ...captured, start: 4, track: 7 };
|
|
182
186
|
const onDeleteAllKeyframes = vi.fn();
|
|
183
|
-
|
|
187
|
+
renderOverlay({
|
|
184
188
|
capturedElement: captured,
|
|
185
189
|
currentElement: current,
|
|
186
190
|
onDeleteAllKeyframes,
|
|
@@ -193,4 +197,23 @@ describe("TimelineOverlays context lifecycle", () => {
|
|
|
193
197
|
|
|
194
198
|
expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
|
|
195
199
|
});
|
|
200
|
+
|
|
201
|
+
it("builds the host's clip menu items once for the current model element and lists them first", () => {
|
|
202
|
+
const current = { ...captured, start: 4, track: 7 };
|
|
203
|
+
const clipMenuItems = vi.fn(() => [{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
|
|
204
|
+
const { rerender } = renderOverlay({
|
|
205
|
+
capturedElement: captured,
|
|
206
|
+
currentElement: current,
|
|
207
|
+
overlays: {
|
|
208
|
+
kfContextMenu: null,
|
|
209
|
+
clipContextMenu: { x: 10, y: 10, sessionEpoch: 2, element: captured },
|
|
210
|
+
clipMenuItems,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
rerender();
|
|
215
|
+
|
|
216
|
+
expect(clipMenuItems).toHaveBeenCalledExactlyOnceWith(current);
|
|
217
|
+
expect(document.body.querySelector('[role="menuitem"]')?.textContent).toBe("Ask");
|
|
218
|
+
});
|
|
196
219
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { EditPopover } from "./EditModal";
|
|
4
4
|
import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
|
|
@@ -152,6 +152,11 @@ export function TimelineClipMenuOverlay() {
|
|
|
152
152
|
useEffect(() => {
|
|
153
153
|
if (clipContextMenu && !element) setClipContextMenu(null);
|
|
154
154
|
}, [element, clipContextMenu, setClipContextMenu]);
|
|
155
|
+
const { clipMenuItems } = overlay;
|
|
156
|
+
const hostItems = useMemo(
|
|
157
|
+
() => (element ? clipMenuItems?.(element) : undefined),
|
|
158
|
+
[clipMenuItems, element],
|
|
159
|
+
);
|
|
155
160
|
if (!clipContextMenu || !element) return null;
|
|
156
161
|
const readCurrentElement = () =>
|
|
157
162
|
resolveTimelineContextElement({
|
|
@@ -183,6 +188,7 @@ export function TimelineClipMenuOverlay() {
|
|
|
183
188
|
onPaste={overlay.onPasteClip}
|
|
184
189
|
onDuplicate={overlay.onDuplicateClip}
|
|
185
190
|
canPaste={overlay.canPasteClip?.() ?? false}
|
|
191
|
+
hostItems={hostItems}
|
|
186
192
|
/>
|
|
187
193
|
);
|
|
188
194
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
4
7
|
type ComponentProps,
|
|
5
8
|
type CSSProperties,
|
|
6
9
|
type MouseEvent,
|
|
@@ -26,6 +29,7 @@ import type { TimelineTheme } from "./timelineTheme";
|
|
|
26
29
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
27
30
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
28
31
|
import { useTimelineProviderState } from "./useTimelineProviderState";
|
|
32
|
+
import { TimelineReadOnlyContext } from "./timelineReadOnly";
|
|
29
33
|
import {
|
|
30
34
|
TimelineEditProvider,
|
|
31
35
|
useTimelineEditContextValue,
|
|
@@ -107,6 +111,7 @@ export interface TimelineOverlaysState {
|
|
|
107
111
|
onPasteClip?: () => Promise<void>;
|
|
108
112
|
onDuplicateClip?: () => Promise<boolean>;
|
|
109
113
|
canPasteClip?: () => boolean;
|
|
114
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
110
115
|
gapContextMenu: TrackGapContextMenuState | null;
|
|
111
116
|
onDismissGapContextMenu: () => void;
|
|
112
117
|
onCloseTrackGap: () => void;
|
|
@@ -180,16 +185,28 @@ export interface TimelineContextValue {
|
|
|
180
185
|
|
|
181
186
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
182
187
|
|
|
183
|
-
|
|
188
|
+
const NO_EDITS: TimelineEditCallbacks = {};
|
|
189
|
+
|
|
190
|
+
export function TimelineProvider({
|
|
191
|
+
children,
|
|
192
|
+
readOnly = false,
|
|
193
|
+
onReadOnlyPress,
|
|
194
|
+
...props
|
|
195
|
+
}: TimelineProps & { children: ReactNode }) {
|
|
184
196
|
const editContext = useTimelineEditContextValue();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
const onReadOnlyPressRef = useRef(onReadOnlyPress);
|
|
198
|
+
useLayoutEffect(() => {
|
|
199
|
+
onReadOnlyPressRef.current = onReadOnlyPress;
|
|
200
|
+
});
|
|
201
|
+
const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []);
|
|
202
|
+
const readOnlyPress = readOnly ? refuse : null;
|
|
203
|
+
return (
|
|
204
|
+
<TimelineReadOnlyContext.Provider value={readOnlyPress}>
|
|
205
|
+
<TimelineEditProvider value={readOnly ? NO_EDITS : (editContext ?? props)}>
|
|
188
206
|
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
|
189
207
|
</TimelineEditProvider>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
208
|
+
</TimelineReadOnlyContext.Provider>
|
|
209
|
+
);
|
|
193
210
|
}
|
|
194
211
|
|
|
195
212
|
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
@@ -12,6 +12,15 @@ export interface TimelineClipRenderContext {
|
|
|
12
12
|
rich: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface TimelineClipMenuItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
shortcut?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
onSelect: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
export interface TimelineProps
|
|
16
25
|
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
17
26
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
@@ -33,8 +42,11 @@ export interface TimelineProps
|
|
|
33
42
|
onPasteClip?: () => Promise<void>;
|
|
34
43
|
onDuplicateClip?: () => Promise<boolean>;
|
|
35
44
|
canPasteClip?: () => boolean;
|
|
45
|
+
clipMenuItems?: (element: TimelineElement) => readonly TimelineClipMenuItem[];
|
|
36
46
|
theme?: Partial<TimelineTheme>;
|
|
37
47
|
showAudioEffects?: boolean;
|
|
38
48
|
showKeyframes?: boolean;
|
|
39
49
|
trackPadding?: TimelineTrackPadding;
|
|
50
|
+
readOnly?: boolean;
|
|
51
|
+
onReadOnlyPress?: () => void;
|
|
40
52
|
}
|
|
@@ -2,10 +2,10 @@ import { useEffect, type RefObject } from "react";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* APG menu keyboard basics for the timeline context menus: focuses the first
|
|
5
|
-
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and
|
|
6
|
-
* focus to the
|
|
7
|
-
* `role="menu"` on the container and `role="menuitem"`
|
|
8
|
-
* (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
5
|
+
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and gives
|
|
6
|
+
* focus back to the element that held it at open, on unmount, unless an item moved
|
|
7
|
+
* it elsewhere. Pair with `role="menu"` on the container and `role="menuitem"`
|
|
8
|
+
* on the buttons (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
9
9
|
*/
|
|
10
10
|
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
|
|
11
11
|
useEffect(() => {
|
|
@@ -39,7 +39,14 @@ export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): v
|
|
|
39
39
|
|
|
40
40
|
return () => {
|
|
41
41
|
menu.removeEventListener("keydown", onKeyDown);
|
|
42
|
-
|
|
42
|
+
const active = document.activeElement;
|
|
43
|
+
const focusLeftWithMenu =
|
|
44
|
+
active === null || active === document.body || menu.contains(active);
|
|
45
|
+
if (
|
|
46
|
+
focusLeftWithMenu &&
|
|
47
|
+
previouslyFocused instanceof HTMLElement &&
|
|
48
|
+
document.contains(previouslyFocused)
|
|
49
|
+
) {
|
|
43
50
|
previouslyFocused.focus();
|
|
44
51
|
}
|
|
45
52
|
};
|
|
@@ -53,7 +53,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
53
53
|
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
|
|
54
54
|
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
55
55
|
{ key: "[", label: "Select clips starting before the playhead" },
|
|
56
|
-
{ key: "]", label: "Select clips
|
|
56
|
+
{ key: "]", label: "Select clips running at or after the playhead" },
|
|
57
57
|
{ key: "⌘G", label: "Group elements" },
|
|
58
58
|
{ key: "⌘⇧G", label: "Ungroup" },
|
|
59
59
|
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
@@ -58,10 +58,12 @@ type PointerDownAction =
|
|
|
58
58
|
*/
|
|
59
59
|
function isIntentBlocked(
|
|
60
60
|
intent: BlockedTimelineEditIntent | null,
|
|
61
|
+
capabilities: TimelineEditCapabilities,
|
|
61
62
|
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
62
63
|
onMoveElement: ClipGestureDeps["onMoveElement"],
|
|
63
64
|
): intent is BlockedTimelineEditIntent {
|
|
64
65
|
if (!intent) return false;
|
|
66
|
+
if (capabilities.readOnly) return true;
|
|
65
67
|
return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -88,7 +90,7 @@ function resolvePointerDownAction(
|
|
|
88
90
|
handleWidth: CLIP_HANDLE_W,
|
|
89
91
|
capabilities,
|
|
90
92
|
});
|
|
91
|
-
if (isIntentBlocked(intent, onResizeElement, onMoveElement)) {
|
|
93
|
+
if (isIntentBlocked(intent, capabilities, onResizeElement, onMoveElement)) {
|
|
92
94
|
return { kind: "block", intent, rect };
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -23,6 +23,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
|
23
23
|
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
|
24
24
|
contentOrigin: number;
|
|
25
25
|
sessionEpoch: number;
|
|
26
|
+
readOnlyPress?: (() => void) | null;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -132,6 +133,7 @@ export function useTimelineAssetDrop({
|
|
|
132
133
|
onBlockDrop,
|
|
133
134
|
onCompositionDrop,
|
|
134
135
|
sessionEpoch,
|
|
136
|
+
readOnlyPress,
|
|
135
137
|
}: UseTimelineAssetDropOptions) {
|
|
136
138
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
137
139
|
const [dropPreview, setDropPreview] = useState<TimelineDropPlacement | null>(null);
|
|
@@ -140,6 +142,7 @@ export function useTimelineAssetDrop({
|
|
|
140
142
|
);
|
|
141
143
|
const autoScrollRafRef = useRef(0);
|
|
142
144
|
const activeDropEpochRef = useRef<number | null>(null);
|
|
145
|
+
const refusedDragRef = useRef(false);
|
|
143
146
|
|
|
144
147
|
const stopAutoScroll = useCallback(() => {
|
|
145
148
|
dragPointerRef.current = null;
|
|
@@ -210,6 +213,12 @@ export function useTimelineAssetDrop({
|
|
|
210
213
|
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
|
211
214
|
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
|
212
215
|
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
|
216
|
+
if (readOnlyPress) {
|
|
217
|
+
e.dataTransfer.dropEffect = "none";
|
|
218
|
+
if (!refusedDragRef.current) readOnlyPress();
|
|
219
|
+
refusedDragRef.current = true;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
213
222
|
e.preventDefault();
|
|
214
223
|
e.dataTransfer.dropEffect = "copy";
|
|
215
224
|
activeDropEpochRef.current = sessionEpoch;
|
|
@@ -222,11 +231,12 @@ export function useTimelineAssetDrop({
|
|
|
222
231
|
);
|
|
223
232
|
syncAutoScroll(e.clientX, e.clientY);
|
|
224
233
|
},
|
|
225
|
-
[resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
|
234
|
+
[readOnlyPress, resolveDropPlacement, sessionEpoch, syncAutoScroll],
|
|
226
235
|
);
|
|
227
236
|
|
|
228
237
|
const clearDropPreview = useCallback(() => {
|
|
229
238
|
activeDropEpochRef.current = null;
|
|
239
|
+
refusedDragRef.current = false;
|
|
230
240
|
stopAutoScroll();
|
|
231
241
|
setIsDragOver(false);
|
|
232
242
|
setDropPreview(null);
|
|
@@ -27,3 +27,27 @@ export function installTimelineMountEnv(): void {
|
|
|
27
27
|
document.body.innerHTML = "";
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export const KEYFRAMED_CARD = new Map([
|
|
32
|
+
[
|
|
33
|
+
"card",
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
id: "card-position",
|
|
37
|
+
targetSelector: "#card",
|
|
38
|
+
method: "to" as const,
|
|
39
|
+
position: 0,
|
|
40
|
+
duration: 2,
|
|
41
|
+
properties: {},
|
|
42
|
+
propertyGroup: "position" as const,
|
|
43
|
+
keyframes: {
|
|
44
|
+
format: "percentage" as const,
|
|
45
|
+
keyframes: [
|
|
46
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
47
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
],
|
|
53
|
+
]);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useEffectEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getTimelineEditCapabilities,
|
|
4
|
+
type TimelineEditCapabilities,
|
|
5
|
+
} from "./timelineEditCapabilities";
|
|
6
|
+
|
|
7
|
+
export const TimelineReadOnlyContext = createContext<(() => void) | null>(null);
|
|
8
|
+
|
|
9
|
+
/** The refusal to report while the timeline is read-only; null while it is editable. */
|
|
10
|
+
export function useTimelineReadOnlyPress(): (() => void) | null {
|
|
11
|
+
return useContext(TimelineReadOnlyContext);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const READ_ONLY_CLIP: TimelineEditCapabilities = {
|
|
15
|
+
canMove: false,
|
|
16
|
+
canTrimStart: false,
|
|
17
|
+
canTrimEnd: false,
|
|
18
|
+
readOnly: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Read-only locks every clip; its presses then take the blocked-edit path to the refusal. */
|
|
22
|
+
export function useTimelineClipCapabilities(): typeof getTimelineEditCapabilities {
|
|
23
|
+
return useTimelineReadOnlyPress() ? () => READ_ONLY_CLIP : getTimelineEditCapabilities;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read-only swaps each callback for the refusal: the press is reported instead of acted on. */
|
|
27
|
+
export function refuseWhenReadOnly<T extends object>(
|
|
28
|
+
readOnlyPress: (() => void) | null,
|
|
29
|
+
callbacks: T,
|
|
30
|
+
): T {
|
|
31
|
+
if (!readOnlyPress) return callbacks;
|
|
32
|
+
return Object.fromEntries(Object.keys(callbacks).map((key) => [key, readOnlyPress])) as T;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Once the switch into read-only lands, drop every edit already in flight without committing it. */
|
|
36
|
+
export function useAbandonEditsOnReadOnly(readOnly: boolean, abandon: () => void): void {
|
|
37
|
+
const abandonNow = useEffectEvent(abandon);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (readOnly) abandonNow();
|
|
40
|
+
}, [readOnly]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reports once if the press that started at `start` travels far enough to have been a drag. */
|
|
44
|
+
export function reportPressOnTravel(
|
|
45
|
+
start: { clientX: number; clientY: number },
|
|
46
|
+
refuse: (() => void) | null,
|
|
47
|
+
threshold = 4,
|
|
48
|
+
): void {
|
|
49
|
+
if (!refuse) return;
|
|
50
|
+
const stop = () => {
|
|
51
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
52
|
+
window.removeEventListener("pointerup", stop, true);
|
|
53
|
+
window.removeEventListener("pointercancel", stop, true);
|
|
54
|
+
};
|
|
55
|
+
const onMove = (event: PointerEvent) => {
|
|
56
|
+
if (Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) < threshold)
|
|
57
|
+
return;
|
|
58
|
+
stop();
|
|
59
|
+
refuse();
|
|
60
|
+
};
|
|
61
|
+
window.addEventListener("pointermove", onMove, true);
|
|
62
|
+
window.addEventListener("pointerup", stop, true);
|
|
63
|
+
window.addEventListener("pointercancel", stop, true);
|
|
64
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ShiftAxis,
|
|
27
27
|
} from "./automationLaneDragMath";
|
|
28
28
|
import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
|
|
29
|
+
import { reportPressOnTravel, useTimelineReadOnlyPress } from "./timelineReadOnly";
|
|
29
30
|
|
|
30
31
|
/** How far a press may travel and still count as a click rather than a drag. */
|
|
31
32
|
const CLICK_SLOP_PX = 3;
|
|
@@ -109,6 +110,7 @@ export function useAutomationLaneGestures({
|
|
|
109
110
|
duration,
|
|
110
111
|
rangeSelection,
|
|
111
112
|
}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult {
|
|
113
|
+
const timelineReadOnlyPress = useTimelineReadOnlyPress();
|
|
112
114
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
113
115
|
const [curveIndex, setCurveIndex] = useState<number | null>(null);
|
|
114
116
|
const [hint, setHint] = useState<string | null>(null);
|
|
@@ -267,6 +269,13 @@ export function useAutomationLaneGestures({
|
|
|
267
269
|
[hitIndex, segmentDrag, segmentIndex],
|
|
268
270
|
);
|
|
269
271
|
|
|
272
|
+
const reportLaneEditPress = useCallback(
|
|
273
|
+
(e: ReactPointerEvent<SVGSVGElement>) => {
|
|
274
|
+
if (gestureAt(e)) reportPressOnTravel(e, timelineReadOnlyPress);
|
|
275
|
+
},
|
|
276
|
+
[gestureAt, timelineReadOnlyPress],
|
|
277
|
+
);
|
|
278
|
+
|
|
270
279
|
const onPointerDown = useCallback(
|
|
271
280
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
272
281
|
if (e.button !== 0) return;
|
|
@@ -281,6 +290,7 @@ export function useAutomationLaneGestures({
|
|
|
281
290
|
// so the field sat open until Enter however far away the next click landed.
|
|
282
291
|
if (editing) commitEdit();
|
|
283
292
|
if (readOnly) {
|
|
293
|
+
reportLaneEditPress(e);
|
|
284
294
|
// The lane sits below the clip bar, so the timeline's selection handler
|
|
285
295
|
// never sees this press; selecting here is the only way in. The press then
|
|
286
296
|
// goes on to arm a range drag rather than being spent on the selection: a
|
|
@@ -323,6 +333,7 @@ export function useAutomationLaneGestures({
|
|
|
323
333
|
gestureAt,
|
|
324
334
|
lane,
|
|
325
335
|
readOnly,
|
|
336
|
+
reportLaneEditPress,
|
|
326
337
|
onSelect,
|
|
327
338
|
rangeDrag,
|
|
328
339
|
editing,
|
|
@@ -526,7 +537,7 @@ export function useAutomationLaneGestures({
|
|
|
526
537
|
|
|
527
538
|
const onDoubleClick = useCallback(
|
|
528
539
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
529
|
-
if (readOnly) return;
|
|
540
|
+
if (readOnly) return timelineReadOnlyPress?.();
|
|
530
541
|
e.stopPropagation();
|
|
531
542
|
e.preventDefault();
|
|
532
543
|
const onPoint = hitIndex(e.clientX, e.clientY);
|
|
@@ -550,7 +561,7 @@ export function useAutomationLaneGestures({
|
|
|
550
561
|
const { t, v } = pointAt(e.clientX, e.clientY);
|
|
551
562
|
commitPoints(mergeInsertPoint(lane.points, t, v), true);
|
|
552
563
|
},
|
|
553
|
-
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
|
|
564
|
+
[lane, pointAt, commitPoints, readOnly, timelineReadOnlyPress, hitIndex, segmentIndex],
|
|
554
565
|
);
|
|
555
566
|
|
|
556
567
|
return {
|
|
@@ -93,6 +93,7 @@ interface TimelineKeyframeRetimeCoordinator {
|
|
|
93
93
|
* is a viewport-wide question, not a per-keyframe one.
|
|
94
94
|
*/
|
|
95
95
|
latest: PendingTimelineKeyframeRetime | null;
|
|
96
|
+
cancelActive: () => void;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
type TimelineRetimePointerEvent = Pick<
|
|
@@ -115,6 +116,7 @@ function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordin
|
|
|
115
116
|
preview: null,
|
|
116
117
|
previewListeners: new Set(),
|
|
117
118
|
latest: null,
|
|
119
|
+
cancelActive: () => {},
|
|
118
120
|
};
|
|
119
121
|
keyframeRetimeCoordinators.set(owner, coordinator);
|
|
120
122
|
return coordinator;
|
|
@@ -186,6 +188,10 @@ export interface TimelineKeyframeRetimeHandle {
|
|
|
186
188
|
* Starts a keyframe retime on the stable timeline viewport. The row/button is
|
|
187
189
|
* only an entry point: window listeners own the gesture through virtualization.
|
|
188
190
|
*/
|
|
191
|
+
export function cancelTimelineKeyframeRetime(viewport: HTMLElement | null): void {
|
|
192
|
+
if (viewport) keyframeRetimeCoordinators.get(viewport)?.cancelActive();
|
|
193
|
+
}
|
|
194
|
+
|
|
189
195
|
export function beginTimelineKeyframeRetime(
|
|
190
196
|
input: TimelineKeyframeRetimeInput,
|
|
191
197
|
): TimelineKeyframeRetimeHandle {
|
|
@@ -256,6 +262,7 @@ export function beginTimelineKeyframeRetime(
|
|
|
256
262
|
teardownListeners: null,
|
|
257
263
|
};
|
|
258
264
|
coordinator.actor = actor;
|
|
265
|
+
coordinator.cancelActive = () => cancel(actor);
|
|
259
266
|
|
|
260
267
|
const matchesPointer = (event: TimelineRetimePointerEvent) =>
|
|
261
268
|
actor.pointerId === null || event.pointerId === actor.pointerId;
|