@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -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
+ }
@@ -1,5 +1,4 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
3
2
  import { type DomEditSelection } from "./domEditing";
4
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
5
4
  import { useMarqueeGestures } from "./marqueeCommit";
@@ -21,6 +20,12 @@ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures
21
20
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
22
21
  import { GridOverlay } from "./GridOverlay";
23
22
  import type { GestureRecordingState } from "./GestureRecordControl";
23
+ import { DomEditCropHandles } from "./DomEditCropHandles";
24
+ import { DomEditRotateHandle } from "./DomEditRotateHandle";
25
+ import { hugRectForElement } from "./domEditOverlayCrop";
26
+ import { useCropOverlay } from "../../hooks/useCropMode";
27
+ import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
28
+ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
24
29
 
25
30
  // Re-exports for external consumers — preserving existing import paths.
26
31
  export {
@@ -69,6 +74,9 @@ interface DomEditOverlayProps {
69
74
  next: { width: number; height: number },
70
75
  ) => Promise<void> | void;
71
76
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
77
+ onStyleCommit?: (property: string, value: string) => Promise<void> | void;
78
+ cropMode?: boolean;
79
+ onCropModeChange?: (active: boolean) => void;
72
80
  gridVisible?: boolean;
73
81
  gridSpacing?: number;
74
82
  recordingState?: GestureRecordingState;
@@ -96,6 +104,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
96
104
  onGroupPathOffsetCommit,
97
105
  onBoxSizeCommit,
98
106
  onRotationCommit,
107
+ onStyleCommit,
108
+ cropMode = false,
109
+ onCropModeChange,
99
110
  onMarqueeSelect,
100
111
  }: DomEditOverlayProps) {
101
112
  const overlayRef = useRef<HTMLDivElement | null>(null);
@@ -103,29 +114,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
103
114
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
104
115
  onMarqueeSelectRef.current = onMarqueeSelect;
105
116
 
106
- // fallow-ignore-next-line complexity
107
- const selectionShapeStyles = (() => {
108
- const fallback = {
109
- borderRadius: 8 as string | number,
110
- clipPath: undefined as string | undefined,
111
- };
112
- if (!selection?.element) return fallback;
113
- try {
114
- const tag = selection.element.tagName.toLowerCase();
115
- if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
116
- const win = selection.element.ownerDocument.defaultView;
117
- if (!win) return fallback;
118
- const cs = win.getComputedStyle(selection.element);
119
- const br = cs.borderRadius;
120
- const cp = cs.clipPath;
121
- return {
122
- borderRadius: br && br !== "0px" ? br : 4,
123
- clipPath: cp && cp !== "none" ? cp : undefined,
124
- };
125
- } catch {
126
- return fallback;
127
- }
128
- })();
117
+ const selectionShapeStyles = readDomEditSelectionShapeStyles(selection);
129
118
  const gestureRef = useRef<GestureState | null>(null);
130
119
  const groupGestureRef = useRef<GroupGestureState | null>(null);
131
120
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
@@ -151,6 +140,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
151
140
  onBoxSizeCommitRef.current = onBoxSizeCommit;
152
141
  const onRotationCommitRef = useRef(onRotationCommit);
153
142
  onRotationCommitRef.current = onRotationCommit;
143
+ const onStyleCommitRef = useRef(onStyleCommit);
144
+ onStyleCommitRef.current = onStyleCommit;
154
145
  const onBlockedMoveRef = useRef(onBlockedMove);
155
146
  onBlockedMoveRef.current = onBlockedMove;
156
147
  const onManualDragStartRef = useRef(onManualDragStart);
@@ -181,49 +172,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
181
172
  rafPausedRef,
182
173
  });
183
174
 
