mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): non-destructive crop + cross-project asset view
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
import type { DomEditSelection } from "./components/editor/domEditing";
|
||||
import { StudioHeader } from "./components/StudioHeader";
|
||||
import { useGestureCommit } from "./hooks/useGestureCommit";
|
||||
import { useCropModeProps } from "./hooks/useCropMode";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
|
||||
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
||||
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
||||
@@ -82,6 +83,7 @@ export function StudioApp() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
||||
const cropModeProps = useCropModeProps();
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
activeCompPathRef.current = activeCompPath;
|
||||
@@ -369,6 +371,7 @@ export function StudioApp() {
|
||||
isGestureRecordingRef,
|
||||
});
|
||||
handleToggleRecordingRef.current = handleToggleRecording;
|
||||
const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
|
||||
const canvasRectRef = useRef<CanvasRect | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (gestureState !== "recording" || !previewIframe) {
|
||||
@@ -537,10 +540,9 @@ export function StudioApp() {
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
isGestureRecording={gestureState === "recording"}
|
||||
recordingState={gestureState}
|
||||
onToggleRecording={
|
||||
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
||||
}
|
||||
onToggleRecording={recordingToggle}
|
||||
blockPreview={blockPreview}
|
||||
{...cropModeProps}
|
||||
gestureOverlay={
|
||||
gestureState === "recording" && previewIframe ? (
|
||||
<GestureTrailOverlay
|
||||
@@ -564,13 +566,12 @@ export function StudioApp() {
|
||||
}}
|
||||
recordingState={gestureState}
|
||||
recordingDuration={gestureRecording.recordingDuration}
|
||||
onToggleRecording={
|
||||
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
||||
}
|
||||
onToggleRecording={recordingToggle}
|
||||
sdkSession={sdkHandle.session}
|
||||
reloadPreview={reloadPreview}
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
recordEdit={editHistory.recordEdit}
|
||||
{...cropModeProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface StudioPreviewAreaProps {
|
||||
isGestureRecording?: boolean;
|
||||
recordingState?: GestureRecordingState;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
gestureOverlay?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -93,6 +95,8 @@ export function StudioPreviewArea({
|
||||
isGestureRecording,
|
||||
recordingState,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
blockPreview,
|
||||
gestureOverlay,
|
||||
}: StudioPreviewAreaProps) {
|
||||
@@ -132,6 +136,7 @@ export function StudioPreviewArea({
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
handleDomStyleCommit,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapMoveKeyframeToPlayhead,
|
||||
handleGsapMoveKeyframe,
|
||||
@@ -375,6 +380,9 @@ export function StudioPreviewArea({
|
||||
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
||||
onBoxSizeCommit={handleDomBoxSizeCommit}
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
onStyleCommit={handleDomStyleCommit}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
gridVisible={snapPrefs.gridVisible}
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
recordingState={recordingState}
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface StudioRightPanelProps {
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
cropMode?: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
||||
sdkSession: Composition | null;
|
||||
reloadPreview: () => void;
|
||||
@@ -70,6 +72,8 @@ export function StudioRightPanel({
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
cropMode,
|
||||
onCropModeChange,
|
||||
sdkSession,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -393,6 +397,8 @@ export function StudioRightPanel({
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
cropMode={cropMode}
|
||||
onCropModeChange={onCropModeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab";
|
||||
import { globalAssetRows } from "./GlobalAssetsView";
|
||||
|
||||
const assets = ["bgm.mp3", "logo.png", "orphan.wav"];
|
||||
const used = new Set(["bgm.mp3", "logo.png"]);
|
||||
|
||||
describe("filterByUsage", () => {
|
||||
it("returns everything for 'all'", () => {
|
||||
expect(filterByUsage(assets, used, "all")).toEqual(assets);
|
||||
});
|
||||
|
||||
it("keeps only referenced assets for 'used'", () => {
|
||||
expect(filterByUsage(assets, used, "used")).toEqual(["bgm.mp3", "logo.png"]);
|
||||
});
|
||||
|
||||
it("keeps only unreferenced assets for 'unused'", () => {
|
||||
expect(filterByUsage(assets, used, "unused")).toEqual(["orphan.wav"]);
|
||||
});
|
||||
|
||||
it("treats everything as unused when nothing is referenced", () => {
|
||||
expect(filterByUsage(assets, new Set(), "used")).toEqual([]);
|
||||
expect(filterByUsage(assets, new Set(), "unused")).toEqual(assets);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveUsedPaths", () => {
|
||||
it("matches the asset-list format across every src shape", () => {
|
||||
const used = deriveUsedPaths([
|
||||
{ src: "assets/logo.png" }, // raw authored relative path
|
||||
{ src: "/api/projects/demo/preview/assets/bgm.mp3" }, // served form
|
||||
{ src: "./assets/icon.svg" }, // ./-prefixed
|
||||
{ src: "assets/clip.mp4?v=2" }, // cache-busted
|
||||
{}, // no src — skipped
|
||||
]);
|
||||
expect(used.has("assets/logo.png")).toBe(true);
|
||||
expect(used.has("assets/bgm.mp3")).toBe(true);
|
||||
expect(used.has("assets/icon.svg")).toBe(true);
|
||||
expect(used.has("assets/clip.mp4")).toBe(true);
|
||||
expect(used.size).toBe(4);
|
||||
});
|
||||
|
||||
it("an authored relative src lines up with the asset entry (the live bug class)", () => {
|
||||
const used = deriveUsedPaths([{ src: "assets/logo.png" }]);
|
||||
// asset-list entries are project-relative (see serveUrl = preview/${asset})
|
||||
expect(filterByUsage(["assets/logo.png", "assets/orphan.wav"], used, "used")).toEqual([
|
||||
"assets/logo.png",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countUsage", () => {
|
||||
it("counts used vs unused", () => {
|
||||
expect(countUsage(assets, used)).toEqual({ used: 2, unused: 1 });
|
||||
});
|
||||
|
||||
it("is all-unused with an empty used set", () => {
|
||||
expect(countUsage(assets, new Set())).toEqual({ used: 0, unused: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalAssetRows", () => {
|
||||
const recs = [
|
||||
{ id: "bgm_001", type: "bgm", description: "calm ambient" },
|
||||
{ id: "img_001", type: "image", entity: "Acme" },
|
||||
{ sha: "abc", type: "sfx" },
|
||||
];
|
||||
|
||||
it("maps records to display rows with a sensible label", () => {
|
||||
const rows = globalAssetRows(recs);
|
||||
expect(rows).toEqual([
|
||||
{ id: "bgm_001", type: "bgm", label: "calm ambient" },
|
||||
{ id: "img_001", type: "image", label: "Acme" },
|
||||
{ id: "abc", type: "sfx", label: "abc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by id / type / description / entity, case-insensitively", () => {
|
||||
expect(globalAssetRows(recs, "ACME").map((r) => r.id)).toEqual(["img_001"]);
|
||||
expect(globalAssetRows(recs, "bgm").map((r) => r.id)).toEqual(["bgm_001"]);
|
||||
expect(globalAssetRows(recs, "ambient").map((r) => r.id)).toEqual(["bgm_001"]);
|
||||
});
|
||||
|
||||
it("empty query returns all", () => {
|
||||
expect(globalAssetRows(recs, " ").length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
FILTER_ORDER,
|
||||
} from "./assetHelpers";
|
||||
import { AudioRow } from "./AudioRow";
|
||||
import { GlobalAssetsView } from "./GlobalAssetsView";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -172,6 +174,51 @@ function ImageCard({
|
||||
);
|
||||
}
|
||||
|
||||
export type UsageFilter = "all" | "used" | "unused";
|
||||
|
||||
/** Filter assets by whether the composition references them. Pure — unit-tested. */
|
||||
export function filterByUsage(
|
||||
assets: string[],
|
||||
usedPaths: Set<string>,
|
||||
usageFilter: UsageFilter,
|
||||
): string[] {
|
||||
if (usageFilter === "used") return assets.filter((a) => usedPaths.has(a));
|
||||
if (usageFilter === "unused") return assets.filter((a) => !usedPaths.has(a));
|
||||
return assets;
|
||||
}
|
||||
|
||||
/** Count used vs unused over a media set. Pure — unit-tested. */
|
||||
export function countUsage(
|
||||
assets: string[],
|
||||
usedPaths: Set<string>,
|
||||
): { used: number; unused: number } {
|
||||
let used = 0;
|
||||
for (const a of assets) if (usedPaths.has(a)) used++;
|
||||
return { used, unused: assets.length - used };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-relative asset paths referenced by composition elements — the set the
|
||||
* "in use" badge, used-first sort, and usage filter all key on. Element src is
|
||||
* the raw authored value (timelineElementHelpers sets entry.src =
|
||||
* getAttribute("src")), so it can be a relative path ("assets/x.png"), a
|
||||
* "./"-prefixed path, the served "/api/projects/<id>/preview/assets/x.png" form,
|
||||
* or carry a ?query — normalize all of them to the bare project path so they
|
||||
* match the asset-list entries. Pure — unit-tested.
|
||||
*/
|
||||
export function deriveUsedPaths(elements: Array<{ src?: string }>): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const el of elements) {
|
||||
if (!el.src) continue;
|
||||
const s = el.src
|
||||
.replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix
|
||||
.replace(/^\.?\//, "") // strip leading ./ or /
|
||||
.split(/[?#]/)[0]; // drop query / hash
|
||||
if (s) paths.add(s);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export const AssetsTab = memo(function AssetsTab({
|
||||
projectId,
|
||||
assets,
|
||||
@@ -183,7 +230,11 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
||||
const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Cross-project view: the global media-use cache (~/.media). The view itself
|
||||
// (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active.
|
||||
const [viewMode, setViewMode] = useState<"local" | "global">("local");
|
||||
const [manifest, setManifest] = useState<
|
||||
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
||||
>(new Map());
|
||||
@@ -246,19 +297,11 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
}, []);
|
||||
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const usedPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
for (const el of elements) {
|
||||
if (el.src) {
|
||||
const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, "");
|
||||
paths.add(src);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}, [elements]);
|
||||
const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]);
|
||||
|
||||
const mediaAssets = useMemo(() => {
|
||||
const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
|
||||
const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
|
||||
const all = filterByUsage(media, usedPaths, usageFilter);
|
||||
if (!searchQuery) return all;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return all.filter((a) => {
|
||||
@@ -266,7 +309,7 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
const rec = manifest.get(a);
|
||||
return rec?.description?.toLowerCase().includes(q);
|
||||
});
|
||||
}, [assets, searchQuery, manifest]);
|
||||
}, [assets, searchQuery, manifest, usageFilter, usedPaths]);
|
||||
|
||||
const categorized = useMemo(() => {
|
||||
const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
|
||||
@@ -291,6 +334,17 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
return c;
|
||||
}, [mediaAssets, categorized]);
|
||||
|
||||
// Usage counts over the full media set (independent of the active usage filter,
|
||||
// so the chips don't show their own filtered totals).
|
||||
const usageCounts = useMemo(
|
||||
() =>
|
||||
countUsage(
|
||||
assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)),
|
||||
usedPaths,
|
||||
),
|
||||
[assets, usedPaths],
|
||||
);
|
||||
|
||||
const visibleCategories =
|
||||
activeFilter === "all"
|
||||
? FILTER_ORDER.filter((c) => categorized[c].length > 0)
|
||||
@@ -308,6 +362,22 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
>
|
||||
{/* Header — matches design panel Section pattern */}
|
||||
<div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
|
||||
{/* Scope toggle — this project's assets vs the global media-use cache */}
|
||||
<div className="flex gap-1 mb-2.5 p-0.5 rounded-md bg-panel-input">
|
||||
{(["local", "global"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setViewMode(m)}
|
||||
className={`flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors ${
|
||||
viewMode === m
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "text-panel-text-3 hover:text-panel-text-1"
|
||||
}`}
|
||||
>
|
||||
{m === "local" ? "This project" : "All projects"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Import */}
|
||||
{onImport && (
|
||||
<>
|
||||
@@ -377,8 +447,8 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter chips — panel-input style */}
|
||||
{mediaAssets.length > 0 && (
|
||||
{/* Filter chips — panel-input style (local view only) */}
|
||||
{viewMode === "local" && mediaAssets.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveFilter("all")}
|
||||
@@ -405,13 +475,41 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
{/* Usage filter — show only assets the composition references, or only the unused ones */}
|
||||
{usageCounts.used > 0 && usageCounts.unused > 0 && (
|
||||
<>
|
||||
<span className="w-px self-stretch bg-panel-input mx-0.5" aria-hidden="true" />
|
||||
<button
|
||||
onClick={() => setUsageFilter(usageFilter === "used" ? "all" : "used")}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
||||
usageFilter === "used"
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
||||
}`}
|
||||
>
|
||||
In use {usageCounts.used}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsageFilter(usageFilter === "unused" ? "all" : "unused")}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
||||
usageFilter === "unused"
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
||||
}`}
|
||||
>
|
||||
Unused {usageCounts.unused}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Asset list */}
|
||||
<div className="flex-1 overflow-y-auto mt-1">
|
||||
{mediaAssets.length === 0 ? (
|
||||
{viewMode === "global" ? (
|
||||
<GlobalAssetsView searchQuery={searchQuery} />
|
||||
) : mediaAssets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
||||
<svg
|
||||
width="24"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBlockCatalog } from "../../hooks/useBlockCatalog";
|
||||
@@ -234,7 +235,7 @@ function buildAgentPrompt(
|
||||
captions: [
|
||||
`Using /hyperframes, add the "${title}" caption style (registry: ${name}) to my composition.`,
|
||||
`${description}`,
|
||||
`Transcribe the audio with /hyperframes-media, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
|
||||
`Transcribe the audio with /media-use, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
|
||||
].join("\n\n"),
|
||||
vfx: [
|
||||
`Using /hyperframes, add the "${title}" VFX (registry: ${name}) as a full-screen overlay on my composition.`,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
// Cross-project asset view — the global media-use cache (~/.media), fetched from
|
||||
// /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
|
||||
// focused on the local view.
|
||||
|
||||
export interface GlobalAssetRecord {
|
||||
id?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
entity?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface GlobalAssetRow {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize global records into display rows, filtered by an optional query
|
||||
* (id / type / description / entity). Pure — unit-tested.
|
||||
*/
|
||||
export function globalAssetRows(records: GlobalAssetRecord[], query = ""): GlobalAssetRow[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return records
|
||||
.filter((r) =>
|
||||
!q
|
||||
? true
|
||||
: [r.id, r.type, r.description, r.entity].some(
|
||||
(f) => f && String(f).toLowerCase().includes(q),
|
||||
),
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id ?? r.sha ?? "asset",
|
||||
type: r.type ?? "asset",
|
||||
label: r.description || r.entity || r.id || r.sha || "asset",
|
||||
}));
|
||||
}
|
||||
|
||||
export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
|
||||
const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/assets/global")
|
||||
.then((r) => (r.ok ? r.json() : { assets: [] }))
|
||||
.then((d) => {
|
||||
if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRecords([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => globalAssetRows(records ?? [], searchQuery), [records, searchQuery]);
|
||||
|
||||
if (records === null) {
|
||||
return <p className="px-4 py-3 text-[11px] text-panel-text-5">Loading global assets…</p>;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="px-4 py-3 text-[11px] text-panel-text-5">
|
||||
No assets in the global cache yet. Resolved media is promoted to <code>~/.media</code> and
|
||||
becomes reusable across projects.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="px-4 py-2 border-t border-panel-border text-[11px] text-panel-text-5">
|
||||
{rows.length} reusable across all projects
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="px-4 py-1.5 flex items-center gap-2.5 border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
title={`${row.id} · ${row.type}`}
|
||||
>
|
||||
<span className="text-[9px] font-medium text-neutral-600 uppercase w-10 flex-shrink-0">
|
||||
{row.type}
|
||||
</span>
|
||||
<span className="text-xs text-panel-text-1 truncate">{row.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useReducer } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
export interface CropModeProps {
|
||||
cropMode: boolean;
|
||||
onCropModeChange: (active: boolean) => void;
|
||||
}
|
||||
|
||||
/** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
|
||||
* and the overlay all share one switch without prop threading. */
|
||||
export function useCropModeProps(): CropModeProps {
|
||||
const cropMode = usePlayerStore((s) => s.cropMode);
|
||||
const setCropMode = usePlayerStore((s) => s.setCropMode);
|
||||
return useMemo(
|
||||
() => ({
|
||||
cropMode,
|
||||
onCropModeChange: setCropMode,
|
||||
}),
|
||||
[cropMode, setCropMode],
|
||||
);
|
||||
}
|
||||
|
||||
import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
|
||||
|
||||
/** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
|
||||
* and the box clip that makes the selection outline hug the cropped region.
|
||||
* The box div itself always sits at the FULL element bounds — gestures write
|
||||
* its position directly during drags, so moving/resizing it in React would
|
||||
* fight them. The hug is purely visual: the element's inset clip-path scaled
|
||||
* into overlay space and applied to the box. */
|
||||
export function useCropOverlay(params: {
|
||||
selection: DomEditSelection | null;
|
||||
groupCount: number;
|
||||
cropMode: boolean;
|
||||
onCropModeChange?: (active: boolean) => void;
|
||||
overlayRect: OverlayRect | null;
|
||||
}) {
|
||||
const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropMode || !onCropModeChange) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onCropModeChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [cropMode, onCropModeChange]);
|
||||
|
||||
// Publish availability so the canvas toolbar shows the Crop button only
|
||||
// when the selection can take a clip-path crop.
|
||||
const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
|
||||
const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
|
||||
useEffect(() => {
|
||||
setCropAvailable(cropAvailable);
|
||||
return () => setCropAvailable(false);
|
||||
}, [cropAvailable, setCropAvailable]);
|
||||
|
||||
// Crop-mode exit restores the element's clip in an effect cleanup — after
|
||||
// this hook already read it. One forced re-render picks up the fresh insets
|
||||
// so the selection box hugs the crop immediately.
|
||||
const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
|
||||
useEffect(() => {
|
||||
if (!cropMode) bumpAfterExit();
|
||||
}, [cropMode]);
|
||||
|
||||
const cropInsets = selection ? readElementCropInsets(selection.element) : null;
|
||||
const hasCropInsets = Boolean(
|
||||
cropInsets &&
|
||||
(cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
|
||||
);
|
||||
|
||||
// Scaled insets for the crop outline child + the resize-handle shift. The
|
||||
// box div itself stays border-less at full bounds; a child draws the
|
||||
// outline ON the crop boundary (a clip on the box would swallow the
|
||||
// border everywhere the crop edge doesn't touch the element edge).
|
||||
const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
||||
const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
||||
const cropOutlineInsetPx =
|
||||
cropInsets && hasCropInsets && !cropMode
|
||||
? {
|
||||
top: cropInsets.top * sy,
|
||||
right: cropInsets.right * sx,
|
||||
bottom: cropInsets.bottom * sy,
|
||||
left: cropInsets.left * sx,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { hasCropInsets, cropOutlineInsetPx };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
// @vitest-environment happy-dom
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
@@ -183,6 +184,7 @@ function createSelection(
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
|
||||
@@ -60,6 +60,15 @@ const SHORTCUT_SECTIONS = [
|
||||
{ key: "⇧ Drag", label: "Uniform resize" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Crop",
|
||||
hints: [
|
||||
{ key: "DblClick", label: "Crop selected element" },
|
||||
{ key: "Drag edge", label: "Adjust crop side" },
|
||||
{ key: "Drag frame", label: "Move crop window" },
|
||||
{ key: "Esc", label: "Exit crop (or click outside)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Panels",
|
||||
hints: [
|
||||
|
||||
@@ -111,6 +111,15 @@ interface PlayerState {
|
||||
autoKeyframeEnabled: boolean;
|
||||
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
||||
|
||||
/** Crop mode. Armed from the preview toolbar, the Clip panel, or a
|
||||
* double-click on a croppable selection; while armed, edge handles on the
|
||||
* selection adjust a non-destructive clip-path inset. `available` is
|
||||
* published by DomEditOverlay when the selection can be cropped. */
|
||||
cropMode: boolean;
|
||||
setCropMode: (active: boolean) => void;
|
||||
cropAvailable: boolean;
|
||||
setCropAvailable: (available: boolean) => void;
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
toggleSelectedElementId: (id: string) => void;
|
||||
@@ -246,6 +255,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
||||
autoKeyframeEnabled: true,
|
||||
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
||||
cropMode: false,
|
||||
setCropMode: (active) => set({ cropMode: active }),
|
||||
cropAvailable: false,
|
||||
setCropAvailable: (available) =>
|
||||
set(available ? { cropAvailable: true } : { cropAvailable: false, cropMode: false }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
|
||||
Reference in New Issue
Block a user