[eric] trackpad navigation optimizaton for ux

This commit is contained in:
ciregenz
2026-05-15 10:13:22 -07:00
parent 09e0174139
commit 1c3aab08dd
9 changed files with 250 additions and 95 deletions
@@ -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);
}
}, []);
+16 -1
View File
@@ -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);
}
}, []);
+52 -30
View File
@@ -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,
+17 -5
View File
@@ -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) {