mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 01:37:43 +02:00
[eric] canvas: multi-drag followers ride an imperative translate channel, zero renders per drag frame
This commit is contained in:
@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { addViewCard, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import TetherLayerHost from './TetherLayerHost';
|
||||
import { useLiveMultiDrag } from '../hooks/interaction/useLiveMultiDrag';
|
||||
import DashboardCardLayer from './DashboardCardLayer';
|
||||
import DashboardOverlays from './DashboardOverlays';
|
||||
import { useCanvasContextMenu } from './useCanvasContextMenu';
|
||||
@@ -62,7 +63,7 @@ interface DashboardCanvasProps {
|
||||
highlightedCardId: string | null;
|
||||
autoFocusSessionId: string | null;
|
||||
focusedCardId: string | null;
|
||||
multiDragDelta: { dx: number; dy: number } | null;
|
||||
multiDragActive: boolean;
|
||||
shakeDirection: Direction | null;
|
||||
neighborDirections: NeighborDirections;
|
||||
toolbarOpen: boolean;
|
||||
@@ -124,7 +125,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
highlightedCardId,
|
||||
autoFocusSessionId,
|
||||
focusedCardId,
|
||||
multiDragDelta,
|
||||
multiDragActive,
|
||||
shakeDirection,
|
||||
neighborDirections,
|
||||
toolbarOpen,
|
||||
@@ -163,6 +164,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
onTidy,
|
||||
onSearchPaletteClose,
|
||||
}) => {
|
||||
useLiveMultiDrag();
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
const { washOpacity, grain } = useThemeWash();
|
||||
// A single picked color stores gradient=null, so fall back to the accent (mirrors BeatShell).
|
||||
@@ -469,7 +471,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
highlightedCardId={highlightedCardId}
|
||||
autoFocusSessionId={autoFocusSessionId}
|
||||
focusedCardId={focusedCardId}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
shakeDirection={shakeDirection}
|
||||
spawnOriginsRef={spawnOriginsRef}
|
||||
revealSpawnedRef={revealSpawnedRef}
|
||||
|
||||
@@ -39,7 +39,7 @@ interface DashboardCardLayerProps {
|
||||
highlightedCardId: string | null;
|
||||
autoFocusSessionId: string | null;
|
||||
focusedCardId: string | null;
|
||||
multiDragDelta: { dx: number; dy: number } | null;
|
||||
multiDragActive: boolean;
|
||||
shakeDirection: Direction | null;
|
||||
spawnOriginsRef: RefObject<Record<string, SpawnOrigin>>;
|
||||
revealSpawnedRef: RefObject<Set<string>>;
|
||||
@@ -71,7 +71,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
highlightedCardId,
|
||||
autoFocusSessionId,
|
||||
focusedCardId,
|
||||
multiDragDelta,
|
||||
multiDragActive,
|
||||
shakeDirection,
|
||||
spawnOriginsRef,
|
||||
revealSpawnedRef,
|
||||
@@ -153,7 +153,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
isSelected={isSel}
|
||||
isHighlighted={highlightedCardId === sid}
|
||||
// Only selected cards need the live drag delta; passing it to everyone broke memo equality for unselected cards on every mouse-move during multi-drag.
|
||||
multiDragDelta={isSel ? multiDragDelta : null}
|
||||
multiDragActive={isSel && multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
@@ -187,7 +187,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cmdHeld={cmdHeld}
|
||||
isSelected={selection.isSelected(cardKey)}
|
||||
isHighlighted={highlightedCardId === cardKey}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
@@ -214,7 +214,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cmdHeld={cmdHeld}
|
||||
isSelected={selection.isSelected(bc.browser_id)}
|
||||
isHighlighted={highlightedCardId === bc.browser_id}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={selection.isSelected(bc.browser_id) && multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
@@ -227,7 +227,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
workflowsHub={workflowsHub}
|
||||
selection={selection}
|
||||
highlightedCardId={highlightedCardId}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
getCanvasState={getCanvasState}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface DashboardWindowCardsProps {
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
selection: ReturnType<typeof useDashboardSelection>;
|
||||
highlightedCardId: string | null;
|
||||
multiDragDelta: { dx: number; dy: number } | null;
|
||||
multiDragActive: boolean;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
|
||||
onDragStart: (id: string, type: CardType) => void;
|
||||
@@ -30,7 +30,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
workflowsHub,
|
||||
selection,
|
||||
highlightedCardId,
|
||||
multiDragDelta,
|
||||
multiDragActive,
|
||||
getCanvasState,
|
||||
onCardSelect,
|
||||
onDragStart,
|
||||
@@ -61,7 +61,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
|
||||
multiDragActive={selection.isSelected('workflows-hub') && multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
@@ -79,7 +79,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected(SETTINGS_CARD_ID)}
|
||||
isHighlighted={highlightedCardId === SETTINGS_CARD_ID}
|
||||
multiDragDelta={selection.isSelected(SETTINGS_CARD_ID) ? multiDragDelta : null}
|
||||
multiDragActive={selection.isSelected(SETTINGS_CARD_ID) && multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
@@ -97,7 +97,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected(MARKETPLACE_CARD_ID)}
|
||||
isHighlighted={highlightedCardId === MARKETPLACE_CARD_ID}
|
||||
multiDragDelta={selection.isSelected(MARKETPLACE_CARD_ID) ? multiDragDelta : null}
|
||||
multiDragActive={selection.isSelected(MARKETPLACE_CARD_ID) && multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
|
||||
@@ -268,7 +268,7 @@ interface OuterProps {
|
||||
exitTarget?: { x: number; y: number };
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -305,7 +305,7 @@ const SNAP_THRESHOLD = 60;
|
||||
|
||||
const AgentCard: React.FC<Props> = ({
|
||||
session, expanded: expandedInStore, cardX, cardY, cardWidth, cardHeight, getCanvasState, spawnFrom, exitTarget,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
shakeDirection,
|
||||
}) => {
|
||||
@@ -765,12 +765,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
const browserShot = useBrowserPillShot(session.id, pillMode && !pillArtifact);
|
||||
|
||||
// justDraggedRef: the motion.div parks at the START position for the whole imperative drag, so the end-of-drag commit must snap (not spring) to the final spot or the card visibly re-glides from where the drag began.
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta) || justDraggedRef.current;
|
||||
const noTransition = isDragging || isResizing || (isSelected && multiDragActive) || justDraggedRef.current;
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const activeX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const activeX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const activeY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const activeW = localResize?.w ?? cardWidth;
|
||||
const activeH = localResize?.h ?? cardHeight;
|
||||
const tiledSize = useTiledCard({ cardId: session.id, zone: tileZone, active: true, originX: activeX, originY: activeY, getCamera: getCanvasState });
|
||||
|
||||
@@ -189,7 +189,7 @@ interface Props {
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
// Belongs to a non-active dashboard but kept mounted-hidden so its webContents + sessionStorage survive the switch.
|
||||
keepAliveHidden?: boolean;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean, originTarget?: EventTarget | null) => void;
|
||||
@@ -204,7 +204,7 @@ interface Props {
|
||||
|
||||
const BrowserCard: React.FC<Props> = ({
|
||||
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragActive = false, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
@@ -345,11 +345,19 @@ const BrowserCard: React.FC<Props> = ({
|
||||
const hasDockRect = !!dockRect;
|
||||
useEffect(() => {
|
||||
if (!dockedTo || !hasDockRect) return undefined;
|
||||
// Clear only after having followed: the old unconditional else cleared translate on EVERY
|
||||
// drag frame of every other card, and would fight the multi-drag channel's writes.
|
||||
let wasFollowing = false;
|
||||
const off = subscribeLiveDrag((info) => {
|
||||
const el = rootElRef.current;
|
||||
if (!el) return;
|
||||
if (info && info.cardId === dockedTo) el.style.translate = `${info.dx}px ${info.dy}px`;
|
||||
else el.style.translate = '';
|
||||
if (info && info.cardId === dockedTo) {
|
||||
el.style.translate = `${info.dx}px ${info.dy}px`;
|
||||
wasFollowing = true;
|
||||
} else if (wasFollowing) {
|
||||
el.style.translate = '';
|
||||
wasFollowing = false;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
@@ -1045,13 +1053,11 @@ const BrowserCard: React.FC<Props> = ({
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, browserId]);
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
const noTransition = isDragging || isResizing || (isSelected && multiDragActive);
|
||||
// During a drag, move the card by a COMPOSITOR transform, not left/top layout: while edge-panning, the canvas transform and the card's left/top update land a frame apart, and the webview's guest surface follows the transform immediately while left/top relayouts late, so the browser visibly shimmers back and forth. A transform for the drag delta rides the same compositor path as the canvas pan, so they move together in one frame.
|
||||
const dragging = isDragging && !!localDragPos && !localResize;
|
||||
const dragTx = dragging ? displayX - cardX : 0;
|
||||
|
||||
@@ -42,7 +42,7 @@ interface CanvasWindowCardProps {
|
||||
background: string; highlightColor: string;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
isSelected?: boolean; isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
|
||||
onDragStart?: (id: string, type: CardType) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -64,7 +64,7 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
minimized = false, minWidth, minHeight, background, highlightColor,
|
||||
getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
onCommitPosition, onCommitSize,
|
||||
onMinimize, onClose,
|
||||
@@ -158,16 +158,14 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
|
||||
getCanvasState, onCommitPosition, onCommitSize, untileForResize: tiling.untileForResize,
|
||||
});
|
||||
|
||||
const mdDx = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const dx = (localResize?.x ?? localDragPos?.x ?? cardX) + mdDx;
|
||||
const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy;
|
||||
const dx = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const dy = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const dw = localResize?.w ?? cardWidth;
|
||||
const dh = localResize?.h ?? cardHeight;
|
||||
const tiledSize = useTiledCard({ cardId, zone: tiling.zone, active: !minimized, originX: dx, originY: dy, getCamera: getCanvasState });
|
||||
|
||||
const border = isHighlighted ? `2px solid ${highlightColor}` : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.subtle}`;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
const noTransition = isDragging || isResizing || (isSelected && multiDragActive);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -95,7 +95,7 @@ interface Props {
|
||||
cmdHeld?: boolean;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -142,7 +142,7 @@ const BootingBody: React.FC = () => {
|
||||
|
||||
const DashboardViewCard: React.FC<Props> = ({
|
||||
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
const cardKey = cardKeyProp ?? output.id;
|
||||
@@ -613,13 +613,11 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
previewRef.current?.reload();
|
||||
};
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
const noTransition = isDragging || isResizing || (isSelected && multiDragActive);
|
||||
// Drag via a compositor transform, not left/top: an app card's webview surface shimmers back and forth while edge-panning otherwise (the transform and the late left/top relayout desync a frame). Same fix as BrowserCard.
|
||||
const dragging = isDragging && !!localDragPos && !localResize;
|
||||
const dragTx = dragging ? displayX - cardX : 0;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Co-selected cards follow a drag OUTSIDE React, same shape as marqueeLiveChannel: per-frame
|
||||
// deltas at pointer rate re-rendered every selected card's whole subtree (the last ENG-88 hole).
|
||||
|
||||
export interface MultiDragUpdate {
|
||||
ids: ReadonlyArray<string>;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
type Listener = (update: MultiDragUpdate | null) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function publishMultiDrag(update: MultiDragUpdate | null): void {
|
||||
for (const listener of listeners) listener(update);
|
||||
}
|
||||
|
||||
export function subscribeMultiDrag(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { moveCards } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
|
||||
import type { CanvasActions } from './useCanvasControls';
|
||||
import { publishLiveDrag } from './liveDragChannel';
|
||||
import { publishMultiDrag } from './multiDragLiveChannel';
|
||||
import { setCanvasInteractionActive } from '@/shared/canvasInteractionState';
|
||||
|
||||
type Selection = ReturnType<typeof useDashboardSelection>;
|
||||
@@ -34,7 +35,11 @@ export function useCardDrag({
|
||||
}: UseCardDragArgs) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
|
||||
// Per-frame deltas ride multiDragLiveChannel (style writes, zero renders); this flag renders
|
||||
// exactly twice per gesture and exists so follower cards suppress their spring on the commit.
|
||||
const [multiDragActive, setMultiDragActive] = useState(false);
|
||||
const multiDragActiveRef = useRef(false);
|
||||
const followIdsRef = useRef<string[]>([]);
|
||||
const activeDragCardRef = useRef<string | null>(null);
|
||||
const isMultiDragRef = useRef(false);
|
||||
|
||||
@@ -86,6 +91,7 @@ export function useCardDrag({
|
||||
// Multi only when there is actually company: a lone selected card on this path made every drag after the first pay a setState per frame.
|
||||
if (selection.isSelected(id) && selection.selectedArray().length > 1) {
|
||||
isMultiDragRef.current = true;
|
||||
followIdsRef.current = selection.selectedArray().filter((s) => s.id !== id).map((s) => s.id);
|
||||
} else {
|
||||
// Grabbing an unselected card SELECTS just it (was deselectAll, which left nothing selected, so the next spawn had no anchor and flew to viewport-center far from the card you just moved). Also survives the stale-read where the capture-phase click already selected it.
|
||||
selection.selectCard(id, type, false);
|
||||
@@ -106,7 +112,11 @@ export function useCardDrag({
|
||||
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
|
||||
}
|
||||
if (isMultiDragRef.current) {
|
||||
setMultiDragDelta({ dx, dy });
|
||||
if (!multiDragActiveRef.current) {
|
||||
multiDragActiveRef.current = true;
|
||||
setMultiDragActive(true);
|
||||
}
|
||||
publishMultiDrag({ ids: followIdsRef.current, dx, dy });
|
||||
}
|
||||
if (activeDragCardRef.current) {
|
||||
publishLiveDrag({ cardId: activeDragCardRef.current, dx, dy });
|
||||
@@ -121,8 +131,16 @@ export function useCardDrag({
|
||||
document.body.classList.remove('dashboard-marquee-active');
|
||||
setCanvasInteractionActive(false);
|
||||
isMultiDragRef.current = false;
|
||||
setMultiDragDelta(null);
|
||||
followIdsRef.current = [];
|
||||
publishMultiDrag(null);
|
||||
publishLiveDrag(null);
|
||||
if (multiDragActiveRef.current) {
|
||||
// Stay active through the moveCards commit paint (followers must snap, not spring), then release next frame.
|
||||
requestAnimationFrame(() => {
|
||||
multiDragActiveRef.current = false;
|
||||
setMultiDragActive(false);
|
||||
});
|
||||
}
|
||||
}, [stopEdgePan, canvasActions]);
|
||||
|
||||
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
|
||||
@@ -152,7 +170,7 @@ export function useCardDrag({
|
||||
}, [clearDrag, stopEdgePan]);
|
||||
|
||||
return {
|
||||
multiDragDelta,
|
||||
multiDragActive,
|
||||
handleCardDragStart,
|
||||
handleCardDragMove,
|
||||
handleCardDragEnd,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { subscribeMultiDrag } from './multiDragLiveChannel';
|
||||
|
||||
// The one consumer of multiDragLiveChannel: style-writes `translate` on co-selected card roots
|
||||
// per frame. CSS `translate` composes with `transform`, so framer-motion's scale/spring inline
|
||||
// transforms are never clobbered (the transform-trap that bit the minimized-cards work).
|
||||
export function useLiveMultiDrag(): void {
|
||||
useEffect(() => {
|
||||
const els = new Map<string, HTMLElement | null>();
|
||||
return subscribeMultiDrag((update) => {
|
||||
if (!update) {
|
||||
for (const el of els.values()) { if (el) el.style.translate = ''; }
|
||||
els.clear();
|
||||
return;
|
||||
}
|
||||
const value = `${update.dx}px ${update.dy}px`;
|
||||
for (const id of update.ids) {
|
||||
let el = els.get(id);
|
||||
if (el === undefined) {
|
||||
el = document.querySelector<HTMLElement>(`[data-osw-card="${CSS.escape(id)}"]`);
|
||||
els.set(id, el);
|
||||
}
|
||||
if (el) el.style.translate = value;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
}), [canvas.actions]);
|
||||
|
||||
const {
|
||||
multiDragDelta,
|
||||
multiDragActive,
|
||||
handleCardDragStart,
|
||||
handleCardDragMove,
|
||||
handleCardDragEnd,
|
||||
@@ -342,7 +342,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
cards, viewCards, browserCards, keepAliveBrowserCards, outputs, glowingAgentCards,
|
||||
workflowCards, workflowsHub,
|
||||
expandedSessionIds, tetherInputs, highlightedCardId, autoFocusSessionId,
|
||||
focusedCardId, multiDragDelta, shakeDirection,
|
||||
focusedCardId, multiDragActive, shakeDirection,
|
||||
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, canvasEmpty,
|
||||
toolbarRef, spawnOriginsRef, revealSpawnedRef, measuredHeightsRef, getCanvasState,
|
||||
toolbarPrefill,
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: CardType) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -38,7 +38,7 @@ interface Props {
|
||||
const MarketplaceAppCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
@@ -73,7 +73,7 @@ const MarketplaceAppCard: React.FC<Props> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={isSelected}
|
||||
isHighlighted={isHighlighted}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface Props {
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: CardType) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -39,7 +39,7 @@ interface Props {
|
||||
const SettingsAppCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
@@ -75,7 +75,7 @@ const SettingsAppCard: React.FC<Props> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={isSelected}
|
||||
isHighlighted={isHighlighted}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
multiDragActive?: boolean;
|
||||
onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: CardType) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
@@ -29,7 +29,7 @@ interface Props {
|
||||
const WorkflowsAppCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
getCanvasState,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
isSelected = false, isHighlighted = false, multiDragActive = false,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const WC = useWC();
|
||||
@@ -67,7 +67,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={isSelected}
|
||||
isHighlighted={isHighlighted}
|
||||
multiDragDelta={multiDragDelta}
|
||||
multiDragActive={multiDragActive}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
|
||||
Reference in New Issue
Block a user