feat(studio): non-destructive crop + cross-project asset view

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-06 23:40:32 -04:00
parent 8c3590a90e
commit 676f461f6a
39 changed files with 1646 additions and 208 deletions
@@ -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 });
}