@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.
- package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.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 +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- 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 +27 -47
- 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/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -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/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -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/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- 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/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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 ${
|
|
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:
|
|
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
|
-
{
|
|
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={{
|
|
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
|
+
}
|