Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/sidebar/AssetCard.tsx",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/EditorShell.tsx",
"packages/studio/src/components/nle/TimelinePane.tsx",
"packages/studio/src/components/nle/useTimelineEditCallbacks.ts",
Expand Down Expand Up @@ -456,6 +459,12 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
// studio-dnd pr22 removes this with the final config.
"packages/studio/src/hooks/useDomEditWiring.ts",
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
// studio-dnd pr22 removes this with the final config.
"packages/studio/src/hooks/useGsapSelectionHandlers.ts",
// TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
// studio-dnd pr22 removes this with the final config.
"packages/studio/src/components/StudioPreviewArea.tsx",
Expand Down Expand Up @@ -659,6 +668,9 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/components/sidebar/AssetCard.tsx",
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/components/editor/DomEditSelectionChrome.tsx",
Expand Down
78 changes: 55 additions & 23 deletions packages/studio/src/components/editor/DomEditCropHandles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,40 +31,53 @@ interface DomEditCropHandlesProps {
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
}

// Gap (px) between an edge handle and the element edge, so the handle sits
// clear of the element body and can't intercept a move-drag.
const EDGE_HANDLE_GAP = 8;
// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
// the crop edge (flush against it, never over the element body, so a body
// drag always MOVES), LENGTH runs along the edge. The visible pill is smaller
// and centered inside the strip.
const EDGE_HIT_THICKNESS = 12;
const EDGE_HIT_LENGTH = 32;

/** Place an edge handle just OUTSIDE the given crop edge (translate pushes it
* fully past the boundary). Keeps the element body free for moving. */
/** Place an edge handle's hit strip just OUTSIDE the given crop edge
* (translate pushes it fully past the boundary). Keeps the element body free
* for moving. Corners stay free for the selection's own resize handles. */
function edgeHandlePlacement(
edge: CropEdge,
rect: { left: number; top: number; width: number; height: number },
) {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
if (edge === "top") {
return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" };
return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" };
}
if (edge === "bottom") {
return {
left: cx,
top: rect.top + rect.height + EDGE_HANDLE_GAP,
transform: "translate(-50%, 0)",
};
return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" };
}
if (edge === "left") {
return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" };
return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" };
}
return {
left: rect.left + rect.width + EDGE_HANDLE_GAP,
top: cy,
transform: "translate(0, -50%)",
};
return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" };
}

const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];

/** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */
function edgeHandleMetrics(vertical: boolean): {
hitWidth: number;
hitHeight: number;
cursor: string;
pillWidth: number;
pillHeight: number;
} {
return {
hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH,
hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS,
cursor: vertical ? "ew-resize" : "ns-resize",
pillWidth: vertical ? 4 : 24,
pillHeight: vertical ? 24 : 4,
};
}

/**
* Always-on crop, integrated with the selection (no crop "mode"): while a
* croppable element is selected its clip is lifted so the FULL content shows and
Expand All @@ -83,6 +96,7 @@ export function DomEditCropHandles({
}: DomEditCropHandlesProps) {
const gestureRef = useRef<CropGestureState | null>(null);
const [dragging, setDragging] = useState(false);
const [hotEdge, setHotEdge] = useState<CropEdge | null>(null);
// readElementCropInsets returns null for a clip this tool can't represent
// (circle/polygon/non-px inset): the crop UI must fully stand down for that
// element — no lift, no handles — or select+deselect replaces the authored
Expand Down Expand Up @@ -304,6 +318,7 @@ export function DomEditCropHandles({
<button
type="button"
aria-label="Reposition crop"
title="Reposition crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style={{
Expand All @@ -323,31 +338,48 @@ export function DomEditCropHandles({
)}
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
edge (via edgeHandlePlacement) so they never overlap the element body:
dragging the body always MOVES, only a handle crops. */}
dragging the body always MOVES, only a handle crops. The pill is
hover-revealed (or shown while dragging / once a crop exists) so the
resting selection chrome stays uncluttered; the hit strip is always
live, and the title names the affordance. */}
{EDGES.map((edge) => {
const vertical = edge === "left" || edge === "right";
const place = edgeHandlePlacement(edge, cropRect);
const revealed = dragging || hasCrop || hotEdge === edge;
const m = edgeHandleMetrics(vertical);
return (
<button
key={edge}
type="button"
aria-label={`Crop ${edge}`}
title="Crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute rounded-full bg-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
style={{
left: place.left,
top: place.top,
width: vertical ? 5 : 26,
height: vertical ? 26 : 5,
width: m.hitWidth,
height: m.hitHeight,
transform: place.transform,
cursor: vertical ? "ew-resize" : "ns-resize",
cursor: m.cursor,
touchAction: "none",
}}
onPointerEnter={() => setHotEdge(edge)}
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
onPointerDown={(event) => startCropGesture(edge, event)}
onPointerMove={updateCropGesture}
onPointerUp={finishCropGesture}
onPointerCancel={cancelCropGesture}
/>
>
<span
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
style={{
width: m.pillWidth,
height: m.pillHeight,
opacity: revealed ? 1 : 0,
}}
/>
</button>
);
})}
</div>
Expand Down
Loading
Loading