@hyperframes/studio 0.7.90 → 0.7.92
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-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
- package/dist/assets/index-DIntkrQl.js +428 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.html +1 -1
- package/dist/index.js +6495 -5429
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/Timeline.test.ts +2 -3
- package/src/player/components/Timeline.tsx +47 -48
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.tsx +6 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
- package/src/player/components/TimelineClipDiamonds.tsx +66 -211
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +0 -1
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -15
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineLaneProps.ts +0 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +11 -4
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/playerStore.test.ts +76 -0
- package/src/player/store/playerStore.ts +19 -11
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-Cql15Yur.js +0 -428
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
2
|
-
import { useState } from "react";
|
|
3
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
3
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
5
4
|
import type { DomEditSelection } from "./domEditing";
|
|
6
5
|
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
7
6
|
import { parseTimingValue } from "./propertyPanelTimingSection";
|
|
8
7
|
import { CommitField } from "./propertyPanelPrimitives";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
type GsapAnimationEditCallbacks,
|
|
12
|
-
withTrackedGsapAnimationCallbacks,
|
|
13
|
-
clearFocusedEaseSegment,
|
|
14
|
-
} from "./gsapAnimationCallbacks";
|
|
8
|
+
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
|
15
9
|
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
16
|
-
import {
|
|
17
|
-
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
|
10
|
+
import { GsapAnimationList } from "./GsapAnimationList";
|
|
18
11
|
|
|
19
12
|
export function FlatTimingRow({
|
|
20
13
|
element,
|
|
@@ -136,19 +129,11 @@ export function FlatMotionSection({
|
|
|
136
129
|
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
|
137
130
|
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
138
131
|
} & GsapAnimationEditCallbacks) {
|
|
139
|
-
const track = useTrackDesignInput();
|
|
140
|
-
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
|
141
|
-
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
|
142
|
-
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
143
132
|
// Only consume a focus request aimed at the element THIS panel renders (not
|
|
144
133
|
// the store's selectedElementId, which flips synchronously during async
|
|
145
134
|
// selection resolution), so a shared class-selector animation id can't open
|
|
146
135
|
// the wrong element's editor.
|
|
147
136
|
const renderedElementId = scopedElementKey(element);
|
|
148
|
-
const focusedHere =
|
|
149
|
-
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
|
150
|
-
? focusedEaseSegment
|
|
151
|
-
: null;
|
|
152
137
|
|
|
153
138
|
return (
|
|
154
139
|
<div className="space-y-3">
|
|
@@ -174,26 +159,13 @@ export function FlatMotionSection({
|
|
|
174
159
|
</p>
|
|
175
160
|
)}
|
|
176
161
|
{!multipleTimelines && !unsupportedTimelinePattern && (
|
|
177
|
-
<
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
flat
|
|
185
|
-
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
|
186
|
-
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
|
187
|
-
/>
|
|
188
|
-
))}
|
|
189
|
-
<GsapAddAnimationControl
|
|
190
|
-
open={addMenuOpen}
|
|
191
|
-
setOpen={setAddMenuOpen}
|
|
192
|
-
onAddAnimation={onAddAnimation}
|
|
193
|
-
track={track}
|
|
194
|
-
variant="flat"
|
|
195
|
-
/>
|
|
196
|
-
</div>
|
|
162
|
+
<GsapAnimationList
|
|
163
|
+
{...callbacks}
|
|
164
|
+
elementId={renderedElementId}
|
|
165
|
+
animations={animations}
|
|
166
|
+
onAddAnimation={onAddAnimation}
|
|
167
|
+
variant="flat"
|
|
168
|
+
/>
|
|
197
169
|
)}
|
|
198
170
|
</>
|
|
199
171
|
)}
|
|
@@ -123,8 +123,8 @@ export interface PropertyPanelProps {
|
|
|
123
123
|
) => void;
|
|
124
124
|
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
|
125
125
|
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
126
|
-
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
127
126
|
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
|
|
127
|
+
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
128
128
|
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
|
129
129
|
onCommitAnimatedProperty?: (
|
|
130
130
|
selection: DomEditSelection,
|
|
@@ -317,17 +317,44 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
|
|
320
|
-
const
|
|
320
|
+
const circle: TimelineElement = {
|
|
321
|
+
...element,
|
|
322
|
+
id: "circle",
|
|
323
|
+
key: "scenes/main.html#circle",
|
|
324
|
+
domId: "circle",
|
|
325
|
+
sourceFile: "scenes/main.html",
|
|
326
|
+
};
|
|
327
|
+
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
|
|
328
|
+
const scaleAnimation: GsapAnimation = {
|
|
329
|
+
...otherKeyframedAnimation,
|
|
330
|
+
id: "circle-to-0-scale",
|
|
331
|
+
properties: {},
|
|
332
|
+
propertyGroup: "scale",
|
|
333
|
+
keyframes: {
|
|
334
|
+
format: "percentage",
|
|
335
|
+
keyframes: [
|
|
336
|
+
{ percentage: 0, properties: { scale: 1 } },
|
|
337
|
+
{ percentage: 100, properties: { scale: 2 } },
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
usePlayerStore.setState({
|
|
342
|
+
elements: [element, circle],
|
|
343
|
+
gsapAnimations: new Map([
|
|
344
|
+
["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]],
|
|
345
|
+
]),
|
|
346
|
+
});
|
|
347
|
+
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
|
|
321
348
|
const view = renderCallbacks();
|
|
322
349
|
|
|
323
350
|
await act(async () => {
|
|
324
|
-
view.callbacks.onDeleteAllKeyframes?.(circle);
|
|
351
|
+
view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
|
|
325
352
|
await Promise.resolve();
|
|
326
353
|
});
|
|
327
354
|
|
|
328
355
|
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
scaleAnimation.id,
|
|
357
|
+
circleSelection,
|
|
331
358
|
);
|
|
332
359
|
view.unmount();
|
|
333
360
|
});
|
|
@@ -360,6 +387,19 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
360
387
|
view.unmount();
|
|
361
388
|
});
|
|
362
389
|
|
|
390
|
+
it("does not delete a different lane when an explicit animation identity is stale", async () => {
|
|
391
|
+
const { circle } = arrangeClickedCircle();
|
|
392
|
+
const view = renderCallbacks();
|
|
393
|
+
|
|
394
|
+
await act(async () => {
|
|
395
|
+
view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id");
|
|
396
|
+
await Promise.resolve();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
|
|
400
|
+
view.unmount();
|
|
401
|
+
});
|
|
402
|
+
|
|
363
403
|
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
|
364
404
|
const { circle } = arrangeClickedCircle();
|
|
365
405
|
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
|
@@ -391,6 +431,40 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
391
431
|
view.unmount();
|
|
392
432
|
});
|
|
393
433
|
|
|
434
|
+
it("does not delete a different keyframe when its explicit animation identity is stale", () => {
|
|
435
|
+
const view = renderCallbacks();
|
|
436
|
+
|
|
437
|
+
act(() => {
|
|
438
|
+
view.callbacks.onDeleteKeyframe?.("box", {
|
|
439
|
+
percentage: 100,
|
|
440
|
+
propertyGroup: "position",
|
|
441
|
+
tweenPercentage: 100,
|
|
442
|
+
animationId: "missing-animation-id",
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
|
447
|
+
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
|
448
|
+
view.unmount();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("does not move a different keyframe when its explicit animation identity is stale", async () => {
|
|
452
|
+
const view = renderCallbacks();
|
|
453
|
+
|
|
454
|
+
await act(async () => {
|
|
455
|
+
view.callbacks.onMoveKeyframeToPlayhead?.(element, {
|
|
456
|
+
percentage: 100,
|
|
457
|
+
propertyGroup: "position",
|
|
458
|
+
tweenPercentage: 100,
|
|
459
|
+
animationId: "missing-animation-id",
|
|
460
|
+
});
|
|
461
|
+
await Promise.resolve();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
|
|
465
|
+
view.unmount();
|
|
466
|
+
});
|
|
467
|
+
|
|
394
468
|
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
|
|
395
469
|
const { circle, selection } = arrangeClickedCircle();
|
|
396
470
|
const view = renderCallbacks();
|
|
@@ -189,17 +189,22 @@ export function useTimelineEditCallbacks({
|
|
|
189
189
|
onSplitElement: handleTimelineElementSplit,
|
|
190
190
|
onRazorSplit: handleRazorSplit,
|
|
191
191
|
onRazorSplitAll: handleRazorSplitAll,
|
|
192
|
-
onDeleteAllKeyframes: (element) => {
|
|
192
|
+
onDeleteAllKeyframes: (element, animationId) => {
|
|
193
193
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
194
194
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
195
195
|
// that the next drag adds to, flinging the element off-screen.
|
|
196
196
|
const elementKey = getTimelineElementIdentity(element);
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
// An explicit animation id scopes the delete to the lane whose menu was
|
|
198
|
+
// opened; without one this is the layer-wide action, and that means
|
|
199
|
+
// EVERY keyframed tween, not just the first. A layer with position AND
|
|
200
|
+
// opacity keyframes used to leave the second one keyframed, so "Delete
|
|
201
|
+
// All Keyframes" visibly did half the job. A stale id matches nothing
|
|
202
|
+
// and deletes nothing, which is the point: it never falls back to a
|
|
203
|
+
// lane the user did not click.
|
|
204
|
+
const animations = resolveElementAnimations(elementKey);
|
|
205
|
+
const anims = animationId
|
|
206
|
+
? animations.filter((animation) => animation.id === animationId)
|
|
207
|
+
: animations.filter((animation) => animation.keyframes);
|
|
203
208
|
if (anims.length === 0) return;
|
|
204
209
|
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
|
205
210
|
if (!selection) return;
|
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
|
2
2
|
import type { CanvasResolution } from "@hyperframes/parsers";
|
|
3
3
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
4
4
|
import { getAnonymousId } from "../../telemetry/config";
|
|
5
|
+
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
5
6
|
import { generateId } from "../../utils/generateId";
|
|
6
7
|
|
|
7
8
|
export interface RenderJob {
|
|
@@ -157,16 +158,28 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
157
158
|
resolution?: string;
|
|
158
159
|
composition?: string;
|
|
159
160
|
variables?: Record<string, unknown>;
|
|
160
|
-
telemetryDistinctId
|
|
161
|
+
telemetryDistinctId?: string;
|
|
162
|
+
telemetryOptOut?: boolean;
|
|
161
163
|
} = {
|
|
162
164
|
fps,
|
|
163
165
|
quality,
|
|
164
166
|
format,
|
|
167
|
+
};
|
|
168
|
+
// The id is MINTED by getAnonymousId(), so calling it unconditionally
|
|
169
|
+
// created a telemetry identity for a profile that had opted out — and
|
|
170
|
+
// then shipped it to the server. The server's own policy cannot see this
|
|
171
|
+
// browser's localStorage or DoNotTrack, so it has to be told: an
|
|
172
|
+
// explicit `telemetryOptOut` suppresses the render outcome, which
|
|
173
|
+
// omitting the id alone does NOT (an old client omits it too, and that
|
|
174
|
+
// falls back to the install id).
|
|
175
|
+
if (browserTelemetryAllowed()) {
|
|
165
176
|
// So the server-emitted render_complete/render_error is attributed to
|
|
166
177
|
// this browser user (same id studio_* events use), making the render
|
|
167
178
|
// funnel joinable. Matches studio_render_start fired just above.
|
|
168
|
-
telemetryDistinctId
|
|
169
|
-
}
|
|
179
|
+
body.telemetryDistinctId = getAnonymousId();
|
|
180
|
+
} else {
|
|
181
|
+
body.telemetryOptOut = true;
|
|
182
|
+
}
|
|
170
183
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
171
184
|
if (composition) body.composition = composition;
|
|
172
185
|
if (opts.variables && Object.keys(opts.variables).length > 0) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
// The render request body is the only place the browser can tell the CLI that
|
|
4
|
+
// this profile opted out. The CLI's own policy cannot see localStorage or
|
|
5
|
+
// DoNotTrack in someone else's browser, so if the body says nothing, the
|
|
6
|
+
// server emits render_complete / render_error anyway — attributed to the
|
|
7
|
+
// install id rather than the user's, which is worse than attributing it
|
|
8
|
+
// correctly.
|
|
9
|
+
|
|
10
|
+
import { act } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const policyState = { allowed: true };
|
|
15
|
+
const mintCalls = vi.fn(() => "browser-user-123");
|
|
16
|
+
|
|
17
|
+
vi.mock("../../telemetry/policy", () => ({
|
|
18
|
+
browserTelemetryAllowed: () => policyState.allowed,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("../../telemetry/config", () => ({
|
|
21
|
+
getAnonymousId: () => mintCalls(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../../telemetry/events", () => ({
|
|
24
|
+
trackStudioRenderStart: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const { useRenderQueue } = await import("./useRenderQueue");
|
|
28
|
+
|
|
29
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
30
|
+
|
|
31
|
+
let root: Root | null = null;
|
|
32
|
+
|
|
33
|
+
/** Body of the POST the hook makes when a render is started. */
|
|
34
|
+
async function startRenderBody(): Promise<Record<string, unknown>> {
|
|
35
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
|
36
|
+
Promise.resolve(
|
|
37
|
+
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
44
|
+
vi.stubGlobal(
|
|
45
|
+
"EventSource",
|
|
46
|
+
class {
|
|
47
|
+
close(): void {}
|
|
48
|
+
addEventListener(): void {}
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let api: ReturnType<typeof useRenderQueue> | null = null;
|
|
53
|
+
function Harness(): null {
|
|
54
|
+
api = useRenderQueue("demo");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const host = document.createElement("div");
|
|
58
|
+
document.body.append(host);
|
|
59
|
+
root = createRoot(host);
|
|
60
|
+
act(() => {
|
|
61
|
+
root?.render(<Harness />);
|
|
62
|
+
});
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await api?.startRender({ fps: 30, quality: "standard", format: "mp4" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
|
|
68
|
+
const body = post?.[1]?.body;
|
|
69
|
+
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
|
|
70
|
+
return JSON.parse(String(body)) as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
policyState.allowed = true;
|
|
75
|
+
mintCalls.mockClear();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (root) act(() => root?.unmount());
|
|
80
|
+
root = null;
|
|
81
|
+
document.body.innerHTML = "";
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("render request telemetry fields", () => {
|
|
86
|
+
it("sends the browser id when this profile allows telemetry", async () => {
|
|
87
|
+
const body = await startRenderBody();
|
|
88
|
+
expect(body["telemetryDistinctId"]).toBe("browser-user-123");
|
|
89
|
+
expect(body["telemetryOptOut"]).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("mints no id and says so explicitly when the profile opted out", async () => {
|
|
93
|
+
policyState.allowed = false;
|
|
94
|
+
const body = await startRenderBody();
|
|
95
|
+
// Minting is itself the leak: getAnonymousId() CREATES and persists an
|
|
96
|
+
// identity, so calling it for an opted-out profile is wrong even if the
|
|
97
|
+
// value were never sent.
|
|
98
|
+
expect(mintCalls).not.toHaveBeenCalled();
|
|
99
|
+
expect(body["telemetryDistinctId"]).toBeUndefined();
|
|
100
|
+
// Omission alone would be read as an old client and fall back to the
|
|
101
|
+
// install id — the flag is what actually suppresses the server event.
|
|
102
|
+
expect(body["telemetryOptOut"]).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -57,9 +57,9 @@ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async
|
|
|
57
57
|
const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
|
|
58
58
|
fn: undefined,
|
|
59
59
|
};
|
|
60
|
-
|
|
61
60
|
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
|
62
61
|
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
62
|
+
|
|
63
63
|
vi.mock("../utils/sdkResolverShadow", () => ({
|
|
64
64
|
runResolverShadow: vi.fn(),
|
|
65
65
|
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
|
|
@@ -537,11 +537,11 @@ export function useDomEditSession({
|
|
|
537
537
|
handleGsapRemoveAllKeyframes,
|
|
538
538
|
handleResetSelectedElementKeyframes,
|
|
539
539
|
handleUpdateKeyframeEase,
|
|
540
|
+
handleUpdateSegmentEase,
|
|
540
541
|
handleSetAllKeyframeEases,
|
|
541
542
|
commitAnimatedProperty,
|
|
542
543
|
commitAnimatedProperties,
|
|
543
544
|
handleSetArcPath,
|
|
544
|
-
handleUpdateSegmentEase,
|
|
545
545
|
handleUpdateArcSegment,
|
|
546
546
|
handleUnroll,
|
|
547
547
|
invalidateGsapCache: bumpGsapCache,
|
|
@@ -7,9 +7,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
7
7
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
8
|
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
11
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
12
|
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
|
11
13
|
|
|
12
14
|
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
|
15
|
+
type CommitResult = { ok: boolean; changed: boolean };
|
|
16
|
+
type CommitOptions = { onResult?: (result: CommitResult) => void };
|
|
17
|
+
type CommitCall = [selection: unknown, mutation: unknown, options: CommitOptions];
|
|
18
|
+
|
|
19
|
+
function readCommitOptions(args: unknown[]): CommitOptions {
|
|
20
|
+
// The hook accepts the production writer type; these test doubles expose only
|
|
21
|
+
// the third tuple member they exercise.
|
|
22
|
+
return (args as unknown as CommitCall)[2];
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
let cleanup: (() => void) | null = null;
|
|
15
26
|
afterEach(() => {
|
|
@@ -21,15 +32,14 @@ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSe
|
|
|
21
32
|
|
|
22
33
|
function successfulCommitMutation() {
|
|
23
34
|
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
|
24
|
-
const options = args
|
|
25
|
-
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
|
26
|
-
};
|
|
35
|
+
const options = readCommitOptions(args);
|
|
27
36
|
options.onResult?.({ ok: true, changed: true });
|
|
28
37
|
});
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
function renderKeyframeOps(over: {
|
|
32
41
|
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
|
42
|
+
commitMutationSafely?: (...args: unknown[]) => Promise<void>;
|
|
33
43
|
trackGsapSaveFailure: (...args: unknown[]) => void;
|
|
34
44
|
}) {
|
|
35
45
|
const captured: { api: HookApi | null } = { api: null };
|
|
@@ -41,7 +51,7 @@ function renderKeyframeOps(over: {
|
|
|
41
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
42
52
|
commitMutation: over.commitMutation as any,
|
|
43
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
44
|
-
commitMutationSafely: (() => {}) as any,
|
|
54
|
+
commitMutationSafely: (over.commitMutationSafely ?? (async () => {})) as any,
|
|
45
55
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
|
|
46
56
|
trackGsapSaveFailure: over.trackGsapSaveFailure as any,
|
|
47
57
|
sdkSession: null,
|
|
@@ -135,7 +145,7 @@ describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
|
|
135
145
|
|
|
136
146
|
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
|
137
147
|
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
|
138
|
-
const options = args
|
|
148
|
+
const options = readCommitOptions(args);
|
|
139
149
|
options.onResult?.({ ok: true, changed: false });
|
|
140
150
|
});
|
|
141
151
|
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
|
@@ -203,6 +213,74 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
|
|
203
213
|
);
|
|
204
214
|
});
|
|
205
215
|
|
|
216
|
+
it("clears delete-all cache only when persistence confirms the change", async () => {
|
|
217
|
+
let finishCommit: (() => void) | undefined;
|
|
218
|
+
const commitMutationSafely = vi.fn(
|
|
219
|
+
(...args: unknown[]) =>
|
|
220
|
+
new Promise<void>((resolve) => {
|
|
221
|
+
const options = readCommitOptions(args);
|
|
222
|
+
finishCommit = () => {
|
|
223
|
+
options.onResult?.({ ok: true, changed: true });
|
|
224
|
+
resolve();
|
|
225
|
+
};
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
const api = renderKeyframeOps({
|
|
229
|
+
commitMutation: successfulCommitMutation(),
|
|
230
|
+
commitMutationSafely,
|
|
231
|
+
trackGsapSaveFailure: vi.fn(),
|
|
232
|
+
});
|
|
233
|
+
const cached: KeyframeCacheEntry = {
|
|
234
|
+
format: "percentage",
|
|
235
|
+
keyframes: [
|
|
236
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
237
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
|
|
241
|
+
|
|
242
|
+
const pending = api.removeAllKeyframes(selection, "box-to-0-position");
|
|
243
|
+
expect(commitMutationSafely).toHaveBeenCalledWith(
|
|
244
|
+
selection,
|
|
245
|
+
{ type: "remove-all-keyframes", animationId: "box-to-0-position" },
|
|
246
|
+
expect.objectContaining({ label: "Remove all keyframes", softReload: true }),
|
|
247
|
+
);
|
|
248
|
+
expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
|
|
249
|
+
|
|
250
|
+
finishCommit?.();
|
|
251
|
+
await pending;
|
|
252
|
+
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("clears the live DOM identity for a selector-only selection", async () => {
|
|
256
|
+
const element = document.createElement("div");
|
|
257
|
+
element.id = "box";
|
|
258
|
+
const selectorOnlySelection: DomEditSelection = {
|
|
259
|
+
...selection,
|
|
260
|
+
id: undefined,
|
|
261
|
+
selector: ".box",
|
|
262
|
+
element,
|
|
263
|
+
};
|
|
264
|
+
const commitMutationSafely = vi.fn(async (...args: unknown[]) => {
|
|
265
|
+
const options = readCommitOptions(args);
|
|
266
|
+
options.onResult?.({ ok: true, changed: true });
|
|
267
|
+
});
|
|
268
|
+
const api = renderKeyframeOps({
|
|
269
|
+
commitMutation: successfulCommitMutation(),
|
|
270
|
+
commitMutationSafely,
|
|
271
|
+
trackGsapSaveFailure: vi.fn(),
|
|
272
|
+
});
|
|
273
|
+
const cached: KeyframeCacheEntry = {
|
|
274
|
+
format: "percentage",
|
|
275
|
+
keyframes: [{ percentage: 0, properties: { x: 0 } }],
|
|
276
|
+
};
|
|
277
|
+
usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
|
|
278
|
+
|
|
279
|
+
await api.removeAllKeyframes(selectorOnlySelection, "box-to-0-position");
|
|
280
|
+
|
|
281
|
+
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
206
284
|
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
|
207
285
|
const commitMutation = successfulCommitMutation();
|
|
208
286
|
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "../utils/sdkCutover";
|
|
16
16
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
17
17
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
18
|
-
import { idFromSelector } from "./gsapShared";
|
|
19
18
|
import {
|
|
20
19
|
clearKeyframeCacheForElement,
|
|
21
20
|
readKeyframeSnapshot,
|
|
@@ -336,11 +335,9 @@ export function useGsapKeyframeOps({
|
|
|
336
335
|
const removeAllKeyframes = useCallback(
|
|
337
336
|
async (selection: DomEditSelection, animationId: string) => {
|
|
338
337
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
const elementId = selection.id ?? idFromSelector(selection.selector);
|
|
343
|
-
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
|
338
|
+
// A class/descendant selector can resolve a live element whose selection
|
|
339
|
+
// deliberately has no id. The cache is still keyed by that DOM id.
|
|
340
|
+
const cacheElementId = selection.id || selection.element?.id;
|
|
344
341
|
if (sdkSession && sdkDeps) {
|
|
345
342
|
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
|
346
343
|
targetPath,
|
|
@@ -349,12 +346,26 @@ export function useGsapKeyframeOps({
|
|
|
349
346
|
sdkDeps,
|
|
350
347
|
{ label: "Remove all keyframes" },
|
|
351
348
|
);
|
|
352
|
-
if (cutoverCommittedOrThrow(handled))
|
|
349
|
+
if (cutoverCommittedOrThrow(handled)) {
|
|
350
|
+
if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
353
|
}
|
|
354
|
-
commitMutationSafely(
|
|
354
|
+
await commitMutationSafely(
|
|
355
355
|
selection,
|
|
356
356
|
{ type: "remove-all-keyframes", animationId },
|
|
357
|
-
{
|
|
357
|
+
{
|
|
358
|
+
label: "Remove all keyframes",
|
|
359
|
+
softReload: true,
|
|
360
|
+
// The committed result is the single success boundary: clearing
|
|
361
|
+
// before it makes failed saves lie, while waiting for the reload leaves
|
|
362
|
+
// stale diamonds visible during the source round-trip.
|
|
363
|
+
onResult: (result) => {
|
|
364
|
+
if (result.changed !== false && cacheElementId) {
|
|
365
|
+
clearKeyframeCacheForElement(targetPath, cacheElementId);
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
},
|
|
358
369
|
);
|
|
359
370
|
},
|
|
360
371
|
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|