@hyperframes/studio 0.8.82 → 0.8.83
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-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
- package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +669 -330
- package/dist/index.html +1 -1
- package/dist/index.js +20501 -23167
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +7 -43
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/index.ts +45 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/playerEntry.ts +1 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
package/src/index.ts
CHANGED
|
@@ -1,35 +1,5 @@
|
|
|
1
1
|
// UI primitives
|
|
2
|
-
export
|
|
3
|
-
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
|
4
|
-
export { HyperframesLogo } from "./components/StudioHeader";
|
|
5
|
-
export { IconButton } from "./components/ui/IconButton";
|
|
6
|
-
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
|
7
|
-
export { HyperframesLoader } from "./components/ui/HyperframesLoader";
|
|
8
|
-
export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
|
|
9
|
-
export { Tooltip } from "./components/ui/Tooltip";
|
|
10
|
-
export { cn } from "./components/ui/cn";
|
|
11
|
-
export {
|
|
12
|
-
ContextMenu,
|
|
13
|
-
Menu,
|
|
14
|
-
MenuItem,
|
|
15
|
-
MenuRadioGroup,
|
|
16
|
-
MenuRadioItem,
|
|
17
|
-
MenuSeparator,
|
|
18
|
-
MenuShortcut,
|
|
19
|
-
popupSurface,
|
|
20
|
-
} from "./components/ui/Menu";
|
|
21
|
-
export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
|
|
22
|
-
export { Popover } from "./components/ui/Popover";
|
|
23
|
-
export { Input, fieldBase, fieldText } from "./components/ui/Input";
|
|
24
|
-
export type { InputProps } from "./components/ui/Input";
|
|
25
|
-
export { NumberField } from "./components/ui/NumberField";
|
|
26
|
-
export type { NumberFieldProps } from "./components/ui/NumberField";
|
|
27
|
-
export { Select } from "./components/ui/Select";
|
|
28
|
-
export type { SelectOption, SelectProps } from "./components/ui/Select";
|
|
29
|
-
export { Slider } from "./components/ui/Slider";
|
|
30
|
-
export type { SliderProps } from "./components/ui/Slider";
|
|
31
|
-
export { Toggle } from "./components/ui/Toggle";
|
|
32
|
-
export type { ToggleProps } from "./components/ui/Toggle";
|
|
2
|
+
export * from "./ui";
|
|
33
3
|
|
|
34
4
|
// NLE Layout
|
|
35
5
|
export { EditorShell } from "./components/EditorShell";
|
|
@@ -154,7 +124,10 @@ export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/h
|
|
|
154
124
|
export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
155
125
|
export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
|
|
156
126
|
export { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
157
|
-
export type {
|
|
127
|
+
export type {
|
|
128
|
+
TimelineZIndexReorderCommit,
|
|
129
|
+
UseTimelineEditingOptions,
|
|
130
|
+
} from "./hooks/useTimelineEditingTypes";
|
|
158
131
|
// A host's own waitForPendingDomEditSaves must also call this, or undo/redo
|
|
159
132
|
// can race a write still in flight (see useTrackPendingTimelineEdit.ts).
|
|
160
133
|
export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
|
|
@@ -193,9 +166,49 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
|
193
166
|
export { useClipboard } from "./hooks/useClipboard";
|
|
194
167
|
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
195
168
|
|
|
169
|
+
// DOM editing: the canvas session, persistence and overlay, for a host outside EditorShell.
|
|
170
|
+
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
171
|
+
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
172
|
+
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
173
|
+
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
|
|
174
|
+
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
|
|
175
|
+
export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
|
|
176
|
+
export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
|
|
177
|
+
export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
|
|
178
|
+
export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
|
|
179
|
+
|
|
196
180
|
export {
|
|
197
181
|
playSeamTransitionLoop,
|
|
198
182
|
type SeamTransitionFrameSource,
|
|
199
183
|
type SeamTransitionLoopHandle,
|
|
200
184
|
type SeamTransitionLoopOptions,
|
|
201
185
|
} from "@hyperframes/shader-transitions";
|
|
186
|
+
|
|
187
|
+
// Editor gestures for a host's own preview overlay: snapping and the marquee
|
|
188
|
+
export {
|
|
189
|
+
SNAP_THRESHOLD_PX,
|
|
190
|
+
snapEngagedForTravel,
|
|
191
|
+
extractSnapTargets,
|
|
192
|
+
buildCompositionSnapTarget,
|
|
193
|
+
buildGridSnapEdges,
|
|
194
|
+
resolveSnapAdjustment,
|
|
195
|
+
resolveGuideLineRect,
|
|
196
|
+
resolveEquidistanceGuides,
|
|
197
|
+
} from "./components/editor/snapEngine";
|
|
198
|
+
export type {
|
|
199
|
+
SnapEdge,
|
|
200
|
+
SnapTarget,
|
|
201
|
+
SnapGuide,
|
|
202
|
+
SpacingGuide,
|
|
203
|
+
SnapResult,
|
|
204
|
+
Rect as SnapRect,
|
|
205
|
+
} from "./components/editor/snapEngine";
|
|
206
|
+
export { collectSnapContext } from "./components/editor/snapTargetCollection";
|
|
207
|
+
export type { SnapContext } from "./components/editor/snapTargetCollection";
|
|
208
|
+
export { SnapGuideOverlay } from "./components/editor/SnapGuideOverlay";
|
|
209
|
+
export type { SnapGuideOverlayProps, SnapGuidesState } from "./components/editor/SnapGuideOverlay";
|
|
210
|
+
export { useMarqueeGestures } from "./components/editor/marqueeCommit";
|
|
211
|
+
export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/marqueeCommit";
|
|
212
|
+
export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
|
|
213
|
+
export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
|
|
214
|
+
export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const packageDir = resolve(import.meta.dirname, "..");
|
|
7
|
+
const { subpaths } = JSON.parse(readFileSync(resolve(packageDir, "package-subpaths.json"), "utf8"));
|
|
8
|
+
|
|
9
|
+
function refuse(name: string): never {
|
|
10
|
+
throw new Error(`a light entry loaded ${name}`);
|
|
11
|
+
}
|
|
12
|
+
vi.mock("@hyperframes/core", () => refuse("@hyperframes/core"));
|
|
13
|
+
vi.mock("@hyperframes/shader-transitions", () => refuse("@hyperframes/shader-transitions"));
|
|
14
|
+
vi.mock("@codemirror/view", () => refuse("@codemirror/view"));
|
|
15
|
+
vi.mock("@codemirror/state", () => refuse("@codemirror/state"));
|
|
16
|
+
vi.mock("mediabunny", () => refuse("mediabunny"));
|
|
17
|
+
vi.mock("gsap", () => refuse("gsap"));
|
|
18
|
+
vi.mock("@hyperframes/parsers", () => refuse("@hyperframes/parsers"));
|
|
19
|
+
vi.mock("@hyperframes/parsers/hf-ids", () => refuse("@hyperframes/parsers/hf-ids"));
|
|
20
|
+
vi.mock("@hyperframes/sdk", () => refuse("@hyperframes/sdk"));
|
|
21
|
+
vi.mock("dockview-react", () => refuse("dockview-react"));
|
|
22
|
+
|
|
23
|
+
describe("entries a host imports for its first screen", () => {
|
|
24
|
+
it.each(["./ui", "./player"])(
|
|
25
|
+
"%s loads without the editor, the parsers, gsap or media decoding",
|
|
26
|
+
async (entry) => {
|
|
27
|
+
await expect(import(resolve(packageDir, subpaths[entry].source))).resolves.toBeDefined();
|
|
28
|
+
},
|
|
29
|
+
// The icon set alone is thousands of modules to transform on a cold run.
|
|
30
|
+
30_000,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -71,8 +71,8 @@ function render(
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function pointer(type: string, clientX: number, pointerId = 1) {
|
|
75
|
-
const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
|
|
74
|
+
function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
|
|
75
|
+
const event = new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 });
|
|
76
76
|
Object.defineProperty(event, "pointerId", { value: pointerId });
|
|
77
77
|
return event;
|
|
78
78
|
}
|
|
@@ -134,14 +134,14 @@ describe("TimelineClipFades", () => {
|
|
|
134
134
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
135
135
|
if (!handle) throw new Error("expected a fade-out handle");
|
|
136
136
|
armCapture(handle);
|
|
137
|
-
//
|
|
138
|
-
act(() => handle.dispatchEvent(pointer("pointerdown",
|
|
139
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
137
|
+
// Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
|
|
138
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
|
|
139
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
|
|
140
140
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
141
141
|
// Way past the start: clamps to duration − fadeIn = 9 s.
|
|
142
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
142
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
|
|
143
143
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
|
|
144
|
-
act(() => handle.dispatchEvent(pointer("pointerup",
|
|
144
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
|
|
145
145
|
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
|
|
146
146
|
act(() => root.unmount());
|
|
147
147
|
});
|
|
@@ -151,9 +151,9 @@ describe("TimelineClipFades", () => {
|
|
|
151
151
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
152
152
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
153
153
|
armCapture(handle);
|
|
154
|
-
act(() => handle.dispatchEvent(pointer("pointerdown",
|
|
155
|
-
act(() => handle.dispatchEvent(pointer("pointermove",
|
|
156
|
-
act(() => handle.dispatchEvent(pointer("pointerup",
|
|
154
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
|
|
155
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
|
|
156
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
|
|
157
157
|
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
|
|
158
158
|
act(() => root.unmount());
|
|
159
159
|
});
|
|
@@ -179,6 +179,27 @@ describe("TimelineClipFades", () => {
|
|
|
179
179
|
act(() => root.unmount());
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
it.each([
|
|
183
|
+
["above the window", 300, -40],
|
|
184
|
+
["on the window's right edge", window.innerWidth, 10],
|
|
185
|
+
])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
|
|
186
|
+
const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
|
|
187
|
+
render(clip);
|
|
188
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
189
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
190
|
+
armCapture(handle);
|
|
191
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
192
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
193
|
+
act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
|
|
194
|
+
expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
195
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
|
196
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
197
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
198
|
+
"0,0 100,0 0,100",
|
|
199
|
+
);
|
|
200
|
+
act(() => root.unmount());
|
|
201
|
+
});
|
|
202
|
+
|
|
182
203
|
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
183
204
|
const outer = vi.fn();
|
|
184
205
|
const { host, root } = render(clip, { onClipPointerDown: outer });
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@hyperframes/core/audio-fade";
|
|
15
15
|
import type { TimelineElement } from "../store/playerStore";
|
|
16
16
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
|
+
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
17
18
|
|
|
18
19
|
type FadeEdge = "in" | "out";
|
|
19
20
|
|
|
@@ -136,7 +137,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
|
|
136
137
|
const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
|
137
138
|
const g = endGesture(e);
|
|
138
139
|
if (!g) return;
|
|
139
|
-
if (cancelled || !g.moved) return revertGesture(g);
|
|
140
|
+
if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
|
|
140
141
|
setDraft({ edge: g.edge, seconds: g.last });
|
|
141
142
|
void onSetElementAttributeQuiet?.(
|
|
142
143
|
el,
|
|
@@ -4,18 +4,20 @@ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
|
|
|
4
4
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import type { TimelineTheme } from "./timelineTheme";
|
|
6
6
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
7
|
+
import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
|
|
7
8
|
|
|
8
9
|
export interface TimelineClipRenderContext {
|
|
9
10
|
priority: "overscan" | "visible" | "interaction";
|
|
10
11
|
rich: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export interface TimelineProps
|
|
14
|
+
export interface TimelineProps
|
|
15
|
+
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
14
16
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
15
17
|
sessionEpoch?: number;
|
|
16
|
-
/** keepPlaying: true preserves the current play state across the seek. */
|
|
17
18
|
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
18
19
|
onDrillDown?: (element: TimelineElement) => void;
|
|
20
|
+
/** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
|
|
19
21
|
renderClipContent?: (
|
|
20
22
|
element: TimelineElement,
|
|
21
23
|
style: { clip: string; label: string },
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/** Bare keys Studio's
|
|
2
|
-
export const STUDIO_PLAIN_KEYS = {
|
|
1
|
+
/** Bare keys Studio's hotkeys bind; the shortcuts list names them from here. */
|
|
2
|
+
export const STUDIO_PLAIN_KEYS = {
|
|
3
|
+
fullscreen: "f",
|
|
4
|
+
split: "s",
|
|
5
|
+
record: "r",
|
|
6
|
+
addKeyframe: "k",
|
|
7
|
+
} as const;
|
|
3
8
|
|
|
4
9
|
export interface ShortcutHint {
|
|
5
10
|
key: string;
|
|
@@ -32,10 +37,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
32
37
|
{
|
|
33
38
|
title: "Keyframes (when an element is selected)",
|
|
34
39
|
hints: [
|
|
35
|
-
{ key:
|
|
40
|
+
{ key: hintKey(STUDIO_PLAIN_KEYS.addKeyframe), label: "Add / remove keyframe at playhead" },
|
|
36
41
|
{ key: "Del", label: "Delete selected keyframe" },
|
|
37
|
-
{ key: "H", label: "Toggle hold / bezier" },
|
|
38
|
-
{ key: "U", label: "Expand / collapse properties" },
|
|
39
42
|
{ key: hintKey(STUDIO_PLAIN_KEYS.record), label: "Record gesture" },
|
|
40
43
|
],
|
|
41
44
|
},
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
1
5
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveClipRenderContext } from "./timelineClipChildren";
|
|
6
|
+
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
|
|
7
|
+
import { getTrackStyle } from "./timelineIcons";
|
|
3
8
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
9
|
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
5
15
|
const clip: TimelineElement = {
|
|
6
16
|
id: "clip",
|
|
7
17
|
tag: "video",
|
|
@@ -29,3 +39,26 @@ describe("resolveClipRenderContext", () => {
|
|
|
29
39
|
});
|
|
30
40
|
});
|
|
31
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("renderClipChildren", () => {
|
|
44
|
+
// happy-dom cannot hit-test, so this pins what decides the hit: the picture takes no input.
|
|
45
|
+
it("lets a press on a picture stacked above the trim handles fall through to them", () => {
|
|
46
|
+
const host = document.createElement("div");
|
|
47
|
+
document.body.append(host);
|
|
48
|
+
const root = createRoot(host);
|
|
49
|
+
const picture = () =>
|
|
50
|
+
createElement("div", { "data-picture": "", style: { position: "absolute", zIndex: 10 } });
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
root.render(renderClipChildren(clip, getTrackStyle("video"), picture, undefined));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// happy-dom does not inherit pointer-events, so walk up to the layer that turns input off.
|
|
57
|
+
let node = host.querySelector<HTMLElement>("[data-picture]");
|
|
58
|
+
expect(node).not.toBeNull();
|
|
59
|
+
while (node && node !== host && node.style.pointerEvents !== "none") node = node.parentElement;
|
|
60
|
+
expect(node?.style.pointerEvents).toBe("none");
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
host.remove();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -57,10 +57,13 @@ export function renderClipChildren(
|
|
|
57
57
|
{renderClipOverlay?.(element)}
|
|
58
58
|
{!renderClipContent && <ClipLintDot element={element} />}
|
|
59
59
|
{renderClipContent && (
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
<div
|
|
60
|
+
// The picture can paint above the trim handles, so it takes no input and presses reach them.
|
|
61
|
+
// borderRadius: inherit clips it to the clip's rounded corners; the clip itself is
|
|
62
|
+
// overflow-visible because keyframe diamonds hang outside its bounds.
|
|
63
|
+
<div
|
|
64
|
+
className="absolute inset-0 overflow-hidden"
|
|
65
|
+
style={{ borderRadius: "inherit", pointerEvents: "none" }}
|
|
66
|
+
>
|
|
64
67
|
{renderClipContent(element, clipStyle, context)}
|
|
65
68
|
</div>
|
|
66
69
|
)}
|
|
@@ -11,86 +11,108 @@ afterEach(() => {
|
|
|
11
11
|
usePlayerStore.getState().reset();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
const element: TimelineElement = {
|
|
15
|
+
id: "clip-1",
|
|
16
|
+
tag: "div",
|
|
17
|
+
start: 0,
|
|
18
|
+
duration: 2,
|
|
19
|
+
track: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function mountGesture(kind: "drag" | "resize") {
|
|
23
|
+
const drag: DraggedClipState = {
|
|
24
|
+
pointerId: 0,
|
|
25
|
+
element,
|
|
26
|
+
originClientX: 0,
|
|
27
|
+
originClientY: 0,
|
|
28
|
+
originScrollLeft: 0,
|
|
29
|
+
originScrollTop: 0,
|
|
30
|
+
pointerClientX: 0,
|
|
31
|
+
pointerClientY: 0,
|
|
32
|
+
pointerOffsetX: 0,
|
|
33
|
+
pointerOffsetY: 0,
|
|
34
|
+
previewStart: 1,
|
|
35
|
+
previewTrack: 0,
|
|
36
|
+
desiredTrack: 0,
|
|
37
|
+
insertRow: null,
|
|
38
|
+
snapTime: null,
|
|
39
|
+
snapType: null,
|
|
40
|
+
started: true,
|
|
41
|
+
};
|
|
42
|
+
const resize: ResizingClipState = {
|
|
43
|
+
pointerId: 0,
|
|
44
|
+
element,
|
|
45
|
+
edge: "end",
|
|
46
|
+
originClientX: 0,
|
|
47
|
+
previewStart: 0,
|
|
48
|
+
previewDuration: 3,
|
|
49
|
+
started: true,
|
|
50
|
+
};
|
|
51
|
+
const draggedClipRef = { current: kind === "drag" ? drag : null };
|
|
52
|
+
const resizingClipRef = { current: kind === "resize" ? resize : null };
|
|
53
|
+
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
54
|
+
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
55
|
+
};
|
|
56
|
+
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
57
|
+
...previous,
|
|
58
|
+
previewStart: 3,
|
|
59
|
+
}));
|
|
60
|
+
const onMoveElement = vi.fn();
|
|
61
|
+
const onResizeElement = vi.fn();
|
|
62
|
+
const updateElement = vi.fn();
|
|
63
|
+
const stopAutoScroll = vi.fn();
|
|
64
|
+
const cancelGestureRef = { current: () => false };
|
|
65
|
+
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
66
|
+
lifecycleRef: { current: { kind, phase: "active", pointerId: null, sessionEpoch: 0 } },
|
|
67
|
+
sessionEpochRef: { current: 0 },
|
|
68
|
+
cancelGestureRef,
|
|
69
|
+
scrollRef: { current: null },
|
|
70
|
+
draggedClipRef,
|
|
71
|
+
resizingClipRef,
|
|
72
|
+
blockedClipRef: { current: null },
|
|
73
|
+
groupResizeRef: { current: null },
|
|
74
|
+
suppressClickRef: { current: false },
|
|
75
|
+
gestureSelectedKeysRef: { current: new Set() },
|
|
76
|
+
elementsRef: { current: [element] },
|
|
77
|
+
trackOrderRef: { current: [0] },
|
|
78
|
+
setDraggedClipState: setDraggedClip,
|
|
79
|
+
setResizingClipState: () => {},
|
|
80
|
+
setShowPopover: () => {},
|
|
81
|
+
setRangeSelectionRef: { current: null },
|
|
82
|
+
applyResizePointerRef: { current: () => {} },
|
|
83
|
+
syncClipDragAutoScrollRef: { current: () => {} },
|
|
84
|
+
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
85
|
+
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
86
|
+
publishDraggedClip: setDraggedClip,
|
|
87
|
+
updateElement,
|
|
88
|
+
onMoveElementRef: { current: onMoveElement },
|
|
89
|
+
onMoveElementsRef: { current: undefined },
|
|
90
|
+
onResizeElementRef: { current: onResizeElement },
|
|
91
|
+
onResizeElementsRef: { current: undefined },
|
|
92
|
+
onBlockedEditAttemptRef: { current: undefined },
|
|
93
|
+
readZIndexRef: { current: undefined },
|
|
94
|
+
onStackingPatchesRef: { current: undefined },
|
|
95
|
+
refreshAfterLaneMoveRef: { current: undefined },
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
draggedClipRef,
|
|
99
|
+
resizingClipRef,
|
|
100
|
+
updateDraggedClipPreview,
|
|
101
|
+
onMoveElement,
|
|
102
|
+
onResizeElement,
|
|
103
|
+
updateElement,
|
|
104
|
+
stopAutoScroll,
|
|
105
|
+
cancelGestureRef,
|
|
106
|
+
dispose,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const release = (clientX: number, clientY: number) =>
|
|
111
|
+
window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));
|
|
112
|
+
|
|
14
113
|
describe("timeline clip drag gesture lifecycle", () => {
|
|
15
114
|
it("finishes a drag after virtualization unmounts its source row", () => {
|
|
16
|
-
const
|
|
17
|
-
id: "clip-1",
|
|
18
|
-
tag: "div",
|
|
19
|
-
start: 0,
|
|
20
|
-
duration: 2,
|
|
21
|
-
track: 0,
|
|
22
|
-
};
|
|
23
|
-
const drag: DraggedClipState = {
|
|
24
|
-
pointerId: 0,
|
|
25
|
-
element,
|
|
26
|
-
originClientX: 0,
|
|
27
|
-
originClientY: 0,
|
|
28
|
-
originScrollLeft: 0,
|
|
29
|
-
originScrollTop: 0,
|
|
30
|
-
pointerClientX: 0,
|
|
31
|
-
pointerClientY: 0,
|
|
32
|
-
pointerOffsetX: 0,
|
|
33
|
-
pointerOffsetY: 0,
|
|
34
|
-
previewStart: 1,
|
|
35
|
-
previewTrack: 0,
|
|
36
|
-
desiredTrack: 0,
|
|
37
|
-
insertRow: null,
|
|
38
|
-
snapTime: null,
|
|
39
|
-
snapType: null,
|
|
40
|
-
started: true,
|
|
41
|
-
};
|
|
42
|
-
const draggedClipRef = { current: drag as DraggedClipState | null };
|
|
43
|
-
const resizingClipRef = { current: null as ResizingClipState | null };
|
|
44
|
-
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
45
|
-
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
46
|
-
};
|
|
47
|
-
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
48
|
-
...previous,
|
|
49
|
-
previewStart: 3,
|
|
50
|
-
}));
|
|
51
|
-
const onMoveElement = vi.fn();
|
|
52
|
-
const stopAutoScroll = vi.fn();
|
|
53
|
-
const cancelGestureRef = { current: () => false };
|
|
54
|
-
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
55
|
-
lifecycleRef: {
|
|
56
|
-
current: {
|
|
57
|
-
kind: "drag",
|
|
58
|
-
phase: "active",
|
|
59
|
-
pointerId: null,
|
|
60
|
-
sessionEpoch: 0,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
sessionEpochRef: { current: 0 },
|
|
64
|
-
cancelGestureRef,
|
|
65
|
-
scrollRef: { current: null },
|
|
66
|
-
draggedClipRef,
|
|
67
|
-
resizingClipRef,
|
|
68
|
-
blockedClipRef: { current: null },
|
|
69
|
-
groupResizeRef: { current: null },
|
|
70
|
-
suppressClickRef: { current: false },
|
|
71
|
-
gestureSelectedKeysRef: { current: new Set() },
|
|
72
|
-
elementsRef: { current: [element] },
|
|
73
|
-
trackOrderRef: { current: [0] },
|
|
74
|
-
setDraggedClipState: setDraggedClip,
|
|
75
|
-
setResizingClipState: () => {},
|
|
76
|
-
setShowPopover: () => {},
|
|
77
|
-
setRangeSelectionRef: { current: null },
|
|
78
|
-
applyResizePointerRef: { current: () => {} },
|
|
79
|
-
syncClipDragAutoScrollRef: { current: () => {} },
|
|
80
|
-
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
81
|
-
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
82
|
-
publishDraggedClip: setDraggedClip,
|
|
83
|
-
updateElement: vi.fn(),
|
|
84
|
-
onMoveElementRef: { current: onMoveElement },
|
|
85
|
-
onMoveElementsRef: { current: undefined },
|
|
86
|
-
onResizeElementRef: { current: undefined },
|
|
87
|
-
onResizeElementsRef: { current: undefined },
|
|
88
|
-
onBlockedEditAttemptRef: { current: undefined },
|
|
89
|
-
readZIndexRef: { current: undefined },
|
|
90
|
-
onStackingPatchesRef: { current: undefined },
|
|
91
|
-
refreshAfterLaneMoveRef: { current: undefined },
|
|
92
|
-
});
|
|
93
|
-
|
|
115
|
+
const g = mountGesture("drag");
|
|
94
116
|
const sourceRow = document.createElement("div");
|
|
95
117
|
sourceRow.dataset.timelineRow = "";
|
|
96
118
|
sourceRow.append(document.createElement("div"));
|
|
@@ -98,15 +120,49 @@ describe("timeline clip drag gesture lifecycle", () => {
|
|
|
98
120
|
sourceRow.remove();
|
|
99
121
|
|
|
100
122
|
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
101
|
-
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
102
|
-
expect(draggedClipRef.current?.previewStart).toBe(3);
|
|
123
|
+
expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(g.draggedClipRef.current?.previewStart).toBe(3);
|
|
103
125
|
|
|
104
126
|
window.dispatchEvent(new MouseEvent("pointerup"));
|
|
105
|
-
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
106
|
-
expect(draggedClipRef.current).toBeNull();
|
|
107
|
-
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
128
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
129
|
+
expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
g.dispose();
|
|
132
|
+
expect(g.cancelGestureRef.current()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
["above the window", 20, -40],
|
|
137
|
+
["past the window's right edge", window.innerWidth + 40, 10],
|
|
138
|
+
["on the window's right edge", window.innerWidth, 10],
|
|
139
|
+
["on the window's bottom edge", 20, window.innerHeight],
|
|
140
|
+
])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
|
|
141
|
+
const g = mountGesture("drag");
|
|
142
|
+
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
143
|
+
release(clientX, clientY);
|
|
144
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
145
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
146
|
+
expect(g.draggedClipRef.current).toBeNull();
|
|
147
|
+
// The cancel ended the gesture, so a later release has nothing to commit.
|
|
148
|
+
release(20, 10);
|
|
149
|
+
expect(g.onMoveElement).not.toHaveBeenCalled();
|
|
150
|
+
g.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("cancels a trim released outside the window", () => {
|
|
154
|
+
const g = mountGesture("resize");
|
|
155
|
+
release(20, -40);
|
|
156
|
+
expect(g.onResizeElement).not.toHaveBeenCalled();
|
|
157
|
+
expect(g.updateElement).not.toHaveBeenCalled();
|
|
158
|
+
expect(g.resizingClipRef.current).toBeNull();
|
|
159
|
+
g.dispose();
|
|
160
|
+
});
|
|
108
161
|
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
it("still commits a drag released on the window's last pixel", () => {
|
|
163
|
+
const g = mountGesture("drag");
|
|
164
|
+
release(window.innerWidth - 1, window.innerHeight - 1);
|
|
165
|
+
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
|
|
166
|
+
g.dispose();
|
|
111
167
|
});
|
|
112
168
|
});
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "./timelineClipDragTypes";
|
|
9
9
|
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
10
10
|
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
11
|
+
import { releasedOutsideWindow } from "./timelinePointerRelease";
|
|
11
12
|
import {
|
|
12
13
|
beginTimelineOptimisticGesture,
|
|
13
14
|
rollbackLatestTimelineOptimisticGesture,
|
|
@@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
349
350
|
};
|
|
350
351
|
|
|
351
352
|
const handleWindowPointerUp = (event: PointerEvent) => {
|
|
353
|
+
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
|
|
352
354
|
const claimed = claimActiveGesture(event);
|
|
353
355
|
if (claimed === "ignored") return;
|
|
354
356
|
if (claimed) {
|
|
@@ -71,6 +71,8 @@ export function useTimelineProviderState({
|
|
|
71
71
|
canPasteClip,
|
|
72
72
|
theme: themeOverrides,
|
|
73
73
|
sessionEpoch = 0,
|
|
74
|
+
previewIframeRef,
|
|
75
|
+
onZIndexReorder,
|
|
74
76
|
}: TimelineProps = {}): TimelineContextValue {
|
|
75
77
|
const {
|
|
76
78
|
onMoveElement,
|
|
@@ -184,6 +186,8 @@ export function useTimelineProviderState({
|
|
|
184
186
|
});
|
|
185
187
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
186
188
|
expandedElementsRef: timelineElementsRef,
|
|
189
|
+
previewIframeRef,
|
|
190
|
+
onZIndexReorder,
|
|
187
191
|
});
|
|
188
192
|
const {
|
|
189
193
|
draggedClip,
|
|
@@ -73,4 +73,50 @@ describe("useTimelineStackingSync", () => {
|
|
|
73
73
|
mocks.actions = null;
|
|
74
74
|
iframe.remove();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it("uses a host's iframe and commit over Studio's own", async () => {
|
|
78
|
+
const studioCommit = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
mocks.actions = {
|
|
80
|
+
previewIframeRef: { current: null },
|
|
81
|
+
handleDomZIndexReorderCommit: studioCommit,
|
|
82
|
+
};
|
|
83
|
+
const iframe = document.createElement("iframe");
|
|
84
|
+
document.body.appendChild(iframe);
|
|
85
|
+
const node = iframe.contentDocument!.createElement("div");
|
|
86
|
+
node.id = "a";
|
|
87
|
+
iframe.contentDocument!.body.appendChild(node);
|
|
88
|
+
const hostCommit = vi.fn().mockResolvedValue(undefined);
|
|
89
|
+
const element: TimelineElement = {
|
|
90
|
+
id: "a",
|
|
91
|
+
key: "a",
|
|
92
|
+
tag: "div",
|
|
93
|
+
start: 0,
|
|
94
|
+
duration: 2,
|
|
95
|
+
track: 0,
|
|
96
|
+
};
|
|
97
|
+
let sync: ReturnType<typeof useTimelineStackingSync> | null = null;
|
|
98
|
+
function Harness() {
|
|
99
|
+
sync = useTimelineStackingSync({
|
|
100
|
+
expandedElementsRef: { current: [element] },
|
|
101
|
+
previewIframeRef: { current: iframe },
|
|
102
|
+
onZIndexReorder: hostCommit,
|
|
103
|
+
});
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const root = mountReactHarness(<Harness />);
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await sync!.applyStackingPatches([{ key: "a", zIndex: 3 }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(sync!.zSyncEnabled).toBe(true);
|
|
113
|
+
expect(hostCommit).toHaveBeenCalledWith(
|
|
114
|
+
[expect.objectContaining({ element: node, zIndex: 3 })],
|
|
115
|
+
undefined,
|
|
116
|
+
);
|
|
117
|
+
expect(studioCommit).not.toHaveBeenCalled();
|
|
118
|
+
act(() => root.unmount());
|
|
119
|
+
mocks.actions = null;
|
|
120
|
+
iframe.remove();
|
|
121
|
+
});
|
|
76
122
|
});
|