@hyperframes/studio 0.8.113 → 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-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
- package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/index-t1LH6H5H.css +1 -0
- 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 +46 -22
- package/dist/index.html +2 -2
- package/dist/index.js +3899 -3487
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/App.tsx +84 -86
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- 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/InlineTextToolbar.tsx +11 -27
- 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/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +6 -6
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- 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/propertyPanelColor.tsx +1 -1
- 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/reapplyPositionEditsAfterSeek.test.ts +25 -0
- 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/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- 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/useAppHotkeys.ts +11 -5
- 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/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +90 -56
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- 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/usePanelLayout.ts +5 -1
- 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/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- 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/hooks/useTimelineEditing.ts +33 -27
- 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/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- 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/assets/index-DbfKVTId.css +0 -1
- 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
|
@@ -345,3 +345,30 @@ describe("DomEditCropHandles preset bar", () => {
|
|
|
345
345
|
expect(useCropPresetBarStore.getState().openFor).toBeNull();
|
|
346
346
|
});
|
|
347
347
|
});
|
|
348
|
+
|
|
349
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
350
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
351
|
+
describe("crop usage", () => {
|
|
352
|
+
it.each([true, false])(
|
|
353
|
+
"counts a crop drag only after a changed writer result (%s)",
|
|
354
|
+
async (changed) => {
|
|
355
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
356
|
+
const saved = vi.fn(async () => ({ ok: true, changed }));
|
|
357
|
+
const { root } = render(makeEl("private-target", "inset(10px)"), saved);
|
|
358
|
+
const handle = document.querySelector<HTMLElement>('[aria-label="Crop right"]')!;
|
|
359
|
+
dragCropRight(handle, 1, [
|
|
360
|
+
[200, 1],
|
|
361
|
+
[180, 1],
|
|
362
|
+
[160, 1],
|
|
363
|
+
]);
|
|
364
|
+
await act(async () => {
|
|
365
|
+
await Promise.resolve();
|
|
366
|
+
});
|
|
367
|
+
expect(saved).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
|
|
369
|
+
changed ? [["feature_used", { feature: "crop", surface: "preview", method: "drag" }]] : [],
|
|
370
|
+
);
|
|
371
|
+
act(() => root.unmount());
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewEditResult, type PreviewMethod } from "../../utils/previewFeatureUsage";
|
|
1
2
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
3
|
import type { DomEditSelection } from "./domEditing";
|
|
3
4
|
import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
|
|
@@ -229,19 +230,21 @@ export function DomEditCropHandles({
|
|
|
229
230
|
// the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
|
|
230
231
|
const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
|
|
231
232
|
if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
|
|
232
|
-
commitClipPath(value);
|
|
233
|
+
commitClipPath(value, "drag");
|
|
233
234
|
};
|
|
234
235
|
|
|
235
|
-
const commitClipPath = (value: string) => {
|
|
236
|
+
const commitClipPath = (value: string, method: PreviewMethod) => {
|
|
236
237
|
const commit = onStyleCommit?.("clip-path", value);
|
|
237
|
-
void Promise.resolve(commit)
|
|
238
|
+
void Promise.resolve(commit)
|
|
239
|
+
.then((result) => trackPreviewEditResult("crop", method, result))
|
|
240
|
+
.catch(() => undefined);
|
|
238
241
|
};
|
|
239
242
|
|
|
240
243
|
const applyPresetInsets = (insets: ClipPathInsetSides | null) => {
|
|
241
244
|
const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
|
|
242
245
|
setState((prev) => ({ ...prev, insets: next }));
|
|
243
246
|
const radius = readCropFollowingResize(selection.element)?.radius ?? 0;
|
|
244
|
-
commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius));
|
|
247
|
+
commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius), "button");
|
|
245
248
|
};
|
|
246
249
|
|
|
247
250
|
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
|
|
1
2
|
import type { RotationCommit } from "./rotationDraft";
|
|
2
3
|
import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
|
|
3
4
|
import { type DomEditSelection } from "./domEditing";
|
|
@@ -98,7 +99,10 @@ export interface DomEditOverlayProps {
|
|
|
98
99
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
99
100
|
recordingState?: GestureRecordingState;
|
|
100
101
|
onToggleRecording?: () => void;
|
|
101
|
-
onMarqueeSelect?: (
|
|
102
|
+
onMarqueeSelect?: (
|
|
103
|
+
selections: DomEditSelection[],
|
|
104
|
+
additive: boolean,
|
|
105
|
+
) => DomSelectionResult | void;
|
|
102
106
|
/**
|
|
103
107
|
* Delete the selected canvas element.
|
|
104
108
|
* Wire to handleDomEditElementDelete from useDomEditActionsContext —
|
|
@@ -18,11 +18,14 @@ import {
|
|
|
18
18
|
type InlineInputState,
|
|
19
19
|
DIM,
|
|
20
20
|
} from "./FileTreeIcons";
|
|
21
|
+
import { menuClasses } from "../ui/menuStyle";
|
|
21
22
|
|
|
22
23
|
export type { ContextMenuState, InlineInputState };
|
|
23
24
|
export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
|
|
24
25
|
|
|
25
26
|
const SZ_ICON = 14;
|
|
27
|
+
const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2 active:bg-neutral-700`;
|
|
28
|
+
const DANGER_ROW = `${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2 active:bg-danger/25`;
|
|
26
29
|
|
|
27
30
|
// ── Context Menu Component ──
|
|
28
31
|
|
|
@@ -101,14 +104,14 @@ export function ContextMenu({
|
|
|
101
104
|
ref={menuRef}
|
|
102
105
|
role="menu"
|
|
103
106
|
onKeyDown={handleMenuKeyDown}
|
|
104
|
-
className=
|
|
107
|
+
className={`${menuClasses.panel} fixed z-50 min-w-[160px]`}
|
|
105
108
|
style={{ left: adjustedX, top: adjustedY }}
|
|
106
109
|
>
|
|
107
110
|
{state.targetIsFolder && (
|
|
108
111
|
<>
|
|
109
112
|
<button
|
|
110
113
|
role="menuitem"
|
|
111
|
-
className=
|
|
114
|
+
className={ROW}
|
|
112
115
|
onClick={() => {
|
|
113
116
|
onNewFile(state.targetPath);
|
|
114
117
|
onClose();
|
|
@@ -119,7 +122,7 @@ export function ContextMenu({
|
|
|
119
122
|
</button>
|
|
120
123
|
<button
|
|
121
124
|
role="menuitem"
|
|
122
|
-
className=
|
|
125
|
+
className={ROW}
|
|
123
126
|
onClick={() => {
|
|
124
127
|
onNewFolder(state.targetPath);
|
|
125
128
|
onClose();
|
|
@@ -128,14 +131,14 @@ export function ContextMenu({
|
|
|
128
131
|
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
129
132
|
New Folder
|
|
130
133
|
</button>
|
|
131
|
-
<div className=
|
|
134
|
+
<div className={menuClasses.divider} />
|
|
132
135
|
</>
|
|
133
136
|
)}
|
|
134
137
|
{!state.targetIsFolder && (
|
|
135
138
|
<>
|
|
136
139
|
<button
|
|
137
140
|
role="menuitem"
|
|
138
|
-
className=
|
|
141
|
+
className={ROW}
|
|
139
142
|
onClick={() => {
|
|
140
143
|
onNewFile(parentPath);
|
|
141
144
|
onClose();
|
|
@@ -144,12 +147,12 @@ export function ContextMenu({
|
|
|
144
147
|
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
|
145
148
|
New File
|
|
146
149
|
</button>
|
|
147
|
-
<div className=
|
|
150
|
+
<div className={menuClasses.divider} />
|
|
148
151
|
</>
|
|
149
152
|
)}
|
|
150
153
|
<button
|
|
151
154
|
role="menuitem"
|
|
152
|
-
className=
|
|
155
|
+
className={ROW}
|
|
153
156
|
onClick={() => {
|
|
154
157
|
onRename(state.targetPath);
|
|
155
158
|
onClose();
|
|
@@ -161,7 +164,7 @@ export function ContextMenu({
|
|
|
161
164
|
{!state.targetIsFolder && (
|
|
162
165
|
<button
|
|
163
166
|
role="menuitem"
|
|
164
|
-
className=
|
|
167
|
+
className={ROW}
|
|
165
168
|
onClick={() => {
|
|
166
169
|
onDuplicate(state.targetPath);
|
|
167
170
|
onClose();
|
|
@@ -171,10 +174,10 @@ export function ContextMenu({
|
|
|
171
174
|
Duplicate
|
|
172
175
|
</button>
|
|
173
176
|
)}
|
|
174
|
-
<div className=
|
|
177
|
+
<div className={menuClasses.divider} />
|
|
175
178
|
<button
|
|
176
179
|
role="menuitem"
|
|
177
|
-
className=
|
|
180
|
+
className={DANGER_ROW}
|
|
178
181
|
onClick={() => {
|
|
179
182
|
onDelete(state.targetPath);
|
|
180
183
|
onClose();
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { applyInlineStyle } from "./inlineTextStyleRange";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
readFirstPaintedElement,
|
|
6
|
+
readInlineStyle,
|
|
7
|
+
readInlineStyleSpread,
|
|
8
|
+
} from "./inlineTextStyleRead";
|
|
9
|
+
import { resolvePickerColor } from "./colorValue";
|
|
6
10
|
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -248,7 +252,11 @@ function placeOverSelection(
|
|
|
248
252
|
placeBelow,
|
|
249
253
|
styles,
|
|
250
254
|
colours,
|
|
251
|
-
pickerColour:
|
|
255
|
+
pickerColour: resolvePickerColor(
|
|
256
|
+
styles.color ?? colours[0],
|
|
257
|
+
readFirstPaintedElement(range, "color"),
|
|
258
|
+
DEFAULT_COLOR,
|
|
259
|
+
),
|
|
252
260
|
};
|
|
253
261
|
}
|
|
254
262
|
|
|
@@ -257,27 +265,3 @@ function isBold(weight: string | undefined): boolean {
|
|
|
257
265
|
if (weight === "bold" || weight === "bolder") return true;
|
|
258
266
|
return Number.parseInt(weight, 10) >= 600;
|
|
259
267
|
}
|
|
260
|
-
|
|
261
|
-
/** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
|
|
262
|
-
function toPickerColour(value: string | undefined, doc: Document): string {
|
|
263
|
-
if (!value) return DEFAULT_COLOR;
|
|
264
|
-
const parsed = parseCssColor(value);
|
|
265
|
-
if (parsed) return toHexColor(parsed);
|
|
266
|
-
|
|
267
|
-
// Canvas delegates the full CSS colour grammar to the browser, including
|
|
268
|
-
// named colours that the small serialisation parser intentionally omits.
|
|
269
|
-
// DOM-only test environments can lack a canvas implementation, in which
|
|
270
|
-
// case the picker degrades to its explicit default while the swatch remains
|
|
271
|
-
// truthful because CSS still paints the original value.
|
|
272
|
-
try {
|
|
273
|
-
const context = doc.createElement("canvas").getContext("2d");
|
|
274
|
-
if (!context) return DEFAULT_COLOR;
|
|
275
|
-
context.fillStyle = DEFAULT_COLOR;
|
|
276
|
-
context.fillStyle = value;
|
|
277
|
-
const normalised =
|
|
278
|
-
typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
|
|
279
|
-
return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
|
|
280
|
-
} catch {
|
|
281
|
-
return DEFAULT_COLOR;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
2
|
+
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
1
3
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
2
4
|
import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
3
5
|
import type { DomEditSelection } from "./domEditing";
|
|
@@ -382,15 +384,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
382
384
|
selection &&
|
|
383
385
|
!usePlayerStore.getState().autoKeyframeEnabled
|
|
384
386
|
) {
|
|
387
|
+
const writes = observeGsapGesture((_sel, mutation, options) =>
|
|
388
|
+
commitMutation(mutation, options),
|
|
389
|
+
);
|
|
385
390
|
void commitWholePropertyOffset(
|
|
386
391
|
selection,
|
|
387
392
|
anim,
|
|
388
393
|
{ x, y },
|
|
389
394
|
d.ref.pct,
|
|
390
395
|
iframeRef.current,
|
|
391
|
-
{ commitMutation:
|
|
396
|
+
{ commitMutation: writes.commit! },
|
|
392
397
|
"Move animation path",
|
|
393
|
-
);
|
|
398
|
+
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
394
399
|
} else {
|
|
395
400
|
void commitNode(d.ref, x, y, animId, commitMutation);
|
|
396
401
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Eye, Layers } from "../../icons/SystemIcons";
|
|
2
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
3
|
import { canHideSelections } from "../../utils/timelineInspector";
|
|
4
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
4
5
|
|
|
5
6
|
function FlatEmptyState() {
|
|
6
7
|
return (
|
|
@@ -71,6 +72,7 @@ function FlatMultiSelectState({
|
|
|
71
72
|
// One predicate for both actions and for the handler's own refusal, so the
|
|
72
73
|
// button and the refusal cannot disagree about what audio is.
|
|
73
74
|
const hasAudio = !canHideSelections(multiSelectedElements);
|
|
75
|
+
const trackInput = useTrackDesignInput();
|
|
74
76
|
return (
|
|
75
77
|
<div className="flex flex-col gap-3 px-4 py-3">
|
|
76
78
|
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
|
|
@@ -141,7 +143,11 @@ function FlatMultiSelectState({
|
|
|
141
143
|
<button
|
|
142
144
|
type="button"
|
|
143
145
|
data-flat-multiselect-group="true"
|
|
144
|
-
onClick={
|
|
146
|
+
onClick={() => {
|
|
147
|
+
if (!onGroupSelection) return;
|
|
148
|
+
onGroupSelection();
|
|
149
|
+
trackInput("button", "group-selection");
|
|
150
|
+
}}
|
|
145
151
|
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
|
|
146
152
|
>
|
|
147
153
|
<Layers size={13} />
|
|
@@ -150,7 +156,11 @@ function FlatMultiSelectState({
|
|
|
150
156
|
<button
|
|
151
157
|
type="button"
|
|
152
158
|
data-flat-multiselect-hide-all="true"
|
|
153
|
-
onClick={
|
|
159
|
+
onClick={() => {
|
|
160
|
+
if (!onHideAllSelected) return;
|
|
161
|
+
onHideAllSelected();
|
|
162
|
+
trackInput("button", "hide-all");
|
|
163
|
+
}}
|
|
154
164
|
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
|
|
155
165
|
>
|
|
156
166
|
<Eye size={13} />
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
|
|
3
4
|
import React, { act, useRef } from "react";
|
|
4
5
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
7
|
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
|
|
7
8
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
9
|
import type { DomEditSelection } from "./domEditing";
|
|
@@ -182,3 +183,101 @@ describe("SnapToolbar ruler and safe-margin toggles", () => {
|
|
|
182
183
|
act(() => root.unmount());
|
|
183
184
|
});
|
|
184
185
|
});
|
|
186
|
+
|
|
187
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
188
|
+
beforeEach(() => vi.clearAllMocks());
|
|
189
|
+
describe("preview setting usage", () => {
|
|
190
|
+
it.each([
|
|
191
|
+
["Toggle snap", "snapping"],
|
|
192
|
+
["Toggle grid", "grid"],
|
|
193
|
+
["Toggle ruler", "ruler"],
|
|
194
|
+
["Toggle safe margins", "safe_margins"],
|
|
195
|
+
])("counts the %s button without setting values", (label, feature) => {
|
|
196
|
+
const { root } = renderToolbar();
|
|
197
|
+
const button = document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
|
|
198
|
+
act(() => button.click());
|
|
199
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
200
|
+
feature,
|
|
201
|
+
surface: "preview",
|
|
202
|
+
method: "button",
|
|
203
|
+
});
|
|
204
|
+
act(() => root.unmount());
|
|
205
|
+
});
|
|
206
|
+
it("counts keyboard grid toggling once", () => {
|
|
207
|
+
const { root } = renderToolbar();
|
|
208
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "g", bubbles: true })));
|
|
209
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
210
|
+
feature: "grid",
|
|
211
|
+
surface: "preview",
|
|
212
|
+
method: "keyboard",
|
|
213
|
+
});
|
|
214
|
+
act(() => root.unmount());
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("preview field and key gesture counting", () => {
|
|
219
|
+
it("counts a grid spacing edit once when the field settles", () => {
|
|
220
|
+
const { root } = renderToolbar();
|
|
221
|
+
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
|
|
222
|
+
const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
|
|
223
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
224
|
+
act(() => input.focus());
|
|
225
|
+
for (const value of ["20", "200"]) {
|
|
226
|
+
act(() => {
|
|
227
|
+
setter.call(input, value);
|
|
228
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
232
|
+
act(() => input.blur());
|
|
233
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
234
|
+
feature: "grid_spacing",
|
|
235
|
+
surface: "preview",
|
|
236
|
+
method: "field",
|
|
237
|
+
});
|
|
238
|
+
act(() => root.unmount());
|
|
239
|
+
});
|
|
240
|
+
it("counts snap-to-grid through its committed checkbox", () => {
|
|
241
|
+
const { root } = renderToolbar();
|
|
242
|
+
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
|
|
243
|
+
act(() => document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
|
244
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
245
|
+
feature: "snap_to_grid",
|
|
246
|
+
surface: "preview",
|
|
247
|
+
method: "button",
|
|
248
|
+
});
|
|
249
|
+
act(() => root.unmount());
|
|
250
|
+
});
|
|
251
|
+
it.each(["s", "g"])("does not count auto-repeat as another %s key gesture", (key) => {
|
|
252
|
+
const { root } = renderToolbar();
|
|
253
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
|
|
254
|
+
act(() =>
|
|
255
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key, repeat: true, bubbles: true })),
|
|
256
|
+
);
|
|
257
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
258
|
+
feature: key === "s" ? "snapping" : "grid",
|
|
259
|
+
surface: "preview",
|
|
260
|
+
method: "keyboard",
|
|
261
|
+
});
|
|
262
|
+
act(() => root.unmount());
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("counts a changed field when click-away dismisses it before blur", () => {
|
|
267
|
+
const { root } = renderToolbar();
|
|
268
|
+
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
|
|
269
|
+
const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
|
|
270
|
+
act(() => input.focus());
|
|
271
|
+
act(() => {
|
|
272
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "230");
|
|
273
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
274
|
+
});
|
|
275
|
+
act(() => document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })));
|
|
276
|
+
expect(document.querySelector('input[type="number"]')).toBeNull();
|
|
277
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
278
|
+
feature: "grid_spacing",
|
|
279
|
+
surface: "preview",
|
|
280
|
+
method: "field",
|
|
281
|
+
});
|
|
282
|
+
act(() => root.unmount());
|
|
283
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
|
|
1
2
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
3
|
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
|
3
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
@@ -16,6 +17,16 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
16
17
|
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
|
17
18
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
18
19
|
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
|
20
|
+
const gridSpacingAtFocus = useRef<number | null>(null);
|
|
21
|
+
const currentGridSpacing = useRef(prefs.gridSpacing);
|
|
22
|
+
currentGridSpacing.current = prefs.gridSpacing;
|
|
23
|
+
const settleGridSpacing = useCallback(() => {
|
|
24
|
+
const previous = gridSpacingAtFocus.current;
|
|
25
|
+
gridSpacingAtFocus.current = null;
|
|
26
|
+
if (previous !== null && previous !== currentGridSpacing.current)
|
|
27
|
+
trackPreviewFeatureUsed("grid_spacing", "field");
|
|
28
|
+
}, []);
|
|
29
|
+
useEffect(() => settleGridSpacing, [settleGridSpacing]);
|
|
19
30
|
|
|
20
31
|
const updatePrefs = useCallback(
|
|
21
32
|
(patch: Partial<typeof prefs>) => {
|
|
@@ -26,10 +37,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
26
37
|
|
|
27
38
|
const toggleSnap = useCallback(() => {
|
|
28
39
|
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
40
|
+
trackPreviewFeatureUsed("snapping", "button");
|
|
29
41
|
}, [prefs.snapEnabled, updatePrefs]);
|
|
30
42
|
|
|
31
43
|
const toggleGrid = useCallback(() => {
|
|
32
44
|
updatePrefs({ gridVisible: !prefs.gridVisible });
|
|
45
|
+
trackPreviewFeatureUsed("grid", "button");
|
|
33
46
|
}, [prefs.gridVisible, updatePrefs]);
|
|
34
47
|
|
|
35
48
|
useEffect(() => {
|
|
@@ -41,10 +54,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
41
54
|
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
42
55
|
e.preventDefault();
|
|
43
56
|
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
57
|
+
if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
|
|
44
58
|
}
|
|
45
59
|
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
46
60
|
e.preventDefault();
|
|
47
61
|
updatePrefs({ gridVisible: !prefs.gridVisible });
|
|
62
|
+
if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
|
|
48
63
|
}
|
|
49
64
|
};
|
|
50
65
|
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -56,11 +71,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
56
71
|
const handleClickOutside = (e: MouseEvent) => {
|
|
57
72
|
const target = e.target as Node;
|
|
58
73
|
if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
|
|
74
|
+
settleGridSpacing();
|
|
59
75
|
setGridPopoverOpen(false);
|
|
60
76
|
};
|
|
61
77
|
document.addEventListener("mousedown", handleClickOutside);
|
|
62
78
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
63
|
-
}, [gridPopoverOpen]);
|
|
79
|
+
}, [gridPopoverOpen, settleGridSpacing]);
|
|
64
80
|
|
|
65
81
|
return (
|
|
66
82
|
<div
|
|
@@ -101,7 +117,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
101
117
|
? "bg-studio-accent/20 text-accent-ink"
|
|
102
118
|
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
103
119
|
}`}
|
|
104
|
-
onClick={
|
|
120
|
+
onClick={() => {
|
|
121
|
+
toggle();
|
|
122
|
+
trackPreviewFeatureUsed(key === "rulerVisible" ? "ruler" : "safe_margins", "button");
|
|
123
|
+
}}
|
|
105
124
|
title={`${label} ${visible ? "on" : "off"}`}
|
|
106
125
|
aria-label={`Toggle ${label.toLowerCase()}`}
|
|
107
126
|
aria-pressed={visible}
|
|
@@ -136,6 +155,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
136
155
|
onClick={toggleGrid}
|
|
137
156
|
onContextMenu={(e) => {
|
|
138
157
|
e.preventDefault();
|
|
158
|
+
if (gridPopoverOpen) settleGridSpacing();
|
|
139
159
|
setGridPopoverOpen((v) => !v);
|
|
140
160
|
}}
|
|
141
161
|
title={
|
|
@@ -150,7 +170,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
150
170
|
<button
|
|
151
171
|
type="button"
|
|
152
172
|
className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
|
|
153
|
-
onClick={() =>
|
|
173
|
+
onClick={() => {
|
|
174
|
+
if (gridPopoverOpen) settleGridSpacing();
|
|
175
|
+
setGridPopoverOpen((v) => !v);
|
|
176
|
+
}}
|
|
154
177
|
title="Grid options"
|
|
155
178
|
aria-label="Grid options"
|
|
156
179
|
aria-expanded={gridPopoverOpen}
|
|
@@ -173,9 +196,21 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
173
196
|
max={500}
|
|
174
197
|
step={10}
|
|
175
198
|
value={prefs.gridSpacing}
|
|
199
|
+
onFocus={() => {
|
|
200
|
+
gridSpacingAtFocus.current = prefs.gridSpacing;
|
|
201
|
+
}}
|
|
202
|
+
onBlur={settleGridSpacing}
|
|
203
|
+
onKeyDown={(e) => {
|
|
204
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
205
|
+
}}
|
|
176
206
|
onChange={(e) => {
|
|
177
207
|
const val = Number.parseInt(e.target.value, 10);
|
|
178
|
-
if (
|
|
208
|
+
if (
|
|
209
|
+
Number.isFinite(val) &&
|
|
210
|
+
val >= 10 &&
|
|
211
|
+
val <= 500 &&
|
|
212
|
+
val !== prefs.gridSpacing
|
|
213
|
+
) {
|
|
179
214
|
updatePrefs({ gridSpacing: val });
|
|
180
215
|
}
|
|
181
216
|
}}
|
|
@@ -186,7 +221,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
186
221
|
<input
|
|
187
222
|
type="checkbox"
|
|
188
223
|
checked={prefs.snapToGrid}
|
|
189
|
-
onChange={() =>
|
|
224
|
+
onChange={() => {
|
|
225
|
+
updatePrefs({ snapToGrid: !prefs.snapToGrid });
|
|
226
|
+
trackPreviewFeatureUsed("snap_to_grid", "button");
|
|
227
|
+
}}
|
|
190
228
|
className="accent-studio-accent"
|
|
191
229
|
/>
|
|
192
230
|
<span>Snap to grid</span>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
5
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
4
6
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
5
7
|
import type { DomEditSelection } from "./domEditing";
|
|
6
8
|
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
@@ -178,7 +180,7 @@ function buildHarness(
|
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
const handlers = createDomEditOverlayGestureHandlers(opts);
|
|
181
|
-
return { handlers, commits, selection };
|
|
183
|
+
return { handlers, commits, selection, opts };
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
type OverlayRectLike = {
|
|
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
|
|
|
287
289
|
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
|
|
288
290
|
});
|
|
289
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("resize usage at pointer release", () => {
|
|
294
|
+
it("counts one completed resize despite multiple pointer moves", async () => {
|
|
295
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
296
|
+
const h = buildHarness(async () => ({ ok: true, changed: true }));
|
|
297
|
+
await finishResize(h.handlers);
|
|
298
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
299
|
+
feature: "resize",
|
|
300
|
+
surface: "preview",
|
|
301
|
+
method: "drag",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
it.each([undefined, { ok: true as const, changed: false }])(
|
|
305
|
+
"does not count an unchanged or unconfirmed resize",
|
|
306
|
+
async (result) => {
|
|
307
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
308
|
+
const h = buildHarness(async () => result);
|
|
309
|
+
await finishResize(h.handlers);
|
|
310
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("move and rotation usage", () => {
|
|
316
|
+
it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
|
|
317
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
318
|
+
const h = buildHarness();
|
|
319
|
+
h.selection.capabilities.canApplyManualRotation = true;
|
|
320
|
+
const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
|
|
321
|
+
h.opts.onPathOffsetCommitRef.current = saved;
|
|
322
|
+
h.opts.onRotationCommitRef.current = saved;
|
|
323
|
+
h.handlers.startGesture(kind, evt(200, 50));
|
|
324
|
+
h.handlers.onPointerMove(evt(150, 120));
|
|
325
|
+
h.handlers.onPointerMove(evt(100, 150));
|
|
326
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
327
|
+
h.handlers.onPointerUp(evt(100, 150));
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
expect(saved).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
331
|
+
feature: kind === "drag" ? "move" : "rotate",
|
|
332
|
+
surface: "preview",
|
|
333
|
+
method: "drag",
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* filenames, not composition paths, not chain JSON. Every property below is
|
|
11
11
|
* either a fixed identifier from our own catalogue (a preset id, an effect
|
|
12
12
|
* type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the same line.
|
|
13
|
+
* sends only a route kind in `url_hash` for the same reason. The rack sees
|
|
14
|
+
* the same class of data and must hold the same line.
|
|
16
15
|
*
|
|
17
16
|
* The second rule is about volume: **commit, not preview.** Every control in
|
|
18
17
|
* the rack has a preview path that fires continuously while a slider moves and
|