@hyperframes/studio 0.8.92 → 0.8.94
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-DKJPDHUp.js → hyperframes-player-Bkm-3G56.js} +3 -3
- package/dist/assets/{index-DpzpqJ_j.js → index-BUn5H_np.js} +1 -1
- package/dist/assets/{index-C5_j5-3u.js → index-BupvpoiM.js} +188 -188
- package/dist/assets/{index-CHZ4kFWm.js → index-n2ZRiO44.js} +1 -1
- package/dist/index.d.ts +62 -46
- package/dist/index.html +1 -1
- package/dist/index.js +156 -102
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +17 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
- package/src/components/editor/DomEditCropHandles.tsx +64 -62
- package/src/components/editor/DomEditOverlay.test.ts +39 -0
- package/src/components/editor/DomEditOverlay.tsx +6 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
- package/src/components/editor/SnapToolbar.tsx +1 -1
- package/src/components/editor/domEditOverlayGeometry.ts +2 -0
- package/src/components/nle/PreviewOverlays.tsx +3 -3
- package/src/hooks/gsapResizeIntercept.test.ts +40 -0
- package/src/hooks/gsapResizeIntercept.ts +8 -4
- package/src/index.ts +4 -0
- package/src/player/components/PlayerControls.tsx +2 -15
- package/src/player/components/ShortcutsPanel.test.tsx +33 -1
- package/src/player/components/ShortcutsPanel.tsx +26 -0
- package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
- package/src/player/components/TimelineCanvas.tsx +16 -19
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineLayout.test.ts +47 -0
- package/src/player/components/timelineLayout.ts +22 -15
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineTrackLayout.ts +11 -2
- package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
- package/src/player/components/useTimelineVirtualRows.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
- package/src/player/lib/playbackTypes.ts +1 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { RefObject } from "react";
|
|
2
2
|
import { type DomEditSelection } from "./domEditing";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type GroupOverlayItem,
|
|
5
|
+
type OverlayRect,
|
|
6
|
+
RESIZE_HANDLE_HIT_PX,
|
|
7
|
+
} from "./domEditOverlayGeometry";
|
|
4
8
|
import type { ResizeHandle } from "./domEditOverlayGestures";
|
|
5
9
|
import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
10
|
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
@@ -23,10 +27,6 @@ const RESIZE_HANDLE_DEFS: Array<{
|
|
|
23
27
|
{ handle: "se", cursor: "nwse-resize", x: "right", y: "bottom" },
|
|
24
28
|
];
|
|
25
29
|
|
|
26
|
-
// Visible dot is 9px; the pointer target is a 16px invisible square centered
|
|
27
|
-
// on the corner so click targets don't shrink with the smaller dot.
|
|
28
|
-
const RESIZE_HANDLE_HIT_PX = 16;
|
|
29
|
-
|
|
30
30
|
type CropInset = { top: number; right: number; bottom: number; left: number };
|
|
31
31
|
const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
32
32
|
|
|
@@ -59,6 +59,7 @@ interface DomEditGroupChromeProps {
|
|
|
59
59
|
groupOverlayItems: GroupOverlayItem[];
|
|
60
60
|
groupBounds: OverlayRect;
|
|
61
61
|
allowCanvasMovement: boolean;
|
|
62
|
+
allowBodyDrag: boolean;
|
|
62
63
|
groupCanMove: boolean;
|
|
63
64
|
gestures: GestureHandlers;
|
|
64
65
|
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
@@ -71,6 +72,7 @@ export function DomEditGroupChrome({
|
|
|
71
72
|
groupOverlayItems,
|
|
72
73
|
groupBounds,
|
|
73
74
|
allowCanvasMovement,
|
|
75
|
+
allowBodyDrag,
|
|
74
76
|
groupCanMove,
|
|
75
77
|
gestures,
|
|
76
78
|
onBoxMouseDown,
|
|
@@ -100,10 +102,10 @@ export function DomEditGroupChrome({
|
|
|
100
102
|
top: groupBounds.top,
|
|
101
103
|
width: groupBounds.width,
|
|
102
104
|
height: groupBounds.height,
|
|
103
|
-
cursor: canManipulate && groupCanMove ? "move" : "default",
|
|
105
|
+
cursor: !allowBodyDrag ? undefined : canManipulate && groupCanMove ? "move" : "default",
|
|
104
106
|
}}
|
|
105
107
|
onPointerDown={(e) => {
|
|
106
|
-
if (!canManipulate || e.shiftKey) return;
|
|
108
|
+
if (!canManipulate || e.shiftKey || !allowBodyDrag) return;
|
|
107
109
|
gestures.startGroupDrag(e);
|
|
108
110
|
}}
|
|
109
111
|
onMouseDown={onBoxMouseDown}
|
|
@@ -117,6 +119,7 @@ interface DomEditSelectionChromeProps {
|
|
|
117
119
|
selection: DomEditSelection;
|
|
118
120
|
overlayRect: OverlayRect;
|
|
119
121
|
allowCanvasMovement: boolean;
|
|
122
|
+
allowBodyDrag: boolean;
|
|
120
123
|
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
|
|
121
124
|
boxRef: RefObject<HTMLDivElement | null>;
|
|
122
125
|
boxChromeClass: string;
|
|
@@ -145,6 +148,7 @@ export function DomEditSelectionChrome({
|
|
|
145
148
|
selection,
|
|
146
149
|
overlayRect,
|
|
147
150
|
allowCanvasMovement,
|
|
151
|
+
allowBodyDrag,
|
|
148
152
|
cropOutlineInsetPx,
|
|
149
153
|
boxRef,
|
|
150
154
|
boxChromeClass,
|
|
@@ -197,8 +201,11 @@ export function DomEditSelectionChrome({
|
|
|
197
201
|
width: overlayRect.width,
|
|
198
202
|
height: overlayRect.height,
|
|
199
203
|
clipPath: boxClipPath,
|
|
200
|
-
cursor:
|
|
201
|
-
|
|
204
|
+
cursor: !allowBodyDrag
|
|
205
|
+
? undefined
|
|
206
|
+
: canManipulate && selection.capabilities.canApplyManualOffset
|
|
207
|
+
? "move"
|
|
208
|
+
: "default",
|
|
202
209
|
}}
|
|
203
210
|
onPointerDown={(e) => {
|
|
204
211
|
// A second press opens the element's text for editing, and must be
|
|
@@ -211,7 +218,7 @@ export function DomEditSelectionChrome({
|
|
|
211
218
|
e.stopPropagation();
|
|
212
219
|
return;
|
|
213
220
|
}
|
|
214
|
-
if (!canManipulate || e.shiftKey) return;
|
|
221
|
+
if (!allowBodyDrag || !canManipulate || e.shiftKey) return;
|
|
215
222
|
if (selection.capabilities.canApplyManualOffset) {
|
|
216
223
|
gestures.startGesture("drag", e);
|
|
217
224
|
return;
|
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { GridOverlay } from "./GridOverlay";
|
|
7
7
|
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
8
|
+
import { SnapToolbar } from "./SnapToolbar";
|
|
8
9
|
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
9
10
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
10
11
|
|
|
@@ -92,4 +93,22 @@ describe("PreviewOverlayProvider", () => {
|
|
|
92
93
|
|
|
93
94
|
expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
it("lets presses reach the preview beneath it, except on children that opt in", () => {
|
|
98
|
+
const host = document.createElement("div");
|
|
99
|
+
document.body.append(host);
|
|
100
|
+
const root = createRoot(host);
|
|
101
|
+
act(() => {
|
|
102
|
+
root.render(
|
|
103
|
+
<PreviewOverlayProvider iframe={null}>
|
|
104
|
+
<SnapToolbar />
|
|
105
|
+
</PreviewOverlayProvider>,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const layer = host.firstElementChild;
|
|
110
|
+
expect(layer?.classList.contains("pointer-events-none")).toBe(true);
|
|
111
|
+
expect(layer?.firstElementChild?.classList.contains("pointer-events-auto")).toBe(true);
|
|
112
|
+
act(() => root.unmount());
|
|
113
|
+
});
|
|
95
114
|
});
|
|
@@ -71,7 +71,7 @@ export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProvi
|
|
|
71
71
|
|
|
72
72
|
return (
|
|
73
73
|
<PreviewOverlayContext.Provider value={contextValue}>
|
|
74
|
-
<div ref={overlayRef} className="absolute inset-0">
|
|
74
|
+
<div ref={overlayRef} className="pointer-events-none absolute inset-0">
|
|
75
75
|
{children}
|
|
76
76
|
</div>
|
|
77
77
|
</PreviewOverlayContext.Provider>
|
|
@@ -65,7 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<div
|
|
68
|
-
className="absolute top-2 right-2 z-50 flex items-center gap-1"
|
|
68
|
+
className="pointer-events-auto absolute top-2 right-2 z-50 flex items-center gap-1"
|
|
69
69
|
onPointerDown={(e) => e.stopPropagation()}
|
|
70
70
|
>
|
|
71
71
|
{motionPathCreateAvailable && (
|
|
@@ -9,6 +9,8 @@ import { hugRectForElement } from "./domEditOverlayCrop";
|
|
|
9
9
|
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
|
10
10
|
import { type OverlayMeasurePass, readThroughPass } from "./domEditOverlayMeasurePass";
|
|
11
11
|
|
|
12
|
+
export const RESIZE_HANDLE_HIT_PX = 16;
|
|
13
|
+
|
|
12
14
|
export interface OverlayRect {
|
|
13
15
|
left: number;
|
|
14
16
|
top: number;
|
|
@@ -90,7 +90,7 @@ export function PreviewOverlays({
|
|
|
90
90
|
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
91
91
|
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
92
92
|
{/* Mode indicator + explicit exit */}
|
|
93
|
-
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
|
|
93
|
+
<div className="pointer-events-auto absolute top-2 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
|
|
94
94
|
<span className="h-1.5 w-1.5 rounded-full bg-studio-accent" aria-hidden="true" />
|
|
95
95
|
<span className="text-2xs text-neutral-200">Editing captions</span>
|
|
96
96
|
<button
|
|
@@ -104,7 +104,7 @@ export function PreviewOverlays({
|
|
|
104
104
|
{captionSyncError && (
|
|
105
105
|
<div
|
|
106
106
|
role="alert"
|
|
107
|
-
className="absolute top-10 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
|
|
107
|
+
className="pointer-events-auto absolute top-10 left-1/2 -translate-x-1/2 z-60 flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
|
|
108
108
|
>
|
|
109
109
|
<span className="text-2xs text-red-200">{captionSyncError}</span>
|
|
110
110
|
<button
|
|
@@ -152,7 +152,7 @@ export function PreviewOverlays({
|
|
|
152
152
|
<button
|
|
153
153
|
type="button"
|
|
154
154
|
onClick={enterCaptionMode}
|
|
155
|
-
className="absolute top-2 left-1/2 -translate-x-1/2 z-60 rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-studio-accent"
|
|
155
|
+
className="pointer-events-auto absolute top-2 left-1/2 -translate-x-1/2 z-60 rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-studio-accent"
|
|
156
156
|
>
|
|
157
157
|
Edit captions
|
|
158
158
|
</button>
|
|
@@ -521,3 +521,43 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
521
521
|
// And the live element ends on the drop point, not a drag away from it.
|
|
522
522
|
expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
|
|
523
523
|
});
|
|
524
|
+
|
|
525
|
+
function titleSelection(): DomEditSelection {
|
|
526
|
+
const el = document.createElement("h1");
|
|
527
|
+
el.id = "title";
|
|
528
|
+
document.body.append(el);
|
|
529
|
+
return { id: "title", selector: "#title", element: el } as DomEditSelection;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
it("writes a plain size on an element whose only tween is a fade", async () => {
|
|
533
|
+
const fade = {
|
|
534
|
+
id: "#title-to-0-visual",
|
|
535
|
+
targetSelector: "#title",
|
|
536
|
+
propertyGroup: "visual",
|
|
537
|
+
method: "to",
|
|
538
|
+
properties: { opacity: 0.5 },
|
|
539
|
+
position: 0,
|
|
540
|
+
resolvedStart: 0,
|
|
541
|
+
duration: 10,
|
|
542
|
+
} as unknown as GsapAnimation;
|
|
543
|
+
const commitMutation = vi.fn();
|
|
544
|
+
|
|
545
|
+
const handled = await tryGsapResizeIntercept(
|
|
546
|
+
titleSelection(),
|
|
547
|
+
{ width: 424, height: 237 },
|
|
548
|
+
[fade],
|
|
549
|
+
null,
|
|
550
|
+
commitMutation,
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
expect(handled).toMatchObject({ status: "persisted" });
|
|
554
|
+
const mutations = commitMutation.mock.calls.map((call) => call[1] as { type: string });
|
|
555
|
+
expect(mutations.filter((m) => /keyframe/i.test(m.type))).toEqual([]);
|
|
556
|
+
expect(mutations).toContainEqual({
|
|
557
|
+
type: "add",
|
|
558
|
+
targetSelector: "#title",
|
|
559
|
+
method: "set",
|
|
560
|
+
position: 0,
|
|
561
|
+
properties: { width: 424, height: 237 },
|
|
562
|
+
});
|
|
563
|
+
});
|
|
@@ -148,12 +148,16 @@ export async function tryGsapResizeIntercept(
|
|
|
148
148
|
? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
|
|
149
149
|
: findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
150
150
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// instead of a global set that resizes every frame.
|
|
151
|
+
// Keyframe the size only when a real tween already animates it, as move and
|
|
152
|
+
// rotate do; a fade or a slide on the element gets a plain size.
|
|
154
153
|
if (resizeGroup === "size") {
|
|
155
154
|
const animatedTween = pickClosestToPlayhead(
|
|
156
|
-
workingAnimations.filter(
|
|
155
|
+
workingAnimations.filter(
|
|
156
|
+
(a) =>
|
|
157
|
+
!isInstantHold(a) &&
|
|
158
|
+
resolveTweenDuration(a) > 0 &&
|
|
159
|
+
animationWritesAnyProperty(a, resizeProperties),
|
|
160
|
+
),
|
|
157
161
|
);
|
|
158
162
|
if (animatedTween) {
|
|
159
163
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { EditorShell } from "./components/EditorShell";
|
|
|
6
6
|
export type { EditorShellProps } from "./components/EditorShell";
|
|
7
7
|
export { NLEPreview } from "./components/nle/NLEPreview";
|
|
8
8
|
export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
|
|
9
|
+
export { ShortcutsButton } from "./player/components/ShortcutsPanel";
|
|
10
|
+
export type { ShortcutsButtonProps } from "./player/components/ShortcutsPanel";
|
|
9
11
|
export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
|
|
10
12
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
11
13
|
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
@@ -49,6 +51,8 @@ export {
|
|
|
49
51
|
} from "./player/components/TimelineParts";
|
|
50
52
|
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
51
53
|
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
54
|
+
export { TRACK_H } from "./player/components/timelineLayout";
|
|
55
|
+
export type { TimelineTrackPadding } from "./player/components/timelineLayout";
|
|
52
56
|
|
|
53
57
|
// Clip content thumbnails: used by a host rendering its own timeline lane.
|
|
54
58
|
export { AudioWaveform } from "./player/components/AudioWaveform";
|
|
@@ -6,7 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
|
6
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
|
-
import {
|
|
9
|
+
import { ShortcutsButton } from "./ShortcutsPanel";
|
|
10
10
|
import type { ShortcutSection } from "./studioShortcuts";
|
|
11
11
|
import { SpeedMenu } from "./SpeedMenu";
|
|
12
12
|
import { VolumeControl } from "./VolumeControl";
|
|
@@ -185,10 +185,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
185
185
|
const setAudioMuted = usePlayerStore.getState().setAudioMuted;
|
|
186
186
|
const setAudioVolume = usePlayerStore.getState().setAudioVolume;
|
|
187
187
|
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
188
|
-
const inPoint = usePlayerStore((s) => s.inPoint);
|
|
189
|
-
const outPoint = usePlayerStore((s) => s.outPoint);
|
|
190
|
-
const setInPoint = usePlayerStore.getState().setInPoint;
|
|
191
|
-
const setOutPoint = usePlayerStore.getState().setOutPoint;
|
|
192
188
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
193
189
|
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
|
|
194
190
|
|
|
@@ -288,16 +284,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
288
284
|
{onToggleFullscreen && (
|
|
289
285
|
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
|
290
286
|
)}
|
|
291
|
-
<
|
|
292
|
-
disabled={disabled}
|
|
293
|
-
duration={duration}
|
|
294
|
-
inPoint={inPoint}
|
|
295
|
-
outPoint={outPoint}
|
|
296
|
-
setInPoint={setInPoint}
|
|
297
|
-
setOutPoint={setOutPoint}
|
|
298
|
-
onSeek={onSeek}
|
|
299
|
-
sections={shortcutSections}
|
|
300
|
-
/>
|
|
287
|
+
<ShortcutsButton disabled={disabled} onSeek={onSeek} sections={shortcutSections} />
|
|
301
288
|
</div>
|
|
302
289
|
</div>
|
|
303
290
|
</div>
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act, Profiler } from "react";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
5
|
+
import { ShortcutsButton, ShortcutsPanel } from "./ShortcutsPanel";
|
|
6
6
|
import { PlayerControls } from "./PlayerControls";
|
|
7
7
|
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
8
8
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -200,4 +201,35 @@ describe("ShortcutsPanel", () => {
|
|
|
200
201
|
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
|
|
201
202
|
errors.mockRestore();
|
|
202
203
|
});
|
|
204
|
+
|
|
205
|
+
it("renders standalone for a host toolbar, reading the player store", () => {
|
|
206
|
+
const { duration, inPoint, requestSeek: storeSeek } = usePlayerStore.getState();
|
|
207
|
+
const requestSeek = vi.fn();
|
|
208
|
+
usePlayerStore.setState({ duration: 10, inPoint: 2, requestSeek });
|
|
209
|
+
try {
|
|
210
|
+
const host = document.createElement("div");
|
|
211
|
+
document.body.append(host);
|
|
212
|
+
act(() => {
|
|
213
|
+
mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
|
|
214
|
+
});
|
|
215
|
+
openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
|
|
216
|
+
expect(host.textContent).toContain("Host");
|
|
217
|
+
expect(host.textContent).toContain("00:02");
|
|
218
|
+
|
|
219
|
+
const input = host.querySelector<HTMLInputElement>('input[aria-label="Jump to frame"]')!;
|
|
220
|
+
act(() => {
|
|
221
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
|
|
222
|
+
input,
|
|
223
|
+
"30",
|
|
224
|
+
);
|
|
225
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
226
|
+
});
|
|
227
|
+
act(() => {
|
|
228
|
+
input.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
229
|
+
});
|
|
230
|
+
expect(requestSeek).toHaveBeenCalledWith(1);
|
|
231
|
+
} finally {
|
|
232
|
+
usePlayerStore.setState({ duration, inPoint, requestSeek: storeSeek });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
203
235
|
});
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
|
|
|
2
2
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
3
3
|
import { Tooltip } from "../../components/ui";
|
|
4
4
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
5
6
|
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
6
7
|
|
|
7
8
|
interface ShortcutsPanelProps {
|
|
@@ -262,3 +263,28 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
262
263
|
</div>
|
|
263
264
|
);
|
|
264
265
|
});
|
|
266
|
+
|
|
267
|
+
export interface ShortcutsButtonProps {
|
|
268
|
+
sections?: readonly ShortcutSection[];
|
|
269
|
+
disabled?: boolean;
|
|
270
|
+
onSeek?: (time: number) => void;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function ShortcutsButton({ sections, disabled = false, onSeek }: ShortcutsButtonProps) {
|
|
274
|
+
const duration = usePlayerStore((s) => s.duration);
|
|
275
|
+
const inPoint = usePlayerStore((s) => s.inPoint);
|
|
276
|
+
const outPoint = usePlayerStore((s) => s.outPoint);
|
|
277
|
+
const { setInPoint, setOutPoint, requestSeek } = usePlayerStore.getState();
|
|
278
|
+
return (
|
|
279
|
+
<ShortcutsPanel
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
duration={duration}
|
|
282
|
+
inPoint={inPoint}
|
|
283
|
+
outPoint={outPoint}
|
|
284
|
+
setInPoint={setInPoint}
|
|
285
|
+
setOutPoint={setOutPoint}
|
|
286
|
+
onSeek={onSeek ?? requestSeek}
|
|
287
|
+
sections={sections}
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
10
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
11
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
12
|
+
|
|
13
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
installTimelineMountEnv();
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function mountCanvas(trackPadding?: TimelineProps["trackPadding"]) {
|
|
22
|
+
usePlayerStore.setState({
|
|
23
|
+
duration: 10,
|
|
24
|
+
currentTime: 0,
|
|
25
|
+
timelineReady: true,
|
|
26
|
+
gsapAnimations: new Map(),
|
|
27
|
+
elements: [{ id: "card", label: "Card", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
28
|
+
});
|
|
29
|
+
const host = document.createElement("div");
|
|
30
|
+
document.body.append(host);
|
|
31
|
+
const root = createRoot(host);
|
|
32
|
+
await act(async () => root.render(<Timeline trackPadding={trackPadding} />));
|
|
33
|
+
const canvas = Array.from(host.querySelectorAll<HTMLElement>("div.relative")).find(
|
|
34
|
+
(el) => el.style.height && el.style.width,
|
|
35
|
+
);
|
|
36
|
+
const height = canvas?.style.height;
|
|
37
|
+
const firstRowTop = host.querySelector<HTMLElement>('[data-timeline-row="0"]')?.style.top;
|
|
38
|
+
act(() => root.unmount());
|
|
39
|
+
return { height, firstRowTop };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Timeline trackPadding", () => {
|
|
43
|
+
it("keeps Studio's pads by default", async () => {
|
|
44
|
+
expect(await mountCanvas()).toEqual({
|
|
45
|
+
height: `${RULER_H + TRACKS_TOP_PAD + TRACK_H + TRACKS_BOTTOM_PAD}px`,
|
|
46
|
+
firstRowTop: `${RULER_H + TRACKS_TOP_PAD}px`,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("places the first row and sizes the canvas from the host's pads", async () => {
|
|
51
|
+
expect(await mountCanvas({ top: 0, bottom: TRACK_H })).toEqual({
|
|
52
|
+
height: `${RULER_H + 2 * TRACK_H}px`,
|
|
53
|
+
firstRowTop: `${RULER_H}px`,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// With no top pad, y = 96 is the middle of row 1; any reader still on the default pad sees row 0.
|
|
58
|
+
const ROW1_MID = RULER_H + TRACK_H + TRACK_H / 2;
|
|
59
|
+
const hostPads = { top: 0, bottom: TRACK_H };
|
|
60
|
+
|
|
61
|
+
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
62
|
+
usePlayerStore.setState({
|
|
63
|
+
duration: 10,
|
|
64
|
+
currentTime: 0,
|
|
65
|
+
timelineReady: true,
|
|
66
|
+
gsapAnimations: new Map(),
|
|
67
|
+
selectedElementId: null,
|
|
68
|
+
selectedElementIds: new Set(),
|
|
69
|
+
elements: [0, 1, 2].map((track) => ({
|
|
70
|
+
id: `c${track}`,
|
|
71
|
+
key: `c${track}`,
|
|
72
|
+
domId: `c${track}`,
|
|
73
|
+
tag: "div",
|
|
74
|
+
start: 0,
|
|
75
|
+
duration: 2,
|
|
76
|
+
track,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
const host = document.createElement("div");
|
|
80
|
+
document.body.append(host);
|
|
81
|
+
const root = createRoot(host);
|
|
82
|
+
await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
|
|
83
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
84
|
+
return { host, root, viewport };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
|
|
88
|
+
|
|
89
|
+
it("draws the gap strip, drop preview and insert line, and drops, on the host's rows", async () => {
|
|
90
|
+
const onAssetDrop = vi.fn();
|
|
91
|
+
const { host, root, viewport } = await mountThreeTracks({ onAssetDrop });
|
|
92
|
+
act(() => usePlayerStore.getState().setSelectedElementId("c1"));
|
|
93
|
+
expect(top(host.querySelector('[style*="--timeline-accent-faint"]'))).toBe(
|
|
94
|
+
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const drag = (type: string, clientY: number) => {
|
|
98
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
99
|
+
Object.defineProperties(event, {
|
|
100
|
+
clientX: { value: 400 },
|
|
101
|
+
clientY: { value: clientY },
|
|
102
|
+
dataTransfer: {
|
|
103
|
+
value: {
|
|
104
|
+
types: [TIMELINE_ASSET_MIME],
|
|
105
|
+
files: [],
|
|
106
|
+
dropEffect: "none",
|
|
107
|
+
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
act(() => void viewport.dispatchEvent(event));
|
|
112
|
+
};
|
|
113
|
+
drag("dragover", ROW1_MID);
|
|
114
|
+
expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
|
|
115
|
+
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
116
|
+
);
|
|
117
|
+
drag("dragover", RULER_H / 2);
|
|
118
|
+
expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
|
|
119
|
+
`${RULER_H - 0.5}px`,
|
|
120
|
+
);
|
|
121
|
+
drag("drop", ROW1_MID);
|
|
122
|
+
expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 1 }));
|
|
123
|
+
act(() => root.unmount());
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("drags a clip onto the host's rows and opens a new track in the bottom pad", async () => {
|
|
127
|
+
const { host, root } = await mountThreeTracks({
|
|
128
|
+
onMoveElement: vi.fn(),
|
|
129
|
+
onResizeElement: vi.fn(),
|
|
130
|
+
});
|
|
131
|
+
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
132
|
+
act(
|
|
133
|
+
() =>
|
|
134
|
+
void target.dispatchEvent(
|
|
135
|
+
new PointerEvent(type, {
|
|
136
|
+
bubbles: true,
|
|
137
|
+
button: 0,
|
|
138
|
+
pointerId: 1,
|
|
139
|
+
clientX: 400,
|
|
140
|
+
clientY,
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
|
|
145
|
+
pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
|
|
146
|
+
pointer(window, "pointermove", ROW1_MID);
|
|
147
|
+
const ghost = Array.from(
|
|
148
|
+
host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
|
|
149
|
+
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
|
|
150
|
+
expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
|
|
151
|
+
pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
|
|
152
|
+
expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
|
|
153
|
+
`${RULER_H + 3 * TRACK_H - 0.5}px`,
|
|
154
|
+
);
|
|
155
|
+
act(() => root.unmount());
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -2,16 +2,7 @@ import { memo } from "react";
|
|
|
2
2
|
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
4
|
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
|
|
5
|
-
import {
|
|
6
|
-
RULER_H,
|
|
7
|
-
CLIP_Y,
|
|
8
|
-
TRACKS_TOP_PAD,
|
|
9
|
-
TRACKS_BOTTOM_PAD,
|
|
10
|
-
TRACK_H,
|
|
11
|
-
PLAYHEAD_HEAD_W,
|
|
12
|
-
getTimelineRowTop,
|
|
13
|
-
getTimelineRowHeight,
|
|
14
|
-
} from "./timelineLayout";
|
|
5
|
+
import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
|
|
15
6
|
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
16
7
|
import { TimelineLanes } from "./TimelineLanes";
|
|
17
8
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
@@ -49,8 +40,11 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
49
40
|
<TimelineRulerPart />
|
|
50
41
|
|
|
51
42
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
52
|
-
top half of the CapCut-style padding (see
|
|
53
|
-
<div
|
|
43
|
+
top half of the CapCut-style padding (see TimelineRowGeometry.padding). */}
|
|
44
|
+
<div
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.top }}
|
|
47
|
+
/>
|
|
54
48
|
|
|
55
49
|
<TimelineLanes
|
|
56
50
|
{...props}
|
|
@@ -67,10 +61,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
67
61
|
onRazorSplitAll={props.onRazorSplitAll}
|
|
68
62
|
/>
|
|
69
63
|
|
|
70
|
-
{/* Breathing room below the last track lane (
|
|
64
|
+
{/* Breathing room below the last track lane (1.5 tracks by default) — a real
|
|
71
65
|
scrollable surface, so a clip can be dragged into the void to create a
|
|
72
|
-
new bottom track comfortably (see
|
|
73
|
-
<div
|
|
66
|
+
new bottom track comfortably (see TimelineRowGeometry.padding / canvasHeight). */}
|
|
67
|
+
<div
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
|
|
70
|
+
/>
|
|
74
71
|
|
|
75
72
|
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
|
|
76
73
|
menu row would collapse; a quiet tint for every gap on the selected
|
|
@@ -91,7 +88,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
91
88
|
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
|
92
89
|
className="pointer-events-none absolute"
|
|
93
90
|
style={{
|
|
94
|
-
top:
|
|
91
|
+
top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
|
|
95
92
|
left: props.contentOrigin + gap.start * props.pps,
|
|
96
93
|
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
|
97
94
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -109,7 +106,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
109
106
|
<div
|
|
110
107
|
className="absolute pointer-events-none"
|
|
111
108
|
style={{
|
|
112
|
-
top:
|
|
109
|
+
top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
|
|
113
110
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
|
114
111
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
|
115
112
|
height: draggedClipHeight,
|
|
@@ -129,7 +126,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
129
126
|
data-testid="timeline-drop-preview"
|
|
130
127
|
className="absolute pointer-events-none"
|
|
131
128
|
style={{
|
|
132
|
-
top:
|
|
129
|
+
top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
|
|
133
130
|
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
134
131
|
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
135
132
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -148,7 +145,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
148
145
|
data-testid="timeline-insert-line"
|
|
149
146
|
className="absolute pointer-events-none"
|
|
150
147
|
style={{
|
|
151
|
-
top:
|
|
148
|
+
top: props.rowGeometry.getRowTop(insertLineRow) - 0.5,
|
|
152
149
|
left: props.contentOrigin,
|
|
153
150
|
width: props.trackContentWidth,
|
|
154
151
|
height: 1,
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
|
|
4
4
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import type { TimelineTheme } from "./timelineTheme";
|
|
6
|
+
import type { TimelineTrackPadding } from "./timelineLayout";
|
|
6
7
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
7
8
|
import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
|
|
8
9
|
|
|
@@ -35,4 +36,5 @@ export interface TimelineProps
|
|
|
35
36
|
theme?: Partial<TimelineTheme>;
|
|
36
37
|
showAudioEffects?: boolean;
|
|
37
38
|
showKeyframes?: boolean;
|
|
39
|
+
trackPadding?: TimelineTrackPadding;
|
|
38
40
|
}
|