@hyperframes/studio 0.7.78 → 0.7.80
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-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -5,26 +5,21 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
|
|
|
5
5
|
import {
|
|
6
6
|
clearKeyframeCacheForElement,
|
|
7
7
|
pruneKeyframeCacheToFiles,
|
|
8
|
+
publishKeyframeCache,
|
|
8
9
|
writeGsapAnimationsForElement,
|
|
9
10
|
} from "./gsapKeyframeCacheHelpers";
|
|
10
|
-
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
11
|
+
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
11
12
|
import {
|
|
12
13
|
deduplicateKeyframes,
|
|
13
14
|
isStaticPositionHold,
|
|
14
15
|
synthesizeFlatTweenKeyframes,
|
|
16
|
+
type MergeableKeyframe,
|
|
15
17
|
} from "./gsapTweenSynth";
|
|
16
|
-
import {
|
|
17
|
-
fetchParsedAnimations,
|
|
18
|
-
populateKeyframeCacheFromAst,
|
|
19
|
-
resolveClipTimingBasis,
|
|
20
|
-
} from "./keyframeCacheAstLoad";
|
|
18
|
+
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
|
21
19
|
|
|
22
20
|
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
|
23
|
-
export {
|
|
24
|
-
|
|
25
|
-
resolveClipTimingBasis,
|
|
26
|
-
resolveSelectorElementIds,
|
|
27
|
-
} from "./keyframeCacheAstLoad";
|
|
21
|
+
export { resolveClipTimingBasis } from "./gsapShared";
|
|
22
|
+
export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
|
|
28
23
|
|
|
29
24
|
/** The selected element's identity for matching tweens to it. */
|
|
30
25
|
export interface GsapElementTarget {
|
|
@@ -273,13 +268,7 @@ export function useGsapAnimationsForElement(
|
|
|
273
268
|
domClipChildren,
|
|
274
269
|
);
|
|
275
270
|
|
|
276
|
-
const allKeyframes:
|
|
277
|
-
GsapKeyframesData["keyframes"][0] & {
|
|
278
|
-
tweenPercentage?: number;
|
|
279
|
-
propertyGroup?: string;
|
|
280
|
-
animationId?: string;
|
|
281
|
-
}
|
|
282
|
-
> = [];
|
|
271
|
+
const allKeyframes: MergeableKeyframe[] = [];
|
|
283
272
|
let format: GsapKeyframesData["format"] = "percentage";
|
|
284
273
|
let ease: string | undefined;
|
|
285
274
|
let easeEach: string | undefined;
|
|
@@ -325,11 +314,15 @@ export function useGsapAnimationsForElement(
|
|
|
325
314
|
...(ease ? { ease } : {}),
|
|
326
315
|
...(easeEach ? { easeEach } : {}),
|
|
327
316
|
};
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
317
|
+
// PropertyPanel reads the cache by bare elementId (without sourceFile
|
|
318
|
+
// prefix), so the same entry is written under the bare key for
|
|
319
|
+
// cross-component lookups. Both keys land in one publish: a reader that woke
|
|
320
|
+
// between two separate writes saw the prefixed key updated and the bare one
|
|
321
|
+
// still stale.
|
|
322
|
+
publishKeyframeCache((draft) => {
|
|
323
|
+
draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
|
|
324
|
+
draft.keyframeCache.set(elementId, merged);
|
|
325
|
+
});
|
|
333
326
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
334
327
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
335
328
|
|
|
@@ -428,29 +421,35 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
428
421
|
}
|
|
429
422
|
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
430
423
|
if (scanned.size === 0) return false;
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
424
|
+
// One publish for the whole scan: a scan of a 120-clip composition used to
|
|
425
|
+
// emit up to three store notifications per element, and every subscriber
|
|
426
|
+
// in between re-rendered against a cache only partly filled in.
|
|
427
|
+
publishKeyframeCache((draft) => {
|
|
428
|
+
for (const [id, data] of scanned) {
|
|
429
|
+
const cacheKey = `${sf}#${id}`;
|
|
430
|
+
const fallbackKey = `index.html#${id}`;
|
|
431
|
+
const alreadyCached =
|
|
432
|
+
draft.keyframeCache.has(cacheKey) ||
|
|
433
|
+
draft.keyframeCache.has(fallbackKey) ||
|
|
434
|
+
draft.keyframeCache.has(id);
|
|
435
|
+
if (alreadyCached) continue;
|
|
436
|
+
// Skip position-only set tweens from runtime too, same filter as AST path
|
|
437
|
+
const isPosOnly =
|
|
438
|
+
data.keyframes.length === 1 &&
|
|
439
|
+
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
|
|
440
|
+
if (isPosOnly) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const entry = {
|
|
444
|
+
format: "percentage" as const,
|
|
445
|
+
keyframes: data.keyframes,
|
|
446
|
+
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
447
|
+
};
|
|
448
|
+
draft.keyframeCache.set(cacheKey, entry);
|
|
449
|
+
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
|
|
450
|
+
draft.keyframeCache.set(id, entry);
|
|
444
451
|
}
|
|
445
|
-
|
|
446
|
-
format: "percentage" as const,
|
|
447
|
-
keyframes: data.keyframes,
|
|
448
|
-
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
449
|
-
};
|
|
450
|
-
setKeyframeCache(cacheKey, entry);
|
|
451
|
-
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
|
452
|
-
setKeyframeCache(id, entry);
|
|
453
|
-
}
|
|
452
|
+
});
|
|
454
453
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
455
454
|
return true;
|
|
456
455
|
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, type RefObject } from "react";
|
|
2
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
3
|
+
import type { AnimationKeyframeTarget } from "./gsapTweenSynth";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
|
|
6
|
+
interface KeyframeEaseCommitsInput {
|
|
7
|
+
gsapCommitMutation: CommitMutation;
|
|
8
|
+
domEditSelectionRef: RefObject<DomEditSelection | null>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useKeyframeEaseCommits({
|
|
12
|
+
gsapCommitMutation,
|
|
13
|
+
domEditSelectionRef,
|
|
14
|
+
}: KeyframeEaseCommitsInput) {
|
|
15
|
+
const handleUpdateSegmentEase = useCallback(
|
|
16
|
+
(targets: AnimationKeyframeTarget[], ease: string) => {
|
|
17
|
+
const selection = domEditSelectionRef.current;
|
|
18
|
+
if (!selection || targets.length === 0) return;
|
|
19
|
+
const options = {
|
|
20
|
+
label: targets.length === 1 ? "Update keyframe ease" : "Update segment ease",
|
|
21
|
+
softReload: true,
|
|
22
|
+
};
|
|
23
|
+
const calls = targets.map(({ animationId, tweenPercentage }) => ({
|
|
24
|
+
selection,
|
|
25
|
+
mutation: {
|
|
26
|
+
type: "update-keyframe",
|
|
27
|
+
animationId,
|
|
28
|
+
percentage: tweenPercentage,
|
|
29
|
+
properties: {},
|
|
30
|
+
ease,
|
|
31
|
+
},
|
|
32
|
+
options,
|
|
33
|
+
}));
|
|
34
|
+
if (calls.length === 1) {
|
|
35
|
+
const call = calls[0];
|
|
36
|
+
if (call) void gsapCommitMutation(call.selection, call.mutation, call.options);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const batch = gsapCommitMutation.batch;
|
|
40
|
+
if (batch) {
|
|
41
|
+
void batch(calls, options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// A wrapped commit (a gesture transaction, say) carries no batch
|
|
45
|
+
// transport. Serial keeps the edit correct at the cost of one round trip
|
|
46
|
+
// per tween and one undo entry per tween; an optional-chained call here
|
|
47
|
+
// would silently drop the whole edit instead.
|
|
48
|
+
for (const call of calls)
|
|
49
|
+
void gsapCommitMutation(call.selection, call.mutation, call.options);
|
|
50
|
+
},
|
|
51
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const handleUpdateKeyframeEase = useCallback(
|
|
55
|
+
(animationId: string, percentage: number, ease: string) =>
|
|
56
|
+
handleUpdateSegmentEase([{ animationId, tweenPercentage: percentage }], ease),
|
|
57
|
+
[handleUpdateSegmentEase],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const handleSetAllKeyframeEases = useCallback(
|
|
61
|
+
(animationId: string, ease: string) => {
|
|
62
|
+
const sel = domEditSelectionRef.current;
|
|
63
|
+
if (!sel) return;
|
|
64
|
+
gsapCommitMutation(
|
|
65
|
+
sel,
|
|
66
|
+
{
|
|
67
|
+
type: "update-meta",
|
|
68
|
+
animationId,
|
|
69
|
+
updates: { easeEach: ease, resetKeyframeEases: true },
|
|
70
|
+
},
|
|
71
|
+
{ label: "Apply ease to all segments", softReload: true },
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases };
|
|
78
|
+
}
|
|
@@ -66,4 +66,25 @@ describe("KeyframeDiamondContextMenu", () => {
|
|
|
66
66
|
expect(onDelete).toHaveBeenCalledWith("box", target);
|
|
67
67
|
expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// An arc waypoint on a two-anchor path cannot be dropped on its own, so the
|
|
71
|
+
// caller withholds onDelete rather than offering an entry that does nothing.
|
|
72
|
+
it("hides the single-node delete when no handler is given", () => {
|
|
73
|
+
const host = document.createElement("div");
|
|
74
|
+
document.body.appendChild(host);
|
|
75
|
+
const root = createRoot(host);
|
|
76
|
+
act(() =>
|
|
77
|
+
root.render(
|
|
78
|
+
<KeyframeDiamondContextMenu state={state} onClose={() => {}} onDeleteAll={vi.fn()} />,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const labels = Array.from(document.body.querySelectorAll("button")).map(
|
|
83
|
+
(button) => button.textContent,
|
|
84
|
+
);
|
|
85
|
+
expect(labels).toEqual(["Delete All Keyframes"]);
|
|
86
|
+
|
|
87
|
+
act(() => root.unmount());
|
|
88
|
+
host.remove();
|
|
89
|
+
});
|
|
69
90
|
});
|
|
@@ -19,7 +19,10 @@ export interface KeyframeDiamondContextMenuState {
|
|
|
19
19
|
interface KeyframeDiamondContextMenuProps {
|
|
20
20
|
state: KeyframeDiamondContextMenuState;
|
|
21
21
|
onClose: () => void;
|
|
22
|
-
|
|
22
|
+
/** Omitted where this node cannot be deleted on its own (see the arc-waypoint
|
|
23
|
+
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
|
|
24
|
+
* worse than no entry. */
|
|
25
|
+
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
23
26
|
onDeleteAll: (element: TimelineElement) => void;
|
|
24
27
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
25
28
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
@@ -43,7 +46,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
const menuWidth = 200;
|
|
46
|
-
|
|
49
|
+
// Measured off the rendered rows, so the flip-up test below stays right as
|
|
50
|
+
// optional entries drop out.
|
|
51
|
+
const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
|
|
47
52
|
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
48
53
|
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
49
54
|
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
@@ -71,16 +76,18 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
71
76
|
)}
|
|
72
77
|
|
|
73
78
|
{/* Delete */}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
{onDelete && (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
83
|
+
onClick={() => {
|
|
84
|
+
onDelete(state.elementId, keyframe);
|
|
85
|
+
onClose();
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
Delete Keyframe
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
84
91
|
|
|
85
92
|
<button
|
|
86
93
|
type="button"
|
|
@@ -22,7 +22,10 @@ export function LayerDisclosureRow({
|
|
|
22
22
|
/** Same adaptive width the lane rows use: a narrowed header column must not
|
|
23
23
|
* leave this row hanging over the clips it labels. */
|
|
24
24
|
columnWidth: number;
|
|
25
|
-
/** Id of the element holding the lanes this row's caret
|
|
25
|
+
/** Id of the CANVAS-side element holding the diamond lanes this row's caret
|
|
26
|
+
* expands (see TimelinePropertyLanes). The caret also reveals the per-lane
|
|
27
|
+
* control rows in this column, but the diamonds are what following the
|
|
28
|
+
* reference should land on. */
|
|
26
29
|
lanesId: string;
|
|
27
30
|
onToggleClipExpanded: () => void;
|
|
28
31
|
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, Profiler } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const roots: Root[] = [];
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function renderPanel(onRender = vi.fn()) {
|
|
18
|
+
const host = document.createElement("div");
|
|
19
|
+
document.body.append(host);
|
|
20
|
+
const root = createRoot(host);
|
|
21
|
+
roots.push(root);
|
|
22
|
+
act(() => {
|
|
23
|
+
root.render(
|
|
24
|
+
<Profiler id="shortcuts-panel" onRender={onRender}>
|
|
25
|
+
<ShortcutsPanel
|
|
26
|
+
disabled={false}
|
|
27
|
+
duration={10}
|
|
28
|
+
inPoint={null}
|
|
29
|
+
outPoint={null}
|
|
30
|
+
setInPoint={vi.fn()}
|
|
31
|
+
setOutPoint={vi.fn()}
|
|
32
|
+
onSeek={vi.fn()}
|
|
33
|
+
/>
|
|
34
|
+
</Profiler>,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const trigger = host.querySelector<HTMLButtonElement>(
|
|
39
|
+
'button[aria-label="Shortcuts and tools"]',
|
|
40
|
+
)!;
|
|
41
|
+
return { host, trigger, onRender };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pressAndClick(target: HTMLElement): void {
|
|
45
|
+
target.dispatchEvent(
|
|
46
|
+
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
|
|
47
|
+
);
|
|
48
|
+
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
|
|
49
|
+
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
|
|
50
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openPanel(trigger: HTMLButtonElement): void {
|
|
54
|
+
act(() => pressAndClick(trigger));
|
|
55
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("ShortcutsPanel", () => {
|
|
59
|
+
it.each(["page", "button", "panel"] as const)(
|
|
60
|
+
"closes with Escape from the %s-focused position",
|
|
61
|
+
(focusPosition) => {
|
|
62
|
+
const { host, trigger } = renderPanel();
|
|
63
|
+
openPanel(trigger);
|
|
64
|
+
|
|
65
|
+
let eventTarget: Document | HTMLElement;
|
|
66
|
+
if (focusPosition === "page") {
|
|
67
|
+
document.body.tabIndex = -1;
|
|
68
|
+
document.body.focus();
|
|
69
|
+
eventTarget = document;
|
|
70
|
+
expect(document.activeElement).toBe(document.body);
|
|
71
|
+
} else if (focusPosition === "button") {
|
|
72
|
+
trigger.focus();
|
|
73
|
+
eventTarget = trigger;
|
|
74
|
+
expect(document.activeElement).toBe(trigger);
|
|
75
|
+
} else {
|
|
76
|
+
const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
|
|
77
|
+
panelInput.focus();
|
|
78
|
+
eventTarget = panelInput;
|
|
79
|
+
expect(document.activeElement).toBe(panelInput);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
eventTarget.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
87
|
+
expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it("closes on capture-phase pointerdown even when its default is prevented", () => {
|
|
92
|
+
const preventDefault = (event: Event) => event.preventDefault();
|
|
93
|
+
document.addEventListener("pointerdown", preventDefault, true);
|
|
94
|
+
const { host, trigger } = renderPanel();
|
|
95
|
+
openPanel(trigger);
|
|
96
|
+
const outside = document.createElement("div");
|
|
97
|
+
document.body.append(outside);
|
|
98
|
+
const pointerDown = new PointerEvent("pointerdown", {
|
|
99
|
+
bubbles: true,
|
|
100
|
+
cancelable: true,
|
|
101
|
+
button: 0,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
act(() => outside.dispatchEvent(pointerDown));
|
|
105
|
+
|
|
106
|
+
document.removeEventListener("pointerdown", preventDefault, true);
|
|
107
|
+
expect(pointerDown.defaultPrevented).toBe(true);
|
|
108
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
109
|
+
expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not close on a click inside the panel", () => {
|
|
113
|
+
const { host, trigger } = renderPanel();
|
|
114
|
+
openPanel(trigger);
|
|
115
|
+
const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
|
|
116
|
+
|
|
117
|
+
act(() => pressAndClick(panelInput));
|
|
118
|
+
|
|
119
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
120
|
+
expect(host.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("toggles once per trigger click", () => {
|
|
124
|
+
const { trigger } = renderPanel();
|
|
125
|
+
|
|
126
|
+
act(() => pressAndClick(trigger));
|
|
127
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
128
|
+
|
|
129
|
+
act(() => pressAndClick(trigger));
|
|
130
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not re-render when Escape is pressed while closed", () => {
|
|
134
|
+
const { trigger, onRender } = renderPanel();
|
|
135
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
142
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("connects the trigger to the dialog with aria-controls", () => {
|
|
146
|
+
const { host, trigger } = renderPanel();
|
|
147
|
+
openPanel(trigger);
|
|
148
|
+
const panelId = trigger.getAttribute("aria-controls");
|
|
149
|
+
const panel = panelId ? host.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
|
|
150
|
+
|
|
151
|
+
expect(panelId).not.toBeNull();
|
|
152
|
+
expect(panel?.getAttribute("role")).toBe("dialog");
|
|
153
|
+
// Non-modal on purpose: focus is not trapped and the editor behind stays
|
|
154
|
+
// operable, so aria-modal would lie to assistive tech about inertness.
|
|
155
|
+
expect(panel?.getAttribute("aria-modal")).toBeNull();
|
|
156
|
+
expect(panel?.id).toBe(panelId);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback,
|
|
1
|
+
import { useState, useCallback, useId, memo } from "react";
|
|
2
2
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
3
3
|
import { Tooltip } from "../../components/ui";
|
|
4
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
5
|
|
|
5
6
|
const SHORTCUT_SECTIONS = [
|
|
6
7
|
{
|
|
@@ -108,20 +109,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
108
109
|
}: ShortcutsPanelProps) {
|
|
109
110
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
110
111
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (!showShortcuts) return;
|
|
115
|
-
const handleMouseDown = (e: MouseEvent) => {
|
|
116
|
-
if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
|
|
117
|
-
setShowShortcuts(false);
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
document.addEventListener("mousedown", handleMouseDown);
|
|
121
|
-
return () => {
|
|
122
|
-
document.removeEventListener("mousedown", handleMouseDown);
|
|
123
|
-
};
|
|
124
|
-
}, [showShortcuts]);
|
|
112
|
+
const shortcutsPanelId = useId();
|
|
113
|
+
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
|
|
114
|
+
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
|
|
125
115
|
|
|
126
116
|
const commitJumpFrame = useCallback(() => {
|
|
127
117
|
if (disabled) return;
|
|
@@ -158,6 +148,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
158
148
|
}`}
|
|
159
149
|
aria-label="Shortcuts and tools"
|
|
160
150
|
aria-expanded={showShortcuts}
|
|
151
|
+
aria-controls={shortcutsPanelId}
|
|
161
152
|
>
|
|
162
153
|
<svg
|
|
163
154
|
width="11"
|
|
@@ -177,6 +168,11 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
177
168
|
</Tooltip>
|
|
178
169
|
{showShortcuts && (
|
|
179
170
|
<div
|
|
171
|
+
id={shortcutsPanelId}
|
|
172
|
+
role="dialog"
|
|
173
|
+
// Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
|
|
174
|
+
// not trapped and the rest of the editor stays operable, so claiming
|
|
175
|
+
// modality would make assistive tech treat the whole app as inert.
|
|
180
176
|
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
|
181
177
|
style={{
|
|
182
178
|
background: "#161618",
|
|
@@ -293,7 +293,9 @@ describe("Timeline provider boundary", () => {
|
|
|
293
293
|
// mounted before we query it.
|
|
294
294
|
act(() => {});
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
// "1", not "0": the label carries the 1-based display row, while the
|
|
297
|
+
// callback below still routes by the track's own key.
|
|
298
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 1"]');
|
|
297
299
|
expect(button).not.toBeNull();
|
|
298
300
|
if (!button) throw new Error("Expected a track visibility toggle");
|
|
299
301
|
|
|
@@ -20,7 +20,8 @@ import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
|
20
20
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
21
21
|
import type { Rect } from "../../utils/marqueeGeometry";
|
|
22
22
|
import { TimelineClip } from "./TimelineClip";
|
|
23
|
-
import { TimelineLanes
|
|
23
|
+
import { TimelineLanes } from "./TimelineLanes";
|
|
24
|
+
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
24
25
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
25
26
|
import { useTimelineRevealClip } from "./useTimelineRevealClip";
|
|
26
27
|
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
|
|
@@ -641,7 +641,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
641
641
|
act(() => root.unmount());
|
|
642
642
|
});
|
|
643
643
|
|
|
644
|
-
const renderSegmentLane = (lastAmbiguous: boolean) => {
|
|
644
|
+
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
|
|
645
645
|
const host = document.createElement("div");
|
|
646
646
|
document.body.append(host);
|
|
647
647
|
const root = createRoot(host);
|
|
@@ -658,9 +658,20 @@ describe("TimelineClipDiamonds", () => {
|
|
|
658
658
|
<TimelineDiamondLane
|
|
659
659
|
keyframesData={{
|
|
660
660
|
format: "percentage",
|
|
661
|
-
keyframes: [
|
|
661
|
+
keyframes: [
|
|
662
|
+
kf(0),
|
|
663
|
+
kf(50),
|
|
664
|
+
kf(100, {
|
|
665
|
+
collidingAnimationTargets: lastAmbiguous
|
|
666
|
+
? [
|
|
667
|
+
{ animationId: "anim-1", tweenPercentage: 100 },
|
|
668
|
+
{ animationId: "anim-2", tweenPercentage: 75 },
|
|
669
|
+
]
|
|
670
|
+
: undefined,
|
|
671
|
+
}),
|
|
672
|
+
],
|
|
662
673
|
}}
|
|
663
|
-
clipWidthPx={
|
|
674
|
+
clipWidthPx={clipWidthPx}
|
|
664
675
|
clipHeightPx={48}
|
|
665
676
|
accentColor="#4ba3d2"
|
|
666
677
|
isSelected
|
|
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
|
|
|
675
686
|
return { host, root };
|
|
676
687
|
};
|
|
677
688
|
|
|
678
|
-
it("
|
|
679
|
-
//
|
|
680
|
-
// keyframe
|
|
689
|
+
it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
|
|
690
|
+
// Both segments (0->50, 50->100) render their ease button; the 50->100
|
|
691
|
+
// segment ends on a keyframe shared by two animations and the button now
|
|
692
|
+
// bulk-edits both rather than being hidden.
|
|
681
693
|
const { host, root } = renderSegmentLane(true);
|
|
682
|
-
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(
|
|
694
|
+
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
683
695
|
act(() => root.unmount());
|
|
684
696
|
});
|
|
685
697
|
|
|
686
|
-
it("
|
|
698
|
+
it("shows the inline ease button on single-animation merged segments", () => {
|
|
687
699
|
const { host, root } = renderSegmentLane(false);
|
|
688
700
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
689
701
|
act(() => root.unmount());
|
|
@@ -698,6 +710,21 @@ describe("TimelineClipDiamonds", () => {
|
|
|
698
710
|
expect(segment?.style.pointerEvents).toBe("none");
|
|
699
711
|
expect(ease?.style.pointerEvents).toBe("auto");
|
|
700
712
|
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
|
713
|
+
// Room to spare here, so the button carries the 24x24 WCAG 2.5.8 overlay.
|
|
714
|
+
expect(ease?.className).toContain("before:h-6");
|
|
715
|
+
act(() => root.unmount());
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("drops the 24x24 ease overlay when the segment is too narrow to hold it", () => {
|
|
719
|
+
// The segment wrapper outranks the diamonds, so an overlay wider than the
|
|
720
|
+
// clear span between them would steal their clicks at fit zoom. 40px of clip
|
|
721
|
+
// across three keyframes leaves well under 24px of clear span per segment.
|
|
722
|
+
const { host, root } = renderSegmentLane(false, 40);
|
|
723
|
+
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
724
|
+
|
|
725
|
+
expect(ease).not.toBeNull();
|
|
726
|
+
expect(ease?.className).not.toContain("before:h-6");
|
|
727
|
+
expect(ease?.style.width).toBe("16px");
|
|
701
728
|
act(() => root.unmount());
|
|
702
729
|
});
|
|
703
730
|
|
|
@@ -27,6 +27,12 @@ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
|
|
27
27
|
// stops there: at the zoom floor the gap alone left a ~7px target, which is
|
|
28
28
|
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
|
|
29
29
|
// below this width; each diamond still owns the half-gap around its own centre.
|
|
30
|
+
//
|
|
31
|
+
// Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
|
|
32
|
+
// own spacing exception: at normal keyframe density the neighbour gap is under
|
|
33
|
+
// 24px, so a 24px floor would make adjacent diamonds' hit boxes OVERLAP — one
|
|
34
|
+
// diamond swallowing its neighbour's clicks is a worse 2.5.8 failure than a
|
|
35
|
+
// small target. Do not "fix" this to 24.
|
|
30
36
|
const KF_MIN_HIT_W = 12;
|
|
31
37
|
|
|
32
38
|
/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
|
|
@@ -141,6 +147,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
141
147
|
? Math.round(clipHeightPx * 0.45)
|
|
142
148
|
: Math.round(LANE_H * DIAMOND_RATIO);
|
|
143
149
|
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
|
150
|
+
// Hit-box height only — the glyph keeps `marker.visualSize`. 24 is the WCAG 2.2
|
|
151
|
+
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
|
|
152
|
+
// shrunken box: 24px there would reach up into the beat strip.
|
|
153
|
+
const hitHeight = beatsActive ? diamondSize : 24;
|
|
144
154
|
const sorted = keyframesData.keyframes
|
|
145
155
|
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
|
146
156
|
.sort((a, b) => a.percentage - b.percentage);
|
|
@@ -412,7 +422,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
412
422
|
top: centerY,
|
|
413
423
|
transform: "translateY(-50%)",
|
|
414
424
|
width: marker.hitWidth,
|
|
415
|
-
height:
|
|
425
|
+
height: hitHeight,
|
|
416
426
|
zIndex: isHighlighted ? 2 : 1,
|
|
417
427
|
pointerEvents: "auto",
|
|
418
428
|
background: "none",
|