@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
|
@@ -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,
|
|
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { shouldUseSdkCutover } from "../utils/sdkCutover";
|
|
6
6
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
7
|
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
8
9
|
import type { UseDomEditSessionParams } from "./useDomEditSession";
|
|
9
10
|
|
|
10
11
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
@@ -57,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
|
|
|
57
58
|
fn: undefined,
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
|
62
|
+
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
60
63
|
vi.mock("../utils/sdkResolverShadow", () => ({
|
|
61
64
|
runResolverShadow: vi.fn(),
|
|
62
65
|
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
|
|
@@ -83,11 +86,11 @@ vi.mock("./useDomEditCommits", () => ({
|
|
|
83
86
|
}));
|
|
84
87
|
vi.mock("./useDomSelection", () => ({
|
|
85
88
|
useDomSelection: () => ({
|
|
86
|
-
domEditSelection:
|
|
89
|
+
domEditSelection: domEditSelectionRef.current,
|
|
87
90
|
domEditGroupSelections: [],
|
|
88
91
|
domEditHoverSelection: null,
|
|
89
92
|
activeGroupElement: null,
|
|
90
|
-
domEditSelectionRef
|
|
93
|
+
domEditSelectionRef,
|
|
91
94
|
domEditGroupSelectionsRef: { current: [] },
|
|
92
95
|
setActiveGroupElement: vi.fn(),
|
|
93
96
|
applyDomSelection: vi.fn(),
|
|
@@ -123,7 +126,7 @@ vi.mock("./useGsapTweenCache", () => ({
|
|
|
123
126
|
}));
|
|
124
127
|
vi.mock("./useGsapScriptCommits", () => ({
|
|
125
128
|
useGsapScriptCommits: () => ({
|
|
126
|
-
commitMutation:
|
|
129
|
+
commitMutation: gsapCommitMutation,
|
|
127
130
|
updateGsapProperty: vi.fn(),
|
|
128
131
|
updateGsapMeta: vi.fn(),
|
|
129
132
|
deleteGsapAnimation: vi.fn(),
|
|
@@ -277,3 +280,149 @@ describe("onReorderShadow source filter", () => {
|
|
|
277
280
|
}
|
|
278
281
|
});
|
|
279
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("bulk segment ease commits", () => {
|
|
285
|
+
it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
|
|
286
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
287
|
+
const selection: DomEditSelection = {
|
|
288
|
+
id: "hero",
|
|
289
|
+
element: document.createElement("div"),
|
|
290
|
+
label: "Hero",
|
|
291
|
+
tagName: "DIV",
|
|
292
|
+
sourceFile: "index.html",
|
|
293
|
+
compositionPath: "index.html",
|
|
294
|
+
isCompositionHost: false,
|
|
295
|
+
isInsideLockedComposition: false,
|
|
296
|
+
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
|
297
|
+
textContent: null,
|
|
298
|
+
dataAttributes: {},
|
|
299
|
+
inlineStyles: {},
|
|
300
|
+
computedStyles: {},
|
|
301
|
+
textFields: [],
|
|
302
|
+
capabilities: {
|
|
303
|
+
canSelect: true,
|
|
304
|
+
canEditStyles: true,
|
|
305
|
+
canCrop: true,
|
|
306
|
+
canMove: true,
|
|
307
|
+
canResize: true,
|
|
308
|
+
canApplyManualOffset: true,
|
|
309
|
+
canApplyManualSize: true,
|
|
310
|
+
canApplyManualRotation: true,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
domEditSelectionRef.current = selection;
|
|
314
|
+
gsapCommitMutation.mockClear();
|
|
315
|
+
gsapCommitMutation.batch.mockClear();
|
|
316
|
+
let updateSegmentEase:
|
|
317
|
+
| ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void)
|
|
318
|
+
| undefined;
|
|
319
|
+
|
|
320
|
+
function Probe() {
|
|
321
|
+
const params: UseDomEditSessionParams = {
|
|
322
|
+
projectId: "proj-1",
|
|
323
|
+
activeCompPath: "index.html",
|
|
324
|
+
isMasterView: false,
|
|
325
|
+
compIdToSrc: new Map(),
|
|
326
|
+
captionEditMode: false,
|
|
327
|
+
compositionLoading: false,
|
|
328
|
+
previewIframeRef: { current: null },
|
|
329
|
+
timelineElements: [],
|
|
330
|
+
setSelectedTimelineElementId: vi.fn(),
|
|
331
|
+
setRightCollapsed: vi.fn(),
|
|
332
|
+
setRightPanelTab: vi.fn(),
|
|
333
|
+
showToast: vi.fn(),
|
|
334
|
+
refreshPreviewDocumentVersion: vi.fn(),
|
|
335
|
+
queueDomEditSave: async <T,>(save: () => Promise<T>) => save(),
|
|
336
|
+
readProjectFile: async () => "",
|
|
337
|
+
writeProjectFile: async () => {},
|
|
338
|
+
updateEditingFileContent: vi.fn(),
|
|
339
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
340
|
+
editHistory: { recordEdit: async () => {} },
|
|
341
|
+
fileTree: [],
|
|
342
|
+
importedFontAssetsRef: { current: [] },
|
|
343
|
+
projectDir: null,
|
|
344
|
+
projectIdRef: { current: "proj-1" },
|
|
345
|
+
previewIframe: null,
|
|
346
|
+
refreshKey: 0,
|
|
347
|
+
previewDocumentVersion: 0,
|
|
348
|
+
rightPanelTab: "design",
|
|
349
|
+
applyStudioManualEditsToPreviewRef: { current: async () => {} },
|
|
350
|
+
syncPreviewHistoryHotkey: vi.fn(),
|
|
351
|
+
reloadPreview: vi.fn(),
|
|
352
|
+
setRefreshKey: vi.fn(),
|
|
353
|
+
};
|
|
354
|
+
updateSegmentEase = useDomEditSession(params).handleUpdateSegmentEase;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const container = document.createElement("div");
|
|
359
|
+
const root = createRoot(container);
|
|
360
|
+
act(() => root.render(<Probe />));
|
|
361
|
+
try {
|
|
362
|
+
expect(updateSegmentEase).toBeTypeOf("function");
|
|
363
|
+
if (!updateSegmentEase) return;
|
|
364
|
+
const options = { label: "Update segment ease", softReload: true };
|
|
365
|
+
const targets = [
|
|
366
|
+
{ animationId: "move-x", tweenPercentage: 20 },
|
|
367
|
+
{ animationId: "move-y", tweenPercentage: 50 },
|
|
368
|
+
{ animationId: "fade", tweenPercentage: 80 },
|
|
369
|
+
];
|
|
370
|
+
updateSegmentEase(targets, "power2.inOut");
|
|
371
|
+
|
|
372
|
+
expect(gsapCommitMutation).not.toHaveBeenCalled();
|
|
373
|
+
expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(gsapCommitMutation.batch).toHaveBeenCalledWith(
|
|
375
|
+
targets.map(({ animationId, tweenPercentage }) => ({
|
|
376
|
+
selection,
|
|
377
|
+
mutation: {
|
|
378
|
+
type: "update-keyframe",
|
|
379
|
+
animationId,
|
|
380
|
+
percentage: tweenPercentage,
|
|
381
|
+
properties: {},
|
|
382
|
+
ease: "power2.inOut",
|
|
383
|
+
},
|
|
384
|
+
options,
|
|
385
|
+
})),
|
|
386
|
+
options,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
gsapCommitMutation.mockClear();
|
|
390
|
+
gsapCommitMutation.batch.mockClear();
|
|
391
|
+
updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none");
|
|
392
|
+
expect(gsapCommitMutation).toHaveBeenCalledTimes(1);
|
|
393
|
+
expect(gsapCommitMutation).toHaveBeenCalledWith(
|
|
394
|
+
selection,
|
|
395
|
+
{
|
|
396
|
+
type: "update-keyframe",
|
|
397
|
+
animationId: "fade",
|
|
398
|
+
percentage: 25,
|
|
399
|
+
properties: {},
|
|
400
|
+
ease: "none",
|
|
401
|
+
},
|
|
402
|
+
{ label: "Update keyframe ease", softReload: true },
|
|
403
|
+
);
|
|
404
|
+
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
|
|
405
|
+
|
|
406
|
+
gsapCommitMutation.mockClear();
|
|
407
|
+
updateSegmentEase([], "linear");
|
|
408
|
+
expect(gsapCommitMutation).not.toHaveBeenCalled();
|
|
409
|
+
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
|
|
410
|
+
|
|
411
|
+
// A commit with no batch transport (a wrapped one, e.g. inside a gesture
|
|
412
|
+
// transaction) must still write every tween. Optional-chaining the batch
|
|
413
|
+
// call would drop the whole edit here and report nothing.
|
|
414
|
+
gsapCommitMutation.mockClear();
|
|
415
|
+
const restoreBatch = gsapCommitMutation.batch;
|
|
416
|
+
Reflect.deleteProperty(gsapCommitMutation, "batch");
|
|
417
|
+
try {
|
|
418
|
+
updateSegmentEase(targets, "power1.in");
|
|
419
|
+
expect(gsapCommitMutation).toHaveBeenCalledTimes(targets.length);
|
|
420
|
+
} finally {
|
|
421
|
+
gsapCommitMutation.batch = restoreBatch;
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
domEditSelectionRef.current = null;
|
|
425
|
+
act(() => root.unmount());
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -19,8 +19,7 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
|
19
19
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
20
20
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
21
21
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
22
|
-
|
|
23
|
-
// ── Types ──
|
|
22
|
+
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
24
23
|
|
|
25
24
|
interface RecordEditInput {
|
|
26
25
|
label: string;
|
|
@@ -71,8 +70,6 @@ export interface UseDomEditSessionParams {
|
|
|
71
70
|
forceReloadSdkSession?: () => void;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
// ── Hook ──
|
|
75
|
-
|
|
76
73
|
export function useDomEditSession({
|
|
77
74
|
projectId,
|
|
78
75
|
activeCompPath,
|
|
@@ -113,8 +110,6 @@ export function useDomEditSession({
|
|
|
113
110
|
forceReloadSdkSession,
|
|
114
111
|
}: UseDomEditSessionParams) {
|
|
115
112
|
void _setRefreshKey;
|
|
116
|
-
// ── Selection ──
|
|
117
|
-
|
|
118
113
|
const {
|
|
119
114
|
domEditSelection,
|
|
120
115
|
domEditGroupSelections,
|
|
@@ -149,8 +144,6 @@ export function useDomEditSession({
|
|
|
149
144
|
rightPanelTab,
|
|
150
145
|
});
|
|
151
146
|
|
|
152
|
-
// ── Agent modal ──
|
|
153
|
-
|
|
154
147
|
const {
|
|
155
148
|
agentModalOpen,
|
|
156
149
|
agentModalAnchorPoint,
|
|
@@ -423,9 +416,6 @@ export function useDomEditSession({
|
|
|
423
416
|
removeAllKeyframes,
|
|
424
417
|
handleDomManualEditsReset,
|
|
425
418
|
});
|
|
426
|
-
|
|
427
|
-
// ── Preview interaction ──
|
|
428
|
-
|
|
429
419
|
const {
|
|
430
420
|
handlePreviewCanvasMouseDown,
|
|
431
421
|
handlePreviewCanvasPointerMove,
|
|
@@ -444,9 +434,6 @@ export function useDomEditSession({
|
|
|
444
434
|
setActiveGroupElement,
|
|
445
435
|
onClickToSource,
|
|
446
436
|
});
|
|
447
|
-
|
|
448
|
-
// ── GSAP-aware geometry intercepts + animated property commit ──
|
|
449
|
-
|
|
450
437
|
const {
|
|
451
438
|
handleGsapAwarePathOffsetCommit,
|
|
452
439
|
handleGsapAwareGroupPathOffsetCommit,
|
|
@@ -473,45 +460,8 @@ export function useDomEditSession({
|
|
|
473
460
|
setArcPath,
|
|
474
461
|
updateArcSegment,
|
|
475
462
|
});
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
(animationId: string, percentage: number, ease: string) => {
|
|
479
|
-
const sel = domEditSelectionRef.current;
|
|
480
|
-
if (!sel) return;
|
|
481
|
-
gsapCommitMutation(
|
|
482
|
-
sel,
|
|
483
|
-
{
|
|
484
|
-
type: "update-keyframe",
|
|
485
|
-
animationId,
|
|
486
|
-
percentage,
|
|
487
|
-
properties: {},
|
|
488
|
-
ease,
|
|
489
|
-
},
|
|
490
|
-
{ label: "Update keyframe ease", softReload: true },
|
|
491
|
-
);
|
|
492
|
-
},
|
|
493
|
-
[gsapCommitMutation, domEditSelectionRef],
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
// Apply one ease to every segment at once (AE select-all + F9): set easeEach
|
|
497
|
-
// and strip per-keyframe overrides in a single mutation.
|
|
498
|
-
const handleSetAllKeyframeEases = useCallback(
|
|
499
|
-
(animationId: string, ease: string) => {
|
|
500
|
-
const sel = domEditSelectionRef.current;
|
|
501
|
-
if (!sel) return;
|
|
502
|
-
gsapCommitMutation(
|
|
503
|
-
sel,
|
|
504
|
-
{
|
|
505
|
-
type: "update-meta",
|
|
506
|
-
animationId,
|
|
507
|
-
updates: { easeEach: ease, resetKeyframeEases: true },
|
|
508
|
-
},
|
|
509
|
-
{ label: "Apply ease to all segments", softReload: true },
|
|
510
|
-
);
|
|
511
|
-
},
|
|
512
|
-
[gsapCommitMutation, domEditSelectionRef],
|
|
513
|
-
);
|
|
514
|
-
|
|
463
|
+
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
|
|
464
|
+
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
|
|
515
465
|
return {
|
|
516
466
|
// State
|
|
517
467
|
domEditSelection,
|
|
@@ -591,6 +541,7 @@ export function useDomEditSession({
|
|
|
591
541
|
commitAnimatedProperty,
|
|
592
542
|
commitAnimatedProperties,
|
|
593
543
|
handleSetArcPath,
|
|
544
|
+
handleUpdateSegmentEase,
|
|
594
545
|
handleUpdateArcSegment,
|
|
595
546
|
handleUnroll,
|
|
596
547
|
invalidateGsapCache: bumpGsapCache,
|
|
@@ -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", () => {
|