@hyperframes/studio 0.7.38 → 0.7.40

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.
Files changed (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.38",
3
+ "version": "0.7.40",
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/core": "0.7.38",
50
- "@hyperframes/parsers": "0.7.38",
51
- "@hyperframes/sdk": "0.7.38",
52
- "@hyperframes/studio-server": "0.7.38",
53
- "@hyperframes/player": "0.7.38"
49
+ "@hyperframes/parsers": "0.7.40",
50
+ "@hyperframes/studio-server": "0.7.40",
51
+ "@hyperframes/sdk": "0.7.40",
52
+ "@hyperframes/core": "0.7.40",
53
+ "@hyperframes/player": "0.7.40"
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.38"
68
+ "@hyperframes/producer": "0.7.40"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -37,6 +37,7 @@ 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";
40
41
  import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
41
42
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
42
43
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -82,6 +83,7 @@ export function StudioApp() {
82
83
  const [refreshKey, setRefreshKey] = useState(0);
83
84
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
84
85
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
86
+ const cropModeProps = useCropModeProps();
85
87
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
86
88
  const activeCompPathRef = useRef(activeCompPath);
87
89
  activeCompPathRef.current = activeCompPath;
@@ -358,7 +360,6 @@ export function StudioApp() {
358
360
  resetErrors: resetConsoleErrors,
359
361
  } = useConsoleErrorCapture(previewIframe);
360
362
  const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
361
- // Gesture recording
362
363
  const handleToggleRecordingRef = useRef<() => void>(() => {});
363
364
  const domEditSessionRef = useRef(domEditSession);
364
365
  domEditSessionRef.current = domEditSession;
@@ -369,6 +370,7 @@ export function StudioApp() {
369
370
  isGestureRecordingRef,
370
371
  });
371
372
  handleToggleRecordingRef.current = handleToggleRecording;
373
+ const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
372
374
  const canvasRectRef = useRef<CanvasRect | null>(null);
373
375
  useLayoutEffect(() => {
374
376
  if (gestureState !== "recording" || !previewIframe) {
@@ -528,6 +530,7 @@ export function StudioApp() {
528
530
  handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
529
531
  handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
530
532
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
533
+ handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
531
534
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
532
535
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
533
536
  handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -537,10 +540,9 @@ export function StudioApp() {
537
540
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
538
541
  isGestureRecording={gestureState === "recording"}
539
542
  recordingState={gestureState}
540
- onToggleRecording={
541
- STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
542
- }
543
+ onToggleRecording={recordingToggle}
543
544
  blockPreview={blockPreview}
545
+ {...cropModeProps}
544
546
  gestureOverlay={
545
547
  gestureState === "recording" && previewIframe ? (
546
548
  <GestureTrailOverlay
@@ -564,13 +566,13 @@ export function StudioApp() {
564
566
  }}
565
567
  recordingState={gestureState}
566
568
  recordingDuration={gestureRecording.recordingDuration}
567
- onToggleRecording={
568
- STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
569
- }
569
+ onToggleRecording={recordingToggle}
570
570
  sdkSession={sdkHandle.session}
571
571
  reloadPreview={reloadPreview}
572
572
  domEditSaveTimestampRef={domEditSaveTimestampRef}
573
573
  recordEdit={editHistory.recordEdit}
574
+ {...cropModeProps}
575
+ onToggleElementHidden={timelineEditing.handleToggleElementHidden}
574
576
  />
575
577
  )}
576
578
  </div>
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
58
58
  element: TimelineElement,
59
59
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
60
60
  ) => Promise<void> | void;
61
+ handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
61
62
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
62
63
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
63
64
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -69,6 +70,8 @@ export interface StudioPreviewAreaProps {
69
70
  isGestureRecording?: boolean;
70
71
  recordingState?: GestureRecordingState;
71
72
  onToggleRecording?: () => void;
73
+ cropMode?: boolean;
74
+ onCropModeChange?: (active: boolean) => void;
72
75
  gestureOverlay?: ReactNode;
73
76
  }
74
77
 
@@ -83,6 +86,7 @@ export function StudioPreviewArea({
83
86
  handleTimelineFileDrop,
84
87
  handleTimelineElementMove,
85
88
  handleTimelineElementResize,
89
+ handleToggleTrackHidden,
86
90
  handleBlockedTimelineEdit,
87
91
  handleTimelineElementSplit,
88
92
  handleRazorSplit,
@@ -93,6 +97,8 @@ export function StudioPreviewArea({
93
97
  isGestureRecording,
94
98
  recordingState,
95
99
  onToggleRecording,
100
+ cropMode,
101
+ onCropModeChange,
96
102
  blockPreview,
97
103
  gestureOverlay,
98
104
  }: StudioPreviewAreaProps) {
@@ -132,6 +138,7 @@ export function StudioPreviewArea({
132
138
  handleDomGroupPathOffsetCommit,
133
139
  handleDomBoxSizeCommit,
134
140
  handleDomRotationCommit,
141
+ handleDomStyleCommit,
135
142
  handleGsapRemoveKeyframe,
136
143
  handleGsapMoveKeyframeToPlayhead,
137
144
  handleGsapMoveKeyframe,
@@ -160,6 +167,7 @@ export function StudioPreviewArea({
160
167
  // diamond reports a clip-% but the script ops key on the tween-%. Prefers the
161
168
  // anim in the keyframe's property group, falling back to the first keyframed one.
162
169
  const resolveKeyframeTarget = useCallback(
170
+ // fallow-ignore-next-line complexity
163
171
  (pct: number): { animId: string; tweenPct: number } | null => {
164
172
  const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
165
173
  const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -177,6 +185,7 @@ export function StudioPreviewArea({
177
185
  () => ({
178
186
  onMoveElement: handleTimelineElementMove,
179
187
  onResizeElement: handleTimelineElementResize,
188
+ onToggleTrackHidden: handleToggleTrackHidden,
180
189
  onBlockedEditAttempt: handleBlockedTimelineEdit,
181
190
  onSplitElement: handleTimelineElementSplit,
182
191
  onRazorSplit: handleRazorSplit,
@@ -205,6 +214,7 @@ export function StudioPreviewArea({
205
214
  // drop past the boundary (last keyframe past the end, first before the start)
206
215
  // resizes the tween — position/duration grow so the dragged keyframe lands at
207
216
  // the drop while every other keyframe keeps its absolute time (value+ease too).
217
+ // fallow-ignore-next-line complexity
208
218
  onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
209
219
  const target = resolveKeyframeTarget(fromClipPct);
210
220
  const sel = domEditSelection;
@@ -275,6 +285,7 @@ export function StudioPreviewArea({
275
285
  [
276
286
  handleTimelineElementMove,
277
287
  handleTimelineElementResize,
288
+ handleToggleTrackHidden,
278
289
  handleBlockedTimelineEdit,
279
290
  handleTimelineElementSplit,
280
291
  handleRazorSplit,
@@ -375,6 +386,9 @@ export function StudioPreviewArea({
375
386
  onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
376
387
  onBoxSizeCommit={handleDomBoxSizeCommit}
377
388
  onRotationCommit={handleDomRotationCommit}
389
+ onStyleCommit={handleDomStyleCommit}
390
+ cropMode={cropMode}
391
+ onCropModeChange={onCropModeChange}
378
392
  gridVisible={snapPrefs.gridVisible}
379
393
  gridSpacing={snapPrefs.gridSpacing}
380
394
  recordingState={recordingState}
@@ -51,6 +51,8 @@ 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;
54
56
  /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
55
57
  sdkSession: Composition | null;
56
58
  reloadPreview: () => void;
@@ -60,6 +62,7 @@ export interface StudioRightPanelProps {
60
62
  kind: EditHistoryKind;
61
63
  files: Record<string, { before: string; after: string }>;
62
64
  }) => Promise<void>;
65
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
63
66
  }
64
67
 
65
68
  // fallow-ignore-next-line complexity
@@ -70,10 +73,13 @@ export function StudioRightPanel({
70
73
  recordingState,
71
74
  recordingDuration,
72
75
  onToggleRecording,
76
+ cropMode,
77
+ onCropModeChange,
73
78
  sdkSession,
74
79
  reloadPreview,
75
80
  domEditSaveTimestampRef,
76
81
  recordEdit,
82
+ onToggleElementHidden,
77
83
  }: StudioRightPanelProps) {
78
84
  const {
79
85
  rightWidth,
@@ -346,6 +352,7 @@ export function StudioRightPanel({
346
352
  multiSelectCount={domEditGroupSelections.length}
347
353
  copiedAgentPrompt={copiedAgentPrompt}
348
354
  onClearSelection={clearDomSelection}
355
+ onToggleElementHidden={onToggleElementHidden}
349
356
  onUngroup={handleUngroupSelection}
350
357
  onSetStyle={handleDomStyleCommit}
351
358
  onSetAttribute={handleDomAttributeCommit}
@@ -393,6 +400,8 @@ export function StudioRightPanel({
393
400
  recordingState={recordingState}
394
401
  recordingDuration={recordingDuration}
395
402
  onToggleRecording={onToggleRecording}
403
+ cropMode={cropMode}
404
+ onCropModeChange={onCropModeChange}
396
405
  />
397
406
  );
398
407
 
@@ -0,0 +1,238 @@
1
+ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+ import type { DomEditSelection } from "./domEditing";
3
+ import type { OverlayRect } from "./domEditOverlayGeometry";
4
+ import {
5
+ type CropEdge,
6
+ cropRectFromInsets,
7
+ readElementCropInsets,
8
+ resolveCropInsetFromEdgeDrag,
9
+ resolveCropInsetFromMoveDrag,
10
+ } from "./domEditOverlayCrop";
11
+ import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
12
+
13
+ interface CropGestureState {
14
+ edge: CropEdge | "move";
15
+ pointerId: number;
16
+ startX: number;
17
+ startY: number;
18
+ startInsets: ClipPathInsetSides;
19
+ didMove: boolean;
20
+ }
21
+
22
+ interface DomEditCropHandlesProps {
23
+ selection: DomEditSelection;
24
+ overlayRect: OverlayRect;
25
+ onStyleCommit?: (property: string, value: string) => Promise<void> | void;
26
+ }
27
+
28
+ function handleCenter(
29
+ edge: CropEdge,
30
+ rect: { left: number; top: number; width: number; height: number },
31
+ ) {
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 };
36
+ }
37
+
38
+ const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
39
+
40
+ /**
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.
46
+ */
47
+ export function DomEditCropHandles({
48
+ selection,
49
+ overlayRect,
50
+ onStyleCommit,
51
+ }: DomEditCropHandlesProps) {
52
+ const gestureRef = useRef<CropGestureState | null>(null);
53
+ const [state, setState] = useState(() => {
54
+ const parsed = readElementCropInsets(selection.element);
55
+ return {
56
+ element: selection.element,
57
+ insets: {
58
+ top: parsed.top,
59
+ right: parsed.right,
60
+ bottom: parsed.bottom,
61
+ left: parsed.left,
62
+ } as ClipPathInsetSides,
63
+ radius: parsed.radius,
64
+ };
65
+ });
66
+
67
+ // Re-sync when the selection element changes (reselect, undo/redo reload).
68
+ if (state.element !== selection.element) {
69
+ const parsed = readElementCropInsets(selection.element);
70
+ setState({
71
+ element: selection.element,
72
+ insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left },
73
+ radius: parsed.radius,
74
+ });
75
+ }
76
+
77
+ // The value to re-apply when crop mode ends (latest committed crop).
78
+ 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
+ }
87
+
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.
90
+ const liftedRef = useRef(false);
91
+ useEffect(() => {
92
+ const el = selection.element;
93
+ el.style.setProperty("clip-path", "none");
94
+ liftedRef.current = true;
95
+ return () => {
96
+ liftedRef.current = false;
97
+ if (committedRef.current) el.style.setProperty("clip-path", committedRef.current);
98
+ else el.style.removeProperty("clip-path");
99
+ };
100
+ }, [selection.element]);
101
+
102
+ const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
103
+ const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
104
+ const width = overlayRect.width / scaleX;
105
+ const height = overlayRect.height / scaleY;
106
+ const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
107
+
108
+ const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
109
+ if (!onStyleCommit) return;
110
+ event.preventDefault();
111
+ event.stopPropagation();
112
+ event.currentTarget.setPointerCapture(event.pointerId);
113
+ gestureRef.current = {
114
+ edge,
115
+ pointerId: event.pointerId,
116
+ startX: event.clientX,
117
+ startY: event.clientY,
118
+ startInsets: state.insets,
119
+ didMove: false,
120
+ };
121
+ };
122
+
123
+ const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
124
+ const gesture = gestureRef.current;
125
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
126
+ event.preventDefault();
127
+ event.stopPropagation();
128
+ const drag = {
129
+ startInsets: gesture.startInsets,
130
+ deltaX: event.clientX - gesture.startX,
131
+ deltaY: event.clientY - gesture.startY,
132
+ scaleX,
133
+ scaleY,
134
+ };
135
+ const nextInsets =
136
+ gesture.edge === "move"
137
+ ? resolveCropInsetFromMoveDrag(drag)
138
+ : resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height });
139
+ gesture.didMove = true;
140
+ setState((prev) => ({ ...prev, insets: nextInsets }));
141
+ };
142
+
143
+ const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
144
+ const gesture = gestureRef.current;
145
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
146
+ event.preventDefault();
147
+ event.stopPropagation();
148
+ gestureRef.current = null;
149
+ 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.
153
+ const el = selection.element;
154
+ void Promise.resolve(
155
+ onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
156
+ ).then(() => {
157
+ if (liftedRef.current) el.style.setProperty("clip-path", "none");
158
+ });
159
+ };
160
+
161
+ const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
162
+ const gesture = gestureRef.current;
163
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
164
+ event.preventDefault();
165
+ event.stopPropagation();
166
+ setState((prev) => ({ ...prev, insets: gesture.startInsets }));
167
+ gestureRef.current = null;
168
+ };
169
+
170
+ return (
171
+ <>
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
+ >
182
+ <div
183
+ className="absolute"
184
+ 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)",
190
+ }}
191
+ />
192
+ </div>
193
+ {/* Crop frame — drag it to move the whole crop window. */}
194
+ <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)]"
197
+ style={{
198
+ left: cropRect.left,
199
+ top: cropRect.top,
200
+ width: cropRect.width,
201
+ height: cropRect.height,
202
+ cursor: "move",
203
+ touchAction: "none",
204
+ }}
205
+ onPointerDown={(event) => startCropGesture("move", event)}
206
+ onPointerMove={updateCropGesture}
207
+ onPointerUp={finishCropGesture}
208
+ onPointerCancel={cancelCropGesture}
209
+ />
210
+ {EDGES.map((edge) => {
211
+ const center = handleCenter(edge, cropRect);
212
+ const vertical = edge === "left" || edge === "right";
213
+ return (
214
+ <button
215
+ key={edge}
216
+ type="button"
217
+ aria-label={`Crop ${edge}`}
218
+ 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)]"
220
+ style={{
221
+ left: center.left,
222
+ top: center.top,
223
+ width: vertical ? 10 : 28,
224
+ height: vertical ? 28 : 10,
225
+ transform: "translate(-50%, -50%)",
226
+ cursor: vertical ? "ew-resize" : "ns-resize",
227
+ touchAction: "none",
228
+ }}
229
+ onPointerDown={(event) => startCropGesture(edge, event)}
230
+ onPointerMove={updateCropGesture}
231
+ onPointerUp={finishCropGesture}
232
+ onPointerCancel={cancelCropGesture}
233
+ />
234
+ );
235
+ })}
236
+ </>
237
+ );
238
+ }