@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.115",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.115",
|
|
64
|
+
"@hyperframes/player": "0.8.115",
|
|
65
|
+
"@hyperframes/studio-server": "0.8.115",
|
|
66
|
+
"@hyperframes/sdk": "0.8.115",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.115",
|
|
68
|
+
"@hyperframes/parsers": "0.8.115"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.115"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs",
|
|
98
98
|
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
|
|
99
99
|
"test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
|
|
100
|
+
"bench:timeline-bulk-actions": "node tests/e2e/timeline-bulk-actions.mjs",
|
|
100
101
|
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
|
101
102
|
"test:watch": "vitest",
|
|
102
103
|
"test:colors:browser": "node tests/e2e/native-css-colors.mjs",
|
package/src/App.tsx
CHANGED
|
@@ -272,7 +272,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
272
272
|
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
|
|
273
273
|
activeCompPath,
|
|
274
274
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
275
|
-
onToggleRecording: () => handleToggleRecordingRef.current(),
|
|
275
|
+
onToggleRecording: () => handleToggleRecordingRef.current("keyboard"),
|
|
276
276
|
readOnlyPreview,
|
|
277
277
|
});
|
|
278
278
|
const domEditSessionResult = useDomEditSession({
|
|
@@ -363,7 +363,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
363
363
|
resetErrors: resetConsoleErrors,
|
|
364
364
|
} = useConsoleErrorCapture(previewIframe);
|
|
365
365
|
const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
|
|
366
|
-
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
|
366
|
+
const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
|
|
367
367
|
const domEditSessionRef = useRef(domEditSession);
|
|
368
368
|
domEditSessionRef.current = domEditSession;
|
|
369
369
|
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
|
|
@@ -6,6 +6,7 @@ import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav";
|
|
|
6
6
|
import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
|
|
7
7
|
import { Tooltip } from "./ui";
|
|
8
8
|
import { flatActive, flatIdle } from "./timelineToolbarStyles";
|
|
9
|
+
import { menuClasses } from "./ui/menuStyle";
|
|
9
10
|
|
|
10
11
|
function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
|
|
11
12
|
const menuRef = useContextMenuDismiss(onClose);
|
|
@@ -17,14 +18,14 @@ function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: (
|
|
|
17
18
|
ref={menuRef}
|
|
18
19
|
role="menu"
|
|
19
20
|
aria-label="Linked clips"
|
|
20
|
-
className=
|
|
21
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[200px]`}
|
|
21
22
|
style={{ left: x, top: y }}
|
|
22
23
|
>
|
|
23
24
|
<button
|
|
24
25
|
type="button"
|
|
25
26
|
role="menuitemcheckbox"
|
|
26
27
|
aria-checked={visible}
|
|
27
|
-
className=
|
|
28
|
+
className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
|
|
28
29
|
onClick={() => {
|
|
29
30
|
setVisible(!visible);
|
|
30
31
|
onClose();
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type PanelId,
|
|
32
32
|
} from "./panelRegistry";
|
|
33
33
|
import "./dock.css";
|
|
34
|
+
import { menuClasses } from "../ui/menuStyle";
|
|
34
35
|
|
|
35
36
|
const PERSIST_DEBOUNCE_MS = 250;
|
|
36
37
|
|
|
@@ -288,10 +289,7 @@ function WindowMenu() {
|
|
|
288
289
|
Window
|
|
289
290
|
</button>
|
|
290
291
|
{open && (
|
|
291
|
-
<div
|
|
292
|
-
role="menu"
|
|
293
|
-
className="absolute right-0 top-8 z-50 w-44 rounded-md border border-neutral-800 bg-neutral-900 py-1 shadow-lg"
|
|
294
|
-
>
|
|
292
|
+
<div role="menu" className={`${menuClasses.panel} absolute right-0 top-8 z-50 w-44`}>
|
|
295
293
|
{panels.map((id) => (
|
|
296
294
|
<button
|
|
297
295
|
key={id}
|
|
@@ -299,13 +297,13 @@ function WindowMenu() {
|
|
|
299
297
|
role="menuitemcheckbox"
|
|
300
298
|
aria-checked={openPanels.has(id)}
|
|
301
299
|
onClick={() => togglePanel(id)}
|
|
302
|
-
className=
|
|
300
|
+
className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
|
|
303
301
|
>
|
|
304
302
|
<span className="w-3 text-accent-ink">{openPanels.has(id) ? "✓" : ""}</span>
|
|
305
303
|
{PANEL_DEFINITIONS[id].title}
|
|
306
304
|
</button>
|
|
307
305
|
))}
|
|
308
|
-
<div className=
|
|
306
|
+
<div className={menuClasses.divider} />
|
|
309
307
|
<button
|
|
310
308
|
type="button"
|
|
311
309
|
role="menuitem"
|
|
@@ -313,7 +311,7 @@ function WindowMenu() {
|
|
|
313
311
|
resetLayout();
|
|
314
312
|
setOpen(false);
|
|
315
313
|
}}
|
|
316
|
-
className=
|
|
314
|
+
className={`${menuClasses.row} ${menuClasses.rowEnabled}`}
|
|
317
315
|
>
|
|
318
316
|
Reset layout
|
|
319
317
|
</button>
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type ZOrderAction,
|
|
34
34
|
type ZOrderPatch,
|
|
35
35
|
} from "./canvasContextMenuZOrder";
|
|
36
|
+
import { menuClasses } from "../ui/menuStyle";
|
|
36
37
|
|
|
37
38
|
interface CanvasContextMenuProps {
|
|
38
39
|
/** Viewport x of the right-click event. */
|
|
@@ -209,7 +210,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
209
210
|
return createPortal(
|
|
210
211
|
<div
|
|
211
212
|
ref={menuRef}
|
|
212
|
-
className=
|
|
213
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
|
|
213
214
|
style={{ left: adjustedX, top: adjustedY }}
|
|
214
215
|
onPointerDown={stopBubble}
|
|
215
216
|
onMouseDown={stopBubble}
|
|
@@ -252,7 +253,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
252
253
|
);
|
|
253
254
|
})}
|
|
254
255
|
|
|
255
|
-
{hasDivider && <div className=
|
|
256
|
+
{hasDivider && <div className={menuClasses.divider} />}
|
|
256
257
|
|
|
257
258
|
{hasDelete && (
|
|
258
259
|
<button
|
|
@@ -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,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>
|