@hyperframes/studio 0.7.38 → 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.
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
- package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2171 -1458
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +7 -6
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +6 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +9 -30
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +10 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.39",
|
|
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.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/sdk": "0.7.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/core": "0.7.39",
|
|
50
|
+
"@hyperframes/parsers": "0.7.39",
|
|
51
|
+
"@hyperframes/sdk": "0.7.39",
|
|
52
|
+
"@hyperframes/player": "0.7.39",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.39"
|
|
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.39"
|
|
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;
|
|
@@ -369,6 +371,7 @@ export function StudioApp() {
|
|
|
369
371
|
isGestureRecordingRef,
|
|
370
372
|
});
|
|
371
373
|
handleToggleRecordingRef.current = handleToggleRecording;
|
|
374
|
+
const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
|
|
372
375
|
const canvasRectRef = useRef<CanvasRect | null>(null);
|
|
373
376
|
useLayoutEffect(() => {
|
|
374
377
|
if (gestureState !== "recording" || !previewIframe) {
|
|
@@ -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,12 @@ 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}
|
|
574
575
|
/>
|
|
575
576
|
)}
|
|
576
577
|
</div>
|
|
@@ -69,6 +69,8 @@ export interface StudioPreviewAreaProps {
|
|
|
69
69
|
isGestureRecording?: boolean;
|
|
70
70
|
recordingState?: GestureRecordingState;
|
|
71
71
|
onToggleRecording?: () => void;
|
|
72
|
+
cropMode?: boolean;
|
|
73
|
+
onCropModeChange?: (active: boolean) => void;
|
|
72
74
|
gestureOverlay?: ReactNode;
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -93,6 +95,8 @@ export function StudioPreviewArea({
|
|
|
93
95
|
isGestureRecording,
|
|
94
96
|
recordingState,
|
|
95
97
|
onToggleRecording,
|
|
98
|
+
cropMode,
|
|
99
|
+
onCropModeChange,
|
|
96
100
|
blockPreview,
|
|
97
101
|
gestureOverlay,
|
|
98
102
|
}: StudioPreviewAreaProps) {
|
|
@@ -132,6 +136,7 @@ export function StudioPreviewArea({
|
|
|
132
136
|
handleDomGroupPathOffsetCommit,
|
|
133
137
|
handleDomBoxSizeCommit,
|
|
134
138
|
handleDomRotationCommit,
|
|
139
|
+
handleDomStyleCommit,
|
|
135
140
|
handleGsapRemoveKeyframe,
|
|
136
141
|
handleGsapMoveKeyframeToPlayhead,
|
|
137
142
|
handleGsapMoveKeyframe,
|
|
@@ -375,6 +380,9 @@ export function StudioPreviewArea({
|
|
|
375
380
|
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
376
381
|
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
377
382
|
onRotationCommit={handleDomRotationCommit}
|
|
383
|
+
onStyleCommit={handleDomStyleCommit}
|
|
384
|
+
cropMode={cropMode}
|
|
385
|
+
onCropModeChange={onCropModeChange}
|
|
378
386
|
gridVisible={snapPrefs.gridVisible}
|
|
379
387
|
gridSpacing={snapPrefs.gridSpacing}
|
|
380
388
|
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;
|
|
@@ -70,6 +72,8 @@ export function StudioRightPanel({
|
|
|
70
72
|
recordingState,
|
|
71
73
|
recordingDuration,
|
|
72
74
|
onToggleRecording,
|
|
75
|
+
cropMode,
|
|
76
|
+
onCropModeChange,
|
|
73
77
|
sdkSession,
|
|
74
78
|
reloadPreview,
|
|
75
79
|
domEditSaveTimestampRef,
|
|
@@ -393,6 +397,8 @@ export function StudioRightPanel({
|
|
|
393
397
|
recordingState={recordingState}
|
|
394
398
|
recordingDuration={recordingDuration}
|
|
395
399
|
onToggleRecording={onToggleRecording}
|
|
400
|
+
cropMode={cropMode}
|
|
401
|
+
onCropModeChange={onCropModeChange}
|
|
396
402
|
/>
|
|
397
403
|
);
|
|
398
404
|
|
|
@@ -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
|
+
}
|