184
- const [compRect, setCompRect] = useState({
185
- left: 0,
186
- top: 0,
187
- width: 0,
188
- height: 0,
189
- scaleX: 1,
190
- scaleY: 1,
191
- });
192
- useMountEffect(() => {
193
- let frame = 0;
194
- // fallow-ignore-next-line complexity
195
- const update = () => {
196
- frame = requestAnimationFrame(update);
197
- const iframe = iframeRef.current;
198
- const overlayEl = overlayRef.current;
199
- if (!iframe || !overlayEl) return;
200
- const iRect = iframe.getBoundingClientRect();
201
- const oRect = overlayEl.getBoundingClientRect();
202
- const left = iRect.left - oRect.left;
203
- const top = iRect.top - oRect.top;
204
- if (iRect.width <= 0 || iRect.height <= 0) return;
205
- const doc = iframe.contentDocument;
206
- const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
207
- const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
208
- const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
209
- const scaleX = dw > 0 ? iRect.width / dw : 1;
210
- const scaleY = dh > 0 ? iRect.height / dh : 1;
211
- setCompRect((prev) => {
212
- if (
213
- Math.abs(prev.left - left) < 0.5 &&
214
- Math.abs(prev.top - top) < 0.5 &&
215
- Math.abs(prev.width - iRect.width) < 0.5 &&
216
- Math.abs(prev.height - iRect.height) < 0.5 &&
217
- Math.abs(prev.scaleX - scaleX) < 0.001 &&
218
- Math.abs(prev.scaleY - scaleY) < 0.001
219
- )
220
- return prev;
221
- return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
222
- });
223
- };
224
- frame = requestAnimationFrame(update);
225
- return () => cancelAnimationFrame(frame);
175
+ const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
176
+
177
+ const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
178
+ selection,
179
+ groupCount: groupSelections.length,
180
+ cropMode,
181
+ onCropModeChange,
182
+ overlayRect,
226
183
  });
184
+ // Inset crops draw their own outline child; other clip shapes keep the raw mirror.
185
+ const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
186
+ const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
227
187
 
228
188
  // Off-canvas element indicators — dashed outlines for elements positioned
229
189
  // outside the composition bounds so users can find them.
@@ -251,8 +211,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
251
211
  if (!isElementComputedVisible(item.element)) continue;
252
212
  // Groups use their members' union (where they actually render), so a group
253
213
  // whose members sit inside the canvas isn't flagged off-canvas by a stale
254
- // wrapper box.
255
- const r = groupAwareOverlayRect(overlay, iframe, item.element);
214
+ // wrapper box. Crop-hug the result so an inset crop that keeps the visible
215
+ // part on-canvas doesn't flag the element either.
216
+ const base = groupAwareOverlayRect(overlay, iframe, item.element);
217
+ const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
256
218
  if (!r) continue;
257
219
  // Any edge crossing the composition border → gray-zone indicator (the
258
220
  // in-canvas portion is clipped away below, so only the sliver shows).
@@ -325,6 +287,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
325
287
 
326
288
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
327
289
  if (!allowCanvasMovement) return;
290
+ if (cropMode) {
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ return;
294
+ }
328
295
  if (suppressNextOverlayMouseDownRef.current) {
329
296
  suppressNextOverlayMouseDownRef.current = false;
330
297
  suppressNextBoxMouseDownRef.current = false;
@@ -348,6 +315,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
348
315
  // fallow-ignore-next-line complexity
349
316
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
350
317
  if (!allowCanvasMovement || event.button !== 0) return;
318
+ if (cropMode) {
319
+ // Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
320
+ event.preventDefault();
321
+ event.stopPropagation();
322
+ onCropModeChange?.(false);
323
+ return;
324
+ }
351
325
  if (event.shiftKey) {
352
326
  // Use the already-updated hover selection rather than re-resolving async
353
327
  const candidate = hoverSelectionRef.current;
@@ -395,8 +369,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
395
369
  }
396
370
  };
397
371
 
