@hyperframes/studio 0.7.77 → 0.7.79
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-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1432 -1181
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- 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/propertyPanelPrimitives.tsx +3 -1
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +70 -33
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- 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/keyframeCacheAstLoad.ts +5 -71
- 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/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 +4 -11
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- 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 +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- 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/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/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 +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-CBAfprvx.css +0 -1
|
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
41
|
+
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
|
41
42
|
|
|
42
43
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
43
44
|
return compositionLoading;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function readPreviewErrorMessage(event: Event): string {
|
|
48
|
+
if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
|
|
49
|
+
return typeof event.detail.message === "string" && event.detail.message.trim()
|
|
50
|
+
? event.detail.message
|
|
51
|
+
: DEFAULT_PREVIEW_ERROR;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
47
55
|
const root = player.shadowRoot;
|
|
48
56
|
if (!root) return;
|
|
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
122
130
|
const loadCountRef = useRef(0);
|
|
123
131
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
124
132
|
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
+
const retryPreviewRef = useRef<(() => void) | null>(null);
|
|
134
|
+
const retryCountRef = useRef(0);
|
|
125
135
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
126
136
|
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
|
127
137
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
128
138
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
129
139
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
130
140
|
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
141
|
+
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
131
142
|
|
|
132
143
|
// eslint-disable-next-line no-restricted-syntax
|
|
133
144
|
useEffect(() => {
|
|
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
161
172
|
);
|
|
162
173
|
applyPreviewVariablesToUrl(srcUrl);
|
|
163
174
|
const src = srcUrl.pathname + srcUrl.search;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
container.appendChild(player);
|
|
174
|
-
|
|
175
|
-
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
176
|
-
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
177
|
-
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
178
|
-
// against the gray pasteboard, consistent with professional editors.
|
|
179
|
-
if (player.shadowRoot) {
|
|
180
|
-
const pasteboardStyle = document.createElement("style");
|
|
181
|
-
pasteboardStyle.textContent =
|
|
182
|
-
".hfp-container{overflow:visible}" +
|
|
183
|
-
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
184
|
-
player.shadowRoot.appendChild(pasteboardStyle);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
enableInteractiveIframe(player);
|
|
188
|
-
|
|
189
|
-
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
175
|
+
const retryPreview = () => {
|
|
176
|
+
retryCountRef.current += 1;
|
|
177
|
+
const retryUrl = new URL(src, window.location.origin);
|
|
178
|
+
retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
|
|
179
|
+
setPreviewError(null);
|
|
180
|
+
setCompositionLoading(true);
|
|
181
|
+
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
|
|
182
|
+
};
|
|
183
|
+
retryPreviewRef.current = retryPreview;
|
|
190
184
|
const iframe = player.iframeElement;
|
|
191
|
-
if (typeof ref === "function") {
|
|
192
|
-
ref(iframe);
|
|
193
|
-
} else if (ref) {
|
|
194
|
-
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Prevent the web component's built-in click-to-toggle behavior.
|
|
198
|
-
// The studio manages playback exclusively via useTimelinePlayer.
|
|
199
185
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
200
|
-
player.addEventListener("click", preventToggle, { capture: true });
|
|
201
|
-
|
|
202
186
|
const handleShaderTransitionState = (event: Event) => {
|
|
203
187
|
const loading = getShaderTransitionLoading(event);
|
|
204
188
|
if (loading !== null) setShaderTransitionLoading(loading);
|
|
205
189
|
};
|
|
206
|
-
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
207
|
-
|
|
208
190
|
const handleReady = () => {
|
|
191
|
+
setPreviewError(null);
|
|
209
192
|
setCompositionLoading(false);
|
|
210
193
|
};
|
|
211
|
-
const handleError = () => {
|
|
194
|
+
const handleError = (event: Event) => {
|
|
195
|
+
setPreviewError(readPreviewErrorMessage(event));
|
|
212
196
|
setCompositionLoading(false);
|
|
213
197
|
};
|
|
214
|
-
player.addEventListener("ready", handleReady);
|
|
215
|
-
player.addEventListener("error", handleError);
|
|
216
|
-
|
|
217
|
-
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
218
198
|
const handleLoad = () => {
|
|
219
199
|
loadCountRef.current++;
|
|
200
|
+
setPreviewError(null);
|
|
220
201
|
setShaderTransitionLoading(false);
|
|
221
202
|
setCompositionLoading(true);
|
|
222
203
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
264
245
|
setAssetsLoading(false);
|
|
265
246
|
}
|
|
266
247
|
};
|
|
248
|
+
|
|
249
|
+
// Attach lifecycle listeners before assigning src or connecting the
|
|
250
|
+
// custom element. A warm local iframe can otherwise finish before
|
|
251
|
+
// Studio observes its load and never initialize the timeline.
|
|
267
252
|
iframe.addEventListener("load", handleLoad);
|
|
253
|
+
player.addEventListener("click", preventToggle, { capture: true });
|
|
254
|
+
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
|
255
|
+
player.addEventListener("ready", handleReady);
|
|
256
|
+
player.addEventListener("error", handleError);
|
|
257
|
+
|
|
258
|
+
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
259
|
+
if (typeof ref === "function") {
|
|
260
|
+
ref(iframe);
|
|
261
|
+
} else if (ref) {
|
|
262
|
+
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
player.setAttribute("shader-capture-scale", "1");
|
|
266
|
+
player.setAttribute("shader-loading", "player");
|
|
267
|
+
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
|
268
|
+
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
|
269
|
+
player.style.width = "100%";
|
|
270
|
+
player.style.height = "100%";
|
|
271
|
+
player.style.display = "block";
|
|
272
|
+
player.style.background = "transparent";
|
|
273
|
+
player.setAttribute("src", src);
|
|
274
|
+
container.appendChild(player);
|
|
275
|
+
|
|
276
|
+
// Inject pasteboard shadow: let the shadow around the canvas bleed
|
|
277
|
+
// into the surrounding pasteboard area (overflow: visible on the container)
|
|
278
|
+
// and add a subtle outline + drop-shadow so the canvas boundary reads
|
|
279
|
+
// against the gray pasteboard, consistent with professional editors.
|
|
280
|
+
if (player.shadowRoot) {
|
|
281
|
+
const pasteboardStyle = document.createElement("style");
|
|
282
|
+
pasteboardStyle.textContent =
|
|
283
|
+
".hfp-container{overflow:visible}" +
|
|
284
|
+
".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
|
|
285
|
+
player.shadowRoot.appendChild(pasteboardStyle);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
enableInteractiveIframe(player);
|
|
268
289
|
|
|
269
290
|
cleanup = () => {
|
|
270
291
|
iframe.removeEventListener("load", handleLoad);
|
|
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
275
296
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
276
297
|
assetPollRef.current = null;
|
|
277
298
|
container.removeChild(player);
|
|
299
|
+
if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
|
|
278
300
|
// Clear the forwarded ref only if it still points to THIS iframe.
|
|
279
301
|
// During crossfade refreshes the retiring Player unmounts after the
|
|
280
302
|
// new Player has already assigned its iframe to the same ref — blindly
|
|
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
381
403
|
/>
|
|
382
404
|
</div>
|
|
383
405
|
)}
|
|
406
|
+
{previewError && (
|
|
407
|
+
<div
|
|
408
|
+
className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
|
|
409
|
+
data-hyperframes-ignore=""
|
|
410
|
+
data-testid="composition-preview-error"
|
|
411
|
+
>
|
|
412
|
+
<div className="max-w-sm">
|
|
413
|
+
<p className="text-sm font-semibold text-white">Preview failed to load</p>
|
|
414
|
+
<p className="mt-1 text-xs text-neutral-400">{previewError}</p>
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
|
|
418
|
+
onClick={() => retryPreviewRef.current?.()}
|
|
419
|
+
>
|
|
420
|
+
Retry preview
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
384
425
|
</div>
|
|
385
426
|
);
|
|
386
427
|
},
|
|
@@ -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);
|
|
@@ -660,7 +660,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
660
660
|
format: "percentage",
|
|
661
661
|
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
|
662
662
|
}}
|
|
663
|
-
clipWidthPx={
|
|
663
|
+
clipWidthPx={clipWidthPx}
|
|
664
664
|
clipHeightPx={48}
|
|
665
665
|
accentColor="#4ba3d2"
|
|
666
666
|
isSelected
|
|
@@ -698,6 +698,21 @@ describe("TimelineClipDiamonds", () => {
|
|
|
698
698
|
expect(segment?.style.pointerEvents).toBe("none");
|
|
699
699
|
expect(ease?.style.pointerEvents).toBe("auto");
|
|
700
700
|
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
|
701
|
+
// Room to spare here, so the button carries the 24x24 WCAG 2.5.8 overlay.
|
|
702
|
+
expect(ease?.className).toContain("before:h-6");
|
|
703
|
+
act(() => root.unmount());
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("drops the 24x24 ease overlay when the segment is too narrow to hold it", () => {
|
|
707
|
+
// The segment wrapper outranks the diamonds, so an overlay wider than the
|
|
708
|
+
// clear span between them would steal their clicks at fit zoom. 40px of clip
|
|
709
|
+
// across three keyframes leaves well under 24px of clear span per segment.
|
|
710
|
+
const { host, root } = renderSegmentLane(false, 40);
|
|
711
|
+
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
712
|
+
|
|
713
|
+
expect(ease).not.toBeNull();
|
|
714
|
+
expect(ease?.className).not.toContain("before:h-6");
|
|
715
|
+
expect(ease?.style.width).toBe("16px");
|
|
701
716
|
act(() => root.unmount());
|
|
702
717
|
});
|
|
703
718
|
|
|
@@ -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",
|
|
@@ -62,6 +62,12 @@ export function TimelineDiamondConnectors({
|
|
|
62
62
|
// no source animation id (runtime-scanned) so there is no tween to target.
|
|
63
63
|
const target = keyframeTarget(kf);
|
|
64
64
|
const ease = kf.ease ?? globalEase;
|
|
65
|
+
// connectorWidth is the clear span between the two diamonds' edges, so a
|
|
66
|
+
// 24x24 target centred in it overhangs a diamond as soon as the span is
|
|
67
|
+
// narrower than 24. The segment wrapper sits at z-index 3, above the
|
|
68
|
+
// diamonds, so that overhang would win the hit test and steal their
|
|
69
|
+
// clicks at fit zoom. Grow the target only where the room exists.
|
|
70
|
+
const roomForFullTarget = connectorWidth >= 24;
|
|
65
71
|
return (
|
|
66
72
|
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
|
67
73
|
<div
|
|
@@ -103,7 +109,14 @@ export function TimelineDiamondConnectors({
|
|
|
103
109
|
data-keyframe-ease-button=""
|
|
104
110
|
aria-label={`Edit ${ease} easing`}
|
|
105
111
|
title={`Edit ${ease} easing`}
|
|
106
|
-
|
|
112
|
+
// A visible 24x24 badge would collide with the diamonds either
|
|
113
|
+
// side, so the WCAG 2.2 (2.5.8) target is met with a centered
|
|
114
|
+
// transparent ::before overlay; the box stays 16x16. Where the
|
|
115
|
+
// segment is too narrow for that overlay the button keeps its
|
|
116
|
+
// 16x16 hit area, which is WCAG's target-spacing exception:
|
|
117
|
+
// the neighbouring diamonds are themselves the reason it
|
|
118
|
+
// cannot grow, and stealing their clicks is the worse failure.
|
|
119
|
+
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
|
107
120
|
style={{
|
|
108
121
|
left: "50%",
|
|
109
122
|
top: "50%",
|