@hyperframes/studio 0.8.114 → 0.8.115
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-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
- package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +43 -19
- package/dist/index.html +2 -2
- package/dist/index.js +2939 -2596
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { filename } from "./assetHelpers";
|
|
3
|
+
import { menuClasses } from "../ui/menuStyle";
|
|
3
4
|
|
|
4
5
|
/** Reject names that would escape the asset directory or break paths. */
|
|
5
6
|
function isValidAssetName(name: string): boolean {
|
|
@@ -94,8 +95,7 @@ export function ContextMenu({
|
|
|
94
95
|
onClose();
|
|
95
96
|
}, [renameDraft, asset, onRename, onClose]);
|
|
96
97
|
|
|
97
|
-
const itemCls =
|
|
98
|
-
"w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
|
|
98
|
+
const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
101
|
<div
|
|
@@ -110,7 +110,7 @@ export function ContextMenu({
|
|
|
110
110
|
ref={menuRef}
|
|
111
111
|
role="menu"
|
|
112
112
|
aria-label={`Actions for ${filename(asset)}`}
|
|
113
|
-
className=
|
|
113
|
+
className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
|
|
114
114
|
style={{ left: pos.x, top: pos.y }}
|
|
115
115
|
onClick={(e) => e.stopPropagation()}
|
|
116
116
|
>
|
|
@@ -147,7 +147,7 @@ export function ContextMenu({
|
|
|
147
147
|
<button
|
|
148
148
|
role="menuitem"
|
|
149
149
|
onClick={() => setMode("confirm-delete")}
|
|
150
|
-
className={`${
|
|
150
|
+
className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
|
|
151
151
|
>
|
|
152
152
|
Delete
|
|
153
153
|
</button>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { menuClasses } from "./menuStyle";
|
|
5
|
+
|
|
6
|
+
const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
|
|
7
|
+
const root = join(__dirname, "../..");
|
|
8
|
+
|
|
9
|
+
/** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
|
|
10
|
+
const NOT_ROW_MENUS = new Set([
|
|
11
|
+
"player/components/AudioGainDialog.tsx",
|
|
12
|
+
"components/editor/CropPresetBar.tsx",
|
|
13
|
+
"components/editor/EaseCurveSection.tsx",
|
|
14
|
+
"player/components/SpeedMenu.tsx",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function sources(dir: string): string[] {
|
|
18
|
+
return readdirSync(dir).flatMap((name) => {
|
|
19
|
+
const path = join(dir, name);
|
|
20
|
+
if (statSync(path).isDirectory()) return sources(path);
|
|
21
|
+
return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = sources(root).map((path) => ({
|
|
26
|
+
name: relative(root, path).replaceAll("\\", "/"),
|
|
27
|
+
text: readFileSync(path, "utf8"),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
describe("menu style", () => {
|
|
31
|
+
it("adds no padding or margin around a menu's rows", () => {
|
|
32
|
+
for (const key of ["panel", "group", "divider"] as const)
|
|
33
|
+
expect(menuClasses[key], key).not.toMatch(spacing);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
|
|
37
|
+
expect(menuClasses.panel).toContain("overflow-hidden");
|
|
38
|
+
expect(menuClasses.panel).toMatch(/(^| )rounded-/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is the only place a menu panel's fill, border and shadow are written", () => {
|
|
42
|
+
const handBuilt = files
|
|
43
|
+
.filter(({ name }) => !NOT_ROW_MENUS.has(name))
|
|
44
|
+
.filter(({ text }) =>
|
|
45
|
+
[...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
|
|
46
|
+
([, literal]) =>
|
|
47
|
+
/shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
.map(({ name }) => name);
|
|
51
|
+
expect(handBuilt).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is the panel of every role=menu, wherever its element is written", () => {
|
|
55
|
+
const unowned = files
|
|
56
|
+
.filter(({ text }) => text.includes('role="menu"'))
|
|
57
|
+
.filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
|
|
58
|
+
.map(({ name }) => name);
|
|
59
|
+
expect(unowned).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
|
|
2
|
+
export const menuClasses = {
|
|
3
|
+
panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
|
|
4
|
+
group: "empty:hidden border-b border-neutral-700/60",
|
|
5
|
+
divider: "border-t border-neutral-700/60",
|
|
6
|
+
row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
|
|
7
|
+
rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
|
|
8
|
+
rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
|
|
9
|
+
rowDisabled: "text-neutral-600 cursor-not-allowed",
|
|
10
|
+
} as const;
|
|
@@ -251,7 +251,7 @@ export function DomEditProvider({
|
|
|
251
251
|
commitMutationRef.current = commitMutation;
|
|
252
252
|
|
|
253
253
|
const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
|
|
254
|
-
(mutation, options) => commitMutationRef.current(mutation, options),
|
|
254
|
+
(mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
|
|
255
255
|
[],
|
|
256
256
|
);
|
|
257
257
|
|
|
@@ -79,7 +79,7 @@ describe("DOM editing package exports", () => {
|
|
|
79
79
|
front.id = "front";
|
|
80
80
|
parent.append(back, front);
|
|
81
81
|
document.body.append(parent);
|
|
82
|
-
const commitZ = vi.fn(() =>
|
|
82
|
+
const commitZ = vi.fn(async () => ({ durable: true, changed: true }));
|
|
83
83
|
const session = {
|
|
84
84
|
handleDomZIndexReorderCommit: commitZ,
|
|
85
85
|
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
@@ -107,6 +107,11 @@ describe("DOM editing package exports", () => {
|
|
|
107
107
|
await act(async () => {
|
|
108
108
|
expect(zOrder?.apply(sel, toFront)).toBe(true);
|
|
109
109
|
});
|
|
110
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
|
|
111
|
+
feature: "z_order",
|
|
112
|
+
surface: "preview",
|
|
113
|
+
method: "button",
|
|
114
|
+
});
|
|
110
115
|
expect(commitZ).toHaveBeenCalledTimes(1);
|
|
111
116
|
expect(commitZ).toHaveBeenCalledWith(
|
|
112
117
|
expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
|
|
@@ -159,3 +164,6 @@ describe("DOM editing package exports", () => {
|
|
|
159
164
|
await act(async () => root.unmount());
|
|
160
165
|
});
|
|
161
166
|
});
|
|
167
|
+
|
|
168
|
+
vi.mock("./utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
169
|
+
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
@@ -94,7 +94,7 @@ export type DomEditCommitDeclineReason =
|
|
|
94
94
|
| "persist-failed";
|
|
95
95
|
|
|
96
96
|
export type DomEditCommitOutcome =
|
|
97
|
-
| { ok: true; persistence?: DomEditPersistOutcome }
|
|
97
|
+
| { ok: true; persistence?: DomEditPersistOutcome; changed?: boolean }
|
|
98
98
|
| { ok: false; reason: DomEditCommitDeclineReason };
|
|
99
99
|
|
|
100
100
|
export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DomEditPersistOutcome } from "./domEditCommitTypes";
|
|
1
2
|
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
|
2
3
|
import {
|
|
3
4
|
applyElementPositionOffset,
|
|
@@ -23,7 +24,7 @@ export interface ElementOffsetStagerDeps {
|
|
|
23
24
|
selection: DomEditSelection,
|
|
24
25
|
patches: PatchOperation[],
|
|
25
26
|
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
26
|
-
) => Promise<
|
|
27
|
+
) => Promise<DomEditPersistOutcome | undefined>;
|
|
27
28
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
28
29
|
readOnlyPreview?: boolean;
|
|
29
30
|
}
|
|
@@ -59,7 +60,7 @@ function stagePlainTranslate(
|
|
|
59
60
|
selection: DomEditSelection,
|
|
60
61
|
next: { x: number; y: number },
|
|
61
62
|
coalesceKey?: string,
|
|
62
|
-
): { save: () => Promise<
|
|
63
|
+
): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
|
|
63
64
|
const el = selection.element;
|
|
64
65
|
const before = captureStudioPathOffset(el);
|
|
65
66
|
const patches = writePlainMove(el, next);
|
|
@@ -85,9 +86,9 @@ export function stageElementOffset(
|
|
|
85
86
|
next: { x: number; y: number },
|
|
86
87
|
plainTranslate: boolean,
|
|
87
88
|
coalesceKey?: string,
|
|
88
|
-
): { save: () => Promise<
|
|
89
|
+
): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
|
|
89
90
|
const el = selection.element;
|
|
90
|
-
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
|
|
91
|
+
if (readOnlyPreview) return { save: () => Promise.resolve(undefined), rollback: () => undefined };
|
|
91
92
|
if (plainTranslate) {
|
|
92
93
|
refuseGsapTakeover(el, showToast);
|
|
93
94
|
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
|
|
@@ -13,7 +13,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
|
|
|
13
13
|
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
14
14
|
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
15
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
16
|
-
import type {
|
|
16
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
17
17
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
18
18
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
19
19
|
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
@@ -23,25 +23,7 @@ export {
|
|
|
23
23
|
findSizeSetAnimation,
|
|
24
24
|
} from "./gsapDragStaticSetHelpers";
|
|
25
25
|
export interface GsapDragCommitCallbacks {
|
|
26
|
-
commitMutation:
|
|
27
|
-
selection: DomEditSelection,
|
|
28
|
-
mutation: Record<string, unknown>,
|
|
29
|
-
options: {
|
|
30
|
-
label: string;
|
|
31
|
-
coalesceKey?: string;
|
|
32
|
-
coalesceMs?: number;
|
|
33
|
-
softReload?: boolean;
|
|
34
|
-
skipReload?: boolean;
|
|
35
|
-
beforeReload?: () => void;
|
|
36
|
-
/**
|
|
37
|
-
* Value-only fast path: when set, `runCommit` patches the changed tween in
|
|
38
|
-
* the preview runtime in place (instant, no re-run) and only falls back to
|
|
39
|
-
* the soft reload if the patch can't be safely applied. Attached only to
|
|
40
|
-
* value-only `set` commits; structural/keyframe commits omit it.
|
|
41
|
-
*/
|
|
42
|
-
instantPatch?: { selector: string; change: RuntimeTweenChange };
|
|
43
|
-
},
|
|
44
|
-
) => Promise<void>;
|
|
26
|
+
commitMutation: CommitMutation;
|
|
45
27
|
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
46
28
|
}
|
|
47
29
|
|
|
@@ -81,7 +81,12 @@ async function extendTweenAndAddKeyframe(
|
|
|
81
81
|
duration: roundTo3(newDuration),
|
|
82
82
|
keyframes: remappedKfs,
|
|
83
83
|
},
|
|
84
|
-
{
|
|
84
|
+
{
|
|
85
|
+
label: `Move layer (extended keyframe)`,
|
|
86
|
+
keyframeAction: "add",
|
|
87
|
+
softReload: true,
|
|
88
|
+
beforeReload,
|
|
89
|
+
},
|
|
85
90
|
);
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -339,7 +344,12 @@ export async function commitGsapPositionFromDrag(
|
|
|
339
344
|
keyframes: temporalKeyframes,
|
|
340
345
|
ease: "none",
|
|
341
346
|
},
|
|
342
|
-
{
|
|
347
|
+
{
|
|
348
|
+
label: "Move layer (new keyframe)",
|
|
349
|
+
keyframeAction: "add",
|
|
350
|
+
softReload: true,
|
|
351
|
+
beforeReload: restoreOffset,
|
|
352
|
+
},
|
|
343
353
|
);
|
|
344
354
|
return;
|
|
345
355
|
}
|
|
@@ -449,7 +459,12 @@ export async function commitGsapPositionFromDrag(
|
|
|
449
459
|
existingPosAnim
|
|
450
460
|
? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
|
|
451
461
|
: { type: "add-with-keyframes", ...baseKf },
|
|
452
|
-
{
|
|
462
|
+
{
|
|
463
|
+
label: "Move layer (from extended)",
|
|
464
|
+
keyframeAction: "add",
|
|
465
|
+
softReload: true,
|
|
466
|
+
beforeReload: restoreOffset,
|
|
467
|
+
},
|
|
453
468
|
);
|
|
454
469
|
} else {
|
|
455
470
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
3
|
+
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
4
|
+
import type { CommitMutation, MutationResult } from "./gsapScriptCommitTypes";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
|
+
|
|
7
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
8
|
+
beforeEach(() => vi.clearAllMocks());
|
|
9
|
+
const selection = {} as Parameters<CommitMutation>[0];
|
|
10
|
+
const mutation = {
|
|
11
|
+
type: "add-keyframe",
|
|
12
|
+
animationId: "private-target",
|
|
13
|
+
properties: { text: "private-content" },
|
|
14
|
+
};
|
|
15
|
+
function writer(result?: MutationResult): CommitMutation {
|
|
16
|
+
const commit: CommitMutation = async (_selection, _mutation, options) => {
|
|
17
|
+
if (result) options.onResult?.(result);
|
|
18
|
+
};
|
|
19
|
+
commit.batch = async (_calls, options) => {
|
|
20
|
+
if (result) options.onResult?.(result);
|
|
21
|
+
};
|
|
22
|
+
return commit;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("GSAP gesture usage", () => {
|
|
26
|
+
it("counts a multi-write add gesture once from successful writer results", async () => {
|
|
27
|
+
const observed = observeGsapGesture(
|
|
28
|
+
writer({ ok: true, changed: true, mutationChanges: [true] }),
|
|
29
|
+
);
|
|
30
|
+
const onResult = vi.fn();
|
|
31
|
+
await observed.commit!(
|
|
32
|
+
selection,
|
|
33
|
+
{ type: "convert-to-keyframes" },
|
|
34
|
+
{ label: "Convert", onResult },
|
|
35
|
+
);
|
|
36
|
+
await observed.commit!(selection, mutation, { label: "Add" });
|
|
37
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
38
|
+
expect(observed.finish()).toEqual({ ok: true, changed: true });
|
|
39
|
+
expect(onResult).toHaveBeenCalledWith({ ok: true, changed: true, mutationChanges: [true] });
|
|
40
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts a batched insertion once", async () => {
|
|
44
|
+
const observed = observeGsapGesture(
|
|
45
|
+
writer({ ok: true, changed: true, mutationChanges: [true, true] }),
|
|
46
|
+
);
|
|
47
|
+
await observed.commit!.batch!(
|
|
48
|
+
[
|
|
49
|
+
{ selection, mutation, options: { label: "Add" } },
|
|
50
|
+
{ selection, mutation, options: { label: "Add" } },
|
|
51
|
+
],
|
|
52
|
+
{ label: "Add together" },
|
|
53
|
+
);
|
|
54
|
+
expect(observed.finish().changed).toBe(true);
|
|
55
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("retains insertion metadata from an earlier member of a mixed batch", async () => {
|
|
59
|
+
const observed = observeGsapGesture(
|
|
60
|
+
writer({ ok: true, changed: true, mutationChanges: [true, true] }),
|
|
61
|
+
);
|
|
62
|
+
await observed.commit!.batch!(
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
selection,
|
|
66
|
+
mutation: { type: "replace-with-keyframes" },
|
|
67
|
+
options: { label: "Extend", keyframeAction: "add" },
|
|
68
|
+
},
|
|
69
|
+
{ selection, mutation: { type: "update-keyframe" }, options: { label: "Update" } },
|
|
70
|
+
],
|
|
71
|
+
{ label: "Update" },
|
|
72
|
+
);
|
|
73
|
+
observed.finish();
|
|
74
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("uses semantic insertion metadata for a replacement", async () => {
|
|
78
|
+
const observed = observeGsapGesture(
|
|
79
|
+
writer({ ok: true, changed: true, mutationChanges: [true] }),
|
|
80
|
+
);
|
|
81
|
+
await observed.commit!(
|
|
82
|
+
selection,
|
|
83
|
+
{ type: "replace-with-keyframes" },
|
|
84
|
+
{ label: "Replace", keyframeAction: "add" },
|
|
85
|
+
);
|
|
86
|
+
observed.finish();
|
|
87
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
|
|
91
|
+
"does not count resolution without a changed successful write (%j)",
|
|
92
|
+
async (result) => {
|
|
93
|
+
const observed = observeGsapGesture(writer(result));
|
|
94
|
+
await observed.commit!(selection, mutation, { label: "Add" });
|
|
95
|
+
expect(observed.finish()).toEqual({ ok: true, changed: false });
|
|
96
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
it("does not count missing writers or an unfinished group", async () => {
|
|
101
|
+
expect(observeGsapGesture(null).finish()).toEqual({ ok: true, changed: false });
|
|
102
|
+
const observed = observeGsapGesture(writer());
|
|
103
|
+
await observed.commit!(selection, mutation, { label: "Add" });
|
|
104
|
+
expect(observed.finish(true).changed).toBe(false);
|
|
105
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports a successful plain DOM gesture without inventing keyframes", () => {
|
|
109
|
+
expect(observeGsapGesture(null).finish(true).changed).toBe(true);
|
|
110
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it.each([
|
|
115
|
+
[[false, true], 0],
|
|
116
|
+
[[true, false], 1],
|
|
117
|
+
[undefined, 0],
|
|
118
|
+
] as const)(
|
|
119
|
+
"attributes mixed batches only to changed members (%j)",
|
|
120
|
+
async (mutationChanges, count) => {
|
|
121
|
+
const result = {
|
|
122
|
+
ok: true,
|
|
123
|
+
changed: true,
|
|
124
|
+
mutationChanges: mutationChanges && [...mutationChanges],
|
|
125
|
+
};
|
|
126
|
+
const observed = observeGsapGesture(writer(result));
|
|
127
|
+
const mutations = [mutation, { type: "update-property" }];
|
|
128
|
+
await observed.commit!.batch!(
|
|
129
|
+
mutations.map((mutation) => ({ selection, mutation, options: { label: "Edit" } })),
|
|
130
|
+
{ label: "Edit" },
|
|
131
|
+
);
|
|
132
|
+
expect(observed.finish()).toEqual({ ok: true, changed: true });
|
|
133
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
|
|
134
|
+
if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
|
|
135
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
136
|
+
trackKeyframeCommit(mutations, result, { label: "Edit" });
|
|
137
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
it("excludes a suppressed preparation from mixed-batch attribution", async () => {
|
|
142
|
+
const result = { ok: true, changed: true, mutationChanges: [true, false] };
|
|
143
|
+
const calls = [
|
|
144
|
+
{
|
|
145
|
+
selection,
|
|
146
|
+
mutation: { type: "convert-to-keyframes" },
|
|
147
|
+
options: { label: "Prepare", keyframeTelemetry: false },
|
|
148
|
+
},
|
|
149
|
+
{ selection, mutation, options: { label: "Add" } },
|
|
150
|
+
];
|
|
151
|
+
const observed = observeGsapGesture(writer(result));
|
|
152
|
+
await observed.commit!.batch!(calls, { label: "Add" });
|
|
153
|
+
expect(observed.finish()).toEqual({ ok: true, changed: true });
|
|
154
|
+
trackKeyframeCommit(
|
|
155
|
+
calls.map((call) => call.mutation),
|
|
156
|
+
result,
|
|
157
|
+
{ label: "Add" },
|
|
158
|
+
calls.map((call) => call.options),
|
|
159
|
+
);
|
|
160
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommitMutationCall,
|
|
3
|
+
CommitMutation,
|
|
4
|
+
CommitMutationOptions,
|
|
5
|
+
MutationResult,
|
|
6
|
+
} from "./gsapScriptCommitTypes";
|
|
7
|
+
import {
|
|
8
|
+
keyframeUsageActions,
|
|
9
|
+
changedMutationIndices,
|
|
10
|
+
primaryKeyframeAction,
|
|
11
|
+
trackKeyframeUsage,
|
|
12
|
+
type KeyframeUsageAction,
|
|
13
|
+
} from "../utils/keyframeUsage";
|
|
14
|
+
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
|
|
15
|
+
|
|
16
|
+
export function observeGsapGesture(writer: CommitMutation | null) {
|
|
17
|
+
let changed = false;
|
|
18
|
+
let pendingResults = 0;
|
|
19
|
+
const actions = new Set<KeyframeUsageAction>();
|
|
20
|
+
const observe = (calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
|
21
|
+
pendingResults += 1;
|
|
22
|
+
return {
|
|
23
|
+
...options,
|
|
24
|
+
keyframeTelemetry: false,
|
|
25
|
+
onResult: (result: MutationResult) => {
|
|
26
|
+
pendingResults -= 1;
|
|
27
|
+
options.onResult?.(result);
|
|
28
|
+
if (!result.ok || result.changed !== true) return;
|
|
29
|
+
changed = true;
|
|
30
|
+
const members = changedMutationIndices(
|
|
31
|
+
result,
|
|
32
|
+
calls.length,
|
|
33
|
+
calls.map((call) => call.options),
|
|
34
|
+
).map((index) => calls[index]!);
|
|
35
|
+
for (const action of keyframeUsageActions(
|
|
36
|
+
members.map((call) => call.mutation),
|
|
37
|
+
members.map((call) => call.options.keyframeAction),
|
|
38
|
+
))
|
|
39
|
+
actions.add(action);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
let commit: CommitMutation | null = null;
|
|
44
|
+
if (writer) {
|
|
45
|
+
commit = (selection, mutation, options) =>
|
|
46
|
+
writer(selection, mutation, observe([{ selection, mutation, options }], options));
|
|
47
|
+
if (writer.batch) {
|
|
48
|
+
const batch = writer.batch;
|
|
49
|
+
commit.batch = (calls, options) => batch(calls, observe(calls, options));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
commit,
|
|
54
|
+
recordDomResult: (result: { changed: boolean } | undefined) => {
|
|
55
|
+
changed ||= result?.changed === true;
|
|
56
|
+
},
|
|
57
|
+
finish: (domChanged = false): GeometryCommitResult => {
|
|
58
|
+
if (pendingResults !== 0) return { ok: true, changed: false };
|
|
59
|
+
const action = primaryKeyframeAction(actions);
|
|
60
|
+
if (action) trackKeyframeUsage(action);
|
|
61
|
+
return { ok: true, changed: changed || domChanged };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -4,23 +4,14 @@ import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/glob
|
|
|
4
4
|
import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
5
5
|
import { roundToCenti } from "../utils/rounding";
|
|
6
6
|
|
|
7
|
-
type
|
|
8
|
-
selection: DomEditSelection,
|
|
9
|
-
mutation: Record<string, unknown>,
|
|
10
|
-
options: {
|
|
11
|
-
label: string;
|
|
12
|
-
coalesceKey?: string;
|
|
13
|
-
softReload?: boolean;
|
|
14
|
-
skipReload?: boolean;
|
|
15
|
-
},
|
|
16
|
-
) => Promise<void>;
|
|
7
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
17
8
|
|
|
18
9
|
export async function commitKeyframeAtTimeImpl(
|
|
19
10
|
selection: DomEditSelection,
|
|
20
11
|
absoluteTime: number,
|
|
21
12
|
animations: GsapAnimation[],
|
|
22
13
|
properties: Record<string, number | string>,
|
|
23
|
-
commitMutation:
|
|
14
|
+
commitMutation: CommitMutation,
|
|
24
15
|
): Promise<void> {
|
|
25
16
|
// Matching an authored tween is a string compare against what the author
|
|
26
17
|
// wrote, so it keeps using the selection's own selector; the NEW tween below
|
|
@@ -38,7 +29,7 @@ export async function commitKeyframeAtTimeImpl(
|
|
|
38
29
|
await commitMutation(
|
|
39
30
|
selection,
|
|
40
31
|
{ type: "convert-to-keyframes", animationId: tween.id },
|
|
41
|
-
{ label: "Convert to keyframes", skipReload: true },
|
|
32
|
+
{ label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
|
|
42
33
|
);
|
|
43
34
|
}
|
|
44
35
|
|
|
@@ -3,10 +3,16 @@ import { afterEach, expect, it, vi } from "vitest";
|
|
|
3
3
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
6
9
|
import { computeCurrentPercentage } from "./gsapDragCommit";
|
|
7
10
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
8
11
|
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
13
|
+
|
|
9
14
|
afterEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
10
16
|
vi.restoreAllMocks();
|
|
11
17
|
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
12
18
|
});
|
|
@@ -553,3 +559,30 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
|
|
|
553
559
|
expect(handled).toEqual({ status: "element-size" });
|
|
554
560
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
555
561
|
});
|
|
562
|
+
|
|
563
|
+
it.each([
|
|
564
|
+
[5, 1],
|
|
565
|
+
[0.5, 1],
|
|
566
|
+
[0.2, 0],
|
|
567
|
+
])("counts scale resize insertion at %ss", async (time, count) => {
|
|
568
|
+
const el = makeGradedElement();
|
|
569
|
+
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1 });
|
|
570
|
+
usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
|
|
571
|
+
const writer: CommitMutation = async (_selection, _mutation, options) => {
|
|
572
|
+
options.onResult?.({ ok: true, changed: true });
|
|
573
|
+
};
|
|
574
|
+
const outcome = observeGsapGesture(writer);
|
|
575
|
+
const result = await tryGsapResizeIntercept(
|
|
576
|
+
{ id: "clip", selector: "#clip", element: el } as DomEditSelection,
|
|
577
|
+
{ width: 700, height: 400 },
|
|
578
|
+
[keyframedScaleFixture()],
|
|
579
|
+
iframe,
|
|
580
|
+
outcome.commit!,
|
|
581
|
+
);
|
|
582
|
+
expect(result.status).toBe("persisted");
|
|
583
|
+
outcome.finish();
|
|
584
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
|
|
585
|
+
if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
|
|
586
|
+
el.remove();
|
|
587
|
+
document.querySelector("#__hf_color_grading_clip")?.remove();
|
|
588
|
+
});
|
|
@@ -541,6 +541,7 @@ export async function tryGsapResizeIntercept(
|
|
|
541
541
|
? `Resize (extended to ${ct.toFixed(2)}s)`
|
|
542
542
|
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
|
|
543
543
|
softReload: true,
|
|
544
|
+
...(!collidingKf ? { keyframeAction: "add" as const } : {}),
|
|
544
545
|
},
|
|
545
546
|
);
|
|
546
547
|
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
@@ -3,10 +3,12 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
5
5
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
6
|
+
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
|
|
6
7
|
|
|
7
8
|
export interface MutationResult {
|
|
8
9
|
ok: boolean;
|
|
9
10
|
changed?: boolean;
|
|
11
|
+
mutationChanges?: boolean[];
|
|
10
12
|
parsed?: ParsedGsap;
|
|
11
13
|
before?: string;
|
|
12
14
|
after?: string;
|
|
@@ -15,6 +17,9 @@ export interface MutationResult {
|
|
|
15
17
|
|
|
16
18
|
export interface CommitMutationOptions {
|
|
17
19
|
label: string;
|
|
20
|
+
keyframeTelemetry?: boolean;
|
|
21
|
+
keyframeAction?: KeyframeUsageAction;
|
|
22
|
+
keyframeProperty?: string;
|
|
18
23
|
/** Observe the durable writer result without duplicating the request path. */
|
|
19
24
|
onResult?: (result: MutationResult) => void;
|
|
20
25
|
coalesceKey?: string;
|
|
@@ -6,6 +6,10 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
6
6
|
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
|
|
7
7
|
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
|
|
8
8
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
9
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
10
|
+
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
11
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
13
|
import { promoteSetToKeyframes } from "./useEnableKeyframes";
|
|
10
14
|
|
|
11
15
|
afterEach(() => {
|
|
@@ -287,3 +291,19 @@ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
|
|
|
287
291
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
288
292
|
});
|
|
289
293
|
});
|
|
294
|
+
|
|
295
|
+
it("counts a keyframe-at-time conversion and insertion as one add gesture", async () => {
|
|
296
|
+
vi.clearAllMocks();
|
|
297
|
+
const el = document.createElement("div");
|
|
298
|
+
el.id = "target";
|
|
299
|
+
document.body.append(el);
|
|
300
|
+
const selection = selectionFor(el);
|
|
301
|
+
const animations = parseGsapScript(
|
|
302
|
+
'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
|
|
303
|
+
).animations;
|
|
304
|
+
const commit: CommitMutation = async (_selection, mutation, options) => {
|
|
305
|
+
trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
|
|
306
|
+
};
|
|
307
|
+
await commitKeyframeAtTimeImpl(selection, 1, animations, { x: 25 }, commit);
|
|
308
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
309
|
+
});
|