372
+ // Selection re-resolves (and the box re-keys) on every click, so native
373
+ // dblclick never fires on the box — detect double-click by pointerdown
374
+ // timestamp (a no-move drag gesture suppresses the click event entirely).
375
+ const lastBoxPointerDownAtRef = useRef(0);
398
376
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
399
377
  if (!allowCanvasMovement) return;
378
+ if (cropMode) {
379
+ event.preventDefault();
380
+ event.stopPropagation();
381
+ return;
382
+ }
400
383
  if (gestureRef.current || groupGestureRef.current) return;
401
384
  if (suppressNextBoxClickRef.current) {
402
385
  suppressNextBoxClickRef.current = false;
@@ -426,22 +409,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
426
409
  }
427
410
  onPointerDown={handleOverlayPointerDown}
428
411
  onMouseDown={handleOverlayMouseDown}
429
- onPointerMove={marquee.onPointerMove}
412
+ onPointerMove={cropMode ? undefined : marquee.onPointerMove}
430
413
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
431
- onPointerUp={marquee.onPointerUp}
432
- onPointerCancel={marquee.onPointerCancel}
414
+ onPointerUp={cropMode ? undefined : marquee.onPointerUp}
415
+ onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
433
416
  >
434
417
  {hoverSelection && hoverRect && compRect.width > 0 && (
435
418
  <div
436
419
  aria-hidden="true"
437
420
  data-dom-edit-hover-box="true"
438
- className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
439
- style={{
440
- left: hoverRect.left,
441
- top: hoverRect.top,
442
- width: hoverRect.width,
443
- height: hoverRect.height,
444
- }}
421
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
422
+ style={hugRectForElement(hoverRect, hoverSelection.element)}
445
423
  />
446
424
  )}
447
425
  {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -450,7 +428,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
450
428
  <div
451
429
  key={item.key}
452
430
  aria-hidden="true"
453
- className="pointer-events-none absolute rounded-xl border border-studio-accent/70 bg-studio-accent/[0.03]"
431
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
454
432
  style={{
455
433
  left: item.rect.left,
456
434
  top: item.rect.top,
@@ -461,7 +439,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
461
439
  ))}
462
440
  <div
463
441
  data-dom-edit-selection-box="true"
