@hyperframes/studio 0.7.44 → 0.7.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.44",
3
+ "version": "0.7.46",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/player": "0.7.44",
50
- "@hyperframes/core": "0.7.44",
51
- "@hyperframes/sdk": "0.7.44",
52
- "@hyperframes/studio-server": "0.7.44",
53
- "@hyperframes/parsers": "0.7.44"
49
+ "@hyperframes/core": "0.7.46",
50
+ "@hyperframes/player": "0.7.46",
51
+ "@hyperframes/parsers": "0.7.46",
52
+ "@hyperframes/sdk": "0.7.46",
53
+ "@hyperframes/studio-server": "0.7.46"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.44"
68
+ "@hyperframes/producer": "0.7.46"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -37,7 +37,6 @@ import {
37
37
  import type { DomEditSelection } from "./components/editor/domEditing";
38
38
  import { StudioHeader } from "./components/StudioHeader";
39
39
  import { useGestureCommit } from "./hooks/useGestureCommit";
40
- import { useCropModeProps } from "./hooks/useCropMode";
41
40
  import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
42
41
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
43
42
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -83,7 +82,6 @@ export function StudioApp() {
83
82
  const [refreshKey, setRefreshKey] = useState(0);
84
83
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
85
84
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
86
- const cropModeProps = useCropModeProps();
87
85
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
88
86
  const activeCompPathRef = useRef(activeCompPath);
89
87
  activeCompPathRef.current = activeCompPath;
@@ -542,7 +540,6 @@ export function StudioApp() {
542
540
  recordingState={gestureState}
543
541
  onToggleRecording={recordingToggle}
544
542
  blockPreview={blockPreview}
545
- {...cropModeProps}
546
543
  gestureOverlay={
547
544
  gestureState === "recording" && previewIframe ? (
548
545
  <GestureTrailOverlay
@@ -571,7 +568,6 @@ export function StudioApp() {
571
568
  reloadPreview={reloadPreview}
572
569
  domEditSaveTimestampRef={domEditSaveTimestampRef}
573
570
  recordEdit={editHistory.recordEdit}
574
- {...cropModeProps}
575
571
  onToggleElementHidden={timelineEditing.handleToggleElementHidden}
576
572
  />
577
573
  )}
@@ -70,8 +70,6 @@ export interface StudioPreviewAreaProps {
70
70
  isGestureRecording?: boolean;
71
71
  recordingState?: GestureRecordingState;
72
72
  onToggleRecording?: () => void;
73
- cropMode?: boolean;
74
- onCropModeChange?: (active: boolean) => void;
75
73
  gestureOverlay?: ReactNode;
76
74
  }
77
75
 
@@ -97,8 +95,6 @@ export function StudioPreviewArea({
97
95
  isGestureRecording,
98
96
  recordingState,
99
97
  onToggleRecording,
100
- cropMode,
101
- onCropModeChange,
102
98
  blockPreview,
103
99
  gestureOverlay,
104
100
  }: StudioPreviewAreaProps) {
@@ -387,8 +383,6 @@ export function StudioPreviewArea({
387
383
  onBoxSizeCommit={handleDomBoxSizeCommit}
388
384
  onRotationCommit={handleDomRotationCommit}
389
385
  onStyleCommit={handleDomStyleCommit}
390
- cropMode={cropMode}
391
- onCropModeChange={onCropModeChange}
392
386
  gridVisible={snapPrefs.gridVisible}
393
387
  gridSpacing={snapPrefs.gridSpacing}
394
388
  recordingState={recordingState}
@@ -51,8 +51,6 @@ export interface StudioRightPanelProps {
51
51
  recordingState?: "idle" | "recording" | "preview";
52
52
  recordingDuration?: number;
53
53
  onToggleRecording?: () => void;
54
- cropMode?: boolean;
55
- onCropModeChange?: (active: boolean) => void;
56
54
  /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
57
55
  sdkSession: Composition | null;
58
56
  reloadPreview: () => void;
@@ -73,8 +71,6 @@ export function StudioRightPanel({
73
71
  recordingState,
74
72
  recordingDuration,
75
73
  onToggleRecording,
76
- cropMode,
77
- onCropModeChange,
78
74
  sdkSession,
79
75
  reloadPreview,
80
76
  domEditSaveTimestampRef,
@@ -400,8 +396,6 @@ export function StudioRightPanel({
400
396
  recordingState={recordingState}
401
397
  recordingDuration={recordingDuration}
402
398
  onToggleRecording={onToggleRecording}
403
- cropMode={cropMode}
404
- onCropModeChange={onCropModeChange}
405
399
  />
406
400
  );
407
401
 
@@ -25,24 +25,50 @@ interface DomEditCropHandlesProps {
25
25
  onStyleCommit?: (property: string, value: string) => Promise<void> | void;
26
26
  }
27
27
 
28
- function handleCenter(
28
+ // Gap (px) between an edge handle and the element edge, so the handle sits
29
+ // clear of the element body and can't intercept a move-drag.
30
+ const EDGE_HANDLE_GAP = 8;
31
+
32
+ /** Place an edge handle just OUTSIDE the given crop edge (translate pushes it
33
+ * fully past the boundary). Keeps the element body free for moving. */
34
+ function edgeHandlePlacement(
29
35
  edge: CropEdge,
30
36
  rect: { left: number; top: number; width: number; height: number },
31
37
  ) {
32
- if (edge === "top") return { left: rect.left + rect.width / 2, top: rect.top };
33
- if (edge === "right") return { left: rect.left + rect.width, top: rect.top + rect.height / 2 };
34
- if (edge === "bottom") return { left: rect.left + rect.width / 2, top: rect.top + rect.height };
35
- return { left: rect.left, top: rect.top + rect.height / 2 };
38
+ const cx = rect.left + rect.width / 2;
39
+ const cy = rect.top + rect.height / 2;
40
+ if (edge === "top") {
41
+ return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" };
42
+ }
43
+ if (edge === "bottom") {
44
+ return {
45
+ left: cx,
46
+ top: rect.top + rect.height + EDGE_HANDLE_GAP,
47
+ transform: "translate(-50%, 0)",
48
+ };
49
+ }
50
+ if (edge === "left") {
51
+ return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" };
52
+ }
53
+ return {
54
+ left: rect.left + rect.width + EDGE_HANDLE_GAP,
55
+ top: cy,
56
+ transform: "translate(0, -50%)",
57
+ };
36
58
  }
37
59
 
38
60
  const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
39
61
 
40
62
  /**
41
- * Pro-editor crop: while crop mode is active the element's clip is lifted so
42
- * the FULL content stays visible; the cropped-out region is dimmed and the
43
- * edge handles sit on the crop lines. Dragging updates the crop live; release
44
- * commits `clip-path: inset(...)` through the normal style-commit path (one
45
- * undo step per drag). Leaving crop mode re-applies the committed crop.
63
+ * Always-on crop, integrated with the selection (no crop "mode"): while a
64
+ * croppable element is selected its clip is lifted so the FULL content shows and
65
+ * the cropped-away area is dimmed, with a dashed outline + an edge handle per
66
+ * side on the crop boundary. Dragging an edge crops that side (a rule-of-thirds
67
+ * grid guides framing); release commits `clip-path: inset(...)` through the
68
+ * normal style-commit path (one undo step per drag). When cropped, a center
69
+ * handle pans the crop window. Corners stay free for the selection's own resize
70
+ * handle. Leaving the selection restores the committed crop. The clip-path model
71
+ * is the source of truth — nothing here mutates layout.
46
72
  */
47
73
  export function DomEditCropHandles({
48
74
  selection,
@@ -50,6 +76,7 @@ export function DomEditCropHandles({
50
76
  onStyleCommit,
51
77
  }: DomEditCropHandlesProps) {
52
78
  const gestureRef = useRef<CropGestureState | null>(null);
79
+ const [dragging, setDragging] = useState(false);
53
80
  const [state, setState] = useState(() => {
54
81
  const parsed = readElementCropInsets(selection.element);
55
82
  return {
@@ -64,29 +91,38 @@ export function DomEditCropHandles({
64
91
  };
65
92
  });
66
93
 
67
- // Re-sync when the selection element changes (reselect, undo/redo reload).
94
+ // Re-sync when the selection targets a different element (reselect, or an
95
+ // undo/redo that re-keys the node): read its committed crop before the lift
96
+ // effect runs. Read inside the guard so a drag's per-frame setState doesn't
97
+ // re-run getComputedStyle every frame.
68
98
  if (state.element !== selection.element) {
69
- const parsed = readElementCropInsets(selection.element);
99
+ const liveInsets = readElementCropInsets(selection.element);
70
100
  setState({
71
101
  element: selection.element,
72
- insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left },
73
- radius: parsed.radius,
102
+ insets: {
103
+ top: liveInsets.top,
104
+ right: liveInsets.right,
105
+ bottom: liveInsets.bottom,
106
+ left: liveInsets.left,
107
+ },
108
+ radius: liveInsets.radius,
74
109
  });
75
110
  }
76
111
 
77
- // The value to re-apply when crop mode ends (latest committed crop).
112
+ const hasCrop =
113
+ state.insets.top > 0 ||
114
+ state.insets.right > 0 ||
115
+ state.insets.bottom > 0 ||
116
+ state.insets.left > 0;
117
+
118
+ // Latest committed crop — re-applied to the element when the selection drops.
78
119
  const committedRef = useRef<string | null>(null);
79
- {
80
- const hasCrop =
81
- state.insets.top > 0 ||
82
- state.insets.right > 0 ||
83
- state.insets.bottom > 0 ||
84
- state.insets.left > 0;
85
- committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
86
- }
120
+ committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
87
121
 
88
- // Lift the clip while crop mode is active so the full content shows through
89
- // the dim; restore the committed crop on exit/unmount.
122
+ // Lift the clip while the element is selected so the full content shows and the
123
+ // cropped-away area can be dimmed; restore the committed crop on deselect. Keyed
124
+ // on the element so switching selections restores the previous one. Runs after
125
+ // render, so the state re-sync above still reads the element's real committed clip.
90
126
  const liftedRef = useRef(false);
91
127
  useEffect(() => {
92
128
  const el = selection.element;
@@ -118,6 +154,9 @@ export function DomEditCropHandles({
118
154
  startInsets: state.insets,
119
155
  didMove: false,
120
156
  };
157
+ // Clip is already lifted by the selection effect; just flag the drag so the
158
+ // rule-of-thirds grid shows.
159
+ setDragging(true);
121
160
  };
122
161
 
123
162
  const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -146,16 +185,20 @@ export function DomEditCropHandles({
146
185
  event.preventDefault();
147
186
  event.stopPropagation();
148
187
  gestureRef.current = null;
188
+ setDragging(false);
149
189
  if (!gesture.didMove) return;
150
- // Commit to the file; the commit path re-applies the value to the live
151
- // element, so lift it back to "none" afterwards full content + dim is
152
- // the crop-mode presentation.
190
+ // Commit to the file. The commit path re-applies the value to the live
191
+ // element, so re-lift afterwards to keep showing the full content + dim while
192
+ // the element stays selected. Re-lift on both fulfilment and rejection so a
193
+ // failed commit still restores the crop-mode presentation (and the rejection
194
+ // is handled rather than left unhandled).
153
195
  const el = selection.element;
196
+ const reLift = () => {
197
+ if (liftedRef.current) el.style.setProperty("clip-path", "none");
198
+ };
154
199
  void Promise.resolve(
155
200
  onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
156
- ).then(() => {
157
- if (liftedRef.current) el.style.setProperty("clip-path", "none");
158
- });
201
+ ).then(reLift, reLift);
159
202
  };
160
203
 
161
204
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -163,66 +206,102 @@ export function DomEditCropHandles({
163
206
  if (!gesture || gesture.pointerId !== event.pointerId) return;
164
207
  event.preventDefault();
165
208
  event.stopPropagation();
166
- setState((prev) => ({ ...prev, insets: gesture.startInsets }));
167
209
  gestureRef.current = null;
210
+ setDragging(false);
211
+ // Clip stays lifted; the dim follows the reset insets.
212
+ setState((prev) => ({ ...prev, insets: gesture.startInsets }));
168
213
  };
169
214
 
170
215
  return (
171
216
  <>
172
- {/* Dim everything of the element outside the crop region. */}
173
- <div
174
- className="pointer-events-none absolute overflow-hidden"
175
- style={{
176
- left: overlayRect.left,
177
- top: overlayRect.top,
178
- width: overlayRect.width,
179
- height: overlayRect.height,
180
- }}
181
- >
217
+ {/* Dim the cropped-away area whenever the element is cropped and selected,
218
+ so the hidden content is visible (ghosted) without dragging. */}
219
+ {hasCrop && (
182
220
  <div
183
- className="absolute"
221
+ className="pointer-events-none absolute overflow-hidden"
184
222
  style={{
185
- left: cropRect.left - overlayRect.left,
186
- top: cropRect.top - overlayRect.top,
187
- width: cropRect.width,
188
- height: cropRect.height,
189
- boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
223
+ left: overlayRect.left,
224
+ top: overlayRect.top,
225
+ width: overlayRect.width,
226
+ height: overlayRect.height,
190
227
  }}
191
- />
192
- </div>
193
- {/* Crop frame — drag it to move the whole crop window. */}
228
+ >
229
+ <div
230
+ className="absolute"
231
+ style={{
232
+ left: cropRect.left - overlayRect.left,
233
+ top: cropRect.top - overlayRect.top,
234
+ width: cropRect.width,
235
+ height: cropRect.height,
236
+ boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
237
+ }}
238
+ />
239
+ </div>
240
+ )}
241
+ {/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
242
+ shown while dragging. */}
194
243
  <div
195
- data-dom-edit-crop-frame="true"
196
- className="pointer-events-auto absolute border-2 border-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
244
+ aria-hidden="true"
245
+ className="pointer-events-none absolute border border-dashed border-studio-accent"
197
246
  style={{
198
247
  left: cropRect.left,
199
248
  top: cropRect.top,
200
249
  width: cropRect.width,
201
250
  height: cropRect.height,
202
- cursor: "move",
203
- touchAction: "none",
204
251
  }}
205
- onPointerDown={(event) => startCropGesture("move", event)}
206
- onPointerMove={updateCropGesture}
207
- onPointerUp={finishCropGesture}
208
- onPointerCancel={cancelCropGesture}
209
- />
252
+ >
253
+ {dragging && (
254
+ <>
255
+ <div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
256
+ <div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
257
+ <div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
258
+ <div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
259
+ </>
260
+ )}
261
+ </div>
262
+ {/* Reposition handle — a center circle shown only once cropped. Drag it to
263
+ pan the crop window (which part of the element shows) without resizing
264
+ the crop. It's a small, discrete target, so a body drag still MOVES. */}
265
+ {hasCrop && (
266
+ <button
267
+ type="button"
268
+ aria-label="Reposition crop"
269
+ data-dom-edit-crop-handle="true"
270
+ className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
271
+ style={{
272
+ left: cropRect.left + cropRect.width / 2,
273
+ top: cropRect.top + cropRect.height / 2,
274
+ width: 22,
275
+ height: 22,
276
+ transform: "translate(-50%, -50%)",
277
+ cursor: "move",
278
+ touchAction: "none",
279
+ }}
280
+ onPointerDown={(event) => startCropGesture("move", event)}
281
+ onPointerMove={updateCropGesture}
282
+ onPointerUp={finishCropGesture}
283
+ onPointerCancel={cancelCropGesture}
284
+ />
285
+ )}
286
+ {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
287
+ edge (via edgeHandlePlacement) so they never overlap the element body:
288
+ dragging the body always MOVES, only a handle crops. */}
210
289
  {EDGES.map((edge) => {
211
- const center = handleCenter(edge, cropRect);
212
290
  const vertical = edge === "left" || edge === "right";
291
+ const place = edgeHandlePlacement(edge, cropRect);
213
292
  return (
214
293
  <button
215
294
  key={edge}
216
295
  type="button"
217
296
  aria-label={`Crop ${edge}`}
218
297
  data-dom-edit-crop-handle="true"
219
- className="pointer-events-auto absolute rounded-sm border border-studio-accent bg-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
298
+ className="pointer-events-auto absolute rounded-full bg-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
220
299
  style={{
221
- left: center.left,
222
- top: center.top,
223
- width: vertical ? 10 : 28,
224
- height: vertical ? 28 : 10,
225
- transform: "translate(-50%, -50%)",
300
+ left: place.left,
301
+ top: place.top,
302
+ width: vertical ? 5 : 26,
303
+ height: vertical ? 26 : 5,
304
+ transform: place.transform,
226
305
  cursor: vertical ? "ew-resize" : "ns-resize",
227
306
  touchAction: "none",
228
307
  }}
@@ -21,7 +21,7 @@ import type { GestureRecordingState } from "./GestureRecordControl";
21
21
  import { DomEditCropHandles } from "./DomEditCropHandles";
22
22
  import { DomEditRotateHandle } from "./DomEditRotateHandle";
23
23
  import { hugRectForElement } from "./domEditOverlayCrop";
24
- import { useCropOverlay } from "../../hooks/useCropMode";
24
+ import { useCropOverlay } from "../../hooks/useCropOverlay";
25
25
  import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
26
26
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
27
27
  import { useMountEffect } from "../../hooks/useMountEffect";
@@ -75,8 +75,6 @@ interface DomEditOverlayProps {
75
75
  ) => Promise<void> | void;
76
76
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
77
77
  onStyleCommit?: (property: string, value: string) => Promise<void> | void;
78
- cropMode?: boolean;
79
- onCropModeChange?: (active: boolean) => void;
80
78
  gridVisible?: boolean;
81
79
  gridSpacing?: number;
82
80
  recordingState?: GestureRecordingState;
@@ -105,8 +103,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
105
103
  onBoxSizeCommit,
106
104
  onRotationCommit,
107
105
  onStyleCommit,
108
- cropMode = false,
109
- onCropModeChange,
110
106
  onMarqueeSelect,
111
107
  }: DomEditOverlayProps) {
112
108
  const overlayRef = useRef<HTMLDivElement | null>(null);
@@ -178,9 +174,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
178
174
 
179
175
  const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
180
176
  selection,
181
- groupCount: groupSelections.length,
182
- cropMode,
183
- onCropModeChange,
184
177
  overlayRect,
185
178
  });
186
179
  // Inset crops draw their own outline child; other clip shapes keep the raw mirror.
@@ -273,11 +266,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
273
266
 
274
267
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
275
268
  if (!allowCanvasMovement) return;
276
- if (cropMode) {
277
- event.preventDefault();
278
- event.stopPropagation();
279
- return;
280
- }
281
269
  if (suppressNextOverlayMouseDownRef.current) {
282
270
  suppressNextOverlayMouseDownRef.current = false;
283
271
  suppressNextBoxMouseDownRef.current = false;
@@ -301,13 +289,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
301
289
  // fallow-ignore-next-line complexity
302
290
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
303
291
  if (!allowCanvasMovement || event.button !== 0) return;
304
- if (cropMode) {
305
- // Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
306
- event.preventDefault();
307
- event.stopPropagation();
308
- onCropModeChange?.(false);
309
- return;
310
- }
311
292
  if (event.shiftKey) {
312
293
  // Use the already-updated hover selection rather than re-resolving async
313
294
  const candidate = hoverSelectionRef.current;
@@ -355,17 +336,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
355
336
  }
356
337
  };
357
338
 
358
- // Selection re-resolves (and the box re-keys) on every click, so native
359
- // dblclick never fires on the box — detect double-click by pointerdown
360
- // timestamp (a no-move drag gesture suppresses the click event entirely).
361
- const lastBoxPointerDownAtRef = useRef(0);
362
339
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
363
340
  if (!allowCanvasMovement) return;
364
- if (cropMode) {
365
- event.preventDefault();
366
- event.stopPropagation();
367
- return;
368
- }
369
341
  if (gestureRef.current || groupGestureRef.current) return;
370
342
  if (suppressNextBoxClickRef.current) {
371
343
  suppressNextBoxClickRef.current = false;
@@ -395,10 +367,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
395
367
  }
396
368
  onPointerDown={handleOverlayPointerDown}
397
369
  onMouseDown={handleOverlayMouseDown}
398
- onPointerMove={cropMode ? undefined : marquee.onPointerMove}
370
+ onPointerMove={marquee.onPointerMove}
399
371
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
400
- onPointerUp={cropMode ? undefined : marquee.onPointerUp}
401
- onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
372
+ onPointerUp={marquee.onPointerUp}
373
+ onPointerCancel={marquee.onPointerCancel}
402
374
  >
403
375
  {hoverSelection && hoverRect && compRect.width > 0 && (
404
376
  <div
@@ -444,7 +416,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
444
416
  )}
445
417
  {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
446
418
  <>
447
- {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
419
+ {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
448
420
  <DomEditRotateHandle
449
421
  overlayRect={overlayRect}
450
422
  cropOutlineInsetPx={cropOutlineInsetPx}
@@ -466,27 +438,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
466
438
  height: overlayRect.height,
467
439
  clipPath: boxClipPath,
468
440
  cursor:
469
- allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
441
+ allowCanvasMovement && selection.capabilities.canApplyManualOffset
470
442
  ? "move"
471
443
  : "default",
472
444
  }}
473
445
  onPointerDown={(e) => {
474
- if (cropMode) {
475
- e.preventDefault();
476
- e.stopPropagation();
477
- return;
478
- }
479
446
  if (!allowCanvasMovement || e.shiftKey) return;
480
- const now = Date.now();
481
- const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
482
- lastBoxPointerDownAtRef.current = now;
483
- if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
484
- lastBoxPointerDownAtRef.current = 0;
485
- e.preventDefault();
486
- e.stopPropagation();
487
- onCropModeChange(true);
488
- return;
489
- }
490
447
  if (selection.capabilities.canApplyManualOffset) {
491
448
  gestures.startGesture("drag", e);
492
449
  return;
@@ -515,7 +472,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
515
472
  }}
516
473
  />
517
474
  )}
518
- {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
475
+ {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
519
476
  <div
520
477
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
521
478
  style={{
@@ -533,7 +490,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
533
490
  />
534
491
  )}
535
492
  </div>
536
- {cropMode && (
493
+ {selection.capabilities.canCrop && groupSelections.length <= 1 && (
537
494
  <DomEditCropHandles
538
495
  selection={selection}
539
496
  overlayRect={overlayRect}
@@ -100,8 +100,6 @@ export const PropertyPanel = memo(function PropertyPanel({
100
100
  recordingState,
101
101
  recordingDuration,
102
102
  onToggleRecording,
103
- cropMode,
104
- onCropModeChange,
105
103
  }: PropertyPanelProps) {
106
104
  const styles = element?.computedStyles ?? EMPTY_STYLES;
107
105
  const { showToast } = useStudioShellContext();
@@ -584,8 +582,6 @@ export const PropertyPanel = memo(function PropertyPanel({
584
582
  onSetStyle={onSetStyle}
585
583
  onImportAssets={onImportAssets}
586
584
  gsapBorderRadius={gsapBorderRadius}
587
- cropMode={cropMode}
588
- onCropModeChange={onCropModeChange}
589
585
  />
590
586
  )}
591
587
  </div>
@@ -1,5 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
2
+ import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
5
 
@@ -39,9 +39,6 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
39
39
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
40
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
41
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
42
- const cropAvailable = usePlayerStore((s) => s.cropAvailable);
43
- const cropMode = usePlayerStore((s) => s.cropMode);
44
- const setCropMode = usePlayerStore((s) => s.setCropMode);
45
42
  const popoverRef = useRef<HTMLDivElement>(null);
46
43
  const gridButtonRef = useRef<HTMLButtonElement>(null);
47
44
 
@@ -102,22 +99,6 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
102
99
  className="absolute top-2 right-2 z-50 flex items-center gap-1"
103
100
  onPointerDown={(e) => e.stopPropagation()}
104
101
  >
105
- {cropAvailable && (
106
- <button
107
- type="button"
108
- className={`rounded-md p-1.5 transition-colors ${
109
- cropMode
110
- ? "bg-studio-accent/20 text-studio-accent"
111
- : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
112
- }`}
113
- onClick={() => setCropMode(!cropMode)}
114
- title={cropMode ? "Exit crop (Esc)" : "Crop selection"}
115
- aria-label="Crop selection"
116
- aria-pressed={cropMode}
117
- >
118
- <Crop size={16} weight={cropMode ? "fill" : "regular"} />
119
- </button>
120
- )}
121
102
  {motionPathCreateAvailable && (
122
103
  <button
123
104
  type="button"
@@ -65,6 +65,22 @@ describe("resolveCropInsetFromEdgeDrag", () => {
65
65
  });
66
66
  });
67
67
 
68
+ describe("resolveCropInsetFromMoveDrag", () => {
69
+ const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
70
+
71
+ it("shifts opposing insets together so the crop size stays constant", () => {
72
+ expect(
73
+ resolveCropInsetFromMoveDrag({ startInsets, deltaX: 20, deltaY: -10, scaleX: 2, scaleY: 1 }),
74
+ ).toEqual({ top: 0, right: 10, bottom: 40, left: 50 });
75
+ });
76
+
77
+ it("clamps the window inside the element bounds", () => {
78
+ expect(
79
+ resolveCropInsetFromMoveDrag({ startInsets, deltaX: 999, deltaY: 999, scaleX: 1, scaleY: 1 }),
80
+ ).toEqual({ top: 40, right: 0, bottom: 0, left: 60 });
81
+ });
82
+ });
83
+
68
84
  describe("cropRectFromInsets", () => {
69
85
  it("shrinks the overlay rect by scaled insets", () => {
70
86
  expect(
@@ -88,19 +104,3 @@ describe("cropRectFromInsets", () => {
88
104
  expect(r.height).toBe(0);
89
105
  });
90
106
  });
91
-
92
- describe("resolveCropInsetFromMoveDrag", () => {
93
- const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
94
-
95
- it("shifts opposing insets together so the crop size stays constant", () => {
96
- expect(
97
- resolveCropInsetFromMoveDrag({ startInsets, deltaX: 20, deltaY: -10, scaleX: 2, scaleY: 1 }),
98
- ).toEqual({ top: 0, right: 10, bottom: 40, left: 50 });
99
- });
100
-
101
- it("clamps the window inside the element bounds", () => {
102
- expect(
103
- resolveCropInsetFromMoveDrag({ startInsets, deltaX: 999, deltaY: 999, scaleX: 1, scaleY: 1 }),
104
- ).toEqual({ top: 40, right: 0, bottom: 0, left: 60 });
105
- });
106
- });