mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 18:14:53 +02:00
[eric] trackpad navigation optimizaton for ux
This commit is contained in:
@@ -149,7 +149,9 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
|
||||
if (result) {
|
||||
setPicker(result);
|
||||
} else {
|
||||
setPicker((p) => ({ ...p, visible: false }));
|
||||
// See ChatInput: bail when already hidden to avoid a per-keystroke
|
||||
// re-render of the whole editor on every keypress.
|
||||
setPicker((p) => p.visible ? { ...p, visible: false } : p);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1158,7 +1158,22 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
|
||||
return (
|
||||
<Box key={msg.id} sx={{ '&:hover .msg-actions': { opacity: 1 } }}>
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
'&:hover .msg-actions': { opacity: 1 },
|
||||
// Cheap virtualization: tells the browser to skip
|
||||
// paint + layout for bubbles outside the scroll
|
||||
// viewport. `contain-intrinsic-size: auto N` reserves
|
||||
// a placeholder height so the scrollbar doesn't jump,
|
||||
// and `auto` lets the browser remember the actual
|
||||
// height after first render. Works alongside the
|
||||
// container's overflow-anchor. Chrome 85+ (Electron
|
||||
// covers this).
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: 'auto 120px',
|
||||
}}
|
||||
>
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
editing={isEditing}
|
||||
|
||||
@@ -936,7 +936,11 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
if (result) {
|
||||
setPicker(result);
|
||||
} else {
|
||||
setPicker((p) => ({ ...p, visible: false }));
|
||||
// Bail when picker is already hidden. Previously this spread a new
|
||||
// object on every keystroke (`{...p, visible:false}`), which React
|
||||
// saw as a state change and re-rendered ChatInput (2400 lines) on
|
||||
// every keypress — that was the 199ms input delay on typing.
|
||||
setPicker((p) => p.visible ? { ...p, visible: false } : p);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -240,9 +240,13 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
interface OuterProps {
|
||||
sessionId: string;
|
||||
expanded: boolean;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
// Stable getter — cards read pan/zoom on demand (drag math) instead of
|
||||
// receiving them as props. Without this, every wheel/pan tick on the
|
||||
// canvas re-rendered every card, even though the canvas root's CSS
|
||||
// transform is what actually moves them visually. Cards only need the
|
||||
// values inside drag callbacks; making it a ref-backed getter keeps
|
||||
// pan/zoom out of memo equality entirely.
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
spawnFrom?: { x: number; y: number; type?: 'branch' };
|
||||
exitTarget?: { x: number; y: number };
|
||||
isSelected?: boolean;
|
||||
@@ -268,6 +272,7 @@ interface Props extends Omit<OuterProps, 'sessionId'> {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
cardZOrder: number;
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
}
|
||||
|
||||
const MIN_W = 480;
|
||||
@@ -282,7 +287,7 @@ const GLOW_FADE_MS = 2500;
|
||||
const SNAP_THRESHOLD = 60;
|
||||
|
||||
const AgentCard: React.FC<Props> = ({
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, spawnFrom, exitTarget,
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, getCanvasState, spawnFrom, exitTarget,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
shakeDirection,
|
||||
@@ -371,46 +376,47 @@ const AgentCard: React.FC<Props> = ({
|
||||
const justDraggedRef = useRef(false);
|
||||
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
|
||||
|
||||
// Use refs for pan so drag callbacks don't recreate on every pan frame
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
const cs = getCanvasState();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
|
||||
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(session.id, 'agent');
|
||||
}, [cardX, cardY, onDragStart, session.id]);
|
||||
}, [cardX, cardY, onDragStart, session.id, getCanvasState]);
|
||||
|
||||
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change effect)
|
||||
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change event)
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
if (!ds || !didDrag.current) return;
|
||||
const { clientX, clientY } = lastPointerRef.current;
|
||||
const rawDx = clientX - ds.startX;
|
||||
const rawDy = clientY - ds.startY;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - ds.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - ds.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - ds.startPanX) / z;
|
||||
const panDy = (cs.panY - ds.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
|
||||
onDragMove?.(dx, dy, clientX, clientY);
|
||||
}, [onDragMove]);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
// When pan changes during an active drag, recompute position so card tracks cursor
|
||||
// When pan changes during an active drag (edge-pan or wheel-zoom-while-
|
||||
// dragging), Dashboard dispatches `openswarm:canvas-pan-changed`. Only
|
||||
// active during a drag so non-dragging cards stay subscribed-to-nothing.
|
||||
useEffect(() => {
|
||||
if (isDragging && didDrag.current) {
|
||||
recomputeDragPos();
|
||||
}
|
||||
}, [panX, panY, isDragging, recomputeDragPos]);
|
||||
if (!isDragging) return;
|
||||
const onPanChange = () => {
|
||||
if (didDrag.current) recomputeDragPos();
|
||||
};
|
||||
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
|
||||
}, [isDragging, recomputeDragPos]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -424,9 +430,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const cs = getCanvasState();
|
||||
const z = cs.zoom;
|
||||
const panDx = (cs.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (cs.panY - dragState.current.startPanY) / z;
|
||||
const dx = (e.clientX - dragState.current.startX) / z - panDx;
|
||||
const dy = (e.clientY - dragState.current.startY) / z - panDy;
|
||||
if (didDrag.current) {
|
||||
@@ -454,7 +461,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
|
||||
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight, getCanvasState]);
|
||||
|
||||
// ---- Unified edge / corner resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -495,8 +502,9 @@ const AgentCard: React.FC<Props> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
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;
|
||||
|
||||
@@ -510,7 +518,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
[getCanvasState],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
@@ -647,6 +655,16 @@ const AgentCard: React.FC<Props> = ({
|
||||
// boxShadows legitimately extend past the card border — `paint`
|
||||
// containment would clip those visuals.
|
||||
contain: 'layout style',
|
||||
// Promote each card to its own compositor layer so paint
|
||||
// invalidations (hover effects, streaming content updates,
|
||||
// highlight pulses) stay contained to that one card's layer
|
||||
// instead of forcing the canvas's GPU-promoted root layer to
|
||||
// re-paint. The performance trace showed pointer hover events
|
||||
// costing 100-200ms of pure presentation time before this,
|
||||
// because every hover-cross re-painted the entire canvas
|
||||
// composite. Costs ~card_area*4 bytes of GPU memory per card;
|
||||
// trivial on modern hardware for the dashboard's card counts.
|
||||
willChange: 'transform',
|
||||
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
|
||||
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
|
||||
bgcolor: c.bg.surface,
|
||||
@@ -740,8 +758,12 @@ const AgentCard: React.FC<Props> = ({
|
||||
},
|
||||
}),
|
||||
...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && {
|
||||
// Hover: borderColor only. Was previously also bumping boxShadow
|
||||
// from .sm to .md, but the trace data showed pointer hover events
|
||||
// costing 120-207ms PRESENTATION because every shadow change
|
||||
// forced a full GPU re-blur of every card on the transformed
|
||||
// canvas layer. Border color is layout-free and ~free to paint.
|
||||
'&:hover': {
|
||||
boxShadow: c.shadow.md,
|
||||
borderColor: hasPending ? c.status.warning : c.border.strong,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -88,10 +88,6 @@ const webviewPreloadPath: string | undefined = isElectron
|
||||
|| (window as any).openswarm?.getWebviewPreloadPath?.())
|
||||
: undefined;
|
||||
|
||||
if (isElectron) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[openswarm:card-module] webviewPreloadPath =', webviewPreloadPath);
|
||||
}
|
||||
|
||||
type WebviewElement = BrowserWebview;
|
||||
|
||||
@@ -230,8 +226,14 @@ const BrowserCard: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const onIpcMessage = (e: any) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[openswarm:card] webview ipc-message:', e?.channel, e?.args);
|
||||
// Was previously logging every ipc-message. The preload forwards
|
||||
// every guest-page console call as `webview-console`, so popular
|
||||
// sites (anything with analytics, telemetry, dev hot reload, etc.)
|
||||
// produced hundreds of host-side console.warn calls per second,
|
||||
// each blocking the main thread when DevTools is open. That was
|
||||
// the dominant cause of the "click-then-jump" lag on dashboards
|
||||
// with browser cards. Drop the unconditional log; ipc channels
|
||||
// we actually care about are handled in the branches below.
|
||||
if (e?.channel === 'passkey-detected') {
|
||||
setPasskeyDialogOpen(true);
|
||||
} else if (e?.channel === 'canvas-wheel-zoom') {
|
||||
@@ -662,6 +664,9 @@ const BrowserCard: React.FC<Props> = ({
|
||||
position: 'absolute',
|
||||
// contain: webview repaints don't shake neighbor cards.
|
||||
contain: 'layout style',
|
||||
// Own compositor layer so hover/paint invalidations stay
|
||||
// contained to this card. See AgentCard for full rationale.
|
||||
willChange: 'transform',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
|
||||
@@ -213,6 +213,16 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
const restoredExpandedRef = useRef(false);
|
||||
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
|
||||
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
|
||||
// Stable getter — AgentCards read pan/zoom on demand during drag math.
|
||||
const getCanvasState = useCallback(() => canvasStateRef.current, []);
|
||||
// Notify the currently dragging card (if any) that pan/zoom changed so
|
||||
// it can re-pin to the cursor. useEffect rather than render-body
|
||||
// dispatchEvent: side effects during render are a React anti-pattern
|
||||
// and can fire twice in strict mode. Effect runs after commit, so
|
||||
// exactly once per real pan/zoom delta.
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
|
||||
}, [canvas.panX, canvas.panY, canvas.zoom]);
|
||||
|
||||
// ---- Edge panning during card drag ----
|
||||
const EDGE_ZONE = 60;
|
||||
@@ -1973,19 +1983,21 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
const isSel = selection.isSelected(sid);
|
||||
return (
|
||||
<AgentCard
|
||||
key={sid}
|
||||
sessionId={sid}
|
||||
expanded={expandedSessionIds.includes(sid)}
|
||||
zoom={canvas.zoom}
|
||||
panX={canvas.panX}
|
||||
panY={canvas.panY}
|
||||
getCanvasState={getCanvasState}
|
||||
spawnFrom={origin}
|
||||
exitTarget={exitTarget}
|
||||
isSelected={selection.isSelected(sid)}
|
||||
isSelected={isSel}
|
||||
isHighlighted={highlightedCardId === sid}
|
||||
multiDragDelta={multiDragDelta}
|
||||
// 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}
|
||||
onCardSelect={handleCardSelect}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
|
||||
@@ -260,6 +260,9 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
position: 'absolute',
|
||||
// contain: iframe app repaints don't shake the rest of the dashboard.
|
||||
contain: 'layout style',
|
||||
// Own compositor layer so hover/paint invalidations stay
|
||||
// contained to this card. See AgentCard for full rationale.
|
||||
willChange: 'transform',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
|
||||
@@ -269,6 +269,9 @@ const NoteCard: React.FC<Props> = ({
|
||||
height: displayH,
|
||||
// contain: reflow inside this note doesn't shake the dashboard.
|
||||
contain: 'layout style',
|
||||
// Own compositor layer so hover/paint invalidations stay
|
||||
// contained to this note. See AgentCard for full rationale.
|
||||
willChange: 'transform',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: palette.bg,
|
||||
border: isHighlighted
|
||||
|
||||
@@ -181,6 +181,65 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const el = viewportRef.current;
|
||||
if (!el || !enabled) return; // Skip wheel listener when canvas is hidden
|
||||
|
||||
// RAF-coalesce wheel-driven state updates. Trackpads fire wheel
|
||||
// events at ~120Hz; without batching, every event triggered a full
|
||||
// Dashboard re-render (all hooks + selectors + the cards .map). The
|
||||
// visible pan was fine because transform-only changes are cheap to
|
||||
// composite, but the JS-side render storm at 120fps caused the
|
||||
// "low FPS" feel during two-finger drag. Accumulating deltas per
|
||||
// frame caps Dashboard re-renders at the display's refresh rate
|
||||
// (usually 60Hz), with no perceptible motion difference because we
|
||||
// apply all the accumulated deltas in one shot.
|
||||
let pendingPanDx = 0;
|
||||
let pendingPanDy = 0;
|
||||
let pendingZoomDy = 0;
|
||||
let pendingZoomCenter: { cx: number; cy: number } | null = null;
|
||||
let wheelRafId: number | null = null;
|
||||
|
||||
const flushWheel = () => {
|
||||
wheelRafId = null;
|
||||
const dx = pendingPanDx; const dy = pendingPanDy;
|
||||
const zDy = pendingZoomDy; const zCenter = pendingZoomCenter;
|
||||
pendingPanDx = 0; pendingPanDy = 0;
|
||||
pendingZoomDy = 0; pendingZoomCenter = null;
|
||||
|
||||
if (zCenter && zDy !== 0) {
|
||||
setState((prev) => {
|
||||
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return {
|
||||
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio,
|
||||
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio,
|
||||
zoom: newZoom,
|
||||
};
|
||||
});
|
||||
} else if (dx !== 0 || dy !== 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX - dx,
|
||||
panY: prev.panY - dy,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleWheelFlush = () => {
|
||||
if (wheelRafId != null) return;
|
||||
wheelRafId = requestAnimationFrame(flushWheel);
|
||||
};
|
||||
|
||||
// Cache "is this element a scrollable child" decision per node. The
|
||||
// canvas wheel handler walks up from e.target to el on every event;
|
||||
// without caching, it called getComputedStyle on every ancestor on
|
||||
// every wheel event (120Hz from a trackpad × 5-10 ancestors × style
|
||||
// recalc). That was the dominant cost of trackpad two-finger
|
||||
// navigation — RAF-coalescing the state update only fixed half of
|
||||
// the problem. WeakMap entries get GC'd with their elements; no
|
||||
// manual invalidation needed for unmounted DOM. We do invalidate
|
||||
// explicitly when a node's scroll capacity might have changed (see
|
||||
// the resize observer below).
|
||||
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not
|
||||
const isPinchZoom = e.ctrlKey || e.metaKey;
|
||||
@@ -191,19 +250,35 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
|
||||
let target = e.target as HTMLElement | null;
|
||||
while (target && target !== el) {
|
||||
const style = getComputedStyle(target);
|
||||
const overflowY = style.overflowY;
|
||||
const overflowX = style.overflowX;
|
||||
// Cached classification: 'scrollable' = has overflow auto/scroll
|
||||
// AND content exceeds its frame in some direction. 'not' = neither.
|
||||
// Fast path: cheap scrollHeight/scrollWidth read (a layout-flushing
|
||||
// property, but no style recalc) before paying for getComputedStyle.
|
||||
let cls = scrollableCache.get(target);
|
||||
if (cls === undefined) {
|
||||
const couldScroll =
|
||||
target.scrollHeight > target.clientHeight ||
|
||||
target.scrollWidth > target.clientWidth;
|
||||
if (couldScroll) {
|
||||
const style = getComputedStyle(target);
|
||||
const oy = style.overflowY;
|
||||
const ox = style.overflowX;
|
||||
const isOverflowScrollable =
|
||||
oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll';
|
||||
cls = isOverflowScrollable ? 'scrollable' : 'not';
|
||||
} else {
|
||||
cls = 'not';
|
||||
}
|
||||
scrollableCache.set(target, cls);
|
||||
}
|
||||
|
||||
const canScrollY =
|
||||
target.scrollHeight > target.clientHeight &&
|
||||
(overflowY === 'auto' || overflowY === 'scroll');
|
||||
const canScrollX =
|
||||
target.scrollWidth > target.clientWidth &&
|
||||
(overflowX === 'auto' || overflowX === 'scroll');
|
||||
|
||||
if ((canScrollY || canScrollX) && !isPinchZoom) {
|
||||
// Check if at scroll boundary in the scroll direction
|
||||
if (cls === 'scrollable' && !isPinchZoom) {
|
||||
// Re-read scrollHeight/clientHeight here (cheap, no style recalc)
|
||||
// to make the at-boundary check responsive — the cached decision
|
||||
// is structural (does this element have overflow:auto/scroll AND
|
||||
// exceed its frame); the current scroll position is dynamic.
|
||||
const canScrollY = target.scrollHeight > target.clientHeight;
|
||||
const canScrollX = target.scrollWidth > target.clientWidth;
|
||||
const atYBoundary = !canScrollY ||
|
||||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
|
||||
(dy < 0 && target.scrollTop <= 1);
|
||||
@@ -228,28 +303,19 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
}
|
||||
|
||||
if (isPinchZoom) {
|
||||
// Pinch gesture → zoom centered on cursor
|
||||
// Pinch gesture → accumulate zoom deltas + last cursor position.
|
||||
// factor = 2^(-Σdy·s) which equals the product of per-event
|
||||
// factors, so accumulating dy is mathematically identical to
|
||||
// applying each event one at a time.
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
|
||||
setState((prev) => {
|
||||
const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return {
|
||||
panX: cx - (cx - prev.panX) * ratio,
|
||||
panY: cy - (cy - prev.panY) * ratio,
|
||||
zoom: newZoom,
|
||||
};
|
||||
});
|
||||
pendingZoomDy += dy;
|
||||
pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
|
||||
scheduleWheelFlush();
|
||||
} else {
|
||||
// Two-finger scroll → pan
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: prev.panX - dx,
|
||||
panY: prev.panY - dy,
|
||||
}));
|
||||
// Two-finger scroll → accumulate pan deltas.
|
||||
pendingPanDx += dx;
|
||||
pendingPanDy += dy;
|
||||
scheduleWheelFlush();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,28 +330,23 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const detail = (e as CustomEvent).detail || {};
|
||||
const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = (detail.clientX ?? 0) - rect.left;
|
||||
const cy = (detail.clientY ?? 0) - rect.top;
|
||||
if (inertiaFrameRef.current) {
|
||||
cancelAnimationFrame(inertiaFrameRef.current);
|
||||
inertiaFrameRef.current = null;
|
||||
}
|
||||
setState((prev) => {
|
||||
const factor = Math.pow(2, -dy * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return {
|
||||
panX: cx - (cx - prev.panX) * ratio,
|
||||
panY: cy - (cy - prev.panY) * ratio,
|
||||
zoom: newZoom,
|
||||
};
|
||||
});
|
||||
pendingZoomDy += dy;
|
||||
pendingZoomCenter = {
|
||||
cx: (detail.clientX ?? 0) - rect.left,
|
||||
cy: (detail.clientY ?? 0) - rect.top,
|
||||
};
|
||||
scheduleWheelFlush();
|
||||
};
|
||||
window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom);
|
||||
if (wheelRafId != null) cancelAnimationFrame(wheelRafId);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
@@ -303,26 +364,54 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
};
|
||||
}, [cancelAnimation, cancelInertia]);
|
||||
|
||||
// RAF-coalesce mouse-drag pan. Mouse events fire at 60-240Hz; without
|
||||
// batching, every event called setState directly and Dashboard
|
||||
// re-rendered at the same rate, causing the "hop hop hop" feel when
|
||||
// dragging the canvas with the cursor. Velocity history still captures
|
||||
// per-event (for inertia accuracy on mouseup) — only the React state
|
||||
// update is throttled.
|
||||
const dragRafRef = useRef<number | null>(null);
|
||||
const latestDragRef = useRef<{ dx: number; dy: number } | null>(null);
|
||||
const flushDrag = useCallback(() => {
|
||||
dragRafRef.current = null;
|
||||
const start = panStartRef.current;
|
||||
const latest = latestDragRef.current;
|
||||
if (!start || !latest) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: start.panX + latest.dx,
|
||||
panY: start.panY + latest.dy,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.x;
|
||||
const dy = e.clientY - start.y;
|
||||
|
||||
// Track velocity (keep last 5 positions)
|
||||
// Velocity history is per-event so inertia stays accurate on
|
||||
// mouseup. Cheap; just pushes to a length-5 ring buffer.
|
||||
const now = performance.now();
|
||||
const history = velocityHistoryRef.current;
|
||||
history.push({ x: e.clientX, y: e.clientY, t: now });
|
||||
if (history.length > 5) history.shift();
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
panX: start.panX + dx,
|
||||
panY: start.panY + dy,
|
||||
}));
|
||||
}, []);
|
||||
latestDragRef.current = { dx, dy };
|
||||
if (dragRafRef.current == null) {
|
||||
dragRafRef.current = requestAnimationFrame(flushDrag);
|
||||
}
|
||||
}, [flushDrag]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Apply any pending drag delta synchronously so the final position
|
||||
// matches where the cursor was released, then drop the scheduled RAF.
|
||||
if (dragRafRef.current != null) {
|
||||
cancelAnimationFrame(dragRafRef.current);
|
||||
dragRafRef.current = null;
|
||||
flushDrag();
|
||||
}
|
||||
latestDragRef.current = null;
|
||||
const wasPanning = !!panStartRef.current;
|
||||
let didInertia = false;
|
||||
if (wasPanning) {
|
||||
|
||||
Reference in New Issue
Block a user