mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(studio): non-destructive crop + cross-project asset view
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { OverlayRect } from "./domEditOverlayGeometry";
|
||||
import {
|
||||
type CropEdge,
|
||||
cropRectFromInsets,
|
||||
readElementCropInsets,
|
||||
resolveCropInsetFromEdgeDrag,
|
||||
resolveCropInsetFromMoveDrag,
|
||||
} from "./domEditOverlayCrop";
|
||||
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
||||
|
||||
interface CropGestureState {
|
||||
edge: CropEdge | "move";
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startInsets: ClipPathInsetSides;
|
||||
didMove: boolean;
|
||||
}
|
||||
|
||||
interface DomEditCropHandlesProps {
|
||||
selection: DomEditSelection;
|
||||
overlayRect: OverlayRect;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function handleCenter(
|
||||
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 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.
|
||||
*/
|
||||
export function DomEditCropHandles({
|
||||
selection,
|
||||
overlayRect,
|
||||
onStyleCommit,
|
||||
}: DomEditCropHandlesProps) {
|
||||
const gestureRef = useRef<CropGestureState | null>(null);
|
||||
const [state, setState] = useState(() => {
|
||||
const parsed = readElementCropInsets(selection.element);
|
||||
return {
|
||||
element: selection.element,
|
||||
insets: {
|
||||
top: parsed.top,
|
||||
right: parsed.right,
|
||||
bottom: parsed.bottom,
|
||||
left: parsed.left,
|
||||
} as ClipPathInsetSides,
|
||||
radius: parsed.radius,
|
||||
};
|
||||
});
|
||||
|
||||
// Re-sync when the selection element changes (reselect, undo/redo reload).
|
||||
if (state.element !== selection.element) {
|
||||
const parsed = readElementCropInsets(selection.element);
|
||||
setState({
|
||||
element: selection.element,
|
||||
insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left },
|
||||
radius: parsed.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;
|
||||
}
|
||||
|
||||
// Lift the clip while crop mode is active so the full content shows through
|
||||
// the dim; restore the committed crop on exit/unmount.
|
||||
const liftedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const el = selection.element;
|
||||
el.style.setProperty("clip-path", "none");
|
||||
liftedRef.current = true;
|
||||
return () => {
|
||||
liftedRef.current = false;
|
||||
if (committedRef.current) el.style.setProperty("clip-path", committedRef.current);
|
||||
else el.style.removeProperty("clip-path");
|
||||
};
|
||||
}, [selection.element]);
|
||||
|
||||
const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
||||
const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
||||
const width = overlayRect.width / scaleX;
|
||||
const height = overlayRect.height / scaleY;
|
||||
const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
|
||||
|
||||
const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!onStyleCommit) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
gestureRef.current = {
|
||||
edge,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startInsets: state.insets,
|
||||
didMove: false,
|
||||
};
|
||||
};
|
||||
|
||||
const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const drag = {
|
||||
startInsets: gesture.startInsets,
|
||||
deltaX: event.clientX - gesture.startX,
|
||||
deltaY: event.clientY - gesture.startY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
};
|
||||
const nextInsets =
|
||||
gesture.edge === "move"
|
||||
? resolveCropInsetFromMoveDrag(drag)
|
||||
: resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height });
|
||||
gesture.didMove = true;
|
||||
setState((prev) => ({ ...prev, insets: nextInsets }));
|
||||
};
|
||||
|
||||
const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
gestureRef.current = null;
|
||||
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.
|
||||
const el = selection.element;
|
||||
void Promise.resolve(
|
||||
onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
|
||||
).then(() => {
|
||||
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
||||
});
|
||||
};
|
||||
|
||||
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
|
||||
gestureRef.current = null;
|
||||
};
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{/* Crop frame — drag it to move the whole crop window. */}
|
||||
<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)]"
|
||||
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}
|
||||
/>
|
||||
{EDGES.map((edge) => {
|
||||
const center = handleCenter(edge, cropRect);
|
||||
const vertical = edge === "left" || edge === "right";
|
||||
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)]"
|
||||
style={{
|
||||
left: center.left,
|
||||
top: center.top,
|
||||
width: vertical ? 10 : 28,
|
||||
height: vertical ? 28 : 10,
|
||||
transform: "translate(-50%, -50%)",
|
||||
cursor: vertical ? "ew-resize" : "ns-resize",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={(event) => startCropGesture(edge, event)}
|
||||
onPointerMove={updateCropGesture}
|
||||
onPointerUp={finishCropGesture}
|
||||
onPointerCancel={cancelCropGesture}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
@@ -21,6 +20,12 @@ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures
|
||||
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
||||
import { GridOverlay } from "./GridOverlay";
|
||||
import type { GestureRecordingState } from "./GestureRecordControl";
|
||||
import { DomEditCropHandles } from "./DomEditCropHandles";
|
||||
import { DomEditRotateHandle } from "./DomEditRotateHandle";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import { useCropOverlay } from "../../hooks/useCropMode";
|
||||
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
||||
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
||||
|
||||
// Re-exports for external consumers — preserving existing import paths.
|
||||
export {
|
||||
@@ -69,6 +74,9 @@ interface DomEditOverlayProps {
|
||||
next: { width: number; height: number },
|
||||
) => 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;
|
||||
@@ -96,6 +104,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onGroupPathOffsetCommit,
|
||||
onBoxSizeCommit,
|
||||
onRotationCommit,
|
||||
onStyleCommit,
|
||||
cropMode = false,
|
||||
onCropModeChange,
|
||||
onMarqueeSelect,
|
||||
}: DomEditOverlayProps) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -103,29 +114,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
||||
onMarqueeSelectRef.current = onMarqueeSelect;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const selectionShapeStyles = (() => {
|
||||
const fallback = {
|
||||
borderRadius: 8 as string | number,
|
||||
clipPath: undefined as string | undefined,
|
||||
};
|
||||
if (!selection?.element) return fallback;
|
||||
try {
|
||||
const tag = selection.element.tagName.toLowerCase();
|
||||
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
|
||||
const win = selection.element.ownerDocument.defaultView;
|
||||
if (!win) return fallback;
|
||||
const cs = win.getComputedStyle(selection.element);
|
||||
const br = cs.borderRadius;
|
||||
const cp = cs.clipPath;
|
||||
return {
|
||||
borderRadius: br && br !== "0px" ? br : 4,
|
||||
clipPath: cp && cp !== "none" ? cp : undefined,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
})();
|
||||
const selectionShapeStyles = readDomEditSelectionShapeStyles(selection);
|
||||
const gestureRef = useRef<GestureState | null>(null);
|
||||
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
||||
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
||||
@@ -151,6 +140,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onBoxSizeCommitRef.current = onBoxSizeCommit;
|
||||
const onRotationCommitRef = useRef(onRotationCommit);
|
||||
onRotationCommitRef.current = onRotationCommit;
|
||||
const onStyleCommitRef = useRef(onStyleCommit);
|
||||
onStyleCommitRef.current = onStyleCommit;
|
||||
const onBlockedMoveRef = useRef(onBlockedMove);
|
||||
onBlockedMoveRef.current = onBlockedMove;
|
||||
const onManualDragStartRef = useRef(onManualDragStart);
|
||||
@@ -181,49 +172,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
rafPausedRef,
|
||||
});
|
||||
|
||||
const [compRect, setCompRect] = useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
useMountEffect(() => {
|
||||
let frame = 0;
|
||||
// fallow-ignore-next-line complexity
|
||||
const update = () => {
|
||||
frame = requestAnimationFrame(update);
|
||||
const iframe = iframeRef.current;
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!iframe || !overlayEl) return;
|
||||
const iRect = iframe.getBoundingClientRect();
|
||||
const oRect = overlayEl.getBoundingClientRect();
|
||||
const left = iRect.left - oRect.left;
|
||||
const top = iRect.top - oRect.top;
|
||||
if (iRect.width <= 0 || iRect.height <= 0) return;
|
||||
const doc = iframe.contentDocument;
|
||||
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
||||
const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
||||
const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
||||
const scaleX = dw > 0 ? iRect.width / dw : 1;
|
||||
const scaleY = dh > 0 ? iRect.height / dh : 1;
|
||||
setCompRect((prev) => {
|
||||
if (
|
||||
Math.abs(prev.left - left) < 0.5 &&
|
||||
Math.abs(prev.top - top) < 0.5 &&
|
||||
Math.abs(prev.width - iRect.width) < 0.5 &&
|
||||
Math.abs(prev.height - iRect.height) < 0.5 &&
|
||||
Math.abs(prev.scaleX - scaleX) < 0.001 &&
|
||||
Math.abs(prev.scaleY - scaleY) < 0.001
|
||||
)
|
||||
return prev;
|
||||
return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
|
||||
});
|
||||
};
|
||||
frame = requestAnimationFrame(update);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
|
||||
|
||||
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.
|
||||
const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
|
||||
const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
|
||||
|
||||
// Off-canvas element indicators — dashed outlines for elements positioned
|
||||
// outside the composition bounds so users can find them.
|
||||
@@ -251,8 +211,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
if (!isElementComputedVisible(item.element)) continue;
|
||||
// Groups use their members' union (where they actually render), so a group
|
||||
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
||||
// wrapper box.
|
||||
const r = groupAwareOverlayRect(overlay, iframe, item.element);
|
||||
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
|
||||
// part on-canvas doesn't flag the element either.
|
||||
const base = groupAwareOverlayRect(overlay, iframe, item.element);
|
||||
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
|
||||
if (!r) continue;
|
||||
// Any edge crossing the composition border → gray-zone indicator (the
|
||||
// in-canvas portion is clipped away below, so only the sliver shows).
|
||||
@@ -325,6 +287,11 @@ 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;
|
||||
@@ -348,6 +315,13 @@ 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;
|
||||
@@ -395,8 +369,17 @@ 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;
|
||||
@@ -426,22 +409,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onPointerMove={marquee.onPointerMove}
|
||||
onPointerMove={cropMode ? undefined : marquee.onPointerMove}
|
||||
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
||||
onPointerUp={marquee.onPointerUp}
|
||||
onPointerCancel={marquee.onPointerCancel}
|
||||
onPointerUp={cropMode ? undefined : marquee.onPointerUp}
|
||||
onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
|
||||
>
|
||||
{hoverSelection && hoverRect && compRect.width > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-dom-edit-hover-box="true"
|
||||
className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={{
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top,
|
||||
width: hoverRect.width,
|
||||
height: hoverRect.height,
|
||||
}}
|
||||
style={hugRectForElement(hoverRect, hoverSelection.element)}
|
||||
/>
|
||||
)}
|
||||
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
||||
@@ -480,49 +458,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
)}
|
||||
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
||||
<>
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: overlayRect.left + overlayRect.width / 2,
|
||||
top: overlayRect.top - 34,
|
||||
width: 28,
|
||||
height: 34,
|
||||
transform: "translateX(-50%)",
|
||||
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
|
||||
<DomEditRotateHandle
|
||||
overlayRect={overlayRect}
|
||||
cropOutlineInsetPx={cropOutlineInsetPx}
|
||||
onStartRotate={(e) => {
|
||||
e.stopPropagation();
|
||||
gestures.startGesture("rotate", e);
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
style={{ cursor: "grab", touchAction: "none" }}
|
||||
title="Rotate"
|
||||
aria-label="Rotate selection"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
gestures.startGesture("rotate", e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
key={selectionKey}
|
||||
ref={boxRef}
|
||||
data-dom-edit-selection-box="true"
|
||||
className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
|
||||
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
clipPath: selectionShapeStyles.clipPath,
|
||||
clipPath: boxClipPath,
|
||||
cursor:
|
||||
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
||||
allowCanvasMovement && !cropMode && 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;
|
||||
@@ -540,10 +518,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onMouseDown={suppressBoxMouseDown}
|
||||
onClick={handleBoxClick}
|
||||
>
|
||||
{allowCanvasMovement && selection.capabilities.canApplyManualSize && (
|
||||
{cropOutlineInsetPx && (
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)] bg-studio-accent/5"
|
||||
style={{
|
||||
left: cropOutlineInsetPx.left,
|
||||
top: cropOutlineInsetPx.top,
|
||||
right: cropOutlineInsetPx.right,
|
||||
bottom: cropOutlineInsetPx.bottom,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowCanvasMovement && !cropMode && 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={{ cursor: "se-resize", touchAction: "none" }}
|
||||
style={{
|
||||
cursor: "se-resize",
|
||||
touchAction: "none",
|
||||
...(cropOutlineInsetPx && {
|
||||
right: cropOutlineInsetPx.right - 6,
|
||||
bottom: cropOutlineInsetPx.bottom - 6,
|
||||
}),
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
gestures.startGesture("resize", e);
|
||||
@@ -551,6 +547,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{cropMode && (
|
||||
<DomEditCropHandles
|
||||
selection={selection}
|
||||
overlayRect={overlayRect}
|
||||
onStyleCommit={onStyleCommitRef.current}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{childRects.length > 0 &&
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { OverlayRect } from "./domEditOverlayGeometry";
|
||||
|
||||
/** Rotate grab-handle above the selection. Anchors to the crop outline when
|
||||
* the element is cropped so it stays next to what's visible on screen. */
|
||||
export function DomEditRotateHandle({
|
||||
overlayRect,
|
||||
cropOutlineInsetPx,
|
||||
onStartRotate,
|
||||
}: {
|
||||
overlayRect: OverlayRect;
|
||||
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
|
||||
onStartRotate: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
const visibleLeft = overlayRect.left + inset.left;
|
||||
const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
|
||||
const visibleTop = overlayRect.top + inset.top;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: visibleLeft + visibleWidth / 2,
|
||||
top: visibleTop - 34,
|
||||
width: 28,
|
||||
height: 34,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
||||
<button
|
||||
type="button"
|
||||
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)]"
|
||||
style={{ cursor: "grab", touchAction: "none" }}
|
||||
title="Rotate"
|
||||
aria-label="Rotate selection"
|
||||
onPointerDown={onStartRotate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildInsetClipPathSides,
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
getClipPathInsetPx,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
inferBoxShadowPreset,
|
||||
inferClipPathPreset,
|
||||
normalizePanelPxValue,
|
||||
parseInsetClipPathSides,
|
||||
setCssFilterFunctionPx,
|
||||
} from "./PropertyPanel";
|
||||
|
||||
@@ -49,6 +51,52 @@ describe("PropertyPanel style helpers", () => {
|
||||
expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0);
|
||||
});
|
||||
|
||||
it("builds and parses 4-side inset clip paths without losing radius", () => {
|
||||
expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe(
|
||||
"inset(10px 20px 30px 40px round 6px)",
|
||||
);
|
||||
expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({
|
||||
top: 10,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
left: 40,
|
||||
radius: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the single-value inset form when all sides are equal", () => {
|
||||
expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe(
|
||||
"inset(12.5px)",
|
||||
);
|
||||
expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({
|
||||
top: 12.5,
|
||||
right: 12.5,
|
||||
bottom: 12.5,
|
||||
left: 12.5,
|
||||
radius: 0,
|
||||
});
|
||||
expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5);
|
||||
});
|
||||
|
||||
it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => {
|
||||
expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({
|
||||
top: 10,
|
||||
right: 20,
|
||||
bottom: 10,
|
||||
left: 20,
|
||||
radius: 0,
|
||||
});
|
||||
expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({
|
||||
top: 10,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
left: 20,
|
||||
radius: 0,
|
||||
});
|
||||
expect(parseInsetClipPathSides("inset(10%)")).toBeNull();
|
||||
expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps stroke width and style edits visually effective", () => {
|
||||
expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([
|
||||
["border-width", "3px"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Layers, Move } from "../../icons/SystemIcons";
|
||||
import { Move } from "../../icons/SystemIcons";
|
||||
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -27,9 +27,11 @@ import { usePlayerStore, liveTime } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { GestureRecordPanelButton } from "./GestureRecordControl";
|
||||
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
|
||||
|
||||
// Re-export helpers that external consumers import from this module
|
||||
export {
|
||||
buildInsetClipPathSides,
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
getCssFilterFunctionPx,
|
||||
@@ -37,6 +39,7 @@ export {
|
||||
inferBoxShadowPreset,
|
||||
inferClipPathPreset,
|
||||
normalizePanelPxValue,
|
||||
parseInsetClipPathSides,
|
||||
setCssFilterFunctionPx,
|
||||
} from "./propertyPanelHelpers";
|
||||
|
||||
@@ -94,6 +97,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
const { showToast } = useStudioShellContext();
|
||||
@@ -160,35 +165,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
};
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-neutral-900">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
{multiSelectCount > 1 ? (
|
||||
<>
|
||||
<Layers size={18} className="mb-3 text-neutral-600" />
|
||||
<p className="text-sm font-medium text-neutral-200">
|
||||
{multiSelectCount} elements selected
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
Select a single element to edit its properties. Click an element in the preview or
|
||||
use the timeline layer panel.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye size={18} className="mb-3 text-neutral-600" />
|
||||
<p className="text-sm font-medium text-neutral-200">
|
||||
Select an element in the preview.
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
The inspector is tuned for element edits with safer geometry controls, color
|
||||
picking, and cleaner grouped layer controls.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
|
||||
}
|
||||
|
||||
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
||||
@@ -581,6 +558,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Eye, Layers } from "../../icons/SystemIcons";
|
||||
|
||||
export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-neutral-900">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
{multiSelectCount > 1 ? (
|
||||
<>
|
||||
<Layers size={18} className="mb-3 text-neutral-600" />
|
||||
<p className="text-sm font-medium text-neutral-200">
|
||||
{multiSelectCount} elements selected
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
Select a single element to edit its properties. Click an element in the preview or use
|
||||
the timeline layer panel.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye size={18} className="mb-3 text-neutral-600" />
|
||||
<p className="text-sm font-medium text-neutral-200">
|
||||
Select an element in the preview.
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
The inspector is tuned for element edits with safer geometry controls, color picking,
|
||||
and cleaner grouped layer controls.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
||||
import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
|
||||
@@ -39,6 +39,9 @@ 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);
|
||||
|
||||
@@ -99,6 +102,22 @@ 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"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
export interface ClipPathInsetSides {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export type ParsedInsetClipPathSides = ClipPathInsetSides & { radius: number };
|
||||
|
||||
function formatClipNumber(value: number): string {
|
||||
const rounded = roundToCenti(value);
|
||||
return Number.isInteger(rounded)
|
||||
? `${rounded}`
|
||||
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function formatClipPx(value: number): string {
|
||||
return `${formatClipNumber(Math.max(0, value))}px`;
|
||||
}
|
||||
|
||||
function parseInsetLengthPx(value: string): number | null {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "0") return 0;
|
||||
const match = /^(-?\d+(?:\.\d+)?)px$/i.exec(normalized);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
|
||||
}
|
||||
|
||||
function sidesFromInsetTokens(tokens: number[]): ClipPathInsetSides | null {
|
||||
if (tokens.length < 1 || tokens.length > 4) return null;
|
||||
// CSS shorthand expansion: T | T R | T R B | T R B L
|
||||
const [top, right = top, bottom = top, left = right] = tokens;
|
||||
if (top === undefined || right === undefined || bottom === undefined || left === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { top, right, bottom, left };
|
||||
}
|
||||
|
||||
export function inferClipPathPreset(
|
||||
value: string | undefined,
|
||||
): "none" | "inset" | "circle" | "custom" {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || normalized === "none") return "none";
|
||||
if (/^inset\(/i.test(normalized)) return "inset";
|
||||
if (/^circle\(/i.test(normalized)) return "circle";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function parseInsetClipPathSides(
|
||||
value: string | undefined,
|
||||
): ParsedInsetClipPathSides | null {
|
||||
// Unambiguous pattern (no nested optional whitespace) to avoid polynomial
|
||||
// backtracking on adversarial input; trim the payload instead.
|
||||
const match = /^inset\(([^()]*)\)$/i.exec(value?.trim() ?? "");
|
||||
if (!match) return null;
|
||||
const parts = match[1]
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.split(/ round /i);
|
||||
const insetPart = parts[0]?.trim();
|
||||
if (!insetPart || parts.length > 2) return null;
|
||||
|
||||
const tokens = insetPart.split(/\s+/).map(parseInsetLengthPx);
|
||||
if (tokens.some((token) => token == null)) return null;
|
||||
const numericTokens: number[] = [];
|
||||
for (const token of tokens) {
|
||||
if (token == null) return null;
|
||||
numericTokens.push(token);
|
||||
}
|
||||
const sides = sidesFromInsetTokens(numericTokens);
|
||||
if (!sides) return null;
|
||||
|
||||
const radiusPart = parts[1]?.trim();
|
||||
const radius = radiusPart ? parseInsetLengthPx(radiusPart) : 0;
|
||||
if (radius == null) return null;
|
||||
return { ...sides, radius };
|
||||
}
|
||||
|
||||
export function getClipPathInsetPx(value: string | undefined): number {
|
||||
const parsed = parseInsetClipPathSides(value);
|
||||
if (!parsed) return 0;
|
||||
const { top, right, bottom, left } = parsed;
|
||||
return top === right && top === bottom && top === left ? top : 0;
|
||||
}
|
||||
|
||||
export function buildClipPathValue(
|
||||
preset: "none" | "inset" | "circle" | "custom",
|
||||
radiusValue: number,
|
||||
fallback: string | undefined,
|
||||
) {
|
||||
if (preset === "custom") return fallback?.trim() || "none";
|
||||
if (preset === "circle") return "circle(50% at 50% 50%)";
|
||||
if (preset === "inset") {
|
||||
return `inset(0 round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function buildInsetClipPathSides(sides: ClipPathInsetSides, radiusPx: number = 0): string {
|
||||
const values = [sides.top, sides.right, sides.bottom, sides.left].map(formatClipPx);
|
||||
const [top, right, bottom, left] = values;
|
||||
const inset =
|
||||
top === right && top === bottom && top === left ? top : `${top} ${right} ${bottom} ${left}`;
|
||||
const radius = Math.max(0, radiusPx);
|
||||
return radius > 0 ? `inset(${inset} round ${formatClipNumber(radius)}px)` : `inset(${inset})`;
|
||||
}
|
||||
|
||||
export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
|
||||
return `inset(${formatClipPx(insetPx)} round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cropRectFromInsets,
|
||||
resolveCropInsetFromEdgeDrag,
|
||||
resolveCropInsetFromMoveDrag,
|
||||
} from "./domEditOverlayCrop";
|
||||
|
||||
describe("resolveCropInsetFromEdgeDrag", () => {
|
||||
const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
|
||||
|
||||
it("converts overlay-space edge movement into element-space inset changes", () => {
|
||||
expect(
|
||||
resolveCropInsetFromEdgeDrag({
|
||||
edge: "left",
|
||||
startInsets,
|
||||
deltaX: 20,
|
||||
deltaY: 0,
|
||||
scaleX: 2,
|
||||
scaleY: 1,
|
||||
width: 200,
|
||||
height: 120,
|
||||
}),
|
||||
).toEqual({ top: 10, right: 20, bottom: 30, left: 50 });
|
||||
|
||||
expect(
|
||||
resolveCropInsetFromEdgeDrag({
|
||||
edge: "right",
|
||||
startInsets,
|
||||
deltaX: 20,
|
||||
deltaY: 0,
|
||||
scaleX: 2,
|
||||
scaleY: 1,
|
||||
width: 200,
|
||||
height: 120,
|
||||
}),
|
||||
).toEqual({ top: 10, right: 10, bottom: 30, left: 40 });
|
||||
});
|
||||
|
||||
it("clamps edited insets so opposing sides never overlap", () => {
|
||||
expect(
|
||||
resolveCropInsetFromEdgeDrag({
|
||||
edge: "left",
|
||||
startInsets,
|
||||
deltaX: 400,
|
||||
deltaY: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: 100,
|
||||
height: 120,
|
||||
}),
|
||||
).toEqual({ top: 10, right: 20, bottom: 30, left: 80 });
|
||||
|
||||
expect(
|
||||
resolveCropInsetFromEdgeDrag({
|
||||
edge: "top",
|
||||
startInsets,
|
||||
deltaX: 0,
|
||||
deltaY: -40,
|
||||
scaleX: 1,
|
||||
scaleY: 2,
|
||||
width: 200,
|
||||
height: 120,
|
||||
}),
|
||||
).toEqual({ top: 0, right: 20, bottom: 30, left: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropRectFromInsets", () => {
|
||||
it("shrinks the overlay rect by scaled insets", () => {
|
||||
expect(
|
||||
cropRectFromInsets(
|
||||
{ left: 100, top: 50, width: 200, height: 100 },
|
||||
{ top: 10, right: 40, bottom: 20, left: 30 },
|
||||
2,
|
||||
1,
|
||||
),
|
||||
).toEqual({ left: 160, top: 60, width: 60, height: 70 });
|
||||
});
|
||||
|
||||
it("clamps to zero size when insets exceed the rect", () => {
|
||||
const r = cropRectFromInsets(
|
||||
{ left: 0, top: 0, width: 100, height: 100 },
|
||||
{ top: 300, right: 300, bottom: 300, left: 300 },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
expect(r.width).toBe(0);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
||||
|
||||
export type CropEdge = "top" | "right" | "bottom" | "left";
|
||||
|
||||
export interface CropScreenRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Element-space insets → the cropped region in overlay (screen) space. */
|
||||
export function cropRectFromInsets(
|
||||
rect: CropScreenRect,
|
||||
insets: ClipPathInsetSides,
|
||||
scaleX: number,
|
||||
scaleY: number,
|
||||
): CropScreenRect {
|
||||
const sx = scaleX > 0 ? scaleX : 1;
|
||||
const sy = scaleY > 0 ? scaleY : 1;
|
||||
const left = rect.left + insets.left * sx;
|
||||
const top = rect.top + insets.top * sy;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, rect.width - (insets.left + insets.right) * sx),
|
||||
height: Math.max(0, rect.height - (insets.top + insets.bottom) * sy),
|
||||
};
|
||||
}
|
||||
|
||||
/** Current inset crop of an element (inline first, computed fallback), or zeros. */
|
||||
export function readElementCropInsets(element: HTMLElement): ClipPathInsetSides & {
|
||||
radius: number;
|
||||
} {
|
||||
const inline = element.style.getPropertyValue("clip-path").trim();
|
||||
const value =
|
||||
inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
|
||||
const parsed = parseInsetClipPathSides(value === "none" ? "" : value);
|
||||
return parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
|
||||
}
|
||||
|
||||
export interface CropInsetDragInput {
|
||||
edge: CropEdge;
|
||||
startInsets: ClipPathInsetSides;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function clampInset(value: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(Math.max(0, value), Math.max(0, max));
|
||||
}
|
||||
|
||||
export function resolveCropInsetFromEdgeDrag(input: CropInsetDragInput): ClipPathInsetSides {
|
||||
const scaleX = input.scaleX > 0 ? input.scaleX : 1;
|
||||
const scaleY = input.scaleY > 0 ? input.scaleY : 1;
|
||||
const next = { ...input.startInsets };
|
||||
|
||||
if (input.edge === "left") {
|
||||
next.left = clampInset(
|
||||
input.startInsets.left + input.deltaX / scaleX,
|
||||
input.width - next.right,
|
||||
);
|
||||
} else if (input.edge === "right") {
|
||||
next.right = clampInset(
|
||||
input.startInsets.right - input.deltaX / scaleX,
|
||||
input.width - next.left,
|
||||
);
|
||||
} else if (input.edge === "top") {
|
||||
next.top = clampInset(
|
||||
input.startInsets.top + input.deltaY / scaleY,
|
||||
input.height - next.bottom,
|
||||
);
|
||||
} else {
|
||||
next.bottom = clampInset(
|
||||
input.startInsets.bottom - input.deltaY / scaleY,
|
||||
input.height - next.top,
|
||||
);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Drag the whole crop window: both opposing insets shift together, the crop
|
||||
* size stays constant, clamped inside the element bounds. */
|
||||
export function resolveCropInsetFromMoveDrag(input: {
|
||||
startInsets: ClipPathInsetSides;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}): ClipPathInsetSides {
|
||||
const sx = input.scaleX > 0 ? input.scaleX : 1;
|
||||
const sy = input.scaleY > 0 ? input.scaleY : 1;
|
||||
const totalX = input.startInsets.left + input.startInsets.right;
|
||||
const totalY = input.startInsets.top + input.startInsets.bottom;
|
||||
const left = Math.min(Math.max(0, input.startInsets.left + input.deltaX / sx), totalX);
|
||||
const top = Math.min(Math.max(0, input.startInsets.top + input.deltaY / sy), totalY);
|
||||
return { left, right: totalX - left, top, bottom: totalY - top };
|
||||
}
|
||||
|
||||
/** Display-only hug: shrink a projected rect by the element's inset crop.
|
||||
* For rects nothing writes back to (e.g. the hover ring). */
|
||||
export function hugRectForElement(
|
||||
rect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
||||
element: HTMLElement,
|
||||
): CropScreenRect {
|
||||
const insets = readElementCropInsets(element);
|
||||
if (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0) return rect;
|
||||
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type DomEditSelection, findElementForSelection } from "./domEditing";
|
||||
import { isElementVisibleThroughAncestors } from "./domEditingDom";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
|
||||
export interface OverlayRect {
|
||||
left: number;
|
||||
@@ -84,6 +85,19 @@ export function resolveDomEditCoordinateScale(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/** toOverlayRect, then shrunk to the element's visible (inset-cropped) region.
|
||||
* For consumers that reason about what's ON SCREEN — snap targets, marquee
|
||||
* hit-tests, display outlines. The selection box must keep the full rect
|
||||
* (it is the gesture coordinate basis). */
|
||||
export function toVisibleOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
): OverlayRect | null {
|
||||
const rect = toOverlayRect(overlayEl, iframe, element);
|
||||
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
|
||||
}
|
||||
|
||||
export function toOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
export interface DomEditSelectionShapeStyles {
|
||||
borderRadius: string | number;
|
||||
clipPath?: string;
|
||||
}
|
||||
|
||||
export function readDomEditSelectionShapeStyles(
|
||||
selection: DomEditSelection | null,
|
||||
): DomEditSelectionShapeStyles {
|
||||
const fallback = {
|
||||
borderRadius: 8,
|
||||
clipPath: undefined,
|
||||
};
|
||||
if (!selection?.element) return fallback;
|
||||
try {
|
||||
const tag = selection.element.tagName.toLowerCase();
|
||||
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
|
||||
const win = selection.element.ownerDocument.defaultView;
|
||||
if (!win) return fallback;
|
||||
const cs = win.getComputedStyle(selection.element);
|
||||
const borderRadius = cs.borderRadius;
|
||||
const clipPath = cs.clipPath;
|
||||
return {
|
||||
borderRadius: borderRadius && borderRadius !== "0px" ? borderRadius : 4,
|
||||
clipPath: clipPath && clipPath !== "none" ? clipPath : undefined,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection-box chrome: none when the crop-outline child draws it, inset
|
||||
* shadow for clip-mirrored shapes, plain border otherwise. */
|
||||
export function resolveBoxChromeClass(hasCropOutline: boolean, boxClipPath?: string): string {
|
||||
if (hasCropOutline) return "";
|
||||
if (boxClipPath) return "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)] bg-studio-accent/5";
|
||||
return "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)] bg-studio-accent/5";
|
||||
}
|
||||
@@ -50,6 +50,9 @@ export const CURATED_STYLE_PROPERTIES = [
|
||||
export interface DomEditCapabilities {
|
||||
canSelect: boolean;
|
||||
canEditStyles: boolean;
|
||||
/** Can take a non-destructive `clip-path: inset()` crop — broader than
|
||||
* canEditStyles (a sub-composition host is croppable from the parent view). */
|
||||
canCrop: boolean;
|
||||
/** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
|
||||
canMove: boolean;
|
||||
/** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
||||
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
||||
import { toOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
|
||||
|
||||
interface MarqueeState {
|
||||
startX: number;
|
||||
@@ -57,7 +58,7 @@ function collectMarqueeHits(
|
||||
const el = item.element;
|
||||
if (!isElementComputedVisible(el)) continue;
|
||||
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
|
||||
const overlayRect = toOverlayRect(overlayEl, iframe, el);
|
||||
const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
|
||||
if (!overlayRect) continue;
|
||||
const r: Rect = {
|
||||
left: overlayRect.left,
|
||||
|
||||
@@ -160,6 +160,16 @@ const BOX_SHADOW_PRESETS = {
|
||||
|
||||
export type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
|
||||
|
||||
export {
|
||||
buildClipPathValue,
|
||||
buildInsetClipPathSides,
|
||||
buildInsetClipPathValue,
|
||||
getClipPathInsetPx,
|
||||
inferClipPathPreset,
|
||||
parseInsetClipPathSides,
|
||||
type ClipPathInsetSides,
|
||||
} from "./clipPathHelpers";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -314,23 +324,6 @@ export function buildBoxShadowPresetValue(
|
||||
return BOX_SHADOW_PRESETS[preset];
|
||||
}
|
||||
|
||||
export function inferClipPathPreset(
|
||||
value: string | undefined,
|
||||
): "none" | "inset" | "circle" | "custom" {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || normalized === "none") return "none";
|
||||
if (/^inset\(/i.test(normalized)) return "inset";
|
||||
if (/^circle\(/i.test(normalized)) return "circle";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function getClipPathInsetPx(value: string | undefined): number {
|
||||
const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
|
||||
if (!match) return 0;
|
||||
const parsed = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
|
||||
export function buildStrokeWidthStyleUpdates(
|
||||
nextWidth: string,
|
||||
currentBorderStyle: string | undefined,
|
||||
@@ -359,23 +352,6 @@ export function buildStrokeStyleUpdates(
|
||||
return updates;
|
||||
}
|
||||
|
||||
export function buildClipPathValue(
|
||||
preset: "none" | "inset" | "circle" | "custom",
|
||||
radiusValue: number,
|
||||
fallback: string | undefined,
|
||||
) {
|
||||
if (preset === "custom") return fallback?.trim() || "none";
|
||||
if (preset === "circle") return "circle(50% at 50% 50%)";
|
||||
if (preset === "inset") {
|
||||
return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
|
||||
return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
|
||||
}
|
||||
|
||||
export function adjustNumericToken(
|
||||
value: string,
|
||||
direction: 1 | -1,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||
import { Eye, Layers, Palette, Scissors, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
buildBoxShadowPresetValue,
|
||||
buildClipPathValue,
|
||||
buildInsetClipPathSides,
|
||||
buildInsetClipPathValue,
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
inferClipPathPreset,
|
||||
LABEL,
|
||||
normalizePanelPxValue,
|
||||
parseInsetClipPathSides,
|
||||
parseNumericValue,
|
||||
parsePxMetricValue,
|
||||
RESPONSIVE_GRID,
|
||||
setCssFilterFunctionPx,
|
||||
type ClipPathInsetSides,
|
||||
type BoxShadowPreset,
|
||||
} from "./propertyPanelHelpers";
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ import { ColorField } from "./propertyPanelColor";
|
||||
import { GradientField, ImageFillField } from "./propertyPanelFill";
|
||||
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StyleSections({
|
||||
projectId,
|
||||
element,
|
||||
@@ -43,6 +47,8 @@ export function StyleSections({
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
gsapBorderRadius,
|
||||
cropMode = false,
|
||||
onCropModeChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
@@ -51,6 +57,8 @@ 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";
|
||||
@@ -86,7 +94,16 @@ export function StyleSections({
|
||||
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
|
||||
const clipPathValue = styles["clip-path"] || "none";
|
||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
||||
const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
||||
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
||||
const clipInsetSides = parsedClipInsets ?? {
|
||||
top: clipInsetValue,
|
||||
right: clipInsetValue,
|
||||
bottom: clipInsetValue,
|
||||
left: clipInsetValue,
|
||||
radius: radiusValue,
|
||||
};
|
||||
const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null;
|
||||
const backgroundImage = styles["background-image"] ?? "none";
|
||||
const hasTextControls = isTextEditableSelection(element);
|
||||
|
||||
@@ -117,6 +134,19 @@ export function StyleSections({
|
||||
}
|
||||
};
|
||||
|
||||
const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => {
|
||||
const next = parsePxMetricValue(nextValue);
|
||||
if (next == null) return;
|
||||
const sides: ClipPathInsetSides = {
|
||||
top: clipInsetSides.top,
|
||||
right: clipInsetSides.right,
|
||||
bottom: clipInsetSides.bottom,
|
||||
left: clipInsetSides.left,
|
||||
};
|
||||
sides[side] = next;
|
||||
onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFlex && (
|
||||
@@ -344,6 +374,50 @@ export function StyleSections({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{showClipInsetSides && (
|
||||
<div className="grid gap-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<MetricField
|
||||
label="T"
|
||||
value={formatPxMetricValue(clipInsetSides.top)}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => commitClipInsetSide("top", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="R"
|
||||
value={formatPxMetricValue(clipInsetSides.right)}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => commitClipInsetSide("right", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="B"
|
||||
value={formatPxMetricValue(clipInsetSides.bottom)}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => commitClipInsetSide("bottom", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="L"
|
||||
value={formatPxMetricValue(clipInsetSides.left)}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => commitClipInsetSide("left", next)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -108,4 +108,6 @@ export interface PropertyPanelProps {
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// fallow-ignore-file unused-file
|
||||
// fallow-ignore-file code-duplication
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
isElementVisibleForOverlay,
|
||||
toOverlayRect,
|
||||
toVisibleOverlayRect,
|
||||
type OverlayRect,
|
||||
} from "./domEditOverlayGeometry";
|
||||
import {
|
||||
@@ -106,7 +105,7 @@ export function collectSnapContext(input: {
|
||||
id: string;
|
||||
}> = [];
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
|
||||
const rect = toVisibleOverlayRect(input.overlayEl, input.iframe, elements[i]);
|
||||
if (rect) entries.push({ rect, id: `snap-target-${i}` });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, type RefObject } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
export interface DomEditCompositionRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}
|
||||
|
||||
function sameRect(a: DomEditCompositionRect, b: DomEditCompositionRect): boolean {
|
||||
const d = (k: keyof DomEditCompositionRect) => Math.abs(a[k] - b[k]);
|
||||
return (
|
||||
d("left") < 0.5 &&
|
||||
d("top") < 0.5 &&
|
||||
d("width") < 0.5 &&
|
||||
d("height") < 0.5 &&
|
||||
d("scaleX") < 0.001 &&
|
||||
d("scaleY") < 0.001
|
||||
);
|
||||
}
|
||||
|
||||
export function useDomEditCompositionRect({
|
||||
iframeRef,
|
||||
overlayRef,
|
||||
}: {
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
}): DomEditCompositionRect {
|
||||
const [compRect, setCompRect] = useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
useMountEffect(() => {
|
||||
let frame = 0;
|
||||
// fallow-ignore-next-line complexity
|
||||
const update = () => {
|
||||
frame = requestAnimationFrame(update);
|
||||
const iframe = iframeRef.current;
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!iframe || !overlayEl) return;
|
||||
const iRect = iframe.getBoundingClientRect();
|
||||
const oRect = overlayEl.getBoundingClientRect();
|
||||
const left = iRect.left - oRect.left;
|
||||
const top = iRect.top - oRect.top;
|
||||
if (iRect.width <= 0 || iRect.height <= 0) return;
|
||||
const doc = iframe.contentDocument;
|
||||
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
||||
const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
||||
const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
||||
const scaleX = dw > 0 ? iRect.width / dw : 1;
|
||||
const scaleY = dh > 0 ? iRect.height / dh : 1;
|
||||
const next = { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
|
||||
setCompRect((prev) => (sameRect(prev, next) ? prev : next));
|
||||
};
|
||||
frame = requestAnimationFrame(update);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
});
|
||||
|
||||
return compRect;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
startGesture as _startGesture,
|
||||
startGroupDrag as _startGroupDrag,
|
||||
} from "./domEditOverlayStartGesture";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import {
|
||||
resolveSnapAdjustment,
|
||||
resolveResizeSnapAdjustment,
|
||||
@@ -183,12 +184,18 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
if (g.kind === "drag") {
|
||||
const sc = g.snapContext;
|
||||
if (sc?.snapEnabled && sc.targets.length > 0) {
|
||||
const movingRect = {
|
||||
left: g.originLeft,
|
||||
top: g.originTop,
|
||||
width: g.originWidth,
|
||||
height: g.originHeight,
|
||||
};
|
||||
// Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
|
||||
const movingRect = hugRectForElement(
|
||||
{
|
||||
left: g.originLeft,
|
||||
top: g.originTop,
|
||||
width: g.originWidth,
|
||||
height: g.originHeight,
|
||||
editScaleX: g.editScaleX,
|
||||
editScaleY: g.editScaleY,
|
||||
},
|
||||
g.selection.element,
|
||||
);
|
||||
const allTargets = sc.compositionTarget
|
||||
? [...sc.targets, sc.compositionTarget]
|
||||
: sc.targets;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import { type DomEditSelection, findElementForSelection } from "./domEditing";
|
||||
import {
|
||||
type GroupOverlayItem,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
rectsEqual,
|
||||
resolveElementForOverlay,
|
||||
selectionCacheKey,
|
||||
toOverlayRect,
|
||||
toVisibleOverlayRect,
|
||||
} from "./domEditOverlayGeometry";
|
||||
|
||||
function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
|
||||
@@ -164,7 +165,7 @@ export function useDomEditOverlayRects({
|
||||
for (let i = 0; i < descendants.length; i++) {
|
||||
const child = descendants[i] as HTMLElement;
|
||||
if (!child.getBoundingClientRect) continue;
|
||||
const r = toOverlayRect(overlayEl, iframe, child);
|
||||
const r = toVisibleOverlayRect(overlayEl, iframe, child);
|
||||
if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
|
||||
}
|
||||
if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
|
||||
@@ -203,7 +204,8 @@ export function useDomEditOverlayRects({
|
||||
if (liveGroupKeys.has(key)) continue;
|
||||
liveGroupKeys.add(key);
|
||||
const el = resolveGroupElement(doc, groupSelection);
|
||||
const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
|
||||
const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
|
||||
const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base;
|
||||
if (el && rect)
|
||||
nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user