@hyperframes/studio 0.6.89 → 0.6.91

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/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-2SbRRd33.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CgYcO2PV.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-D2NkPomd.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.89",
3
+ "version": "0.6.91",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.89",
35
- "@hyperframes/player": "0.6.89"
34
+ "@hyperframes/core": "0.6.91",
35
+ "@hyperframes/player": "0.6.91"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.89"
49
+ "@hyperframes/producer": "0.6.91"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -504,6 +504,8 @@ export function StudioApp() {
504
504
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
505
505
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
506
506
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
507
+ handleRazorSplit={timelineEditing.handleRazorSplit}
508
+ handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
507
509
  setCompIdToSrc={setCompIdToSrc}
508
510
  setCompositionLoading={setCompositionLoading}
509
511
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
@@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps {
52
52
  ) => Promise<void> | void;
53
53
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
54
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
+ handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
56
+ handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
55
57
  setCompIdToSrc: (map: Map<string, string>) => void;
56
58
  setCompositionLoading: (loading: boolean) => void;
57
59
  shouldShowSelectedDomBounds: boolean;
@@ -73,6 +75,8 @@ export function StudioPreviewArea({
73
75
  handleTimelineElementResize,
74
76
  handleBlockedTimelineEdit,
75
77
  handleTimelineElementSplit,
78
+ handleRazorSplit,
79
+ handleRazorSplitAll,
76
80
  setCompIdToSrc,
77
81
  setCompositionLoading,
78
82
  shouldShowSelectedDomBounds,
@@ -146,6 +150,8 @@ export function StudioPreviewArea({
146
150
  onResizeElement={handleTimelineElementResize}
147
151
  onBlockedEditAttempt={handleBlockedTimelineEdit}
148
152
  onSplitElement={handleTimelineElementSplit}
153
+ onRazorSplit={handleRazorSplit}
154
+ onRazorSplitAll={handleRazorSplitAll}
149
155
  onSelectTimelineElement={handleTimelineElementSelect}
150
156
  onDeleteAllKeyframes={(_elId) => {
151
157
  const anim =
@@ -4,13 +4,18 @@ import {
4
4
  getNextTimelineZoomPercent,
5
5
  getTimelineZoomPercent,
6
6
  } from "../player/components/timelineZoom";
7
+ import { useTimelineZoom } from "../player/components/useTimelineZoom";
7
8
  import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
8
9
  import { usePlayerStore, type TimelineElement } from "../player";
9
- import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
10
+ import {
11
+ STUDIO_KEYFRAMES_ENABLED,
12
+ STUDIO_RAZOR_TOOL_ENABLED,
13
+ } from "./editor/manualEditingAvailability";
10
14
  import { Tooltip } from "./ui";
11
15
  import { Scissors } from "../icons/SystemIcons";
12
16
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
17
  import type { DomEditSelection } from "./editor/domEditingTypes";
18
+ import { canSplitElement } from "../utils/timelineElementSplit";
14
19
 
15
20
  function AutoKeyframeToggle() {
16
21
  const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
@@ -58,14 +63,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
58
63
  const anims = session.selectedGsapAnimations;
59
64
  const kfAnim = anims.find((a) => a.keyframes);
60
65
 
66
+ const computePct = (time: number) => {
67
+ const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
68
+ const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
69
+ return elDuration > 0
70
+ ? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
71
+ : 0;
72
+ };
73
+
61
74
  let state: "active" | "inactive" | "none" = "none";
62
75
  if (kfAnim?.keyframes && sel) {
63
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
64
- const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
65
- const pct =
66
- elDuration > 0
67
- ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
68
- : 0;
76
+ const pct = computePct(currentTime);
69
77
  state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
70
78
  ? "active"
71
79
  : "inactive";
@@ -74,15 +82,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
74
82
  return { state, onToggle: sel ? onToggle : undefined };
75
83
  }
76
84
 
85
+ // fallow-ignore-next-line complexity
77
86
  export function TimelineToolbar({
78
87
  toggleTimelineVisibility,
79
88
  domEditSession,
80
89
  onSplitElement,
81
90
  }: TimelineToolbarProps) {
82
- const zoomMode = usePlayerStore((s) => s.zoomMode);
83
- const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
84
- const setZoomMode = usePlayerStore((s) => s.setZoomMode);
85
- const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
91
+ const activeTool = usePlayerStore((s) => s.activeTool);
92
+ const setActiveTool = usePlayerStore((s) => s.setActiveTool);
93
+ const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
86
94
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
87
95
  const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
88
96
 
@@ -93,6 +101,38 @@ export function TimelineToolbar({
93
101
  <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
94
102
  Timeline
95
103
  </div>
104
+ {STUDIO_RAZOR_TOOL_ENABLED && (
105
+ <div className="flex items-center border border-neutral-800 rounded overflow-hidden">
106
+ <Tooltip label="Selection tool (V)">
107
+ <button
108
+ type="button"
109
+ onClick={() => setActiveTool("select")}
110
+ className={`flex h-6 w-6 items-center justify-center transition-colors ${
111
+ activeTool === "select"
112
+ ? "bg-neutral-700 text-neutral-200"
113
+ : "text-neutral-500 hover:text-neutral-300"
114
+ }`}
115
+ >
116
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
117
+ <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
118
+ </svg>
119
+ </button>
120
+ </Tooltip>
121
+ <Tooltip label="Razor tool (B)">
122
+ <button
123
+ type="button"
124
+ onClick={() => setActiveTool("razor")}
125
+ className={`flex h-6 w-6 items-center justify-center transition-colors ${
126
+ activeTool === "razor"
127
+ ? "bg-neutral-700 text-neutral-200"
128
+ : "text-neutral-500 hover:text-neutral-300"
129
+ }`}
130
+ >
131
+ <Scissors size={11} />
132
+ </button>
133
+ </Tooltip>
134
+ </div>
135
+ )}
96
136
  {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
97
137
  <>
98
138
  <Tooltip
@@ -138,9 +178,7 @@ export function TimelineToolbar({
138
178
  const el = selectedElementId
139
179
  ? elements.find((e) => (e.key ?? e.id) === selectedElementId)
140
180
  : null;
141
- const splittable =
142
- el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
143
- if (!splittable) return null;
181
+ if (!el || !canSplitElement(el)) return null;
144
182
  const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
145
183
  return (
146
184
  <Tooltip label="Split clip at playhead (S)">
@@ -25,6 +25,18 @@ describe("manual editing availability", () => {
25
25
  expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
26
26
  });
27
27
 
28
+ it("disables GSAP drag intercept by default", async () => {
29
+ const availability = await loadAvailabilityWithEnv({});
30
+ expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
31
+ });
32
+
33
+ it("enables GSAP drag intercept when env var is set", async () => {
34
+ const availability = await loadAvailabilityWithEnv({
35
+ VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "true",
36
+ });
37
+ expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
38
+ });
39
+
28
40
  it("disables preview selection when the inspector panel flag is explicitly off", async () => {
29
41
  const availability = await loadAvailabilityWithEnv({
30
42
  VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
@@ -47,6 +47,12 @@ export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag
47
47
  true,
48
48
  );
49
49
 
50
+ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
51
+ env,
52
+ ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT"],
53
+ false,
54
+ );
55
+
50
56
  export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
51
57
  env,
52
58
  [STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
@@ -77,6 +83,12 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
77
83
  true,
78
84
  );
79
85
 
86
+ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
87
+ env,
88
+ ["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
89
+ false,
90
+ );
91
+
80
92
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
81
93
 
82
94
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -10,7 +10,7 @@ import {
10
10
  import { useMountEffect } from "../../hooks/useMountEffect";
11
11
  import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
12
12
  import type { TimelineElement } from "../../player";
13
- import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
13
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
14
14
  import { NLEPreview } from "./NLEPreview";
15
15
  import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
16
16
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
@@ -20,7 +20,7 @@ import {
20
20
  getTimelineToggleTitle,
21
21
  } from "../../utils/timelineDiscovery";
22
22
 
23
- interface NLELayoutProps {
23
+ interface NLELayoutProps extends TimelineEditCallbacks {
24
24
  projectId: string;
25
25
  portrait?: boolean;
26
26
  /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
@@ -59,23 +59,7 @@ interface NLELayoutProps {
59
59
  blockName: string,
60
60
  position: { left: number; top: number },
61
61
  ) => Promise<void> | void;
62
- /** Persist timeline move actions back into source HTML */
63
- onMoveElement?: (
64
- element: TimelineElement,
65
- updates: Pick<TimelineElement, "start" | "track">,
66
- ) => Promise<void> | void;
67
- onResizeElement?: (
68
- element: TimelineElement,
69
- updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
70
- ) => Promise<void> | void;
71
- onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
72
- onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
73
62
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
74
- onDeleteKeyframe?: (elementId: string, percentage: number) => void;
75
- onDeleteAllKeyframes?: (elementId: string) => void;
76
- onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
77
- onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
78
- onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
79
63
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
80
64
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
81
65
  /** Whether the timeline panel is visible (default: true) */
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
124
108
  onResizeElement,
125
109
  onBlockedEditAttempt,
126
110
  onSplitElement,
111
+ onRazorSplit,
112
+ onRazorSplitAll,
127
113
  onSelectTimelineElement,
128
114
  onDeleteKeyframe,
129
115
  onDeleteAllKeyframes,
@@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({
460
446
  onResizeElement={onResizeElement}
461
447
  onBlockedEditAttempt={onBlockedEditAttempt}
462
448
  onSplitElement={onSplitElement}
449
+ onRazorSplit={onRazorSplit}
450
+ onRazorSplitAll={onRazorSplitAll}
463
451
  onSelectElement={onSelectTimelineElement}
464
452
  onDeleteKeyframe={onDeleteKeyframe}
465
453
  onDeleteAllKeyframes={onDeleteAllKeyframes}
@@ -1,4 +1,5 @@
1
1
  import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
2
+ import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
2
3
 
3
4
  interface TimelineEditorNoticeProps {
4
5
  onDismiss: () => void;
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
76
77
  "hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
77
78
  }}
78
79
  >
79
- <div
80
- className="absolute top-0 bottom-0"
81
- style={{
82
- left: "50%",
83
- width: 2,
84
- marginLeft: -1,
85
- background: "var(--hf-accent, #3CE6AC)",
86
- boxShadow: "0 0 8px rgba(60,230,172,0.5)",
87
- }}
88
- />
89
- <div
90
- className="absolute"
91
- style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
92
- >
93
- <div
94
- style={{
95
- width: 0,
96
- height: 0,
97
- borderLeft: "6px solid transparent",
98
- borderRight: "6px solid transparent",
99
- borderTop: "8px solid var(--hf-accent, #3CE6AC)",
100
- filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
101
- }}
102
- />
103
- </div>
80
+ <PlayheadIndicator />
104
81
  </div>
105
82
 
106
83
  <div className="flex flex-col gap-1.5">
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
+ import { canSplitElement } from "../utils/timelineElementSplit";
10
+ import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
9
11
 
10
12
  /** Safely resolves contentWindow for a potentially cross-origin iframe. */
11
13
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
@@ -327,7 +329,7 @@ export function useAppHotkeys({
327
329
  const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
328
330
  if (
329
331
  element &&
330
- ["video", "audio", "img"].includes(element.tag) &&
332
+ canSplitElement(element) &&
331
333
  currentTime > element.start &&
332
334
  currentTime < element.start + element.duration
333
335
  ) {
@@ -338,6 +340,51 @@ export function useAppHotkeys({
338
340
  }
339
341
  }
340
342
 
343
+ // B — toggle razor tool
344
+ if (
345
+ STUDIO_RAZOR_TOOL_ENABLED &&
346
+ event.key.toLowerCase() === "b" &&
347
+ !event.metaKey &&
348
+ !event.ctrlKey &&
349
+ !event.altKey &&
350
+ !event.shiftKey &&
351
+ !isEditableTarget(event.target)
352
+ ) {
353
+ event.preventDefault();
354
+ const { activeTool, setActiveTool } = usePlayerStore.getState();
355
+ setActiveTool(activeTool === "razor" ? "select" : "razor");
356
+ return;
357
+ }
358
+
359
+ // V — return to selection tool
360
+ if (
361
+ event.key.toLowerCase() === "v" &&
362
+ !event.metaKey &&
363
+ !event.ctrlKey &&
364
+ !event.altKey &&
365
+ !event.shiftKey &&
366
+ !isEditableTarget(event.target)
367
+ ) {
368
+ event.preventDefault();
369
+ usePlayerStore.getState().setActiveTool("select");
370
+ return;
371
+ }
372
+
373
+ // Escape — exit razor mode (only when no selection to deselect first)
374
+ if (event.key === "Escape" && !isEditableTarget(event.target)) {
375
+ const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
376
+ usePlayerStore.getState();
377
+ if (activeTool === "razor") {
378
+ if (selectedElementId) {
379
+ setSelectedElementId(null);
380
+ } else {
381
+ setActiveTool("select");
382
+ }
383
+ event.preventDefault();
384
+ return;
385
+ }
386
+ }
387
+
341
388
  // Delete / Backspace — remove selected keyframes > reset keyframes > remove element
342
389
  if (
343
390
  (event.key === "Delete" || event.key === "Backspace") &&
@@ -0,0 +1,29 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
2
+
3
+ /**
4
+ * Shared dismiss logic for context menus: closes on outside click or Escape.
5
+ * Returns a ref to attach to the menu container element.
6
+ */
7
+ export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
8
+ const menuRef = useRef<HTMLDivElement>(null);
9
+
10
+ const dismiss = useCallback(
11
+ (e: MouseEvent | KeyboardEvent) => {
12
+ if (e instanceof KeyboardEvent && e.key !== "Escape") return;
13
+ if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
14
+ onClose();
15
+ },
16
+ [onClose],
17
+ );
18
+
19
+ useEffect(() => {
20
+ document.addEventListener("mousedown", dismiss);
21
+ document.addEventListener("keydown", dismiss);
22
+ return () => {
23
+ document.removeEventListener("mousedown", dismiss);
24
+ document.removeEventListener("keydown", dismiss);
25
+ };
26
+ }, [dismiss]);
27
+
28
+ return menuRef;
29
+ }
@@ -1,7 +1,10 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
- import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
4
+ import {
5
+ STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
6
+ STUDIO_GSAP_PANEL_ENABLED,
7
+ } from "../components/editor/manualEditingAvailability";
5
8
  import { type DomEditSelection } from "../components/editor/domEditing";
6
9
  import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
7
10
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
@@ -326,7 +329,7 @@ export function useDomEditSession({
326
329
  // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
327
330
  const handleGsapAwarePathOffsetCommit = useCallback(
328
331
  async (selection: DomEditSelection, next: { x: number; y: number }) => {
329
- if (gsapCommitMutation) {
332
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
330
333
  const handled = await tryGsapDragIntercept(
331
334
  selection,
332
335
  next,
@@ -372,7 +375,7 @@ export function useDomEditSession({
372
375
 
373
376
  const handleGsapAwareBoxSizeCommit = useCallback(
374
377
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
375
- if (gsapCommitMutation) {
378
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
376
379
  const handled = await tryGsapResizeIntercept(
377
380
  selection,
378
381
  next,
@@ -396,7 +399,7 @@ export function useDomEditSession({
396
399
 
397
400
  const handleGsapAwareRotationCommit = useCallback(
398
401
  async (selection: DomEditSelection, next: { angle: number }) => {
399
- if (gsapCommitMutation) {
402
+ if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
400
403
  const handled = await tryGsapRotationIntercept(
401
404
  selection,
402
405
  next.angle,