@hyperframes/studio 0.8.82 → 0.8.84
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-Dd74fv7B.js} +3 -3
- package/dist/assets/{index-DStmfnpZ.js → index-D9dmtDap.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-DFcIBFM8.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-gjbSm-vt.js} +193 -193
- 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-7VDQIGOU.js +1871 -0
- package/dist/chunk-7VDQIGOU.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 +20512 -23172
- 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 +234 -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/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useInlineTextEdit.test.tsx +39 -0
- package/src/hooks/useInlineTextEdit.ts +5 -9
- 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/player/hooks/timelinePlayerSync.test.ts +1 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +41 -7
- package/src/player/lib/timelineElementHelpers.ts +7 -7
- 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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { useKeyframeKeyboard } from "./useKeyframeKeyboard";
|
|
5
|
+
import { dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
6
|
+
import { DEFAULT_SHORTCUT_SECTIONS } from "../player/components/studioShortcuts";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
import { createHappyDomRootHarness } from "../player/components/testRootHarness";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
const { mount } = createHappyDomRootHarness();
|
|
13
|
+
|
|
14
|
+
function KeyframeKeyboard(props: { enabled: boolean; onAddKeyframe: () => void }) {
|
|
15
|
+
useKeyframeKeyboard(props);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Wires the keyframe keys the way TimelineToolbar does; returns the add-keyframe spy. */
|
|
20
|
+
function mountKeyframeKeyboard(enabled: boolean) {
|
|
21
|
+
const onAddKeyframe = vi.fn();
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
act(() =>
|
|
25
|
+
mount(host).render(<KeyframeKeyboard enabled={enabled} onAddKeyframe={onAddKeyframe} />),
|
|
26
|
+
);
|
|
27
|
+
return onAddKeyframe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
|
31
|
+
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
|
32
|
+
window.dispatchEvent(event);
|
|
33
|
+
return event;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function selectKeyframe() {
|
|
37
|
+
usePlayerStore.setState({ selectedElementId: "box", selectedKeyframes: new Set(["box:50"]) });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(selectKeyframe);
|
|
41
|
+
afterEach(() => usePlayerStore.getState().reset());
|
|
42
|
+
|
|
43
|
+
describe("keyframe shortcuts", () => {
|
|
44
|
+
it("every key the Keyframes section lists does something with a keyframe selected", () => {
|
|
45
|
+
mountKeyframeKeyboard(true);
|
|
46
|
+
const cb = {
|
|
47
|
+
onDeleteSelectedKeyframes: vi.fn(),
|
|
48
|
+
onToggleRecording: vi.fn(),
|
|
49
|
+
domEditSelectionRef: { current: null },
|
|
50
|
+
readOnlyPreview: false,
|
|
51
|
+
} as unknown as HotkeyCallbacks;
|
|
52
|
+
const appHotkeys = (event: KeyboardEvent) =>
|
|
53
|
+
dispatchPlainKey(event, event.key.toLowerCase(), cb);
|
|
54
|
+
window.addEventListener("keydown", appHotkeys, true);
|
|
55
|
+
|
|
56
|
+
const section = DEFAULT_SHORTCUT_SECTIONS.find((s) => s.title.startsWith("Keyframes"));
|
|
57
|
+
const dead = (section?.hints ?? []).filter((hint) => {
|
|
58
|
+
selectKeyframe();
|
|
59
|
+
return !press(hint.key === "Del" ? "Delete" : hint.key.toLowerCase()).defaultPrevented;
|
|
60
|
+
});
|
|
61
|
+
window.removeEventListener("keydown", appHotkeys, true);
|
|
62
|
+
|
|
63
|
+
expect(section?.hints.length).toBeGreaterThan(0);
|
|
64
|
+
expect(dead.map((hint) => hint.key)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("K adds a keyframe only while enabled and leaves J and the arrows to playback", () => {
|
|
68
|
+
const playback = vi.fn();
|
|
69
|
+
window.addEventListener("keydown", playback);
|
|
70
|
+
const onAddKeyframe = mountKeyframeKeyboard(true);
|
|
71
|
+
|
|
72
|
+
for (const [key, shiftKey] of [
|
|
73
|
+
["j", false],
|
|
74
|
+
["J", true],
|
|
75
|
+
["ArrowLeft", false],
|
|
76
|
+
["ArrowRight", true],
|
|
77
|
+
] as const) {
|
|
78
|
+
expect(press(key, { shiftKey }).defaultPrevented).toBe(false);
|
|
79
|
+
}
|
|
80
|
+
expect(playback).toHaveBeenCalledTimes(4);
|
|
81
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
82
|
+
|
|
83
|
+
expect(press("k").defaultPrevented).toBe(true);
|
|
84
|
+
expect(onAddKeyframe).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(playback).toHaveBeenCalledTimes(4);
|
|
86
|
+
window.removeEventListener("keydown", playback);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("K stays playback's stop key when no keyframeable element is selected", () => {
|
|
90
|
+
const playback = vi.fn();
|
|
91
|
+
window.addEventListener("keydown", playback);
|
|
92
|
+
const onAddKeyframe = mountKeyframeKeyboard(false);
|
|
93
|
+
|
|
94
|
+
expect(press("k").defaultPrevented).toBe(false);
|
|
95
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
96
|
+
expect(playback).toHaveBeenCalledTimes(1);
|
|
97
|
+
window.removeEventListener("keydown", playback);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { useEffect, useCallback } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
3
3
|
|
|
4
4
|
interface KeyframeKeyboardOptions {
|
|
5
5
|
enabled: boolean;
|
|
6
6
|
onAddKeyframe?: () => void;
|
|
7
|
-
onDeleteKeyframe?: () => void;
|
|
8
|
-
onPrevKeyframe?: () => void;
|
|
9
|
-
onNextKeyframe?: () => void;
|
|
10
|
-
onToggleHold?: () => void;
|
|
11
|
-
onToggleExpand?: () => void;
|
|
12
|
-
onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
|
|
13
7
|
}
|
|
14
8
|
|
|
15
9
|
function isTextInput(el: Element | null): boolean {
|
|
@@ -19,75 +13,19 @@ function isTextInput(el: Element | null): boolean {
|
|
|
19
13
|
return (el as HTMLElement).isContentEditable === true;
|
|
20
14
|
}
|
|
21
15
|
|
|
22
|
-
export function useKeyframeKeyboard({
|
|
23
|
-
enabled,
|
|
24
|
-
onAddKeyframe,
|
|
25
|
-
onDeleteKeyframe,
|
|
26
|
-
onPrevKeyframe,
|
|
27
|
-
onNextKeyframe,
|
|
28
|
-
onToggleHold,
|
|
29
|
-
onToggleExpand,
|
|
30
|
-
onNudgeKeyframe,
|
|
31
|
-
}: KeyframeKeyboardOptions): void {
|
|
16
|
+
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
|
|
32
17
|
const handler = useCallback(
|
|
33
18
|
(e: KeyboardEvent) => {
|
|
34
|
-
if (!enabled) return;
|
|
19
|
+
if (!enabled || !onAddKeyframe) return;
|
|
35
20
|
if (isTextInput(document.activeElement)) return;
|
|
36
21
|
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// usePlaybackKeyboard, so when a handler is absent we must let the event
|
|
43
|
-
// continue. When we DO act, stopImmediatePropagation prevents the playback
|
|
44
|
-
// handler from also firing (e.g. K pausing instead of adding a keyframe).
|
|
45
|
-
// The listener is registered in the capture phase so it runs first.
|
|
46
|
-
const consume = (run: () => void) => {
|
|
47
|
-
e.preventDefault();
|
|
48
|
-
e.stopImmediatePropagation();
|
|
49
|
-
run();
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
switch (e.key.toLowerCase()) {
|
|
53
|
-
case "k":
|
|
54
|
-
if (onAddKeyframe) consume(onAddKeyframe);
|
|
55
|
-
break;
|
|
56
|
-
case "delete":
|
|
57
|
-
case "backspace":
|
|
58
|
-
if (onDeleteKeyframe && hasSelectedKeyframes) consume(onDeleteKeyframe);
|
|
59
|
-
break;
|
|
60
|
-
case "j": {
|
|
61
|
-
const nav = e.shiftKey ? onNextKeyframe : onPrevKeyframe;
|
|
62
|
-
if (nav) consume(nav);
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
case "h":
|
|
66
|
-
if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
|
|
67
|
-
break;
|
|
68
|
-
case "u":
|
|
69
|
-
if (onToggleExpand) consume(onToggleExpand);
|
|
70
|
-
break;
|
|
71
|
-
case "arrowleft":
|
|
72
|
-
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
73
|
-
consume(() => onNudgeKeyframe(-1, e.shiftKey));
|
|
74
|
-
break;
|
|
75
|
-
case "arrowright":
|
|
76
|
-
if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
|
|
77
|
-
consume(() => onNudgeKeyframe(1, e.shiftKey));
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
22
|
+
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
|
|
23
|
+
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.stopImmediatePropagation();
|
|
26
|
+
onAddKeyframe();
|
|
80
27
|
},
|
|
81
|
-
[
|
|
82
|
-
enabled,
|
|
83
|
-
onAddKeyframe,
|
|
84
|
-
onDeleteKeyframe,
|
|
85
|
-
onPrevKeyframe,
|
|
86
|
-
onNextKeyframe,
|
|
87
|
-
onToggleHold,
|
|
88
|
-
onToggleExpand,
|
|
89
|
-
onNudgeKeyframe,
|
|
90
|
-
],
|
|
28
|
+
[enabled, onAddKeyframe],
|
|
91
29
|
);
|
|
92
30
|
|
|
93
31
|
useEffect(() => {
|
|
@@ -29,7 +29,7 @@ interface RecordEditInput {
|
|
|
29
29
|
files: Record<string, { before: string; after: string }>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
interface UsePreviewPersistenceParams {
|
|
32
|
+
export interface UsePreviewPersistenceParams {
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
34
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
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
|
)}
|