464
- className="pointer-events-auto absolute rounded-xl border border-studio-accent bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
442
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
465
443
  style={{
466
444
  left: groupBounds.left,
467
445
  top: groupBounds.top,
@@ -480,49 +458,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({
480
458
  )}
481
459
  {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
482
460
  <>
483
- {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
484
- <div
485
- className="pointer-events-none absolute"
486
- style={{
487
- left: overlayRect.left + overlayRect.width / 2,
488
- top: overlayRect.top - 34,
489
- width: 28,
490
- height: 34,
491
- transform: "translateX(-50%)",
461
+ {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
462
+ <DomEditRotateHandle
463
+ overlayRect={overlayRect}
464
+ cropOutlineInsetPx={cropOutlineInsetPx}
465
+ onStartRotate={(e) => {
466
+ e.stopPropagation();
467
+ gestures.startGesture("rotate", e);
492
468
  }}
493
- >
494
- <div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
495
- <button
496
- type="button"
497
- className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
498
- style={{ cursor: "grab", touchAction: "none" }}
499
- title="Rotate"
500
- aria-label="Rotate selection"
501
- onPointerDown={(e) => {
502
- e.stopPropagation();
503
- gestures.startGesture("rotate", e);
504
- }}
505
- />
506
- </div>
469
+ />
507
470
  )}
508
471
  <div
509
472
  key={selectionKey}
510
473
  ref={boxRef}
511
474
  data-dom-edit-selection-box="true"
512
- className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
475
+ className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
513
476
  style={{
514
477
  left: overlayRect.left,
515
478
  top: overlayRect.top,
516
479
  width: overlayRect.width,
517
480
  height: overlayRect.height,
518
- clipPath: selectionShapeStyles.clipPath,
481
+ clipPath: boxClipPath,
519
482
  cursor:
520
- allowCanvasMovement && selection.capabilities.canApplyManualOffset
483
+ allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
521
484
  ? "move"
522
485
  : "default",
523
486
  }}
524
487
  onPointerDown={(e) => {
488
+ if (cropMode) {
489
+ e.preventDefault();
490
+ e.stopPropagation();
491
+ return;
492
+ }
525
493
  if (!allowCanvasMovement || e.shiftKey) return;
494
+ const now = Date.now();
495
+ const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
496
+ lastBoxPointerDownAtRef.current = now;
497
+ if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
498
+ lastBoxPointerDownAtRef.current = 0;
499
+ e.preventDefault();
500
+ e.stopPropagation();
501
+ onCropModeChange(true);
502
+ return;
503
+ }
526
504
  if (selection.capabilities.canApplyManualOffset) {
527
505
  gestures.startGesture("drag", e);
528
506
  return;
@@ -540,10 +518,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
540
518
  onMouseDown={suppressBoxMouseDown}
541
519
  onClick={handleBoxClick}
542
520
  >
543
- {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
521
+ {cropOutlineInsetPx && (
522
+ <div
523
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
524
+ style={{
525
+ left: cropOutlineInsetPx.left,
526
+ top: cropOutlineInsetPx.top,
527
+ right: cropOutlineInsetPx.right,
528
+ bottom: cropOutlineInsetPx.bottom,
529
+ }}
530
+ />
531
+ )}
532
+ {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
544
533
  <div
545
534
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
546
- style={{ cursor: "se-resize", touchAction: "none" }}
535
+ style={{
536
+ cursor: "se-resize",
537
+ touchAction: "none",
538
+ ...(cropOutlineInsetPx && {
539
+ right: cropOutlineInsetPx.right - 6,
540
+ bottom: cropOutlineInsetPx.bottom - 6,
541
+ }),
542
+ }}
547
543
  onPointerDown={(e) => {
548
544
  e.stopPropagation();
549
545
  gestures.startGesture("resize", e);
@@ -551,6 +547,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
551
547
  />
552
548
  )}
553
549
  </div>
550
+ {cropMode && (
551
+ <DomEditCropHandles
552
+ selection={selection}
553
+ overlayRect={overlayRect}
554
+ onStyleCommit={onStyleCommitRef.current}
555
+ />
556
+ )}
554
557
  </>
555
558
  )}
556
559
  {childRects.length > 0 &&
@@ -0,0 +1,41 @@
1
+ import type { PointerEvent as ReactPointerEvent } from "react";
2
+ import type { OverlayRect } from "./domEditOverlayGeometry";
3
+
4
+ /** Rotate grab-handle above the selection. Anchors to the crop outline when
5
+ * the element is cropped so it stays next to what's visible on screen. */
6
+ export function DomEditRotateHandle({
7
+ overlayRect,
8
+ cropOutlineInsetPx,
9
+ onStartRotate,
10
+ }: {
11
+ overlayRect: OverlayRect;
12
+ cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
13
+ onStartRotate: (e: ReactPointerEvent<HTMLButtonElement>) => void;
14
+ }) {
15
+ const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
16
+ const visibleLeft = overlayRect.left + inset.left;
17
+ const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
18
+ const visibleTop = overlayRect.top + inset.top;
19
+ return (
20
+ <div
21
+ className="pointer-events-none absolute"
22
+ style={{
23
+ left: visibleLeft + visibleWidth / 2,
24
+ top: visibleTop - 34,
25
+ width: 28,
26
+ height: 34,
27
+ transform: "translateX(-50%)",
28
+ }}
29
+ >
30
+ <div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
31
+ <button
32
+ type="button"
33
+ className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
34
+ style={{ cursor: "grab", touchAction: "none" }}
35
+ title="Rotate"
36
+ aria-label="Rotate selection"
37
+ onPointerDown={onStartRotate}
38
+ />
39
+ </div>
40
+ );
41
+ }