@hyperframes/studio 0.8.96 → 0.8.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-ClfxdXeC.js → hyperframes-player-C6muFtfk.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B6vU39NM.js} +1 -1
- package/dist/assets/{index-BNQLP5_v.js → index-BxnoLgxG.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-itc1XziP.js} +198 -192
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +23 -6
- package/dist/index.html +2 -2
- package/dist/index.js +2153 -1969
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +9 -8
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/index.ts +4 -1
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +24 -7
- package/src/player/components/TimelineTypes.ts +12 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +44 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
6
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
5
7
|
import {
|
|
6
8
|
gsapSourceFileForSelection,
|
|
7
9
|
selectElementAnimationsOrRetry,
|
|
10
|
+
useGsapAnimationFetchFallback,
|
|
8
11
|
} from "./useGsapAnimationFetchFallback";
|
|
12
|
+
import { fetchParsedAnimations } from "./useGsapTweenCache";
|
|
9
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
14
|
|
|
15
|
+
vi.mock("./useGsapTweenCache", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("./useGsapTweenCache")>()),
|
|
17
|
+
fetchParsedAnimations: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
11
22
|
describe("gsapSourceFileForSelection", () => {
|
|
12
23
|
it("uses the explicit target source instead of ambient selection state", () => {
|
|
13
24
|
expect(
|
|
@@ -28,8 +39,11 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
28
39
|
expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" });
|
|
29
40
|
});
|
|
30
41
|
|
|
31
|
-
it("
|
|
32
|
-
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
42
|
+
it("resolves to [] for a file with no tweens at all: the endpoint parses the file per request", () => {
|
|
43
|
+
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
44
|
+
kind: "resolved",
|
|
45
|
+
animations: [],
|
|
46
|
+
});
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
it("resolves the matching animations from a warm parse", () => {
|
|
@@ -58,3 +72,30 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
58
72
|
expect(outcome).toEqual({ kind: "resolved", animations: [] });
|
|
59
73
|
});
|
|
60
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("useGsapAnimationFetchFallback", () => {
|
|
77
|
+
const fetchFor = (projectId: string) => {
|
|
78
|
+
let fetcher: ReturnType<typeof useGsapAnimationFetchFallback> | null = null;
|
|
79
|
+
function Probe() {
|
|
80
|
+
fetcher = useGsapAnimationFetchFallback(projectId);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const root = createRoot(document.createElement("div"));
|
|
84
|
+
act(() => root.render(React.createElement(Probe)));
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
const element = document.createElement("div");
|
|
87
|
+
return fetcher!({ id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("answers a GSAP-free file with one parse request, no retries", async () => {
|
|
91
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(parsed([]));
|
|
92
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
93
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("retries a failed fetch once, then reports no animation", async () => {
|
|
97
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(null);
|
|
98
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
99
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -3,13 +3,8 @@ import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
5
5
|
|
|
6
|
-
// A cold parse is the initial-load race: the endpoint is reachable but its parse
|
|
7
|
-
// isn't warm yet (zero total animations). It's worth waiting out (~600ms).
|
|
8
|
-
const COLD_PARSE_RETRIES = 5;
|
|
9
|
-
const COLD_PARSE_DELAY_MS = 120;
|
|
10
6
|
// A hard fetch error (404/403/network/JSON failure → `fetchParsedAnimations`
|
|
11
|
-
// returns null)
|
|
12
|
-
// cold-parse budget. One short retry covers a transient blip; beyond that the
|
|
7
|
+
// returns null) gets one short retry for a transient blip; beyond that the
|
|
13
8
|
// endpoint genuinely isn't serving this file, so fall through to "no animation".
|
|
14
9
|
const FETCH_ERROR_RETRIES = 1;
|
|
15
10
|
const FETCH_ERROR_DELAY_MS = 120;
|
|
@@ -18,17 +13,14 @@ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
|
|
18
13
|
|
|
19
14
|
/**
|
|
20
15
|
* Outcome of resolving an element's animations from a single parse result.
|
|
21
|
-
* - `resolved`: a definitive answer
|
|
22
|
-
*
|
|
16
|
+
* - `resolved`: a definitive answer. The endpoint parses the file on disk per request,
|
|
17
|
+
* so no match, or a file with no tweens at all, means the element has no animation.
|
|
23
18
|
* - `fetch-error`: `fetchParsedAnimations` returned null (HTTP/network/JSON
|
|
24
|
-
* failure) — retry briefly
|
|
25
|
-
* - `cold`: the parse came back reachable but with zero total animations — the
|
|
26
|
-
* initial-load warming race, worth the full cold-parse retry budget.
|
|
19
|
+
* failure) — retry briefly.
|
|
27
20
|
*/
|
|
28
21
|
export type ElementAnimationsOutcome =
|
|
29
22
|
| { kind: "resolved"; animations: GsapAnimation[] }
|
|
30
|
-
| { kind: "fetch-error" }
|
|
31
|
-
| { kind: "cold" };
|
|
23
|
+
| { kind: "fetch-error" };
|
|
32
24
|
|
|
33
25
|
export interface GsapAnimationFetchOptions {
|
|
34
26
|
/** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
|
|
@@ -41,26 +33,19 @@ export function gsapSourceFileForSelection(selection: DomEditSelection): string
|
|
|
41
33
|
return selection.sourceFile || "index.html";
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
/**
|
|
45
|
-
* Classify a parse result for one element. Differentiates a hard fetch failure
|
|
46
|
-
* (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
|
|
47
|
-
* so the caller can apply the right retry budget to each.
|
|
48
|
-
*/
|
|
36
|
+
/** Classify a parse result for one element: a hard fetch failure (`parsed === null`) or its animations. */
|
|
49
37
|
export function selectElementAnimationsOrRetry(
|
|
50
38
|
parsed: Pick<ParsedGsap, "animations"> | null,
|
|
51
39
|
target: { id: string | null; selector: string | null },
|
|
52
40
|
element?: Element | null,
|
|
53
41
|
): ElementAnimationsOutcome {
|
|
54
42
|
if (!parsed) return { kind: "fetch-error" };
|
|
55
|
-
if (parsed.animations.length === 0) return { kind: "cold" };
|
|
56
43
|
return {
|
|
57
44
|
kind: "resolved",
|
|
58
45
|
animations: getAnimationsForElement(parsed.animations, target, element),
|
|
59
46
|
};
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
// Retry policy deliberately distinguishes cold parses from hard fetch errors.
|
|
63
|
-
// fallow-ignore-next-line complexity
|
|
64
49
|
async function fetchElementAnimationsWithRetry(
|
|
65
50
|
projectId: string,
|
|
66
51
|
gsapSourceFile: string,
|
|
@@ -69,25 +54,16 @@ async function fetchElementAnimationsWithRetry(
|
|
|
69
54
|
failOnFetchError: boolean,
|
|
70
55
|
fresh: boolean,
|
|
71
56
|
): Promise<GsapAnimation[]> {
|
|
72
|
-
let
|
|
73
|
-
let errorAttempts = 0;
|
|
74
|
-
for (;;) {
|
|
57
|
+
for (let errorAttempts = 0; ; errorAttempts++) {
|
|
75
58
|
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
|
|
76
59
|
fresh = false;
|
|
77
60
|
const outcome = selectElementAnimationsOrRetry(parsed, target, element);
|
|
78
61
|
if (outcome.kind === "resolved") return outcome.animations;
|
|
79
|
-
if (
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
errorAttempts++;
|
|
85
|
-
await delay(FETCH_ERROR_DELAY_MS);
|
|
86
|
-
continue;
|
|
62
|
+
if (errorAttempts >= FETCH_ERROR_RETRIES) {
|
|
63
|
+
if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
|
|
64
|
+
return [];
|
|
87
65
|
}
|
|
88
|
-
|
|
89
|
-
coldAttempts++;
|
|
90
|
-
await delay(COLD_PARSE_DELAY_MS);
|
|
66
|
+
await delay(FETCH_ERROR_DELAY_MS);
|
|
91
67
|
}
|
|
92
68
|
}
|
|
93
69
|
|
|
@@ -16,9 +16,10 @@ const dotTween = {
|
|
|
16
16
|
} as GsapAnimation;
|
|
17
17
|
vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
|
|
18
18
|
...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
|
|
19
|
-
fetchParsedAnimations: async () => ({ animations: [dotTween] }),
|
|
19
|
+
fetchParsedAnimations: vi.fn(async () => ({ animations: [dotTween] })),
|
|
20
20
|
}));
|
|
21
21
|
const { useGsapAnimationsForElement } = await import("./useGsapTweenCache");
|
|
22
|
+
const { fetchParsedAnimations } = await import("./keyframeCacheAstLoad");
|
|
22
23
|
|
|
23
24
|
it("attributes a class tween by the selected element, not an earlier same-id copy in a sub-composition", async () => {
|
|
24
25
|
document.body.innerHTML =
|
|
@@ -37,3 +38,24 @@ it("attributes a class tween by the selected element, not an earlier same-id cop
|
|
|
37
38
|
expect(animations.map((animation) => animation.id)).toEqual(["dots"]);
|
|
38
39
|
act(() => root.unmount());
|
|
39
40
|
});
|
|
41
|
+
|
|
42
|
+
it("reads a file with no tweens once: the parse endpoint answers from the file on disk", async () => {
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
vi.mocked(fetchParsedAnimations)
|
|
45
|
+
.mockClear()
|
|
46
|
+
.mockResolvedValue({ animations: [] } as never);
|
|
47
|
+
const selection = { id: "card", sourceFile: "index.html" } as DomEditSelection;
|
|
48
|
+
function Harness() {
|
|
49
|
+
useGsapAnimationsForElement("p", "index.html", selection, 0);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const root = mountReactHarness(<Harness />);
|
|
53
|
+
try {
|
|
54
|
+
await act(async () => {});
|
|
55
|
+
await act(async () => vi.advanceTimersByTime(2000));
|
|
56
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
|
|
57
|
+
} finally {
|
|
58
|
+
act(() => root.unmount());
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -41,7 +41,6 @@ export function useGsapAnimationsForElement(
|
|
|
41
41
|
const [multipleTimelines, setMultipleTimelines] = useState(false);
|
|
42
42
|
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
|
|
43
43
|
const lastFetchKeyRef = useRef("");
|
|
44
|
-
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
45
44
|
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
|
|
46
45
|
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
|
|
47
46
|
const domClipChildrenKey = usePlayerStore((s) =>
|
|
@@ -54,11 +53,6 @@ export function useGsapAnimationsForElement(
|
|
|
54
53
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
55
54
|
lastFetchKeyRef.current = fetchKey;
|
|
56
55
|
|
|
57
|
-
if (retryTimerRef.current) {
|
|
58
|
-
clearTimeout(retryTimerRef.current);
|
|
59
|
-
retryTimerRef.current = null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
56
|
if (!projectId) {
|
|
63
57
|
setAllAnimations([]);
|
|
64
58
|
setMultipleTimelines(false);
|
|
@@ -80,28 +74,10 @@ export function useGsapAnimationsForElement(
|
|
|
80
74
|
setAllAnimations(parsed.animations);
|
|
81
75
|
setMultipleTimelines(parsed.multipleTimelines === true);
|
|
82
76
|
setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
|
|
83
|
-
|
|
84
|
-
// Retry once if initial fetch returned 0 animations — handles
|
|
85
|
-
// cold-load race where the sourceFile isn't resolved yet.
|
|
86
|
-
if (parsed.animations.length === 0 && targetKey) {
|
|
87
|
-
retryTimerRef.current = setTimeout(() => {
|
|
88
|
-
if (cancelled) return;
|
|
89
|
-
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
|
|
90
|
-
if (cancelled) return;
|
|
91
|
-
if (retryParsed && retryParsed.animations.length > 0) {
|
|
92
|
-
setAllAnimations(retryParsed.animations);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
}, 800);
|
|
96
|
-
}
|
|
97
77
|
});
|
|
98
78
|
|
|
99
79
|
return () => {
|
|
100
80
|
cancelled = true;
|
|
101
|
-
if (retryTimerRef.current) {
|
|
102
|
-
clearTimeout(retryTimerRef.current);
|
|
103
|
-
retryTimerRef.current = null;
|
|
104
|
-
}
|
|
105
81
|
};
|
|
106
82
|
}, [projectId, sourceFile, version, target?.id, target?.selector]);
|
|
107
83
|
|
package/src/index.ts
CHANGED
|
@@ -62,7 +62,10 @@ export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
|
|
|
62
62
|
export { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
63
63
|
export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
|
|
64
64
|
export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
|
|
65
|
-
export type {
|
|
65
|
+
export type {
|
|
66
|
+
TimelineClipMenuItem,
|
|
67
|
+
TimelineClipRenderContext,
|
|
68
|
+
} from "./player/components/TimelineTypes";
|
|
66
69
|
|
|
67
70
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
68
71
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveTimelineAutoScrollLoopAction,
|
|
13
13
|
} from "./timelineEditing";
|
|
14
14
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
15
|
+
import { useTimelineReadOnlyPress } from "./timelineReadOnly";
|
|
15
16
|
|
|
16
17
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
17
18
|
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
|
|
@@ -88,7 +89,7 @@ function claimBeatDrag(pointerId?: number): BeatDragActor | null {
|
|
|
88
89
|
return actor;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
function cancelBeatDrag(): void {
|
|
92
|
+
export function cancelBeatDrag(): void {
|
|
92
93
|
claimBeatDrag();
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -377,6 +378,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
377
378
|
);
|
|
378
379
|
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
|
379
380
|
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
381
|
+
const readOnlyPress = useTimelineReadOnlyPress();
|
|
380
382
|
|
|
381
383
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
382
384
|
const activeBeatIndex = activeActor
|
|
@@ -420,7 +422,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
420
422
|
top: 0,
|
|
421
423
|
width: BEAT_HIT_W,
|
|
422
424
|
height: BEAT_BAND_H,
|
|
423
|
-
cursor: "ew-resize",
|
|
425
|
+
cursor: readOnlyPress ? "default" : "ew-resize",
|
|
424
426
|
pointerEvents: "auto",
|
|
425
427
|
touchAction: "none",
|
|
426
428
|
}}
|
|
@@ -429,13 +431,14 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
429
431
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
430
432
|
e.preventDefault();
|
|
431
433
|
e.stopPropagation();
|
|
434
|
+
if (readOnlyPress) return readOnlyPress();
|
|
432
435
|
// ⌥ starts no drag: the delete lands on release below, so a
|
|
433
436
|
// slipped ⌥-drag abandons instead of destroying the beat.
|
|
434
437
|
if (e.altKey) return;
|
|
435
438
|
beginBeatDrag(e, t, pps);
|
|
436
439
|
}}
|
|
437
440
|
onClick={(e) => {
|
|
438
|
-
if (!e.altKey) return;
|
|
441
|
+
if (!e.altKey || readOnlyPress) return;
|
|
439
442
|
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
|
|
440
443
|
// attempt reads as a double-click and would destroy the beat.
|
|
441
444
|
e.stopPropagation();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, StrictMode, useRef, useState } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
7
|
+
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const element = {
|
|
16
|
+
id: "title",
|
|
17
|
+
tag: "div",
|
|
18
|
+
start: 0,
|
|
19
|
+
duration: 2,
|
|
20
|
+
track: 0,
|
|
21
|
+
} as unknown as TimelineElement;
|
|
22
|
+
|
|
23
|
+
function renderMenu(hostItems: readonly TimelineClipMenuItem[], onClose = vi.fn()) {
|
|
24
|
+
const host = document.createElement("div");
|
|
25
|
+
document.body.appendChild(host);
|
|
26
|
+
const root = createRoot(host);
|
|
27
|
+
act(() =>
|
|
28
|
+
root.render(
|
|
29
|
+
<ClipContextMenu
|
|
30
|
+
x={10}
|
|
31
|
+
y={10}
|
|
32
|
+
element={element}
|
|
33
|
+
currentTime={1}
|
|
34
|
+
onClose={onClose}
|
|
35
|
+
onSplit={vi.fn()}
|
|
36
|
+
onDelete={vi.fn()}
|
|
37
|
+
onCopy={() => true}
|
|
38
|
+
hostItems={hostItems}
|
|
39
|
+
/>,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
const items = () =>
|
|
43
|
+
Array.from(document.body.querySelectorAll<HTMLButtonElement>("[role=menuitem]"));
|
|
44
|
+
return { items, unmount: () => act(() => root.unmount()) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("ClipContextMenu host items", () => {
|
|
48
|
+
it("lists the host's items first, with their icon and shortcut, and closes on a pick", () => {
|
|
49
|
+
const onSelect = vi.fn();
|
|
50
|
+
const onClose = vi.fn();
|
|
51
|
+
const { items, unmount } = renderMenu(
|
|
52
|
+
[{ id: "ask", label: "Ask", icon: <svg data-testid="ask-icon" />, shortcut: "A", onSelect }],
|
|
53
|
+
onClose,
|
|
54
|
+
);
|
|
55
|
+
expect(items().map((item) => item.textContent)).toEqual(["AskA", "Copy⌘C", "Delete⌫"]);
|
|
56
|
+
expect(items()[0]!.querySelector("[data-testid=ask-icon]")).not.toBeNull();
|
|
57
|
+
act(() => items()[0]!.click());
|
|
58
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
59
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
60
|
+
unmount();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("counts host rows and their divider when keeping the menu inside the window", () => {
|
|
64
|
+
const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
65
|
+
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
|
66
|
+
try {
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.appendChild(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
const ask = { label: "Ask", onSelect: vi.fn() };
|
|
71
|
+
act(() =>
|
|
72
|
+
root.render(
|
|
73
|
+
<ClipContextMenu
|
|
74
|
+
x={10}
|
|
75
|
+
y={550}
|
|
76
|
+
element={element}
|
|
77
|
+
currentTime={1}
|
|
78
|
+
onClose={vi.fn()}
|
|
79
|
+
onSplit={vi.fn()}
|
|
80
|
+
onDelete={vi.fn()}
|
|
81
|
+
onCopy={() => true}
|
|
82
|
+
hostItems={[
|
|
83
|
+
{ id: "a", ...ask },
|
|
84
|
+
{ id: "b", ...ask },
|
|
85
|
+
]}
|
|
86
|
+
/>,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
// Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
|
|
90
|
+
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
|
|
91
|
+
act(() => root.unmount());
|
|
92
|
+
} finally {
|
|
93
|
+
if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps a disabled host item inert", () => {
|
|
98
|
+
const onSelect = vi.fn();
|
|
99
|
+
const { items, unmount } = renderMenu([{ id: "ask", label: "Ask", disabled: true, onSelect }]);
|
|
100
|
+
expect(items()[0]!.disabled).toBe(true);
|
|
101
|
+
act(() => items()[0]!.click());
|
|
102
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
103
|
+
unmount();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("shows only Studio's items when the host adds none", () => {
|
|
107
|
+
const { items, unmount } = renderMenu([]);
|
|
108
|
+
expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
|
|
109
|
+
expect(document.body.querySelectorAll(".border-t")).toHaveLength(1);
|
|
110
|
+
unmount();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
|
|
115
|
+
function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
|
|
116
|
+
const [open, setOpen] = useState(false);
|
|
117
|
+
const [asked, setAsked] = useState(false);
|
|
118
|
+
const existing = useRef<HTMLInputElement>(null);
|
|
119
|
+
const ask = () => (focus === "existing" ? existing.current!.focus() : setAsked(true));
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<button type="button" data-testid="clip" onClick={() => setOpen(true)} />
|
|
123
|
+
<input ref={existing} data-testid="existing" />
|
|
124
|
+
{asked && <input data-testid="mounted" autoFocus />}
|
|
125
|
+
{open && (
|
|
126
|
+
<ClipContextMenu
|
|
127
|
+
x={10}
|
|
128
|
+
y={10}
|
|
129
|
+
element={element}
|
|
130
|
+
currentTime={1}
|
|
131
|
+
onClose={() => setOpen(false)}
|
|
132
|
+
onSplit={vi.fn()}
|
|
133
|
+
onDelete={vi.fn()}
|
|
134
|
+
hostItems={[{ id: "ask", label: "Ask", onSelect: ask }]}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function openFrom(focus: "existing" | "mounted") {
|
|
142
|
+
const host = document.createElement("div");
|
|
143
|
+
document.body.appendChild(host);
|
|
144
|
+
const root = createRoot(host);
|
|
145
|
+
// StrictMode runs the menu's effect cleanup once while it is still mounted, as Studio's dev build does.
|
|
146
|
+
act(() =>
|
|
147
|
+
root.render(
|
|
148
|
+
<StrictMode>
|
|
149
|
+
<FocusHost focus={focus} />
|
|
150
|
+
</StrictMode>,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
const clip = document.body.querySelector<HTMLButtonElement>("[data-testid=clip]")!;
|
|
154
|
+
clip.focus();
|
|
155
|
+
act(() => clip.click());
|
|
156
|
+
const menuItem = () => document.body.querySelector<HTMLButtonElement>("[role=menuitem]");
|
|
157
|
+
expect(document.activeElement).toBe(menuItem());
|
|
158
|
+
return { menuItem, unmount: () => act(() => root.unmount()) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const focusedId = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
|
|
162
|
+
|
|
163
|
+
describe("ClipContextMenu focus on close", () => {
|
|
164
|
+
it.each(["existing", "mounted"] as const)(
|
|
165
|
+
"leaves the focus where a host item put it (%s field)",
|
|
166
|
+
(focus) => {
|
|
167
|
+
const { menuItem, unmount } = openFrom(focus);
|
|
168
|
+
act(() => menuItem()!.click());
|
|
169
|
+
expect(menuItem()).toBeNull();
|
|
170
|
+
expect(focusedId()).toBe(focus);
|
|
171
|
+
unmount();
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it("gives the focus back to the clip when the menu closes without moving it", () => {
|
|
176
|
+
const { menuItem, unmount } = openFrom("existing");
|
|
177
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
178
|
+
expect(menuItem()).toBeNull();
|
|
179
|
+
expect(focusedId()).toBe("clip");
|
|
180
|
+
unmount();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
7
|
+
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
7
8
|
|
|
8
9
|
interface ClipContextMenuProps {
|
|
9
10
|
x: number;
|
|
@@ -17,6 +18,51 @@ interface ClipContextMenuProps {
|
|
|
17
18
|
onPaste?: () => Promise<void>;
|
|
18
19
|
onDuplicate?: () => Promise<boolean>;
|
|
19
20
|
canPaste?: boolean;
|
|
21
|
+
hostItems?: readonly TimelineClipMenuItem[] | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
25
|
+
const itemClass = (enabled: boolean) =>
|
|
26
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
27
|
+
enabled
|
|
28
|
+
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
29
|
+
: " text-neutral-600 cursor-not-allowed"
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
|
|
33
|
+
function HostItems({
|
|
34
|
+
items,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
items: readonly TimelineClipMenuItem[];
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
{items.map((item) => (
|
|
43
|
+
<button
|
|
44
|
+
key={item.id}
|
|
45
|
+
type="button"
|
|
46
|
+
role="menuitem"
|
|
47
|
+
className={itemClass(!item.disabled)}
|
|
48
|
+
disabled={item.disabled}
|
|
49
|
+
onClick={() => {
|
|
50
|
+
onClose();
|
|
51
|
+
item.onSelect();
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<span className="flex items-center gap-2">
|
|
55
|
+
{item.icon}
|
|
56
|
+
{item.label}
|
|
57
|
+
</span>
|
|
58
|
+
{item.shortcut && (
|
|
59
|
+
<span className="text-neutral-500 text-[10px] ml-3">{item.shortcut}</span>
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
20
66
|
}
|
|
21
67
|
|
|
22
68
|
// A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
|
|
@@ -33,6 +79,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
33
79
|
onPaste,
|
|
34
80
|
onDuplicate,
|
|
35
81
|
canPaste,
|
|
82
|
+
hostItems = [],
|
|
36
83
|
}: ClipContextMenuProps) {
|
|
37
84
|
const menuRef = useContextMenuDismiss(onClose);
|
|
38
85
|
useMenuKeyboardNav(menuRef);
|
|
@@ -57,22 +104,16 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
57
104
|
: "Split (move playhead inside clip)";
|
|
58
105
|
|
|
59
106
|
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
107
|
+
const hostRows = hostItems.length;
|
|
108
|
+
const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
109
|
+
const dividerCount =
|
|
110
|
+
(hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
62
111
|
const menuWidth = 200;
|
|
63
112
|
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
64
113
|
const overflowY = y + menuHeight - window.innerHeight;
|
|
65
114
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
66
115
|
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
67
116
|
|
|
68
|
-
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
69
|
-
const itemClass = (enabled: boolean) =>
|
|
70
|
-
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
71
|
-
enabled
|
|
72
|
-
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
73
|
-
: " text-neutral-600 cursor-not-allowed"
|
|
74
|
-
}`;
|
|
75
|
-
|
|
76
117
|
return createPortal(
|
|
77
118
|
<div
|
|
78
119
|
ref={menuRef}
|
|
@@ -81,6 +122,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
81
122
|
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
82
123
|
style={{ left: adjustedX, top: adjustedY }}
|
|
83
124
|
>
|
|
125
|
+
{hostRows > 0 && <HostItems items={hostItems} onClose={onClose} />}
|
|
84
126
|
{splitLabel && (
|
|
85
127
|
<>
|
|
86
128
|
<button
|