Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/actions/setup-e2e/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,20 @@ runs:
- name: Install Playwright browsers
shell: bash
run: uv run playwright install chromium --with-deps

# The dock playground e2e tests (test_dock_playground_*) serve the client
# SOURCE through a Vite dev server, so they need the client's node_modules
# regardless of whether the cached client BUILD was restored. Without this
# they skip silently on client-build cache hits. Cheap on a warm npm cache.
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: src/viser/client/package-lock.json

- name: Install client dependencies (for Vite-served dock tests)
shell: bash
run: |
cd src/viser/client
npm ci
8 changes: 8 additions & 0 deletions .github/workflows/typescript-compile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ jobs:
cd src/viser/client
npm ci
npx tsc

# Client unit tests (vitest, jsdom): layout ops, hit testing, width
# reconciliation, and the dock regression suites. Fast, so they ride
# along with the type check rather than needing their own workflow.
- name: Run vitest
run: |
cd src/viser/client
npx vitest run
469 changes: 271 additions & 198 deletions .test_durations

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/viser/client/dock_test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Viser dock playground</title>
<style>
html,
body,
#root {
margin: 0;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dock/playground.tsx"></script>
</body>
</html>
215 changes: 132 additions & 83 deletions src/viser/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";

// Local imports.
import { SynchronizedCameraControls } from "./CameraControls";
Expand All @@ -37,7 +37,10 @@ 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 {
ControlDockState,
ControlPanelDockSurface,
} from "./ControlPanel/ControlPanelDock";
import { useGuiState } from "./ControlPanel/GuiState";
import { searchParamKey } from "./SearchParamsUtils";
import { WebsocketMessageProducer } from "./WebsocketInterface";
Expand Down Expand Up @@ -337,22 +340,6 @@ function ViewerContents({
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<DockState>({
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(
() =>
Expand Down Expand Up @@ -395,69 +382,126 @@ function ViewerContents({
<BrowserWarning />
<ViserModal />
<CommandPalette />
{/* App layout */}
<DockContext.Provider
value={{
dock,
setDock,
expanded: panelExpanded,
toggleExpanded: togglePanelExpanded,
}}
>
<Box
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
flexDirection: "column",
}}
>
<Titlebar />
<Box
style={{
width: "100%",
position: "relative",
flexGrow: 1,
overflow: "hidden",
display: "flex",
}}
>
<NotificationsPanel />
<Box
style={(theme) => ({
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" && <ViserLogo />}
</Box>
{messageSource === "websocket" && (
<ControlPanel control_layout={controlLayout} />
)}
</Box>
</Box>
</DockContext.Provider>
<AppLayout
darkMode={darkMode}
controlLayout={controlLayout}
showLogo={showLogo}
messageSource={messageSource}
canvases={canvases}
/>
{showStats && <Stats className="stats-panel" />}
</MantineProvider>
</>
);
}

/**
* The app layout below the titlebar: canvas area + control panel. Lives in its
* own component (inside the MantineProvider) so it can read the theme's mobile
* breakpoint.
*/
function AppLayout({
darkMode,
controlLayout,
showLogo,
messageSource,
canvases,
}: {
darkMode: boolean;
controlLayout: "floating" | "collapsible" | "fixed";
showLogo: boolean;
messageSource: "websocket" | "file_playback" | "embed";
canvases: React.ReactNode;
}) {
const mantineTheme = useMantineTheme();
const useMobileView =
useMediaQuery(`(max-width: ${mantineTheme.breakpoints.xs})`) ?? false;
// The floating layout runs on the docking library: the control panel is a
// dock panel over the canvas (draggable, dockable to either edge, resizable,
// minimizable). Sidebar layouts and the mobile bottom sheet are unchanged.
const dockFloating =
controlLayout === "floating" &&
!useMobileView &&
messageSource === "websocket";

// Where the control panel sits, reported by the dock surface. `side: null`
// means it floats freely; a non-null side means it's docked (the dock
// surface insets the canvas itself -- this state only feeds the
// notifications offset).
const [controlDock, setControlDock] = React.useState<ControlDockState>({
side: null,
widthPx: 320,
expanded: true,
});
// Leaving the dock-floating layout (theme switch, mobile resize) unmounts
// the dock surface; clear any stale dock state so the notifications offset
// doesn't keep a defunct inset.
React.useEffect(() => {
if (!dockFloating) {
setControlDock((prev) =>
prev.side === null ? prev : { ...prev, side: null },
);
}
}, [dockFloating]);

const canvasContent = (
<>
{canvases}
{showLogo && messageSource === "websocket" && <ViserLogo />}
</>
);

return (
<Box
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
flexDirection: "column",
}}
>
<Titlebar />
<Box
style={{
width: "100%",
position: "relative",
flexGrow: 1,
overflow: "hidden",
display: "flex",
}}
>
<NotificationsPanel
dockedLeftInsetPx={
controlDock.side === "left" && controlDock.expanded
? controlDock.widthPx
: null
}
/>
<Box
style={(theme) => ({
backgroundColor: darkMode ? theme.colors.dark[9] : "#fff",
overflow: "hidden",
flexGrow: 1,
height: "100%",
})}
>
{dockFloating ? (
<ControlPanelDockSurface onDockStateChange={setControlDock}>
{canvasContent}
</ControlPanelDockSurface>
) : (
canvasContent
)}
</Box>
{messageSource === "websocket" && !dockFloating && (
<ControlPanel control_layout={controlLayout} />
)}
</Box>
</Box>
);
}

function ColorSchemeSetter(props: { darkMode: boolean }) {
const colorScheme = useMantineColorScheme();
// Update data attribute for color scheme.
Expand All @@ -470,13 +514,15 @@ 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;
function NotificationsPanel({
dockedLeftInsetPx,
}: {
/** Width of a left-docked, expanded control panel, or null when the
* top-left is clear. Notifications sit at the top-left; a left-docked panel
* shifts them right by its width so they appear over the canvas instead of
* covering the GUI. */
dockedLeftInsetPx: number | null;
}) {
return (
<Notifications
position="top-left"
Expand All @@ -488,7 +534,10 @@ function NotificationsPanel() {
boxShadow: "0.1em 0 1em 0 rgba(0,0,0,0.1) !important",
position: "absolute",
top: "1em",
left: dockedLeft ? `calc(${dock.width} + 1em)` : "1em",
left:
dockedLeftInsetPx !== null
? `calc(${dockedLeftInsetPx}px + 1em)`
: "1em",
pointerEvents: "none",
},
notification: {
Expand Down
Loading
Loading