diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 88dbcf8a..d225e799 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -1,5 +1,6 @@ import React, { type RefObject } from 'react'; import { AnimatePresence } from 'framer-motion'; +import MarqueeRect from './MarqueeRect'; import AgentCard from '../cards/AgentCard'; import DashboardViewCard from '../cards/DashboardViewCard'; import BrowserCard from '../cards/BrowserCard'; @@ -234,23 +235,8 @@ const DashboardCardLayer: React.FC = ({ onDragEnd={onDragEnd} onBringToFront={onBringToFront} /> - {/* Marquee selection rectangle */} - {selection.marquee && ( -
- )} + {/* Marquee selection rectangle: mounted once per sweep, moved imperatively off the channel */} + {selection.marquee && } ); }; diff --git a/frontend/src/app/pages/Dashboard/canvas/MarqueeRect.tsx b/frontend/src/app/pages/Dashboard/canvas/MarqueeRect.tsx new file mode 100644 index 00000000..4114ea0d --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/MarqueeRect.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef } from 'react'; +import { subscribeMarqueeRect, LiveMarqueeRect } from '../hooks/interaction/marqueeLiveChannel'; + +// The selection rectangle, moved imperatively off the marquee channel: React mounts it once per +// sweep and never re-renders it mid-drag. +const MarqueeRect: React.FC<{ initial: LiveMarqueeRect }> = ({ initial }) => { + const ref = useRef(null); + useEffect(() => subscribeMarqueeRect((r) => { + const el = ref.current; + if (!el || !r) return; + el.style.left = `${r.x}px`; + el.style.top = `${r.y}px`; + el.style.width = `${r.width}px`; + el.style.height = `${r.height}px`; + }), []); + return ( +
+ ); +}; + +export default MarqueeRect; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/marqueeLiveChannel.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/marqueeLiveChannel.ts new file mode 100644 index 00000000..3e834228 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/marqueeLiveChannel.ts @@ -0,0 +1,23 @@ +// Per-frame marquee rects travel OUTSIDE React, same pattern as liveDragChannel: a sweep at pointer +// rate re-rendered the whole card layer per frame just to move one rectangle (measured 61% dropped +// frames). React mounts/unmounts the rect; this channel moves it. + +export interface LiveMarqueeRect { + x: number; + y: number; + width: number; + height: number; +} + +type Listener = (rect: LiveMarqueeRect | null) => void; + +const listeners = new Set(); + +export function publishMarqueeRect(rect: LiveMarqueeRect | null): void { + for (const listener of listeners) listener(rect); +} + +export function subscribeMarqueeRect(listener: Listener): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 009c5d38..746a4d3d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useMemo, useRef, useEffect, RefObject } from 'react'; import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; import { viewCardKey } from '@/shared/state/dashboardLayoutSlice'; +import { publishMarqueeRect } from '../interaction/marqueeLiveChannel'; export type { CardType } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; @@ -26,6 +27,7 @@ interface ScreenToCanvas { const DRAG_THRESHOLD = 4; + function rectsIntersect( a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }, @@ -252,7 +254,9 @@ export function useDashboardSelection( width: Math.abs(end.x - start.x), height: Math.abs(end.y - start.y), }; - setMarquee(rect); + // React mounts the rect once; per-frame movement rides the channel (the layer re-rendered per frame otherwise). + publishMarqueeRect(rect); + setMarquee((prev) => prev ?? rect); const next = computeMarqueeSelection(rect, shiftHeldRef.current); // Same membership = same state object, so sweeping across empty space re-renders nothing. setSelectedIds((prev) => { @@ -287,6 +291,7 @@ export function useDashboardSelection( cancelAnimationFrame(marqueeRafRef.current); marqueeRafRef.current = null; } + publishMarqueeRect(null); setMarquee(null); document.body.style.userSelect = ''; document.body.classList.remove('dashboard-marquee-active');