mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] canvas: a resize ends when the hand lets go, webview or not (ENG-377)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp
This commit is contained in:
co-authored by
Claude Fable 5
parent
8e0d10f49b
commit
0ccc3af040
@@ -61,7 +61,7 @@ import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canv
|
||||
import { setCardSidecar } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { friendlyStatusLabel } from '@/shared/statusLabel';
|
||||
import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles';
|
||||
import { useCanvasWindowResize } from './useCanvasWindowResize';
|
||||
|
||||
/** Extract up to 3 substantive user-prompt steps to seed a workflow. */
|
||||
function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean {
|
||||
@@ -557,93 +557,16 @@ const AgentCard: React.FC<Props> = ({
|
||||
}, [finalizeDrag]);
|
||||
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
origW: number;
|
||||
origH: number;
|
||||
} | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const handleResizeDown = useCallback(
|
||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let effectiveX = cardX;
|
||||
let effectiveY = cardY;
|
||||
let effectiveW = Math.max(cardWidth, MIN_W);
|
||||
let effectiveH = expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight;
|
||||
const popped = tiling.untileForResize();
|
||||
if (popped) {
|
||||
effectiveX = popped.x; effectiveY = popped.y; effectiveW = popped.w; effectiveH = popped.h;
|
||||
setLocalResize({ x: effectiveX, y: effectiveY, w: effectiveW, h: effectiveH });
|
||||
}
|
||||
resizeRef.current = {
|
||||
dir,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: effectiveX,
|
||||
origY: effectiveY,
|
||||
origW: effectiveW,
|
||||
origH: effectiveH,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cardX, cardY, cardWidth, cardHeight, expanded, tiling],
|
||||
);
|
||||
|
||||
const computeResize = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const z = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / z;
|
||||
const dy = (e.clientY - startY) / z;
|
||||
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
|
||||
if (dir.includes('e')) newW = origW + dx;
|
||||
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
|
||||
if (dir.includes('s')) newH = origH + dy;
|
||||
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
|
||||
|
||||
// An enlarged card can't be shrunk below its content-showing height, else the user resizes the
|
||||
// chat down until the transcript vanishes (which felt broken). Collapsed cards keep the tiny floor.
|
||||
const minH = expanded ? EXPANDED_OVERLAY_H : MIN_H;
|
||||
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
|
||||
if (newH < minH) { if (dir.includes('n')) newY = origY + origH - minH; newH = minH; }
|
||||
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[getCanvasState, expanded],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const result = computeResize(e);
|
||||
if (result) setLocalResize(result);
|
||||
},
|
||||
[computeResize],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setCardPosition({ sessionId: session.id, x: result.x, y: result.y }));
|
||||
dispatch(setCardSize({ sessionId: session.id, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, session.id]);
|
||||
const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setCardPosition({ sessionId: session.id, x, y })); }, [dispatch, session.id]);
|
||||
const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setCardSize({ sessionId: session.id, width: w, height: h })); }, [dispatch, session.id]);
|
||||
// An enlarged card can't be shrunk below its content-showing height, else the user resizes the chat down until the transcript vanishes. Collapsed cards keep the tiny floor.
|
||||
const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({
|
||||
cardX, cardY,
|
||||
cardWidth: Math.max(cardWidth, MIN_W),
|
||||
cardHeight: expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : cardHeight,
|
||||
minWidth: MIN_W, minHeight: expanded ? EXPANDED_OVERLAY_H : MIN_H,
|
||||
getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize,
|
||||
});
|
||||
|
||||
const handleRemove = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
@@ -1003,17 +926,13 @@ const AgentCard: React.FC<Props> = ({
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{!pillMode && RESIZE_HANDLE_DEFS.map(({ dir, css }) => (
|
||||
{!pillMode && resizeHandles.map(({ dir, style, ...handlers }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeUp}
|
||||
{...handlers}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
...css,
|
||||
cursor: RESIZE_CURSOR[dir],
|
||||
...style,
|
||||
zIndex: 20,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
|
||||
@@ -89,7 +89,7 @@ const CHROME_TEXT = '#3c3744';
|
||||
const CHROME_TEXT_MUTED = '#8a8494';
|
||||
|
||||
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
|
||||
import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles';
|
||||
import { useCanvasWindowResize } from './useCanvasWindowResize';
|
||||
|
||||
|
||||
// Pill-preview capture cadence: fast until the card has handed the pill a frame, slow upkeep after.
|
||||
@@ -1041,69 +1041,12 @@ const BrowserCard: React.FC<Props> = ({
|
||||
};
|
||||
}, [isDragging, finalizeDrag, abortDrag]);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
origX: number; origY: number; origW: number; origH: number;
|
||||
} | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const handleResizeDown = useCallback(
|
||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const popped = tiling.untileForResize();
|
||||
if (popped) setLocalResize(popped);
|
||||
resizeRef.current = {
|
||||
dir, startX: e.clientX, startY: e.clientY,
|
||||
origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cardX, cardY, cardWidth, cardHeight, tiling],
|
||||
);
|
||||
|
||||
const computeResize = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
if (dir.includes('e')) newW = origW + dx;
|
||||
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
|
||||
if (dir.includes('s')) newH = origH + dy;
|
||||
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
|
||||
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const result = computeResize(e);
|
||||
if (result) setLocalResize(result);
|
||||
},
|
||||
[computeResize],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y }));
|
||||
dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, browserId]);
|
||||
const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setBrowserCardPosition({ browserId, x, y })); }, [dispatch, browserId]);
|
||||
const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setBrowserCardSize({ browserId, width: w, height: h })); }, [dispatch, browserId]);
|
||||
const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({
|
||||
cardX, cardY, cardWidth, cardHeight, minWidth: MIN_W, minHeight: MIN_H,
|
||||
getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize,
|
||||
});
|
||||
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
@@ -2000,20 +1943,12 @@ const BrowserCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
|
||||
{/* Resize handles; a docked mini's size follows the slot, so grabbing an edge used to pop it out of the chat mid-gesture. */}
|
||||
{!dockActive && RESIZE_HANDLE_DEFS.map(({ dir, css }) => (
|
||||
{!dockActive && resizeHandles.map(({ dir, style, ...handlers }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
className="resize-handle"
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeUp}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
cursor: RESIZE_CURSOR[dir],
|
||||
opacity: 0,
|
||||
zIndex: 20,
|
||||
...css,
|
||||
}}
|
||||
{...handlers}
|
||||
sx={{ ...style, opacity: 0, zIndex: 20 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
RuntimeLogLine,
|
||||
} from '@/shared/hooks/useRuntimePreviewUrl';
|
||||
import { postAppConsoleLine, terminalLineFromStream } from '@/shared/appTerminal';
|
||||
import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles';
|
||||
import { useCanvasWindowResize } from './useCanvasWindowResize';
|
||||
|
||||
type AppCardView = 'preview' | 'code' | 'terminal' | 'history';
|
||||
|
||||
@@ -514,69 +514,12 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
}, [finalizeDrag]);
|
||||
useDragEndBackstops(isDragging, finalizeDrag, abortDrag);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
origX: number; origY: number; origW: number; origH: number;
|
||||
} | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const handleResizeDown = useCallback(
|
||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const popped = tiling.untileForResize();
|
||||
if (popped) setLocalResize(popped);
|
||||
resizeRef.current = {
|
||||
dir, startX: e.clientX, startY: e.clientY,
|
||||
origX: popped?.x ?? cardX, origY: popped?.y ?? cardY, origW: popped?.w ?? cardWidth, origH: popped?.h ?? cardHeight,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cardX, cardY, cardWidth, cardHeight, tiling],
|
||||
);
|
||||
|
||||
const computeResize = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
if (dir.includes('e')) newW = origW + dx;
|
||||
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
|
||||
if (dir.includes('s')) newH = origH + dy;
|
||||
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
|
||||
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const result = computeResize(e);
|
||||
if (result) setLocalResize(result);
|
||||
},
|
||||
[computeResize],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setViewCardPosition({ outputId: cardKey, x: result.x, y: result.y }));
|
||||
dispatch(setViewCardSize({ outputId: cardKey, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, cardKey]);
|
||||
const commitResizePosition = useCallback((x: number, y: number) => { dispatch(setViewCardPosition({ outputId: cardKey, x, y })); }, [dispatch, cardKey]);
|
||||
const commitResizeSize = useCallback((w: number, h: number) => { dispatch(setViewCardSize({ outputId: cardKey, width: w, height: h })); }, [dispatch, cardKey]);
|
||||
const { isResizing, live: localResize, handles: resizeHandles } = useCanvasWindowResize({
|
||||
cardX, cardY, cardWidth, cardHeight, minWidth: MIN_W, minHeight: MIN_H,
|
||||
getCanvasState, onCommitPosition: commitResizePosition, onCommitSize: commitResizeSize, untileForResize: tiling.untileForResize,
|
||||
});
|
||||
|
||||
const handleRemove = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
@@ -944,20 +887,12 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
{!isMinimized && RESIZE_HANDLE_DEFS.map(({ dir, css }) => (
|
||||
{!isMinimized && resizeHandles.map(({ dir, style, ...handlers }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
className="resize-handle"
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeUp}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
cursor: RESIZE_CURSOR[dir],
|
||||
opacity: 0,
|
||||
zIndex: RESIZE_HANDLE_Z,
|
||||
...css,
|
||||
}}
|
||||
{...handlers}
|
||||
sx={{ ...style, opacity: 0, zIndex: RESIZE_HANDLE_Z }}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RESIZE_HANDLE_DEFS, RESIZE_CURSOR, type ResizeDir } from './cardResizeHandles';
|
||||
|
||||
export interface LiveRect { x: number; y: number; w: number; h: number }
|
||||
|
||||
|
||||
|
||||
export interface CanvasWindowResizeHandle {
|
||||
dir: string;
|
||||
style: React.CSSProperties;
|
||||
export interface CanvasWindowResizeHandlers {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
onPointerCancel: (e: React.PointerEvent) => void;
|
||||
onLostPointerCapture: (e: React.PointerEvent) => void;
|
||||
}
|
||||
|
||||
export interface CanvasWindowResizeHandle extends CanvasWindowResizeHandlers {
|
||||
dir: ResizeDir;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CanvasWindowResizeState {
|
||||
isResizing: boolean;
|
||||
/** Live geometry while the pointer is down; null once committed to the slice. */
|
||||
live: { x: number; y: number; w: number; h: number } | null;
|
||||
live: LiveRect | null;
|
||||
handles: CanvasWindowResizeHandle[];
|
||||
}
|
||||
|
||||
@@ -26,38 +30,32 @@ interface CanvasWindowResizeArgs {
|
||||
onCommitPosition: (x: number, y: number) => void;
|
||||
onCommitSize: (width: number, height: number) => void;
|
||||
/** Tiling rule 5: grabbing a grip breaks the tile and resizes from the rect the card was filling. */
|
||||
untileForResize?: () => { x: number; y: number; w: number; h: number } | null;
|
||||
untileForResize?: () => LiveRect | null;
|
||||
}
|
||||
|
||||
/** The 8 edge/corner grips of a canvas window: preview the new rect locally, commit it on release. */
|
||||
// Same body class the card drag raises: webviews/iframes go pointer-events:none so a grip dragged inward over a browser page still hears its own release.
|
||||
const GESTURE_SHIELD_CLASS = 'dashboard-marquee-active';
|
||||
|
||||
/** The 8 edge/corner grips of a canvas card: preview the new rect locally, commit it on release.
|
||||
Every way a gesture can end (release on the grip, release over a webview or outside the window,
|
||||
pointercancel, capture lost to a remount, app blur) funnels into one finish that commits the last
|
||||
rect; a move with no button held proves the release was missed and finishes too, so a resize can
|
||||
never keep tracking the cursor after the hand let go. */
|
||||
export function useCanvasWindowResize({
|
||||
cardX, cardY, cardWidth, cardHeight, minWidth, minHeight,
|
||||
getCanvasState, onCommitPosition, onCommitSize, untileForResize,
|
||||
}: CanvasWindowResizeArgs): CanvasWindowResizeState {
|
||||
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
|
||||
const lastRectRef = useRef<LiveRect | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [live, setLive] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [live, setLive] = useState<LiveRect | null>(null);
|
||||
|
||||
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const popped = untileForResize?.() ?? null;
|
||||
if (popped) setLive(popped);
|
||||
resizeRef.current = {
|
||||
dir, sx0: e.clientX, sy0: e.clientY,
|
||||
ox: popped?.x ?? cardX, oy: popped?.y ?? cardY, ow: popped?.w ?? cardWidth, oh: popped?.h ?? cardHeight,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, cardWidth, cardHeight, untileForResize]);
|
||||
|
||||
const compute = useCallback((e: React.PointerEvent) => {
|
||||
const compute = useCallback((clientX: number, clientY: number): LiveRect | null => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
|
||||
const zoom = getCanvasState().zoom;
|
||||
const dx = (e.clientX - sx0) / zoom;
|
||||
const dy = (e.clientY - sy0) / zoom;
|
||||
const dx = (clientX - sx0) / zoom;
|
||||
const dy = (clientY - sy0) / zoom;
|
||||
let nx = ox, ny = oy, nw = ow, nh = oh;
|
||||
if (dir.includes('e')) nw = ow + dx;
|
||||
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
|
||||
@@ -68,23 +66,68 @@ export function useCanvasWindowResize({
|
||||
return { x: nx, y: ny, w: nw, h: nh };
|
||||
}, [getCanvasState, minWidth, minHeight]);
|
||||
|
||||
const onResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
const r = compute(e);
|
||||
if (r) setLive(r);
|
||||
}, [compute]);
|
||||
|
||||
const onResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
const finish = useCallback(() => {
|
||||
if (!resizeRef.current) return;
|
||||
const r = compute(e);
|
||||
const r = lastRectRef.current;
|
||||
resizeRef.current = null;
|
||||
lastRectRef.current = null;
|
||||
setLive(null);
|
||||
setIsResizing(false);
|
||||
document.body.classList.remove(GESTURE_SHIELD_CLASS);
|
||||
if (r) {
|
||||
onCommitPosition(r.x, r.y);
|
||||
onCommitSize(r.w, r.h);
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLive(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [compute, onCommitPosition, onCommitSize]);
|
||||
}, [onCommitPosition, onCommitSize]);
|
||||
|
||||
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const popped = untileForResize?.() ?? null;
|
||||
const origin: LiveRect = popped ?? { x: cardX, y: cardY, w: cardWidth, h: cardHeight };
|
||||
if (popped) setLive(popped);
|
||||
resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: origin.x, oy: origin.y, ow: origin.w, oh: origin.h };
|
||||
lastRectRef.current = origin;
|
||||
setIsResizing(true);
|
||||
document.body.classList.add(GESTURE_SHIELD_CLASS);
|
||||
try { (e.target as HTMLElement).setPointerCapture(e.pointerId); } catch { /* pointer already gone; the window backstops end it */ }
|
||||
}, [cardX, cardY, cardWidth, cardHeight, untileForResize]);
|
||||
|
||||
const onResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
if (e.buttons === 0) { finish(); return; }
|
||||
const r = compute(e.clientX, e.clientY);
|
||||
if (r) { lastRectRef.current = r; setLive(r); }
|
||||
}, [compute, finish]);
|
||||
|
||||
const onResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const r = compute(e.clientX, e.clientY);
|
||||
if (r) lastRectRef.current = r;
|
||||
try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ }
|
||||
finish();
|
||||
}, [compute, finish]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return undefined;
|
||||
// Re-raised here because a re-render mid-gesture re-runs this effect and its cleanup lowers the shield.
|
||||
document.body.classList.add(GESTURE_SHIELD_CLASS);
|
||||
const onUp = (e: PointerEvent): void => {
|
||||
const r = compute(e.clientX, e.clientY);
|
||||
if (r) lastRectRef.current = r;
|
||||
finish();
|
||||
};
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', finish);
|
||||
window.addEventListener('blur', finish);
|
||||
return () => {
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', finish);
|
||||
window.removeEventListener('blur', finish);
|
||||
document.body.classList.remove(GESTURE_SHIELD_CLASS);
|
||||
};
|
||||
}, [isResizing, compute, finish]);
|
||||
|
||||
const handles = RESIZE_HANDLE_DEFS.map(({ dir, css }) => ({
|
||||
dir,
|
||||
@@ -92,6 +135,8 @@ export function useCanvasWindowResize({
|
||||
onPointerDown: onResizeDown(dir),
|
||||
onPointerMove: onResizeMove,
|
||||
onPointerUp: onResizeUp,
|
||||
onPointerCancel: finish,
|
||||
onLostPointerCapture: finish,
|
||||
}));
|
||||
|
||||
return { isResizing, live, handles };
|
||||
|
||||
Reference in New Issue
Block a user