mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): always-on crop with reposition handle, drop crop mode (#2090)
Crop is now part of the element selection instead of a separate mode. Selecting a croppable element shows edge handles just outside each side and, once cropped, the full content with the cropped-away area dimmed plus a center reposition handle to pan the crop window. Dragging the body moves the element, edge handles crop, the center handle pans; corners stay free for the resize handle. Removes the crop-mode toggle (toolbar + property-panel buttons), the cropMode/ cropAvailable player-store state, and the double-click-to-crop gesture. The clip-path inset model is unchanged.
This commit is contained in:
@@ -37,7 +37,6 @@ import {
|
||||
import type { DomEditSelection } from "./components/editor/domEditing";
|
||||
import { StudioHeader } from "./components/StudioHeader";
|
||||
import { useGestureCommit } from "./hooks/useGestureCommit";
|
||||
import { useCropModeProps } from "./hooks/useCropMode";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
|
||||
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
||||
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
||||
@@ -83,7 +82,6 @@ export function StudioApp() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
||||
const cropModeProps = useCropModeProps();
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
@@ -542,7 +540,6 @@ export function StudioApp() {
|
||||
recordingState={gestureState}
|
||||
onToggleRecording={recordingToggle}
|
||||
blockPreview={blockPreview}
|
||||
{...cropModeProps}
|
||||
gestureOverlay={
|
||||
gestureState === "recording" && previewIframe ? (
|
||||
<GestureTrailOverlay
|
||||
@@ -571,7 +568,6 @@ export function StudioApp() {
|
||||
reloadPreview={reloadPreview}
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
recordEdit={editHistory.recordEdit}
|
||||
{...cropModeProps}
|
||||
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -70,8 +70,6 @@ export interface StudioPreviewAreaProps {
|
||||
isGestureRecording?: boolean;
|
||||
recordingState?: GestureRecordingState;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
gestureOverlay?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -97,8 +95,6 @@ export function StudioPreviewArea({
|
||||
isGestureRecording,
|
||||
recordingState,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
blockPreview,
|
||||
gestureOverlay,
|
||||
}: StudioPreviewAreaProps) {
|
||||
@@ -387,8 +383,6 @@ export function StudioPreviewArea({
|
||||
onBoxSizeCommit={handleDomBoxSizeCommit}
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
onStyleCommit={handleDomStyleCommit}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
gridVisible={snapPrefs.gridVisible}
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
recordingState={recordingState}
|
||||
|
||||
@@ -51,8 +51,6 @@ export interface StudioRightPanelProps {
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
||||
sdkSession: Composition | null;
|
||||
reloadPreview: () => void;
|
||||
@@ -73,8 +71,6 @@ export function StudioRightPanel({
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
sdkSession,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -400,8 +396,6 @@ export function StudioRightPanel({
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -25,24 +25,50 @@ interface DomEditCropHandlesProps {
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function handleCenter(
|
||||
// Gap (px) between an edge handle and the element edge, so the handle sits
|
||||
// clear of the element body and can't intercept a move-drag.
|
||||
const EDGE_HANDLE_GAP = 8;
|
||||
|
||||
/** Place an edge handle just OUTSIDE the given crop edge (translate pushes it
|
||||
* fully past the boundary). Keeps the element body free for moving. */
|
||||
function edgeHandlePlacement(
|
||||
edge: CropEdge,
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
) {
|
||||
if (edge === "top") return { left: rect.left + rect.width / 2, top: rect.top };
|
||||
if (edge === "right") return { left: rect.left + rect.width, top: rect.top + rect.height / 2 };
|
||||
if (edge === "bottom") return { left: rect.left + rect.width / 2, top: rect.top + rect.height };
|
||||
return { left: rect.left, top: rect.top + rect.height / 2 };
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
if (edge === "top") {
|
||||
return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" };
|
||||
}
|
||||
if (edge === "bottom") {
|
||||
return {
|
||||
left: cx,
|
||||
top: rect.top + rect.height + EDGE_HANDLE_GAP,
|
||||
transform: "translate(-50%, 0)",
|
||||
};
|
||||
}
|
||||
if (edge === "left") {
|
||||
return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" };
|
||||
}
|
||||
return {
|
||||
left: rect.left + rect.width + EDGE_HANDLE_GAP,
|
||||
top: cy,
|
||||
transform: "translate(0, -50%)",
|
||||
};
|
||||
}
|
||||
|
||||
const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
||||
|
||||
/**
|
||||
* Pro-editor crop: while crop mode is active the element's clip is lifted so
|
||||
* the FULL content stays visible; the cropped-out region is dimmed and the
|
||||
* edge handles sit on the crop lines. Dragging updates the crop live; release
|
||||
* commits `clip-path: inset(...)` through the normal style-commit path (one
|
||||
* undo step per drag). Leaving crop mode re-applies the committed crop.
|
||||
* Always-on crop, integrated with the selection (no crop "mode"): while a
|
||||
* croppable element is selected its clip is lifted so the FULL content shows and
|
||||
* the cropped-away area is dimmed, with a dashed outline + an edge handle per
|
||||
* side on the crop boundary. Dragging an edge crops that side (a rule-of-thirds
|
||||
* grid guides framing); release commits `clip-path: inset(...)` through the
|
||||
* normal style-commit path (one undo step per drag). When cropped, a center
|
||||
* handle pans the crop window. Corners stay free for the selection's own resize
|
||||
* handle. Leaving the selection restores the committed crop. The clip-path model
|
||||
* is the source of truth — nothing here mutates layout.
|
||||
*/
|
||||
export function DomEditCropHandles({
|
||||
selection,
|
||||
@@ -50,6 +76,7 @@ export function DomEditCropHandles({
|
||||
onStyleCommit,
|
||||
}: DomEditCropHandlesProps) {
|
||||
const gestureRef = useRef<CropGestureState | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [state, setState] = useState(() => {
|
||||
const parsed = readElementCropInsets(selection.element);
|
||||
return {
|
||||
@@ -64,29 +91,38 @@ export function DomEditCropHandles({
|
||||
};
|
||||
});
|
||||
|
||||
// Re-sync when the selection element changes (reselect, undo/redo reload).
|
||||
// Re-sync when the selection targets a different element (reselect, or an
|
||||
// undo/redo that re-keys the node): read its committed crop before the lift
|
||||
// effect runs. Read inside the guard so a drag's per-frame setState doesn't
|
||||
// re-run getComputedStyle every frame.
|
||||
if (state.element !== selection.element) {
|
||||
const parsed = readElementCropInsets(selection.element);
|
||||
const liveInsets = readElementCropInsets(selection.element);
|
||||
setState({
|
||||
element: selection.element,
|
||||
insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left },
|
||||
radius: parsed.radius,
|
||||
insets: {
|
||||
top: liveInsets.top,
|
||||
right: liveInsets.right,
|
||||
bottom: liveInsets.bottom,
|
||||
left: liveInsets.left,
|
||||
},
|
||||
radius: liveInsets.radius,
|
||||
});
|
||||
}
|
||||
|
||||
// The value to re-apply when crop mode ends (latest committed crop).
|
||||
const committedRef = useRef<string | null>(null);
|
||||
{
|
||||
const hasCrop =
|
||||
state.insets.top > 0 ||
|
||||
state.insets.right > 0 ||
|
||||
state.insets.bottom > 0 ||
|
||||
state.insets.left > 0;
|
||||
committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
|
||||
}
|
||||
const hasCrop =
|
||||
state.insets.top > 0 ||
|
||||
state.insets.right > 0 ||
|
||||
state.insets.bottom > 0 ||
|
||||
state.insets.left > 0;
|
||||
|
||||
// Lift the clip while crop mode is active so the full content shows through
|
||||
// the dim; restore the committed crop on exit/unmount.
|
||||
// Latest committed crop — re-applied to the element when the selection drops.
|
||||
const committedRef = useRef<string | null>(null);
|
||||
committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
|
||||
|
||||
// Lift the clip while the element is selected so the full content shows and the
|
||||
// cropped-away area can be dimmed; restore the committed crop on deselect. Keyed
|
||||
// on the element so switching selections restores the previous one. Runs after
|
||||
// render, so the state re-sync above still reads the element's real committed clip.
|
||||
const liftedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const el = selection.element;
|
||||
@@ -118,6 +154,9 @@ export function DomEditCropHandles({
|
||||
startInsets: state.insets,
|
||||
didMove: false,
|
||||
};
|
||||
// Clip is already lifted by the selection effect; just flag the drag so the
|
||||
// rule-of-thirds grid shows.
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
@@ -146,16 +185,20 @@ export function DomEditCropHandles({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
gestureRef.current = null;
|
||||
setDragging(false);
|
||||
if (!gesture.didMove) return;
|
||||
// Commit to the file; the commit path re-applies the value to the live
|
||||
// element, so lift it back to "none" afterwards — full content + dim is
|
||||
// the crop-mode presentation.
|
||||
// Commit to the file. The commit path re-applies the value to the live
|
||||
// element, so re-lift afterwards to keep showing the full content + dim while
|
||||
// the element stays selected. Re-lift on both fulfilment and rejection so a
|
||||
// failed commit still restores the crop-mode presentation (and the rejection
|
||||
// is handled rather than left unhandled).
|
||||
const el = selection.element;
|
||||
const reLift = () => {
|
||||
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
||||
};
|
||||
void Promise.resolve(
|
||||
onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
|
||||
).then(() => {
|
||||
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
||||
});
|
||||
).then(reLift, reLift);
|
||||
};
|
||||
|
||||
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
@@ -163,66 +206,102 @@ export function DomEditCropHandles({
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
|
||||
gestureRef.current = null;
|
||||
setDragging(false);
|
||||
// Clip stays lifted; the dim follows the reset insets.
|
||||
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dim everything of the element outside the crop region. */}
|
||||
<div
|
||||
className="pointer-events-none absolute overflow-hidden"
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
}}
|
||||
>
|
||||
{/* Dim the cropped-away area whenever the element is cropped and selected,
|
||||
so the hidden content is visible (ghosted) without dragging. */}
|
||||
{hasCrop && (
|
||||
<div
|
||||
className="absolute"
|
||||
className="pointer-events-none absolute overflow-hidden"
|
||||
style={{
|
||||
left: cropRect.left - overlayRect.left,
|
||||
top: cropRect.top - overlayRect.top,
|
||||
width: cropRect.width,
|
||||
height: cropRect.height,
|
||||
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Crop frame — drag it to move the whole crop window. */}
|
||||
>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: cropRect.left - overlayRect.left,
|
||||
top: cropRect.top - overlayRect.top,
|
||||
width: cropRect.width,
|
||||
height: cropRect.height,
|
||||
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
|
||||
shown while dragging. */}
|
||||
<div
|
||||
data-dom-edit-crop-frame="true"
|
||||
className="pointer-events-auto absolute border-2 border-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute border border-dashed border-studio-accent"
|
||||
style={{
|
||||
left: cropRect.left,
|
||||
top: cropRect.top,
|
||||
width: cropRect.width,
|
||||
height: cropRect.height,
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={(event) => startCropGesture("move", event)}
|
||||
onPointerMove={updateCropGesture}
|
||||
onPointerUp={finishCropGesture}
|
||||
onPointerCancel={cancelCropGesture}
|
||||
/>
|
||||
>
|
||||
{dragging && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
|
||||
<div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
|
||||
<div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
|
||||
<div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Reposition handle — a center circle shown only once cropped. Drag it to
|
||||
pan the crop window (which part of the element shows) without resizing
|
||||
the crop. It's a small, discrete target, so a body drag still MOVES. */}
|
||||
{hasCrop && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reposition crop"
|
||||
data-dom-edit-crop-handle="true"
|
||||
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)]"
|
||||
style={{
|
||||
left: cropRect.left + cropRect.width / 2,
|
||||
top: cropRect.top + cropRect.height / 2,
|
||||
width: 22,
|
||||
height: 22,
|
||||
transform: "translate(-50%, -50%)",
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={(event) => startCropGesture("move", event)}
|
||||
onPointerMove={updateCropGesture}
|
||||
onPointerUp={finishCropGesture}
|
||||
onPointerCancel={cancelCropGesture}
|
||||
/>
|
||||
)}
|
||||
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
|
||||
edge (via edgeHandlePlacement) so they never overlap the element body:
|
||||
dragging the body always MOVES, only a handle crops. */}
|
||||
{EDGES.map((edge) => {
|
||||
const center = handleCenter(edge, cropRect);
|
||||
const vertical = edge === "left" || edge === "right";
|
||||
const place = edgeHandlePlacement(edge, cropRect);
|
||||
return (
|
||||
<button
|
||||
key={edge}
|
||||
type="button"
|
||||
aria-label={`Crop ${edge}`}
|
||||
data-dom-edit-crop-handle="true"
|
||||
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)]"
|
||||
className="pointer-events-auto absolute rounded-full bg-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
||||
style={{
|
||||
left: center.left,
|
||||
top: center.top,
|
||||
width: vertical ? 10 : 28,
|
||||
height: vertical ? 28 : 10,
|
||||
transform: "translate(-50%, -50%)",
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
width: vertical ? 5 : 26,
|
||||
height: vertical ? 26 : 5,
|
||||
transform: place.transform,
|
||||
cursor: vertical ? "ew-resize" : "ns-resize",
|
||||
touchAction: "none",
|
||||
}}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { GestureRecordingState } from "./GestureRecordControl";
|
||||
import { DomEditCropHandles } from "./DomEditCropHandles";
|
||||
import { DomEditRotateHandle } from "./DomEditRotateHandle";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import { useCropOverlay } from "../../hooks/useCropMode";
|
||||
import { useCropOverlay } from "../../hooks/useCropOverlay";
|
||||
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
||||
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
@@ -75,8 +75,6 @@ interface DomEditOverlayProps {
|
||||
) => Promise<void> | void;
|
||||
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
gridVisible?: boolean;
|
||||
gridSpacing?: number;
|
||||
recordingState?: GestureRecordingState;
|
||||
@@ -105,8 +103,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onBoxSizeCommit,
|
||||
onRotationCommit,
|
||||
onStyleCommit,
|
||||
cropMode = false,
|
||||
onCropModeChange,
|
||||
onMarqueeSelect,
|
||||
}: DomEditOverlayProps) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -178,9 +174,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
|
||||
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
|
||||
selection,
|
||||
groupCount: groupSelections.length,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
overlayRect,
|
||||
});
|
||||
// Inset crops draw their own outline child; other clip shapes keep the raw mirror.
|
||||
@@ -273,11 +266,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
|
||||
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!allowCanvasMovement) return;
|
||||
if (cropMode) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (suppressNextOverlayMouseDownRef.current) {
|
||||
suppressNextOverlayMouseDownRef.current = false;
|
||||
suppressNextBoxMouseDownRef.current = false;
|
||||
@@ -301,13 +289,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!allowCanvasMovement || event.button !== 0) return;
|
||||
if (cropMode) {
|
||||
// Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCropModeChange?.(false);
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
// Use the already-updated hover selection rather than re-resolving async
|
||||
const candidate = hoverSelectionRef.current;
|
||||
@@ -355,17 +336,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
};
|
||||
|
||||
// Selection re-resolves (and the box re-keys) on every click, so native
|
||||
// dblclick never fires on the box — detect double-click by pointerdown
|
||||
// timestamp (a no-move drag gesture suppresses the click event entirely).
|
||||
const lastBoxPointerDownAtRef = useRef(0);
|
||||
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!allowCanvasMovement) return;
|
||||
if (cropMode) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (gestureRef.current || groupGestureRef.current) return;
|
||||
if (suppressNextBoxClickRef.current) {
|
||||
suppressNextBoxClickRef.current = false;
|
||||
@@ -395,10 +367,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onPointerMove={cropMode ? undefined : marquee.onPointerMove}
|
||||
onPointerMove={marquee.onPointerMove}
|
||||
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
||||
onPointerUp={cropMode ? undefined : marquee.onPointerUp}
|
||||
onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
|
||||
onPointerUp={marquee.onPointerUp}
|
||||
onPointerCancel={marquee.onPointerCancel}
|
||||
>
|
||||
{hoverSelection && hoverRect && compRect.width > 0 && (
|
||||
<div
|
||||
@@ -444,7 +416,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
)}
|
||||
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
||||
<>
|
||||
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
||||
<DomEditRotateHandle
|
||||
overlayRect={overlayRect}
|
||||
cropOutlineInsetPx={cropOutlineInsetPx}
|
||||
@@ -466,27 +438,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
height: overlayRect.height,
|
||||
clipPath: boxClipPath,
|
||||
cursor:
|
||||
allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
|
||||
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
||||
? "move"
|
||||
: "default",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (cropMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (!allowCanvasMovement || e.shiftKey) return;
|
||||
const now = Date.now();
|
||||
const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
|
||||
lastBoxPointerDownAtRef.current = now;
|
||||
if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
|
||||
lastBoxPointerDownAtRef.current = 0;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCropModeChange(true);
|
||||
return;
|
||||
}
|
||||
if (selection.capabilities.canApplyManualOffset) {
|
||||
gestures.startGesture("drag", e);
|
||||
return;
|
||||
@@ -515,7 +472,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualSize && (
|
||||
<div
|
||||
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
||||
style={{
|
||||
@@ -533,7 +490,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{cropMode && (
|
||||
{selection.capabilities.canCrop && groupSelections.length <= 1 && (
|
||||
<DomEditCropHandles
|
||||
selection={selection}
|
||||
overlayRect={overlayRect}
|
||||
|
||||
@@ -100,8 +100,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
const { showToast } = useStudioShellContext();
|
||||
@@ -584,8 +582,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
||||
import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
|
||||
@@ -39,9 +39,6 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
|
||||
const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
|
||||
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
||||
const cropAvailable = usePlayerStore((s) => s.cropAvailable);
|
||||
const cropMode = usePlayerStore((s) => s.cropMode);
|
||||
const setCropMode = usePlayerStore((s) => s.setCropMode);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -102,22 +99,6 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
className="absolute top-2 right-2 z-50 flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{cropAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
cropMode
|
||||
? "bg-studio-accent/20 text-studio-accent"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
||||
}`}
|
||||
onClick={() => setCropMode(!cropMode)}
|
||||
title={cropMode ? "Exit crop (Esc)" : "Crop selection"}
|
||||
aria-label="Crop selection"
|
||||
aria-pressed={cropMode}
|
||||
>
|
||||
<Crop size={16} weight={cropMode ? "fill" : "regular"} />
|
||||
</button>
|
||||
)}
|
||||
{motionPathCreateAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -65,6 +65,22 @@ describe("resolveCropInsetFromEdgeDrag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCropInsetFromMoveDrag", () => {
|
||||
const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
|
||||
|
||||
it("shifts opposing insets together so the crop size stays constant", () => {
|
||||
expect(
|
||||
resolveCropInsetFromMoveDrag({ startInsets, deltaX: 20, deltaY: -10, scaleX: 2, scaleY: 1 }),
|
||||
).toEqual({ top: 0, right: 10, bottom: 40, left: 50 });
|
||||
});
|
||||
|
||||
it("clamps the window inside the element bounds", () => {
|
||||
expect(
|
||||
resolveCropInsetFromMoveDrag({ startInsets, deltaX: 999, deltaY: 999, scaleX: 1, scaleY: 1 }),
|
||||
).toEqual({ top: 40, right: 0, bottom: 0, left: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropRectFromInsets", () => {
|
||||
it("shrinks the overlay rect by scaled insets", () => {
|
||||
expect(
|
||||
@@ -88,19 +104,3 @@ describe("cropRectFromInsets", () => {
|
||||
expect(r.height).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCropInsetFromMoveDrag", () => {
|
||||
const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
|
||||
|
||||
it("shifts opposing insets together so the crop size stays constant", () => {
|
||||
expect(
|
||||
resolveCropInsetFromMoveDrag({ startInsets, deltaX: 20, deltaY: -10, scaleX: 2, scaleY: 1 }),
|
||||
).toEqual({ top: 0, right: 10, bottom: 40, left: 50 });
|
||||
});
|
||||
|
||||
it("clamps the window inside the element bounds", () => {
|
||||
expect(
|
||||
resolveCropInsetFromMoveDrag({ startInsets, deltaX: 999, deltaY: 999, scaleX: 1, scaleY: 1 }),
|
||||
).toEqual({ top: 40, right: 0, bottom: 0, left: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,8 +85,9 @@ export function resolveCropInsetFromEdgeDrag(input: CropInsetDragInput): ClipPat
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Drag the whole crop window: both opposing insets shift together, the crop
|
||||
* size stays constant, clamped inside the element bounds. */
|
||||
/** Pan the crop window: opposing insets shift together so the crop size stays
|
||||
* constant, clamped inside the element bounds. Repositions which part of the
|
||||
* element shows through a fixed-size crop (the center "reposition" handle). */
|
||||
export function resolveCropInsetFromMoveDrag(input: {
|
||||
startInsets: ClipPathInsetSides;
|
||||
deltaX: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, Layers, Palette, Scissors, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||
import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
@@ -47,8 +47,6 @@ export function StyleSections({
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
gsapBorderRadius,
|
||||
cropMode = false,
|
||||
onCropModeChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
@@ -57,8 +55,6 @@ export function StyleSections({
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
}) {
|
||||
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
||||
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
||||
@@ -404,20 +400,6 @@ export function StyleSections({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={styleEditingDisabled || !onCropModeChange}
|
||||
aria-pressed={cropMode}
|
||||
onClick={() => onCropModeChange?.(!cropMode)}
|
||||
className={`inline-flex h-8 w-fit items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
cropMode
|
||||
? "border-studio-accent/60 bg-studio-accent/15 text-studio-accent"
|
||||
: "border-panel-border bg-panel-input text-panel-text-2 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Scissors size={13} />
|
||||
Crop
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -109,6 +109,4 @@ export interface PropertyPanelProps {
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useMemo, useReducer } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
export interface CropModeProps {
|
||||
cropMode: boolean;
|
||||
onCropModeChange: (active: boolean) => void;
|
||||
}
|
||||
|
||||
/** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
|
||||
* and the overlay all share one switch without prop threading. */
|
||||
export function useCropModeProps(): CropModeProps {
|
||||
const cropMode = usePlayerStore((s) => s.cropMode);
|
||||
const setCropMode = usePlayerStore((s) => s.setCropMode);
|
||||
return useMemo(
|
||||
() => ({
|
||||
cropMode,
|
||||
onCropModeChange: setCropMode,
|
||||
}),
|
||||
[cropMode, setCropMode],
|
||||
);
|
||||
}
|
||||
|
||||
import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
|
||||
|
||||
/** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
|
||||
* and the box clip that makes the selection outline hug the cropped region.
|
||||
* The box div itself always sits at the FULL element bounds — gestures write
|
||||
* its position directly during drags, so moving/resizing it in React would
|
||||
* fight them. The hug is purely visual: the element's inset clip-path scaled
|
||||
* into overlay space and applied to the box. */
|
||||
export function useCropOverlay(params: {
|
||||
selection: DomEditSelection | null;
|
||||
groupCount: number;
|
||||
cropMode: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
overlayRect: OverlayRect | null;
|
||||
}) {
|
||||
const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropMode || !onCropModeChange) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onCropModeChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [cropMode, onCropModeChange]);
|
||||
|
||||
// Publish availability so the canvas toolbar shows the Crop button only
|
||||
// when the selection can take a clip-path crop.
|
||||
const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
|
||||
const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
|
||||
useEffect(() => {
|
||||
setCropAvailable(cropAvailable);
|
||||
return () => setCropAvailable(false);
|
||||
}, [cropAvailable, setCropAvailable]);
|
||||
|
||||
// Crop-mode exit restores the element's clip in an effect cleanup — after
|
||||
// this hook already read it. One forced re-render picks up the fresh insets
|
||||
// so the selection box hugs the crop immediately.
|
||||
const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
|
||||
useEffect(() => {
|
||||
if (!cropMode) bumpAfterExit();
|
||||
}, [cropMode]);
|
||||
|
||||
const cropInsets = selection ? readElementCropInsets(selection.element) : null;
|
||||
const hasCropInsets = Boolean(
|
||||
cropInsets &&
|
||||
(cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
|
||||
);
|
||||
|
||||
// Scaled insets for the crop outline child + the resize-handle shift. The
|
||||
// box div itself stays border-less at full bounds; a child draws the
|
||||
// outline ON the crop boundary (a clip on the box would swallow the
|
||||
// border everywhere the crop edge doesn't touch the element edge).
|
||||
const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
||||
const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
||||
const cropOutlineInsetPx =
|
||||
cropInsets && hasCropInsets && !cropMode
|
||||
? {
|
||||
top: cropInsets.top * sy,
|
||||
right: cropInsets.right * sx,
|
||||
bottom: cropInsets.bottom * sy,
|
||||
left: cropInsets.left * sx,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { hasCropInsets, cropOutlineInsetPx };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
|
||||
|
||||
/** Selection-box crop hug: the outline that makes the selection box hug the
|
||||
* element's committed inset crop. Crop is always-on (no mode) — the draggable
|
||||
* handles live in {@link DomEditCropHandles}; this only shapes the box border.
|
||||
* The box div itself always sits at the FULL element bounds; the hug is purely
|
||||
* visual — the element's inset clip-path scaled into overlay space. */
|
||||
export function useCropOverlay(params: {
|
||||
selection: DomEditSelection | null;
|
||||
overlayRect: OverlayRect | null;
|
||||
}) {
|
||||
const { selection, overlayRect } = params;
|
||||
|
||||
const cropInsets = selection ? readElementCropInsets(selection.element) : null;
|
||||
const hasCropInsets = Boolean(
|
||||
cropInsets &&
|
||||
(cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
|
||||
);
|
||||
|
||||
// Scaled insets for the crop outline child. The box div stays border-less at
|
||||
// full bounds; a child draws the outline ON the crop boundary (a clip on the
|
||||
// box would swallow the border everywhere the crop edge doesn't touch the
|
||||
// element edge).
|
||||
const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
||||
const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
||||
const cropOutlineInsetPx =
|
||||
cropInsets && hasCropInsets
|
||||
? {
|
||||
top: cropInsets.top * sy,
|
||||
right: cropInsets.right * sx,
|
||||
bottom: cropInsets.bottom * sy,
|
||||
left: cropInsets.left * sx,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { hasCropInsets, cropOutlineInsetPx };
|
||||
}
|
||||
@@ -63,10 +63,8 @@ const SHORTCUT_SECTIONS = [
|
||||
{
|
||||
title: "Crop",
|
||||
hints: [
|
||||
{ key: "DblClick", label: "Crop selected element" },
|
||||
{ key: "Drag edge", label: "Adjust crop side" },
|
||||
{ key: "Drag frame", label: "Move crop window" },
|
||||
{ key: "Esc", label: "Exit crop (or click outside)" },
|
||||
{ key: "Drag edge", label: "Crop a side" },
|
||||
{ key: "Drag center", label: "Reposition the crop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,15 +113,6 @@ interface PlayerState {
|
||||
autoKeyframeEnabled: boolean;
|
||||
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
||||
|
||||
/** Crop mode. Armed from the preview toolbar, the Clip panel, or a
|
||||
* double-click on a croppable selection; while armed, edge handles on the
|
||||
* selection adjust a non-destructive clip-path inset. `available` is
|
||||
* published by DomEditOverlay when the selection can be cropped. */
|
||||
cropMode: boolean;
|
||||
setCropMode: (active: boolean) => void;
|
||||
cropAvailable: boolean;
|
||||
setCropAvailable: (available: boolean) => void;
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
toggleSelectedElementId: (id: string) => void;
|
||||
@@ -259,11 +250,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
||||
autoKeyframeEnabled: true,
|
||||
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
||||
cropMode: false,
|
||||
setCropMode: (active) => set({ cropMode: active }),
|
||||
cropAvailable: false,
|
||||
setCropAvailable: (available) =>
|
||||
set(available ? { cropAvailable: true } : { cropAvailable: false, cropMode: false }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
|
||||
Reference in New Issue
Block a user