@hyperframes/studio 0.7.78 → 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-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1303 -1121
- 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.tsx +4 -1
- 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/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-DpkO2Hvw.css +0 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
3
|
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
|
4
|
+
import {
|
|
5
|
+
timelineTrackOrder,
|
|
6
|
+
trackDisplayNumber,
|
|
7
|
+
trackDisplaySuffix,
|
|
8
|
+
} from "../player/components/timelineTrackDisplay";
|
|
4
9
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
5
10
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
6
11
|
import {
|
|
@@ -208,12 +213,17 @@ export async function toggleTimelineTrackHidden({
|
|
|
208
213
|
domEditSaveTimestampRef,
|
|
209
214
|
pendingTimelineEditPathRef,
|
|
210
215
|
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
|
216
|
+
// `track` is the fractional sort key the callback needs; the history entry is
|
|
217
|
+
// read by a human, so it gets the display row instead.
|
|
218
|
+
const suffix = trackDisplaySuffix(
|
|
219
|
+
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
220
|
+
);
|
|
211
221
|
return setElementsHidden({
|
|
212
222
|
projectId,
|
|
213
223
|
activeCompPath,
|
|
214
224
|
elements: timelineElements.filter((element) => element.track === track),
|
|
215
225
|
hidden,
|
|
216
|
-
label: hidden ? `Hide track
|
|
226
|
+
label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
|
|
217
227
|
previewIframe,
|
|
218
228
|
writeProjectFile,
|
|
219
229
|
recordEdit,
|
|
@@ -14,7 +14,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
14
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
16
16
|
import type { SetPatchProps } from "./gsapRuntimePatch";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
selectorFromSelection,
|
|
19
|
+
computeElementPercentage,
|
|
20
|
+
isInstantHold,
|
|
21
|
+
writeTargetSelector,
|
|
22
|
+
tweenTargetsElement,
|
|
23
|
+
} from "./gsapShared";
|
|
18
24
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
19
25
|
import { roundTo3 } from "../utils/rounding";
|
|
20
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
@@ -185,7 +191,9 @@ async function commitStaticSet(
|
|
|
185
191
|
batch.push(entry);
|
|
186
192
|
byGroup.set(group, batch);
|
|
187
193
|
}
|
|
188
|
-
const staticWrites = animations.filter(
|
|
194
|
+
const staticWrites = animations.filter(
|
|
195
|
+
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
|
|
196
|
+
);
|
|
189
197
|
// Resolve every group's target BEFORE committing anything, and coalesce
|
|
190
198
|
// groups that land on the SAME write into one commit: the snapshot is captured
|
|
191
199
|
// once, so if two groups resolved to one legacy mixed write, a first
|
|
@@ -206,7 +214,7 @@ async function commitStaticSet(
|
|
|
206
214
|
}
|
|
207
215
|
// Fresh adds don't reshape existing sets, so their ids can't go stale.
|
|
208
216
|
for (const batch of newSetBatches) {
|
|
209
|
-
await addGlobalStaticSet(selection, batch,
|
|
217
|
+
await addGlobalStaticSet(selection, batch, commit);
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
|
|
@@ -236,18 +244,22 @@ function findGroupOwningStaticWrite(
|
|
|
236
244
|
async function addGlobalStaticSet(
|
|
237
245
|
selection: DomEditSelection,
|
|
238
246
|
batch: [string, number | string][],
|
|
239
|
-
selector: string,
|
|
240
247
|
commit: Commit,
|
|
241
248
|
): Promise<void> {
|
|
242
249
|
const numericProps: SetPatchProps = {};
|
|
243
250
|
for (const [k, v] of batch) {
|
|
244
251
|
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
245
252
|
}
|
|
253
|
+
// A brand-new write, so it must address ONE element: the selection's own
|
|
254
|
+
// selector is the bare class an id-less element yields, which would hold every
|
|
255
|
+
// sibling. No one-element form means no write at all (see writeTargetSelector).
|
|
256
|
+
const target = writeTargetSelector(selection);
|
|
257
|
+
if (!target) return;
|
|
246
258
|
await commit(
|
|
247
259
|
selection,
|
|
248
260
|
{
|
|
249
261
|
type: "add",
|
|
250
|
-
targetSelector:
|
|
262
|
+
targetSelector: target,
|
|
251
263
|
method: "set",
|
|
252
264
|
position: 0,
|
|
253
265
|
properties: Object.fromEntries(batch),
|
|
@@ -259,7 +271,7 @@ async function addGlobalStaticSet(
|
|
|
259
271
|
...(Object.keys(numericProps).length > 0
|
|
260
272
|
? {
|
|
261
273
|
instantPatch: {
|
|
262
|
-
selector,
|
|
274
|
+
selector: target,
|
|
263
275
|
change: { kind: "global-set" as const, props: numericProps },
|
|
264
276
|
},
|
|
265
277
|
}
|
|
@@ -482,7 +494,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
482
494
|
// contaminating a foreign-group tween. Mirror an existing keyframed tween's
|
|
483
495
|
// time range so the new group animates over the same span. The 0% baseline is
|
|
484
496
|
// an `_auto` endpoint so it tracks the nearest keyframe as you add more.
|
|
485
|
-
|
|
497
|
+
// A fresh tween, so its target must address ONE element; with no
|
|
498
|
+
// one-element form the edit is dropped rather than written onto every
|
|
499
|
+
// class sibling (see writeTargetSelector).
|
|
500
|
+
const newTweenTarget = writeTargetSelector(selection);
|
|
501
|
+
if (selector && newTweenTarget) {
|
|
486
502
|
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
|
487
503
|
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
|
488
504
|
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
|
@@ -503,7 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
503
519
|
selection,
|
|
504
520
|
{
|
|
505
521
|
type: "add-with-keyframes",
|
|
506
|
-
targetSelector:
|
|
522
|
+
targetSelector: newTweenTarget,
|
|
507
523
|
position: roundTo3(tStart),
|
|
508
524
|
duration: roundTo3(tDur),
|
|
509
525
|
keyframes,
|
|
@@ -466,3 +466,47 @@ describe("useEnableKeyframes — flat tween transaction", () => {
|
|
|
466
466
|
});
|
|
467
467
|
});
|
|
468
468
|
});
|
|
469
|
+
|
|
470
|
+
describe("useEnableKeyframes — new tween on a class-only element", () => {
|
|
471
|
+
it("targets the selected sibling alone, not every element sharing its class", async () => {
|
|
472
|
+
window.location.hash = "#/project/test-project";
|
|
473
|
+
usePlayerStore.setState({ currentTime: 1 });
|
|
474
|
+
vi.stubGlobal(
|
|
475
|
+
"fetch",
|
|
476
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ animations: [] }) })),
|
|
477
|
+
);
|
|
478
|
+
const scene = document.body.appendChild(document.createElement("div"));
|
|
479
|
+
scene.id = "scene";
|
|
480
|
+
scene.innerHTML = '<i class="group"></i>'.repeat(5);
|
|
481
|
+
const groups = Array.from(scene.querySelectorAll<HTMLElement>(".group"));
|
|
482
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
483
|
+
const enable = renderEnableKeyframes({
|
|
484
|
+
// A bare `.group` here writes `tl.to(".group", …)`, which animates all five
|
|
485
|
+
// siblings and reads back as all five — one add wiped the timeline.
|
|
486
|
+
domEditSelection: {
|
|
487
|
+
selector: ".group",
|
|
488
|
+
selectorIndex: 3,
|
|
489
|
+
sourceFile: "index.html",
|
|
490
|
+
element: groups[3],
|
|
491
|
+
dataAttributes: { start: "0", duration: "2" },
|
|
492
|
+
} as unknown as DomEditSelection,
|
|
493
|
+
selectedGsapAnimations: [],
|
|
494
|
+
previewIframeRef: {
|
|
495
|
+
current: {
|
|
496
|
+
contentWindow: { gsap: { getProperty: () => 7 } },
|
|
497
|
+
} as unknown as HTMLIFrameElement,
|
|
498
|
+
},
|
|
499
|
+
handleGsapAddAnimation: vi.fn(),
|
|
500
|
+
handleGsapConvertToKeyframes: vi.fn(),
|
|
501
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
502
|
+
commitMutation,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await act(async () => enable());
|
|
506
|
+
|
|
507
|
+
const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
|
|
508
|
+
expect(mutation?.targetSelector).toBeTruthy();
|
|
509
|
+
expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
|
|
510
|
+
expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
@@ -13,11 +13,12 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
existingTweenTargetSelector,
|
|
17
17
|
computeElementPercentage,
|
|
18
18
|
KEYFRAME_PCT_MATCH,
|
|
19
19
|
isInstantHold,
|
|
20
20
|
resolveEditableTweenDuration,
|
|
21
|
+
writeTargetSelector,
|
|
21
22
|
} from "./gsapShared";
|
|
22
23
|
import {
|
|
23
24
|
absoluteToPercentage,
|
|
@@ -250,7 +251,10 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
250
251
|
iframe: HTMLIFrameElement | null,
|
|
251
252
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
252
253
|
): Promise<void> {
|
|
253
|
-
|
|
254
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
255
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
256
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
257
|
+
const selector = existingTweenTargetSelector(anim, sel);
|
|
254
258
|
const position = readElementPosition(iframe, sel, anim);
|
|
255
259
|
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
|
256
260
|
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
|
@@ -335,7 +339,10 @@ export async function promoteSetToKeyframes(
|
|
|
335
339
|
t: number,
|
|
336
340
|
iframe: HTMLIFrameElement | null,
|
|
337
341
|
): Promise<void> {
|
|
338
|
-
|
|
342
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
343
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
344
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
345
|
+
const selector = existingTweenTargetSelector(setAnim, sel);
|
|
339
346
|
const setStart = resolveTweenStart(setAnim) ?? 0;
|
|
340
347
|
if (!selector || !session.commitMutation) return;
|
|
341
348
|
// Playhead at or before the set → there's no forward range to promote into.
|
|
@@ -389,7 +396,10 @@ export async function applyArcKeyframeAtPlayhead(
|
|
|
389
396
|
iframe: HTMLIFrameElement | null,
|
|
390
397
|
): Promise<void> {
|
|
391
398
|
if (!session.commitMutation) return;
|
|
392
|
-
|
|
399
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
400
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
401
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
402
|
+
const targetSelector = existingTweenTargetSelector(arcAnim, sel);
|
|
393
403
|
if (!targetSelector) return;
|
|
394
404
|
const start = resolveTweenStart(arcAnim) ?? 0;
|
|
395
405
|
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
|
@@ -523,7 +533,10 @@ export function useEnableKeyframes(
|
|
|
523
533
|
sel.dataAttributes?.duration,
|
|
524
534
|
t,
|
|
525
535
|
);
|
|
526
|
-
|
|
536
|
+
// A brand-new tween: author it against the one element the user selected.
|
|
537
|
+
// The bare class selectorFromSelection hands back for an id-less element
|
|
538
|
+
// animates every sibling sharing the class (see writeTargetSelector).
|
|
539
|
+
const selector = writeTargetSelector(sel);
|
|
527
540
|
|
|
528
541
|
if (!selector) {
|
|
529
542
|
session.handleGsapAddAnimation("to");
|
|
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
13
13
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
16
|
-
import { isInstantHold, idSelector } from "./gsapShared";
|
|
16
|
+
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
|
|
17
17
|
|
|
18
18
|
type RecordedKeyframe = {
|
|
19
19
|
percentage: number;
|
|
@@ -168,11 +168,24 @@ export function useGestureCommit({
|
|
|
168
168
|
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Two different jobs, two different selectors. `selector` is the string an
|
|
172
|
+
// ALREADY-AUTHORED tween is matched against (and retargeted with, so a
|
|
173
|
+
// tween aimed at a whole group stays aimed at it). `writeSelector` is what
|
|
174
|
+
// a NEW tween is authored with: the bare class the id-less case yields here
|
|
175
|
+
// would record the gesture onto every sibling sharing it.
|
|
171
176
|
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
|
172
177
|
if (!selector) {
|
|
173
178
|
showToast("Cannot save — element has no selector", "error");
|
|
174
179
|
return;
|
|
175
180
|
}
|
|
181
|
+
// A recorded gesture becomes a NEW tween, so its target must address one
|
|
182
|
+
// element; the selection's own selector would record the motion onto
|
|
183
|
+
// every sibling sharing its class (see writeTargetSelector).
|
|
184
|
+
const writeSelector = writeTargetSelector(sel);
|
|
185
|
+
if (!writeSelector) {
|
|
186
|
+
showToast("Cannot save: element has no unique selector", "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
176
189
|
if (liveSession.commitMutation) {
|
|
177
190
|
const recStart = recordingStartTimeRef.current;
|
|
178
191
|
const rawKeyframes = sortedPcts.map((pct) => ({
|
|
@@ -186,7 +199,11 @@ export function useGestureCommit({
|
|
|
186
199
|
);
|
|
187
200
|
const allAnims = liveSession.selectedGsapAnimations ?? [];
|
|
188
201
|
const existingPositionTween = hasPositionProps
|
|
189
|
-
? allAnims.find(
|
|
202
|
+
? allAnims.find(
|
|
203
|
+
(a) =>
|
|
204
|
+
a.propertyGroup === "position" &&
|
|
205
|
+
tweenTargetsElement(a.targetSelector, selector, sel.element),
|
|
206
|
+
)
|
|
190
207
|
: undefined;
|
|
191
208
|
if (existingPositionTween) {
|
|
192
209
|
if (isInstantHold(existingPositionTween)) {
|
|
@@ -261,7 +278,7 @@ export function useGestureCommit({
|
|
|
261
278
|
await liveSession.commitMutation(
|
|
262
279
|
{
|
|
263
280
|
type: "add-with-keyframes",
|
|
264
|
-
targetSelector:
|
|
281
|
+
targetSelector: writeSelector,
|
|
265
282
|
position: roundTo3(recStart),
|
|
266
283
|
duration: roundTo3(duration),
|
|
267
284
|
keyframes: groupKfs,
|
|
@@ -287,7 +304,7 @@ export function useGestureCommit({
|
|
|
287
304
|
await liveSession.commitMutation(
|
|
288
305
|
{
|
|
289
306
|
type: "add-with-keyframes",
|
|
290
|
-
targetSelector:
|
|
307
|
+
targetSelector: writeSelector,
|
|
291
308
|
position: roundTo3(recStart),
|
|
292
309
|
duration: roundTo3(duration),
|
|
293
310
|
keyframes: groupKfs,
|
|
@@ -186,6 +186,9 @@ function syncCommittedGsapMutation({
|
|
|
186
186
|
targetPath,
|
|
187
187
|
selection.id ?? undefined,
|
|
188
188
|
mutation,
|
|
189
|
+
// The live preview document is what resolves a class / descendant tween to
|
|
190
|
+
// the elements it really animates; without it only whole-id selectors do.
|
|
191
|
+
iframe?.contentDocument,
|
|
189
192
|
);
|
|
190
193
|
}
|
|
191
194
|
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
|
|
@@ -86,16 +86,24 @@ describe("resolveSelectorElementIds", () => {
|
|
|
86
86
|
expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// A DOM-less LEADING-id fallback attributed `#card .label` to `#card`, the
|
|
90
|
+
// ancestor it merely scopes to. Without a DOM there is nothing to resolve the
|
|
91
|
+
// descendant against, so the honest answer is no element at all.
|
|
92
|
+
it("resolves nothing for a compound selector when there is no DOM", () => {
|
|
93
|
+
expect(resolveSelectorElementIds("#card .label", null)).toEqual([]);
|
|
91
94
|
expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
|
|
92
95
|
});
|
|
93
96
|
|
|
97
|
+
it("still resolves every whole-id part of a group selector without a DOM", () => {
|
|
98
|
+
expect(resolveSelectorElementIds("#a, #b", null)).toEqual(["a", "b"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
94
101
|
// The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
|
|
95
102
|
// dotted). The old local `#id`-only regex read no id at all for those, so they
|
|
96
103
|
// silently dropped out of both DOM-less paths.
|
|
97
104
|
it("falls back to a bracketed id when there is no DOM", () => {
|
|
98
|
-
expect(resolveSelectorElementIds('[id="01-hook"]
|
|
105
|
+
expect(resolveSelectorElementIds('[id="01-hook"]', null)).toEqual(["01-hook"]);
|
|
106
|
+
expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual([]);
|
|
99
107
|
});
|
|
100
108
|
|
|
101
109
|
it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
|
|
@@ -7,24 +7,17 @@ import {
|
|
|
7
7
|
pruneKeyframeCacheToFiles,
|
|
8
8
|
writeGsapAnimationsForElement,
|
|
9
9
|
} from "./gsapKeyframeCacheHelpers";
|
|
10
|
-
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
10
|
+
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
11
11
|
import {
|
|
12
12
|
deduplicateKeyframes,
|
|
13
13
|
isStaticPositionHold,
|
|
14
14
|
synthesizeFlatTweenKeyframes,
|
|
15
15
|
} from "./gsapTweenSynth";
|
|
16
|
-
import {
|
|
17
|
-
fetchParsedAnimations,
|
|
18
|
-
populateKeyframeCacheFromAst,
|
|
19
|
-
resolveClipTimingBasis,
|
|
20
|
-
} from "./keyframeCacheAstLoad";
|
|
16
|
+
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
|
21
17
|
|
|
22
18
|
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
|
23
|
-
export {
|
|
24
|
-
|
|
25
|
-
resolveClipTimingBasis,
|
|
26
|
-
resolveSelectorElementIds,
|
|
27
|
-
} from "./keyframeCacheAstLoad";
|
|
19
|
+
export { resolveClipTimingBasis } from "./gsapShared";
|
|
20
|
+
export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
|
|
28
21
|
|
|
29
22
|
/** The selected element's identity for matching tweens to it. */
|
|
30
23
|
export interface GsapElementTarget {
|
|
@@ -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);
|
|
@@ -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
|
|