diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 679b268e9..4c5e3efec 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -37,6 +37,7 @@ import { isFormElement } from "./utils/isFormElement"; import { ndcFromPointerXy, opencvXyFromPointerXy } from "./utils/pointerCoords"; import { ViewerContext, ViewerContextContents } from "./ViewerContext"; import ControlPanel from "./ControlPanel/ControlPanel"; +import { DockContext, DockState } from "./ControlPanel/DockContext"; import { useGuiState } from "./ControlPanel/GuiState"; import { searchParamKey } from "./SearchParamsUtils"; import { WebsocketMessageProducer } from "./WebsocketInterface"; @@ -329,6 +330,22 @@ function ViewerContents({ children }: { children: React.ReactNode }) { const showStats = viewer.useDevSettings((state) => state.showStats); const { messageSource } = viewer; + // Dock state for the floating control panel. `side: null` means the panel is + // freely floating (the default); a non-null side reserves space for it by + // insetting the canvas. Shared with FloatingPanel via DockContext. + const [dock, setDock] = React.useState({ + side: null, + width: "20em", + }); + // Panel expanded state, lifted so the canvas only reserves space for a docked + // panel while it's expanded (a collapsed docked panel shrinks to its handle). + const [panelExpanded, setPanelExpanded] = React.useState(true); + const togglePanelExpanded = React.useCallback( + () => setPanelExpanded((value) => !value), + [], + ); + const dockReservesSpace = dock.side !== null && panelExpanded; + // Create Mantine theme with custom colors if provided. const mantineTheme = useMemo( () => @@ -372,42 +389,62 @@ function ViewerContents({ children }: { children: React.ReactNode }) { {/* App layout */} - - - + ({ - backgroundColor: darkMode ? theme.colors.dark[9] : "#fff", + style={{ + width: "100%", + position: "relative", flexGrow: 1, overflow: "hidden", - height: "100%", - })} + display: "flex", + }} > - {canvases} - {showLogo && messageSource === "websocket" && } + + ({ + backgroundColor: darkMode ? theme.colors.dark[9] : "#fff", + overflow: "hidden", + // When the panel is docked and expanded, take the canvas out + // of flex flow and inset the docked edge to reserve space for + // it. Otherwise (floating, or docked-but-collapsed) fill the + // row as before. + ...(!dockReservesSpace + ? { flexGrow: 1, height: "100%" } + : { + position: "absolute", + top: 0, + bottom: 0, + left: dock.side === "left" ? dock.width : 0, + right: dock.side === "right" ? dock.width : 0, + }), + })} + > + {canvases} + {showLogo && messageSource === "websocket" && } + + {messageSource === "websocket" && ( + + )} - {messageSource === "websocket" && ( - - )} - + {showStats && } @@ -427,6 +464,12 @@ function ColorSchemeSetter(props: { darkMode: boolean }) { * Notifications panel with fixed styling. */ function NotificationsPanel() { + const { dock, expanded } = React.useContext(DockContext); + // Notifications sit at the top-left. When the control panel is docked on the + // left (and expanded, so it actually reserves that column), shift them right + // by the panel's width so they appear over the canvas instead of covering the + // GUI. A right/none dock leaves the top-left clear, so no offset is needed. + const dockedLeft = dock.side === "left" && expanded; return ( void; + /** Whether the panel is expanded. Lifted here so the canvas can stop + * reserving space when a docked panel is collapsed. */ + expanded: boolean; + toggleExpanded: () => void; +} + +/** Shared between the canvas (which insets to make room for a docked panel) + * and the FloatingPanel (which writes the dock state when dragged to an edge). + * + * Defaults to a floating panel (side: null), so non-floating layouts and the + * un-docked state are unaffected. */ +export const DockContext = React.createContext({ + dock: { side: null, width: "20em" }, + setDock: () => undefined, + expanded: true, + toggleExpanded: () => undefined, +}); diff --git a/src/viser/client/src/ControlPanel/FloatingPanel.tsx b/src/viser/client/src/ControlPanel/FloatingPanel.tsx index deef974c9..d58e49b12 100644 --- a/src/viser/client/src/ControlPanel/FloatingPanel.tsx +++ b/src/viser/client/src/ControlPanel/FloatingPanel.tsx @@ -2,22 +2,46 @@ import { Box, Collapse, Divider, Paper, ScrollArea } from "@mantine/core"; import React from "react"; -import { useDisclosure } from "@mantine/hooks"; +import { DockContext, DockSide } from "./DockContext"; +import { motionExceedsThreshold } from "../dragUtils"; -// Drag Utils -interface DragEvents { - move: "touchmove" | "mousemove"; - end: "touchend" | "mouseup"; +/** Bind a pointer gesture's move/end/cancel listeners on `window` and return a + * detach function. Both the drag and resize gestures capture the pointer on an + * element but listen on `window` so the gesture survives the cursor leaving it; + * they share this move + (up/cancel -> end) wiring. */ +function bindPointerGesture( + onMove: (event: PointerEvent) => void, + onEnd: () => void, +): () => void { + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onEnd); + window.addEventListener("pointercancel", onEnd); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onEnd); + window.removeEventListener("pointercancel", onEnd); + }; } -const touchEvents: DragEvents = { move: "touchmove", end: "touchend" }; -const mouseEvents: DragEvents = { move: "mousemove", end: "mouseup" }; -function isTouchEvent(event: TouchEvent | MouseEvent): event is TouchEvent { - return event.type === "touchmove"; -} -function isMouseEvent(event: TouchEvent | MouseEvent): event is MouseEvent { - return event.type === "mousemove"; -} +// How close (in px) the pointer needs to get to a parent edge before we offer +// to dock to that edge. A generous zone makes docking easy to trigger -- you +// don't have to drag all the way into the edge. +const dockThreshold = 64; + +// Bounds for user resizing of the panel width. The minimum matches the +// smallest preset control width ("small" = 16em), resolved against the panel's +// font size at resize time. +const minWidthEm = 16; +const maxWidthHardCapPx = 600; +// Keep at least this much of the parent visible next to the panel. +const resizeParentPad = 100; +// Invisible resize grip at each edge. It straddles the panel border, sitting +// mostly *outside* the panel so it doesn't overlap the scrollbar (which stays +// at the panel's inner edge) -- you grab just at/past the edge to resize. +const resizeGripWidth = "0.7em"; +// How far the grip pokes past the panel edge (must be <= resizeGripWidth). The +// small remainder stays inside, so grabbing right on the border still works. +const resizeGripOutset = "0.55em"; const FloatingPanelContext = React.createContext; @@ -25,18 +49,8 @@ const FloatingPanelContext = React.createContext void; - dragHandler: ( - event: - | React.TouchEvent - | React.MouseEvent, - ) => void; - dragInfo: React.MutableRefObject<{ - dragging: boolean; - startPosX: number; - startPosY: number; - startClientX: number; - startClientY: number; - }>; + dragHandler: (event: React.PointerEvent) => void; + dragInfo: React.MutableRefObject<{ dragging: boolean }>; }>(null); /** A floating panel for displaying controls. */ @@ -44,21 +58,63 @@ export default function FloatingPanel({ children, width, }: { - children: string | React.ReactNode; + children: React.ReactNode; width: string; }) { const panelWrapperRef = React.useRef(null); - const [expanded, { toggle: toggleExpanded }] = useDisclosure(true); const [maxHeight, setMaxHeight] = React.useState(800); - // Things to track for dragging. - const dragInfo = React.useRef({ - dragging: false, - startPosX: 0, - startPosY: 0, - startClientX: 0, - startClientY: 0, - }); + // Dock state and expand/collapse, shared with the canvas (which insets to + // make room for us, but only while we're docked AND expanded). + const { dock, setDock, expanded, toggleExpanded } = + React.useContext(DockContext); + // Edge currently being hovered during a drag; drives the drop-zone hint. + const [dockHint, setDockHint] = React.useState(null); + // Pending dock side captured during drag, applied on release. Held in a ref + // so the (once-bound) drag-end listener reads the latest value. + const pendingDock = React.useRef(null); + + // User-set width override (px). Null means "use the theme-provided width". + const [widthOverride, setWidthOverride] = React.useState(null); + const effectiveWidth = widthOverride !== null ? `${widthOverride}px` : width; + // Set while actively resizing, so the ResizeObserver below doesn't fight the + // imperative position/width updates. + const resizing = React.useRef(false); + + // Whether a drag is in progress -- read by the handle's onClick to tell a + // drag-release from a click (toggle). Drag start coordinates live as locals + // inside the gesture closure below, not here. + const dragInfo = React.useRef({ dragging: false }); + + // Teardown for an in-flight drag/resize gesture. Gestures normally clean up + // their window listeners and animation frame on pointerup/cancel; this is the + // safety net for the panel unmounting mid-gesture (e.g. the client + // disconnects while dragging), so those side effects don't outlive it. + const activeGestureCleanup = React.useRef<(() => void) | null>(null); + React.useEffect( + () => () => { + activeGestureCleanup.current?.(); + }, + [], + ); + + // The dock state lives in App (it insets the canvas) but is only ever set by + // this panel. When the floating layout is swapped out -- control_layout + // changes to sidebar/collapsible, the mobile breakpoint trips, or the client + // disconnects -- this component unmounts; release the dock so the canvas stops + // reserving space for a panel that's no longer there (otherwise a left/right + // inset gap is left behind). Doing it here, keyed on this panel's own + // lifecycle, covers every one of those cases without App having to know which + // layout ControlPanel chose. useLayoutEffect (not useEffect) so the reset is + // committed in the same frame as the unmount -- otherwise there's a one-frame + // flash where the new layout is painted but the canvas is still inset. + // `setDock` is a stable state setter, so an empty dep list is correct. + React.useLayoutEffect( + () => () => { + setDock({ side: null, width }); + }, + [], + ); // Logic for "fixing" panel locations, which keeps the control panel within // the bounds of the parent div. @@ -107,6 +163,57 @@ export default function FloatingPanel({ ]; } + // Apply the styles for the current dock state. We drive top/left/right/bottom + // imperatively (rather than via the React style prop) so that the drag logic, + // which mutates these directly, never fights with React's reconciliation. + function applyDockLayout(side: DockSide) { + const panel = panelWrapperRef.current; + if (panel === null) return; + if (side === null) { + // Floating: clear the docked styles. Leave top/left alone so the panel + // stays where the drag (or initial placement) put it. + panel.style.height = ""; + panel.style.bottom = ""; + panel.style.right = "auto"; + panel.style.borderRadius = ""; + } else { + // Docked: pin to the edge. Fill the height when expanded; when collapsed, + // shrink to the handle (the canvas reclaims the column). + panel.style.top = "0"; + panel.style.borderRadius = "0"; + if (expanded) { + panel.style.bottom = "0"; + panel.style.height = "100%"; + } else { + panel.style.bottom = ""; + panel.style.height = ""; + } + if (side === "left") { + panel.style.left = "0"; + panel.style.right = "auto"; + } else { + panel.style.left = "auto"; + panel.style.right = "0"; + } + } + } + + // Initial placement (top-right corner) when floating. + React.useLayoutEffect(() => { + const panel = panelWrapperRef.current; + if (panel === null) return; + const parent = panel.parentElement; + if (parent === null) return; + if (dock.side === null && unfixedOffset.current.x === undefined) { + setPanelLocation( + parent.clientWidth - panel.clientWidth - panelBoundaryPad, + panelBoundaryPad, + ); + } + applyDockLayout(dock.side); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dock.side, expanded]); + // Fix locations on resize. React.useEffect(() => { const panel = panelWrapperRef.current; @@ -116,6 +223,21 @@ export default function FloatingPanel({ if (parent === null) return; const observer = new ResizeObserver(() => { + const newMaxHeight = parent.clientHeight - panelBoundaryPad * 2; + setMaxHeight((prev) => (prev !== newMaxHeight ? newMaxHeight : prev)); + + // Don't reposition while the user is actively resizing or dragging the + // panel; those handlers drive width/left/top directly (the drag via a + // transform), and repositioning here would fight them and jitter. + if (resizing.current) return; + if (dragInfo.current.dragging) return; + + // When docked, the panel is pinned via CSS; nothing to re-fix. + if (dock.side !== null) { + applyDockLayout(dock.side); + return; + } + if (unfixedOffset.current.x === undefined) unfixedOffset.current.x = computePanelOffset( panel.offsetLeft, @@ -129,9 +251,6 @@ export default function FloatingPanel({ parent.clientHeight, ); - const newMaxHeight = parent.clientHeight - panelBoundaryPad * 2; - maxHeight !== newMaxHeight && setMaxHeight(newMaxHeight); - let newX = unfixedOffset.current.x; let newY = unfixedOffset.current.y; while (newX < 0) newX += parent.clientWidth; @@ -143,148 +262,497 @@ export default function FloatingPanel({ return () => { observer.disconnect(); }; - }); + // Re-bind only when the dock state changes (the callback closes over + // `dock.side` and, via applyDockLayout, `expanded`); `setMaxHeight`'s + // functional updater keeps it independent of the latest `maxHeight`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dock.side, expanded]); + + const dragHandler = (event: React.PointerEvent) => { + // Ignore presses that bubble in from portaled children (e.g. the share + // modal's overlay). React routes their pointer events through here even + // though they're not in the handle's DOM, and capturing the pointer would + // misroute the follow-up click back to the handle (collapsing the panel). + if (!event.currentTarget.contains(event.target as Node)) return; + // Don't start a drag (or capture the pointer) when the press lands on an + // interactive child like a button -- pointer capture would otherwise + // retarget the resulting click to the handle and break those controls. + if ((event.target as HTMLElement).closest("button, a, input")) return; - const dragHandler = ( - event: - | React.TouchEvent - | React.MouseEvent, - ) => { const state = dragInfo.current; const panel = panelWrapperRef.current; if (!panel) return; - if (event.type == "touchstart") { - event = event as React.TouchEvent; - state.startClientX = event.touches[0].clientX; - state.startClientY = event.touches[0].clientY; - } else { - event = event as React.MouseEvent; - state.startClientX = event.clientX; - state.startClientY = event.clientY; + const parent = panel.parentElement; + if (!parent) return; + + // Pointer position and panel offset at the start of the gesture. Mutated on + // undock (the panel jumps to a floating position and the drag re-bases from + // there); `let` so the applyMove closure sees those updates. + let startClientX = event.clientX; + let startClientY = event.clientY; + + // Capture the pointer on the handle. This guarantees we keep receiving + // pointermove/pointerup even when the cursor passes over (or releases on + // top of) child buttons that stopPropagation, or leaves the window. The + // follow-up click is also retargeted to the handle, so releasing over a + // button doesn't accidentally trigger it. + const handle = event.currentTarget; + const pointerId = event.pointerId; + const pointerType = event.pointerType; + try { + handle.setPointerCapture(pointerId); + } catch { + // The pointer may already be gone; ignore. } - state.startPosX = panel.offsetLeft; - state.startPosY = panel.offsetTop; - const eventNames = event.type == "touchstart" ? touchEvents : mouseEvents; - function dragListener(event: MouseEvent | TouchEvent) { - // Minimum motion. - let deltaX = 0; - let deltaY = 0; - if (isTouchEvent(event)) { - event = event as TouchEvent; - deltaX = event.touches[0].clientX - state.startClientX; - deltaY = event.touches[0].clientY - state.startClientY; - } else if (isMouseEvent(event)) { - event = event as MouseEvent; - deltaX = event.clientX - state.startClientX; - deltaY = event.clientY - state.startClientY; - } - if (Math.abs(deltaX) <= 3 && Math.abs(deltaY) <= 3) return; + + // Remember whether we started docked. We only undock once the user + // actually drags -- a click/tap (no motion) should toggle collapse, not + // undock. + const startedDockedSide = dock.side; + let undocked = false; + + let startPosX = panel.offsetLeft; + let startPosY = panel.offsetTop; + pendingDock.current = null; + + // Cache geometry that doesn't change during a drag. Reading layout + // (clientWidth / getBoundingClientRect) on every pointermove forces a + // synchronous reflow and is the main source of drag jank, so we snapshot + // it once and refresh only on undock (which resizes the panel). `let` for + // that refresh. + let parentW = parent.clientWidth; + let parentH = parent.clientHeight; + let panelW = panel.clientWidth; + let panelH = panel.clientHeight; + let parentRect = parent.getBoundingClientRect(); + + // Last clamped position (parent-relative px), baked into left/top on + // release. `moved` tracks whether we actually repositioned the panel, so a + // click (or a docked panel that was never dragged) doesn't clobber its + // resting styles. + let lastX = startPosX; + let lastY = startPosY; + let moved = false; + + // Pointer events can fire several times per frame (and are coalesced); we + // stash the latest and apply at most once per animation frame, driving the + // position with a GPU-composited transform (no per-frame layout). + let latestEvent: PointerEvent | null = null; + let rafId: number | null = null; + + function applyMove() { + rafId = null; + const event = latestEvent; + const panel = panelWrapperRef.current; + const parent = panel?.parentElement; + if (!event || !panel || !parent) return; + + const deltaX = event.clientX - startClientX; + const deltaY = event.clientY - startClientY; + if ( + !motionExceedsThreshold( + [startClientX, startClientY], + [event.clientX, event.clientY], + ) + ) + return; state.dragging = true; - const newX = state.startPosX + deltaX; - const newY = state.startPosY + deltaY; - [unfixedOffset.current.x, unfixedOffset.current.y] = setPanelLocation( - newX, - newY, + + // First real motion while docked: undock in place by converting the + // panel's current on-screen position into a floating position, then + // continue dragging from there with no jump. + if (startedDockedSide !== null && !undocked) { + undocked = true; + const panelRect = panel.getBoundingClientRect(); + parentRect = parent.getBoundingClientRect(); + setDock({ side: null, width: effectiveWidth }); + applyDockLayout(null); + const newLeft = panelRect.left - parentRect.left; + const newTop = panelRect.top - parentRect.top; + panel.style.left = `${newLeft}px`; + panel.style.top = `${newTop}px`; + panel.style.transform = ""; + // The panel's size changes once it stops filling the docked column; + // refresh the cached geometry so clamping stays correct. + parentW = parent.clientWidth; + parentH = parent.clientHeight; + panelW = panel.clientWidth; + panelH = panel.clientHeight; + // Record the floating offset now. The setDock(null) above re-renders and + // fires the placement layout effect, which resets an *unplaced* panel + // (unfixedOffset.x === undefined) to the top-right corner -- that's what + // made an undock-from-left jump across the screen. Seeding the offset + // marks the panel as already placed so the effect leaves it put. + unfixedOffset.current = { + x: computePanelOffset(newLeft, panelW, parentW), + y: computePanelOffset(newTop, panelH, parentH), + }; + startPosX = newLeft; + startPosY = newTop; + startClientX = event.clientX; + startClientY = event.clientY; + lastX = newLeft; + lastY = newTop; + return; + } + + // Clamp the new position to keep the panel within the parent's bounds. + lastX = Math.max( + panelBoundaryPad, + Math.min(startPosX + deltaX, parentW - panelW - panelBoundaryPad), ); + lastY = Math.max( + panelBoundaryPad, + Math.min(startPosY + deltaY, parentH - panelH - panelBoundaryPad), + ); + moved = true; + panel.style.transform = `translate3d(${lastX - startPosX}px, ${ + lastY - startPosY + }px, 0)`; + unfixedOffset.current.x = computePanelOffset(lastX, panelW, parentW); + unfixedOffset.current.y = computePanelOffset(lastY, panelH, parentH); + + // Offer to dock when the pointer is near a left/right edge of the parent + // AND has moved toward that edge relative to where the drag started. + // Measuring against the initial click (deltaX) rather than the previous + // frame means a panel that begins near an edge won't offer to dock there + // unless the user actually pushes that way -- e.g. the default top-right + // placement won't dock right just because you start dragging it left. + let hint: DockSide = null; + if (event.clientX - parentRect.left < dockThreshold) { + if (deltaX < 0) hint = "left"; + } else if (parentRect.right - event.clientX < dockThreshold) { + if (deltaX > 0) hint = "right"; + } + if (hint !== pendingDock.current) { + pendingDock.current = hint; + setDockHint(hint); + } } - window.addEventListener(eventNames.move, dragListener); - window.addEventListener( - eventNames.end, - () => { - if (event.type == "touchstart") { - state.dragging = false; + + function dragListener(event: PointerEvent) { + latestEvent = event; + if (rafId === null) rafId = requestAnimationFrame(applyMove); + } + function endListener() { + detach(); + activeGestureCleanup.current = null; + if (rafId !== null) { + // Flush the latest pointer position so the panel lands exactly where it + // was released, then drop the pending frame. + cancelAnimationFrame(rafId); + rafId = null; + applyMove(); + } + try { + handle.releasePointerCapture(pointerId); + } catch { + // Already released; ignore. + } + // For touch/pen, no click follows to reset this; do it here. + if (pointerType !== "mouse") state.dragging = false; + + // Bake the drag transform back into left/top and clear it, so the resting + // panel is a plain offset again (what the ResizeObserver and dock layout + // expect). Only when we actually moved and aren't about to dock -- + // otherwise leave the docked/initial styles untouched. + const panel = panelWrapperRef.current; + if (panel !== null) { + panel.style.transform = ""; + panel.style.willChange = ""; + if (moved && pendingDock.current === null) { + panel.style.left = `${lastX}px`; + panel.style.top = `${lastY}px`; } - window.removeEventListener(eventNames.move, dragListener); - }, - { once: true }, - ); + } + + // Commit a pending dock, if any. + const side = pendingDock.current; + pendingDock.current = null; + setDockHint(null); + if (side !== null) { + unfixedOffset.current = {}; + setDock({ side, width: effectiveWidth }); + applyDockLayout(side); + } + } + // Promote to its own layer up front so the first frame is already smooth. + panel.style.willChange = "transform"; + const detach = bindPointerGesture(dragListener, endListener); + activeGestureCleanup.current = () => { + detach(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; }; + // Edges that can be grabbed to resize: when floating, either side; when + // docked, only the edge facing the canvas. + const resizeSides: ("left" | "right")[] = + dock.side === null + ? ["left", "right"] + : [dock.side === "left" ? "right" : "left"]; + const resizeHandler = + (side: "left" | "right") => (event: React.PointerEvent) => { + event.stopPropagation(); + const panel = panelWrapperRef.current; + if (!panel) return; + const parent = panel.parentElement; + if (!parent) return; + + const grip = event.currentTarget; + const pointerId = event.pointerId; + try { + grip.setPointerCapture(pointerId); + } catch { + // Ignore. + } + + resizing.current = true; + const startX = event.clientX; + const startWidth = panel.offsetWidth; + const startParentW = parent.clientWidth; + // Right edge in parent coordinates; kept pinned when resizing a floating + // panel from its left edge. + const startRight = panel.offsetLeft + startWidth; + // Font size is fixed for the gesture; resolve em -> px once (reading it + // each move would force a style recalc). + const emPx = parseFloat(getComputedStyle(panel).fontSize) || 16; + // Only a floating panel grabbed from its left edge needs special + // handling; docked panels are anchored to an edge by CSS, and a + // right-edge grip already keeps the left edge fixed. + const adjustLeft = dock.side === null && side === "left"; + let lastWidth = startWidth; + + // For a left-edge floating resize, pin the panel by its right edge for the + // duration of the gesture. The width lands via React state (so the + // contents reflow) a frame after any imperative `left` update would, so + // driving `left` directly desyncs the two and jitters the right edge. + // Anchoring `right` keeps that edge fixed no matter when the width lands; + // the left edge then simply follows the width. + if (adjustLeft) { + panel.style.right = `${startParentW - startRight}px`; + panel.style.left = "auto"; + } + + // Width updates go through React state (so the contents reflow), which + // would re-render per pointermove. Coalesce to one update per frame, same + // as the drag path -- this also caps the docked-resize setDock() calls + // that re-render the canvas inset. + let pendingWidth: number | null = null; + let rafId: number | null = null; + function flushWidth() { + rafId = null; + if (pendingWidth === null) return; + setWidthOverride(pendingWidth); + if (dock.side !== null) { + setDock({ side: dock.side, width: `${pendingWidth}px` }); + } + } + function resizeMove(event: PointerEvent) { + const panel = panelWrapperRef.current; + const parent = panel?.parentElement; + if (!panel || !parent) return; + const delta = event.clientX - startX; + const rawWidth = + side === "right" ? startWidth + delta : startWidth - delta; + const minWidth = minWidthEm * emPx; + const maxWidth = Math.max( + minWidth, + Math.min(maxWidthHardCapPx, parent.clientWidth - resizeParentPad), + ); + const newWidth = Math.max(minWidth, Math.min(maxWidth, rawWidth)); + lastWidth = newWidth; + pendingWidth = newWidth; + if (rafId === null) rafId = requestAnimationFrame(flushWidth); + } + function resizeEnd() { + detach(); + activeGestureCleanup.current = null; + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + flushWidth(); // Commit the final width. + try { + grip.releasePointerCapture(pointerId); + } catch { + // Ignore. + } + // Convert the right-edge anchor back to a left offset so dragging and + // the ResizeObserver (which read offsetLeft) keep working. Derived from + // the final width so it doesn't depend on React having flushed. + const panel = panelWrapperRef.current; + if (adjustLeft && panel !== null) { + panel.style.left = `${startRight - lastWidth}px`; + panel.style.right = "auto"; + } + resizing.current = false; + // Let the ResizeObserver re-derive the anchored offset from the new size. + unfixedOffset.current = {}; + } + const detach = bindPointerGesture(resizeMove, resizeEnd); + activeGestureCleanup.current = () => { + detach(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }; + return ( + {/* Drop-zone hints, shown while dragging near an edge. */} + - {children} + {/* Invisible resize zones; the ew-resize cursor signals them. They + straddle the panel edge, so they live outside the clipping wrapper. */} + {resizeSides.map((side) => ( + + ))} + {/* Clips content to the panel's (possibly docked -> square) radius, + which the Paper used to do before it had to let the grips overflow. */} + + {children} + ); } +/** Invisible draggable zone on one edge for resizing the panel width. There's + * no visible affordance; the ew-resize cursor on hover is the only cue. */ +function ResizeGrip({ + side, + onPointerDown, +}: { + side: "left" | "right"; + onPointerDown: (event: React.PointerEvent) => void; +}) { + return ( + + ); +} + +/** Translucent overlay shown at an edge while dragging, previewing where the + * panel will dock. */ +function DropZoneHint({ side, width }: { side: DockSide; width: string }) { + if (side === null) return null; + return ( + + ); +} + /** Handle object helps us hide, show, and drag our panel.*/ FloatingPanel.Handle = function FloatingPanelHandle({ children, }: { - children: string | React.ReactNode; + children: React.ReactNode; }) { const panelContext = React.useContext(FloatingPanelContext)!; return ( - <> - { - const state = panelContext.dragInfo.current; - if (state.dragging) { - state.dragging = false; - return; - } - panelContext.toggleExpanded(); - }} - onTouchStart={(event) => { - panelContext.dragHandler(event); - }} - onMouseDown={(event) => { - panelContext.dragHandler(event); - }} - > - {children} - - + { + // Ignore clicks that bubble up from portaled children (e.g. the + // share modal's overlay). React routes their events through here + // even though they're not in the handle's DOM subtree, which would + // otherwise collapse the panel when the modal is dismissed. + if (!event.currentTarget.contains(event.target as Node)) return; + const state = panelContext.dragInfo.current; + if (state.dragging) { + state.dragging = false; + return; + } + panelContext.toggleExpanded(); + }} + onPointerDown={(event) => { + panelContext.dragHandler(event); + }} + > + {children} + ); }; /** Contents of a panel. */ FloatingPanel.Contents = function FloatingPanelContents({ children, }: { - children: string | React.ReactNode; + children: React.ReactNode; }) { const context = React.useContext(FloatingPanelContext)!; return ( diff --git a/src/viser/client/src/ControlPanel/ServerControls.tsx b/src/viser/client/src/ControlPanel/ServerControls.tsx index 5aebc0dfb..e1a513119 100644 --- a/src/viser/client/src/ControlPanel/ServerControls.tsx +++ b/src/viser/client/src/ControlPanel/ServerControls.tsx @@ -5,7 +5,6 @@ import { Image, Checkbox, Divider, - Group, Stack, Text, TextInput, @@ -56,7 +55,7 @@ export default function ServerControls() { }} /> - + - - + + @@ -163,7 +162,7 @@ export default function ServerControls() { }} styles={{ label: { paddingLeft: "8px", letterSpacing: "-0.3px" }, - root: { flex: 1 }, + root: { flex: "1 0 auto" }, }} size="sm" /> @@ -176,11 +175,11 @@ export default function ServerControls() { }} styles={{ label: { paddingLeft: "8px", letterSpacing: "-0.3px" }, - root: { flex: 1 }, + root: { flex: "1 0 auto" }, }} size="sm" /> - + diff --git a/tests/e2e/test_floating_panel.py b/tests/e2e/test_floating_panel.py new file mode 100644 index 000000000..2cc3b785b --- /dev/null +++ b/tests/e2e/test_floating_panel.py @@ -0,0 +1,258 @@ +"""E2E tests for the floating control panel: dragging, docking, undocking, +resizing, and cleanup when the control layout is switched away from floating. + +The default control layout is "floating", so most of these tests use the +default page. The panel, its drag handle, and its resize grips are tagged with +``data-testid`` attributes in ``FloatingPanel.tsx``; the panel also exposes its +dock state via ``data-dock-side`` (``"none" | "left" | "right"``).""" + +from __future__ import annotations + +from playwright.sync_api import FloatRect, Page, ViewportSize, expect + +import viser + +# Wide enough to stay above the mobile breakpoint (xs = 36em = 576px), so the +# floating layout -- not the bottom sheet -- is used. +_VIEWPORT: ViewportSize = {"width": 1280, "height": 720} + + +def _bbox(page: Page, testid: str) -> FloatRect: + box = page.get_by_test_id(testid).bounding_box() + assert box is not None, f"no bounding box for {testid!r}" + return box + + +def _panel_box(page: Page) -> FloatRect: + return _bbox(page, "floating-panel") + + +def _canvas_box(page: Page) -> FloatRect: + box = page.locator("canvas").first.bounding_box() + assert box is not None, "no canvas bounding box" + return box + + +def _dock_side(page: Page) -> str | None: + return page.get_by_test_id("floating-panel").get_attribute("data-dock-side") + + +def _center(box: FloatRect) -> tuple[float, float]: + return (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + + +def _drag( + page: Page, + start: tuple[float, float], + end: tuple[float, float], + steps: int = 25, +) -> None: + """Press at ``start``, move to ``end``, release. Settles for a couple of + animation frames so the rAF-coalesced position/state updates land.""" + page.mouse.move(*start) + page.mouse.down() + page.mouse.move(*end, steps=steps) + page.mouse.up() + page.wait_for_timeout(300) + + +def _drag_handle_to(page: Page, end: tuple[float, float], steps: int = 25) -> None: + _drag(page, _center(_bbox(page, "floating-panel-handle")), end, steps=steps) + + +def test_floating_panel_default_placement(viser_page: Page) -> None: + """By default the panel floats (no dock) in the upper-right corner.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + expect(viser_page.get_by_test_id("floating-panel")).to_be_visible() + assert _dock_side(viser_page) == "none" + + panel = _panel_box(viser_page) + # Right-anchored: its right edge sits near the viewport's right edge... + assert panel["x"] + panel["width"] > _VIEWPORT["width"] * 0.6 + assert _VIEWPORT["width"] - (panel["x"] + panel["width"]) < 60 + # ...and it does not fill the viewport height (that's the docked look). + assert panel["height"] < _VIEWPORT["height"] * 0.9 + + +def test_drag_moves_floating_panel(viser_page: Page) -> None: + """Dragging the handle (away from any edge) repositions the panel without + docking it.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + before = _panel_box(viser_page) + start = _center(_bbox(viser_page, "floating-panel-handle")) + # Move well clear of both edges so no dock is offered. + _drag_handle_to(viser_page, (start[0] - 250, start[1] + 150)) + + assert _dock_side(viser_page) == "none" + after = _panel_box(viser_page) + assert after["x"] < before["x"] - 150 + assert after["y"] > before["y"] + 80 + + +def test_drag_to_left_edge_docks(viser_page: Page) -> None: + """Dragging the handle to the left edge docks the panel there: it pins to + the edge, fills the height, and the canvas insets to reserve its column.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + start = _center(_bbox(viser_page, "floating-panel-handle")) + _drag_handle_to(viser_page, (20, start[1])) + + assert _dock_side(viser_page) == "left" + panel = _panel_box(viser_page) + assert panel["x"] < 5 + assert panel["height"] > _VIEWPORT["height"] * 0.9 + + # The canvas is inset on the left by (about) the panel's width. + canvas = _canvas_box(viser_page) + assert abs(canvas["x"] - panel["width"]) < 30 + + +def test_drag_to_right_edge_docks(viser_page: Page) -> None: + """Dragging the handle to the right edge docks the panel on the right.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + start = _center(_bbox(viser_page, "floating-panel-handle")) + _drag_handle_to(viser_page, (_VIEWPORT["width"] - 20, start[1])) + + assert _dock_side(viser_page) == "right" + panel = _panel_box(viser_page) + assert panel["x"] + panel["width"] > _VIEWPORT["width"] - 5 + assert panel["height"] > _VIEWPORT["height"] * 0.9 + + # The canvas is inset on the right: its left edge stays at 0. + canvas = _canvas_box(viser_page) + assert canvas["x"] < 5 + assert canvas["width"] < _VIEWPORT["width"] - panel["width"] + 30 + + +def test_undock_by_dragging_to_center(viser_page: Page) -> None: + """A docked panel undocks (in place) when dragged back toward the center. + + Docks to the right rather than the left so the handle ends up clear of the + top-left notifications layer, which would otherwise intercept the grab.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + # Dock right first. + start = _center(_bbox(viser_page, "floating-panel-handle")) + _drag_handle_to(viser_page, (_VIEWPORT["width"] - 20, start[1])) + assert _dock_side(viser_page) == "right" + + # Drag the (now top-right) handle toward the middle of the viewport. + _drag_handle_to(viser_page, (_VIEWPORT["width"] / 2, _VIEWPORT["height"] / 2)) + + assert _dock_side(viser_page) == "none" + panel = _panel_box(viser_page) + # Back to a floating panel: no longer full height, and the canvas is no + # longer inset. + assert panel["height"] < _VIEWPORT["height"] * 0.9 + assert _canvas_box(viser_page)["x"] < 5 + + +def test_resize_right_grip_widens_panel(viser_page: Page) -> None: + """Dragging the right resize grip outward increases the panel width.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + before = _panel_box(viser_page) + grip = _center(_bbox(viser_page, "floating-panel-resize-right")) + _drag(viser_page, grip, (grip[0] + 120, grip[1])) + + after = _panel_box(viser_page) + assert after["width"] > before["width"] + 80 + assert _dock_side(viser_page) == "none" + + +def test_resize_left_grip_keeps_right_edge_pinned(viser_page: Page) -> None: + """Dragging the left grip outward widens the panel while its right edge + stays put (the right-anchored resize that avoids jitter).""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + before = _panel_box(viser_page) + right_before = before["x"] + before["width"] + grip = _center(_bbox(viser_page, "floating-panel-resize-left")) + _drag(viser_page, grip, (grip[0] - 120, grip[1])) + + after = _panel_box(viser_page) + assert after["width"] > before["width"] + 80 + assert abs((after["x"] + after["width"]) - right_before) < 12 + + +def _wait_for_client(server: viser.ViserServer) -> viser.ClientHandle: + """Return the first connected client, polling briefly for it to register.""" + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + clients = server.get_clients() + if clients: + return next(iter(clients.values())) + time.sleep(0.05) + raise RuntimeError("no client connected within timeout") + + +def test_notification_offset_clear_of_left_dock( + viser_page: Page, viser_server: viser.ViserServer +) -> None: + """A notification raised while the panel is docked on the left must be + pushed right so it sits over the canvas, not on top of the GUI.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + # Dock the panel to the left. + start = _center(_bbox(viser_page, "floating-panel-handle")) + _drag_handle_to(viser_page, (20, start[1])) + assert _dock_side(viser_page) == "left" + panel = _panel_box(viser_page) + + # Raise a (non-auto-closing) notification from the server. + client = _wait_for_client(viser_server) + client.add_notification( + "Docked test", "Should clear the GUI", auto_close_seconds=None + ) + + notification = viser_page.locator(".mantine-Notification-root").first + expect(notification).to_be_visible(timeout=5_000) + note = notification.bounding_box() + assert note is not None + + # The notification's left edge starts at or past the docked panel's right + # edge -- i.e. it doesn't horizontally overlap the GUI. + panel_right = panel["x"] + panel["width"] + assert note["x"] >= panel_right - 2, ( + f"notification (x={note['x']}) overlaps the left-docked panel " + f"(right edge {panel_right})" + ) + + +def test_switching_layout_releases_dock( + viser_page: Page, viser_server: viser.ViserServer +) -> None: + """Regression: docking, then switching control_layout away from floating, + must release the dock so the canvas stops reserving the panel's column. + + The sidebar layout lives on the right, so a stale *left* dock would leave + the canvas pushed in from the left -- the artifact this guards against.""" + viser_page.set_viewport_size(_VIEWPORT) + viser_page.wait_for_timeout(300) + + # Dock to the left; the canvas should inset from the left. + start = _center(_bbox(viser_page, "floating-panel-handle")) + _drag_handle_to(viser_page, (20, start[1])) + assert _dock_side(viser_page) == "left" + assert _canvas_box(viser_page)["x"] > 100 + + # Switch to a sidebar layout: the floating panel unmounts and must clean up. + viser_server.gui.configure_theme(control_layout="fixed") + expect(viser_page.get_by_test_id("floating-panel")).to_have_count(0, timeout=5_000) + viser_page.wait_for_timeout(300) + + # Canvas no longer carries the (now-defunct) left dock inset. + assert _canvas_box(viser_page)["x"] < 5