@hyperframes/studio 0.8.96 → 0.8.97
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-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-BNQLP5_v.js → index-CWuozokW.js} +1 -1
- 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 +7 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1981 -1779
- 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 +8 -8
- 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/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/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/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- 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/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- 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 +42 -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;
|
|
@@ -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,
|
|
@@ -180,16 +184,28 @@ export interface TimelineContextValue {
|
|
|
180
184
|
|
|
181
185
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
182
186
|
|
|
183
|
-
|
|
187
|
+
const NO_EDITS: TimelineEditCallbacks = {};
|
|
188
|
+
|
|
189
|
+
export function TimelineProvider({
|
|
190
|
+
children,
|
|
191
|
+
readOnly = false,
|
|
192
|
+
onReadOnlyPress,
|
|
193
|
+
...props
|
|
194
|
+
}: TimelineProps & { children: ReactNode }) {
|
|
184
195
|
const editContext = useTimelineEditContextValue();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
const onReadOnlyPressRef = useRef(onReadOnlyPress);
|
|
197
|
+
useLayoutEffect(() => {
|
|
198
|
+
onReadOnlyPressRef.current = onReadOnlyPress;
|
|
199
|
+
});
|
|
200
|
+
const refuse = useCallback(() => onReadOnlyPressRef.current?.(), []);
|
|
201
|
+
const readOnlyPress = readOnly ? refuse : null;
|
|
202
|
+
return (
|
|
203
|
+
<TimelineReadOnlyContext.Provider value={readOnlyPress}>
|
|
204
|
+
<TimelineEditProvider value={readOnly ? NO_EDITS : (editContext ?? props)}>
|
|
188
205
|
<TimelineProviderState {...props}>{children}</TimelineProviderState>
|
|
189
206
|
</TimelineEditProvider>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return <TimelineProviderState {...props}>{children}</TimelineProviderState>;
|
|
207
|
+
</TimelineReadOnlyContext.Provider>
|
|
208
|
+
);
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
function TimelineProviderState({ children, ...props }: TimelineProps & { children: ReactNode }) {
|
|
@@ -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;
|
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
useTimelineDisplayLayout,
|
|
28
28
|
useTimelineTrackLayout,
|
|
29
29
|
} from "./useTimelineTrackLayout";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
cancelTimelineKeyframeRetime,
|
|
32
|
+
useTimelineKeyframeHandlers,
|
|
33
|
+
} from "./useTimelineKeyframeHandlers";
|
|
31
34
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
32
35
|
import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
33
36
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
@@ -40,6 +43,12 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
|
40
43
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
41
44
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
42
45
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
46
|
+
import {
|
|
47
|
+
refuseWhenReadOnly,
|
|
48
|
+
useAbandonEditsOnReadOnly,
|
|
49
|
+
useTimelineReadOnlyPress,
|
|
50
|
+
} from "./timelineReadOnly";
|
|
51
|
+
import { cancelBeatDrag } from "./BeatStrip";
|
|
43
52
|
export function useTimelineProviderState({
|
|
44
53
|
onSeek,
|
|
45
54
|
onDrillDown,
|
|
@@ -92,6 +101,14 @@ export function useTimelineProviderState({
|
|
|
92
101
|
});
|
|
93
102
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
94
103
|
const editContext = useTimelineEditContextOptional();
|
|
104
|
+
const readOnlyPress = useTimelineReadOnlyPress();
|
|
105
|
+
const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
|
|
106
|
+
onBlockedEditAttempt,
|
|
107
|
+
onRazorSplitAll,
|
|
108
|
+
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
|
109
|
+
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
|
110
|
+
onRazorSplit: editContext.onRazorSplit,
|
|
111
|
+
});
|
|
95
112
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
96
113
|
useMusicBeatAnalysis();
|
|
97
114
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
@@ -198,7 +215,7 @@ export function useTimelineProviderState({
|
|
|
198
215
|
onMoveElements: pinnedOnMoveElements,
|
|
199
216
|
onResizeElement: pinnedOnResizeElement,
|
|
200
217
|
onResizeElements: pinnedOnResizeElements,
|
|
201
|
-
onBlockedEditAttempt,
|
|
218
|
+
onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
|
|
202
219
|
onSeek,
|
|
203
220
|
setShowPopover,
|
|
204
221
|
setRangeSelectionRef,
|
|
@@ -218,6 +235,7 @@ export function useTimelineProviderState({
|
|
|
218
235
|
onBlockDrop: pinnedOnBlockDrop,
|
|
219
236
|
onCompositionDrop: pinnedOnCompositionDrop,
|
|
220
237
|
sessionEpoch,
|
|
238
|
+
readOnlyPress,
|
|
221
239
|
});
|
|
222
240
|
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
|
223
241
|
const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
|
|
@@ -337,7 +355,7 @@ export function useTimelineProviderState({
|
|
|
337
355
|
scrollRef,
|
|
338
356
|
contentOrigin,
|
|
339
357
|
pixelsPerSecond: pps,
|
|
340
|
-
onSplitAll: onRazorSplitAll,
|
|
358
|
+
onSplitAll: oneShotEdits.onRazorSplitAll,
|
|
341
359
|
});
|
|
342
360
|
const overlaysProps = useTimelineOverlaysState({
|
|
343
361
|
elements: timelineElements,
|
|
@@ -405,6 +423,14 @@ export function useTimelineProviderState({
|
|
|
405
423
|
handlePointerCancel,
|
|
406
424
|
} = overlaysProps;
|
|
407
425
|
const { rangeSelection, setRangeSelection } = overlays;
|
|
426
|
+
useAbandonEditsOnReadOnly(readOnlyPress !== null, () => {
|
|
427
|
+
setDraggedClip(null);
|
|
428
|
+
cancelBeatDrag();
|
|
429
|
+
cancelTimelineKeyframeRetime(scrollRef.current);
|
|
430
|
+
setClipContextMenu(null);
|
|
431
|
+
setKfContextMenu(null);
|
|
432
|
+
overlays.onDismissGapContextMenu();
|
|
433
|
+
});
|
|
408
434
|
const laneGapStrips = useTimelineGapHighlights({
|
|
409
435
|
gapHighlight,
|
|
410
436
|
tracks,
|
|
@@ -484,22 +510,24 @@ export function useTimelineProviderState({
|
|
|
484
510
|
onClickKeyframe,
|
|
485
511
|
onShiftClickKeyframe,
|
|
486
512
|
onMoveKeyframe,
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
513
|
+
...refuseWhenReadOnly(readOnlyPress, {
|
|
514
|
+
onContextMenuKeyframe,
|
|
515
|
+
onContextMenuClip,
|
|
516
|
+
onContextMenuLane: (e: React.MouseEvent, track: number, time: number) => {
|
|
517
|
+
if (draggedClip?.started || resizingClip) return;
|
|
518
|
+
setClipContextMenu(null);
|
|
519
|
+
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
|
|
520
|
+
},
|
|
521
|
+
}),
|
|
494
522
|
onResizeElement,
|
|
495
523
|
onMoveElement,
|
|
496
524
|
beatDragging,
|
|
497
525
|
draggedElement,
|
|
498
526
|
snapGuide,
|
|
499
527
|
multiDragPreview,
|
|
500
|
-
onToggleTrackHidden:
|
|
501
|
-
onTogglePropertyGroupKeyframe:
|
|
502
|
-
onRazorSplit:
|
|
528
|
+
onToggleTrackHidden: oneShotEdits.onToggleTrackHidden,
|
|
529
|
+
onTogglePropertyGroupKeyframe: oneShotEdits.onTogglePropertyGroupKeyframe,
|
|
530
|
+
onRazorSplit: oneShotEdits.onRazorSplit,
|
|
503
531
|
onRazorSplitAll: editContext.onRazorSplitAll,
|
|
504
532
|
};
|
|
505
533
|
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
|
|
@@ -515,7 +543,7 @@ export function useTimelineProviderState({
|
|
|
515
543
|
);
|
|
516
544
|
const timelineMeta = buildTimelineShellMeta({
|
|
517
545
|
isDragOver: assetDrop.isDragOver,
|
|
518
|
-
hasFileDrop: !!onFileDrop,
|
|
546
|
+
hasFileDrop: !!onFileDrop && !readOnlyPress,
|
|
519
547
|
drop: {
|
|
520
548
|
onDragOver: assetDrop.handleAssetDragOver,
|
|
521
549
|
onDragLeave: assetDrop.handleAssetDragLeave,
|
|
@@ -118,9 +118,9 @@ export function useShadowPreviewReload({
|
|
|
118
118
|
const liveTime = live?.getTime();
|
|
119
119
|
const playing = usePlayerStore.getState().isPlaying;
|
|
120
120
|
live?.pause();
|
|
121
|
-
// The store takes the new document's timeline only now that it is the one on screen.
|
|
122
|
-
pending.commit();
|
|
121
|
+
// The store takes the new document's timeline only now that it is the one on screen, and reads it there.
|
|
123
122
|
iframeRef.current = shadow;
|
|
123
|
+
pending.commit();
|
|
124
124
|
shadowIframeRef.current = null;
|
|
125
125
|
attachIframeShortcutListeners();
|
|
126
126
|
applyPreviewAudioState();
|