@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/dist/assets/{index-8c83yScV.js → index-BGVoOXdg.js} +1 -1
- package/dist/assets/{index-BtAysyap.js → index-BsLQ2qGn.js} +237 -213
- package/dist/assets/{index-BiZvHXPu.js → index-DO2MAFSG.js} +1 -1
- package/dist/assets/index-DoVLXke0.css +1 -0
- package/dist/index.d.ts +0 -10
- package/dist/index.html +2 -2
- package/dist/index.js +354 -363
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +0 -4
- package/src/components/StudioPreviewArea.tsx +0 -6
- package/src/components/StudioRightPanel.tsx +0 -6
- package/src/components/editor/DomEditCropHandles.tsx +146 -67
- package/src/components/editor/DomEditOverlay.tsx +8 -51
- package/src/components/editor/PropertyPanel.tsx +0 -4
- package/src/components/editor/SnapToolbar.tsx +1 -20
- package/src/components/editor/domEditOverlayCrop.test.ts +16 -16
- package/src/components/editor/domEditOverlayCrop.ts +3 -2
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -19
- package/src/components/editor/propertyPanelTypes.ts +0 -2
- package/src/components/storyboard/StoryboardView.tsx +86 -8
- package/src/hooks/useCropOverlay.ts +39 -0
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/store/playerStore.ts +0 -14
- package/dist/assets/index-BpM6cnfP.css +0 -1
- package/src/hooks/useCropMode.ts +0 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
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/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
if (edge === "
|
|
35
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
99
|
+
const liveInsets = readElementCropInsets(selection.element);
|
|
70
100
|
setState({
|
|
71
101
|
element: selection.element,
|
|
72
|
-
insets: {
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
//
|
|
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
|
|
151
|
-
// element, so lift
|
|
152
|
-
// the
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
186
|
-
top:
|
|
187
|
-
width:
|
|
188
|
-
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
className="pointer-events-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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-
|
|
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:
|
|
222
|
-
top:
|
|
223
|
-
width: vertical ?
|
|
224
|
-
height: vertical ?
|
|
225
|
-
transform:
|
|
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/
|
|
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={
|
|
370
|
+
onPointerMove={marquee.onPointerMove}
|
|
399
371
|
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
|
400
|
-
onPointerUp={
|
|
401
|
-
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 &&
|
|
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 &&
|
|
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 &&
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
});
|