@hyperframes/studio 0.8.93 → 0.8.95
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-CkV9KK6A.js → hyperframes-player-DJJKab4_.js} +1 -1
- package/dist/assets/{index-V3fzEfOD.js → index-C1dXTZvs.js} +1 -1
- package/dist/assets/{index-CYk0NwSv.js → index-Cc4by8r7.js} +183 -183
- package/dist/assets/{index-BlLFipOk.js → index-DE1dn3QB.js} +1 -1
- package/dist/index.d.ts +63 -46
- package/dist/index.html +1 -1
- package/dist/index.js +196 -111
- 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/editor/manualEditsSeekReapply.ts +5 -1
- 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.ghostLane.test.tsx +126 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
- package/src/player/components/Timeline.test.ts +3 -3
- package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
- package/src/player/components/TimelineCanvas.tsx +44 -19
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineDragDrop.test.tsx +14 -12
- package/src/player/components/timelineLayout.test.ts +87 -11
- package/src/player/components/timelineLayout.ts +28 -21
- package/src/player/components/useTimelineProviderState.tsx +5 -1
- 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/utils/gsapSoftReload.test.ts +51 -2
- package/src/utils/gsapSoftReload.ts +24 -10
- package/src/utils/gsapUndoRestore.test.ts +49 -0
- package/src/utils/gsapUndoRestore.ts +15 -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;
|
|
@@ -67,7 +67,11 @@ function reapplyRotations(doc: Document): void {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Every mark a reapply below acts on, the legacy double-prefixed ones included.
|
|
70
|
-
const STUDIO_EDIT_ATTRS = [
|
|
70
|
+
export const STUDIO_EDIT_ATTRS = [
|
|
71
|
+
STUDIO_PATH_OFFSET_ATTR,
|
|
72
|
+
STUDIO_BOX_SIZE_ATTR,
|
|
73
|
+
STUDIO_ROTATION_ATTR,
|
|
74
|
+
]
|
|
71
75
|
.flatMap((attr) => [attr, `data-${attr}`])
|
|
72
76
|
.concat(STUDIO_MOTION_ATTR);
|
|
73
77
|
// One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
|
|
@@ -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,126 @@
|
|
|
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, TRACK_H, getTimelineRowTop } 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
|
+
const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
|
|
22
|
+
const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
|
|
23
|
+
|
|
24
|
+
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
25
|
+
usePlayerStore.setState({
|
|
26
|
+
duration: 10,
|
|
27
|
+
currentTime: 0,
|
|
28
|
+
timelineReady: true,
|
|
29
|
+
gsapAnimations: new Map(),
|
|
30
|
+
selectedElementId: null,
|
|
31
|
+
selectedElementIds: new Set(),
|
|
32
|
+
elements: [0, 1, 2].map((track) => ({
|
|
33
|
+
id: `c${track}`,
|
|
34
|
+
key: `c${track}`,
|
|
35
|
+
domId: `c${track}`,
|
|
36
|
+
tag: "div",
|
|
37
|
+
start: 0,
|
|
38
|
+
duration: 2,
|
|
39
|
+
track,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
const host = document.createElement("div");
|
|
43
|
+
document.body.append(host);
|
|
44
|
+
const root = createRoot(host);
|
|
45
|
+
await act(async () => root.render(<Timeline {...props} />));
|
|
46
|
+
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
|
|
47
|
+
return { host, root, ghost };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Timeline ghost lane", () => {
|
|
51
|
+
it("draws one empty track below the last with a drop hint", async () => {
|
|
52
|
+
const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
|
|
53
|
+
expect(ghost()?.textContent).toBe("Drop media here");
|
|
54
|
+
expect(ghost()?.getAttribute("aria-hidden")).toBe("true");
|
|
55
|
+
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
|
|
56
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
57
|
+
act(() => root.unmount());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("drops the hint without a drop handler and the lane without a bottom pad", async () => {
|
|
61
|
+
const readOnly = await mountThreeTracks();
|
|
62
|
+
expect(readOnly.ghost()?.textContent).toBe("");
|
|
63
|
+
act(() => readOnly.root.unmount());
|
|
64
|
+
const unpadded = await mountThreeTracks({ trackPadding: { bottom: 0 } });
|
|
65
|
+
expect(unpadded.ghost()).toBeNull();
|
|
66
|
+
act(() => unpadded.root.unmount());
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lights up under a file dragged into it and opens a new track on drop", async () => {
|
|
70
|
+
const onAssetDrop = vi.fn();
|
|
71
|
+
const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
|
|
72
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
73
|
+
const drag = (type: string, clientY: number) => {
|
|
74
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
75
|
+
Object.defineProperties(event, {
|
|
76
|
+
clientX: { value: 400 },
|
|
77
|
+
clientY: { value: clientY },
|
|
78
|
+
dataTransfer: {
|
|
79
|
+
value: {
|
|
80
|
+
types: [TIMELINE_ASSET_MIME],
|
|
81
|
+
files: [],
|
|
82
|
+
dropEffect: "none",
|
|
83
|
+
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
act(() => void viewport.dispatchEvent(event));
|
|
88
|
+
};
|
|
89
|
+
drag("dragover", ROW1_MID);
|
|
90
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
91
|
+
drag("dragover", GHOST_MID);
|
|
92
|
+
expect(ghost()?.dataset.active).toBe("true");
|
|
93
|
+
drag("drop", GHOST_MID);
|
|
94
|
+
expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 3 }));
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([0, 2])("lights up under clip c%i dragged into it", async (track) => {
|
|
99
|
+
const { host, root, ghost } = await mountThreeTracks({
|
|
100
|
+
onMoveElement: vi.fn(),
|
|
101
|
+
onResizeElement: vi.fn(),
|
|
102
|
+
});
|
|
103
|
+
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
104
|
+
act(
|
|
105
|
+
() =>
|
|
106
|
+
void target.dispatchEvent(
|
|
107
|
+
new PointerEvent(type, {
|
|
108
|
+
bubbles: true,
|
|
109
|
+
button: 0,
|
|
110
|
+
pointerId: 1,
|
|
111
|
+
clientX: 400,
|
|
112
|
+
clientY,
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
|
|
117
|
+
pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
|
|
118
|
+
pointer(window, "pointermove", ROW1_MID);
|
|
119
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
120
|
+
pointer(window, "pointermove", GHOST_MID);
|
|
121
|
+
expect(ghost()?.dataset.active).toBe("true");
|
|
122
|
+
// Dragging the last track's clip adds a preview row; the lane must stay under the pointer.
|
|
123
|
+
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
|
|
124
|
+
act(() => root.unmount());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -108,4 +108,11 @@ describe("Timeline ruler origin", () => {
|
|
|
108
108
|
expect(origin).toBe("80px");
|
|
109
109
|
expect(published).toBe("80");
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it("seeks on a press when the host omits sessionEpoch after a session began", async () => {
|
|
113
|
+
usePlayerStore.getState().beginTimelineSession("host-project");
|
|
114
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBeGreaterThan(0);
|
|
115
|
+
const { time, tickTime } = await seekOnFirstTick({}, false);
|
|
116
|
+
expect(time).toBeCloseTo(tickTime, 1);
|
|
117
|
+
});
|
|
111
118
|
});
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
getTimelineDisplayContentWidth,
|
|
36
36
|
getTimelineFitPps,
|
|
37
37
|
getTimelineLaneTop,
|
|
38
|
+
getTimelineRowTop,
|
|
38
39
|
createTimelineRowGeometry,
|
|
39
40
|
} from "./timelineLayout";
|
|
40
41
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
@@ -1292,9 +1293,8 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1292
1293
|
trackOrder: [0, 3, 7],
|
|
1293
1294
|
},
|
|
1294
1295
|
432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
|
|
1295
|
-
// clientY: rectTop(200) +
|
|
1296
|
-
|
|
1297
|
-
368,
|
|
1296
|
+
// clientY: rectTop(200) + the middle of row 1 → track 3.
|
|
1297
|
+
200 + getTimelineRowTop(1) + TRACK_H / 2,
|
|
1298
1298
|
),
|
|
1299
1299
|
).toEqual({ start: 3, track: 3 });
|
|
1300
1300
|
});
|