diff --git a/docs/content/docs/api-reference/devtools.mdx b/docs/content/docs/api-reference/devtools.mdx index 1377f6336..b46bf8cf5 100644 --- a/docs/content/docs/api-reference/devtools.mdx +++ b/docs/content/docs/api-reference/devtools.mdx @@ -51,7 +51,6 @@ function App() { | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or every event. | | `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. | diff --git a/docs/content/docs/openui-lang/developer-tools.mdx b/docs/content/docs/openui-lang/developer-tools.mdx index ee0c9be2f..e0e9b3153 100644 --- a/docs/content/docs/openui-lang/developer-tools.mdx +++ b/docs/content/docs/openui-lang/developer-tools.mdx @@ -29,7 +29,7 @@ yarn add -D @openuidev/devtools npm install -D @openuidev/devtools ``` -Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set the corner, theme, or other props: +Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set theme or other props. ```tsx import { OpenUIDevtools } from "@openuidev/devtools"; @@ -38,7 +38,7 @@ export function App() { return ( <> {/* your app */} - + > ); } diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 517938bd3..833e81567 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -19,6 +19,8 @@ function App() { The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly. +Drag the floating button to snap it to a different corner, like the Next.js indicator. The corner is stored in `localStorage` and restored on the next visit (default `bottom-right`). The `position` prop is deprecated and ignored. + `@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. In development, `createLibrary()` registers the live library with the widget. A stream event's **Debug** button opens **OpenUI Debug** in its own tray — an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. @@ -30,7 +32,6 @@ Debug renders through the host's own `Renderer`. Its previews stay off the event | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or all. | | `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. | diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 1c7926b09..da779953b 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/devtools", - "version": "0.0.8", + "version": "0.0.9", "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability", "license": "MIT", "type": "module", diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..fcae7622a 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -4,6 +4,18 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index"; +import { SNAP_DURATION_MS, cornerPoint, isLeftPosition, nearestCorner } from "./lib/position"; + +if (typeof globalThis.PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + pointerId: number; + constructor(type: string, init: MouseEventInit & { pointerId?: number } = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + } + } + Object.assign(globalThis, { PointerEvent: PointerEventPolyfill }); +} vi.mock("@openuidev/react-lang", async () => { const { createElement: el } = await import("react"); @@ -77,6 +89,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.useRealTimers(); }); function render(props: OpenUIDevtoolsProps): void { @@ -154,9 +167,15 @@ function overviewStats(): string[] { * Both trays stay mounted so they can transition; a retracted one carries * `inert`. "Showing" therefore means present and not inert. */ +function tray(label: "OpenUI Inspect" | "OpenUI Debug"): HTMLElement { + const node = container.querySelector(`aside[aria-label="${label}"]`); + if (!node) throw new Error(`${label} tray not found`); + return node; +} + function trayShown(label: "OpenUI Inspect" | "OpenUI Debug"): boolean { - const tray = container.querySelector(`aside[aria-label="${label}"]`); - return !!tray && !tray.hasAttribute("inert"); + const node = container.querySelector(`aside[aria-label="${label}"]`); + return !!node && !node.hasAttribute("inert"); } /** The display filters live behind the header settings button. */ @@ -729,4 +748,233 @@ describe("OpenUIDevtools", () => { expect(container.textContent).toContain("Allow popups for this origin"); open.mockRestore(); }); + + it("defaults the toggle to the bottom-right corner", () => { + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.bottom).toBe("16px"); + expect(wrap.style.right).toBe("16px"); + }); + + it("restores a snapped corner from a previous session", () => { + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + }); + + it("ignores a deprecated position prop in favor of the stored corner", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true, position: "top-right" }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("`position` prop is deprecated")); + warn.mockRestore(); + }); + + it("eases the toggle into the nearest corner on drag and remembers it", () => { + vi.useFakeTimers(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); + render({ enabled: true }); + + const button = toggle(); + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 968, + y: 712, + left: 968, + top: 712, + right: 1008, + bottom: 752, + width: 40, + height: 40, + toJSON: () => ({}), + }); + + act(() => { + button.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 0, + clientX: 988, + clientY: 732, + }), + ); + button.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(JSON.parse(window.localStorage.getItem("openui.devtools.config") ?? "{}").position).toBe( + "top-left", + ); + + const wrap = button.parentElement!; + expect(wrap.style.transition).toContain("left"); + expect(wrap.style.transition).toContain(`${SNAP_DURATION_MS}ms`); + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + + act(() => { + vi.advanceTimersByTime(SNAP_DURATION_MS); + }); + + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(wrap.style.right).toBe(""); + expect(wrap.style.bottom).toBe(""); + + remount({ enabled: true }); + expect(toggle().parentElement!.style.top).toBe("16px"); + expect(toggle().parentElement!.style.left).toBe("16px"); + }); + + it("settles a snap onto inset edges after the glide, not leftover left/top", () => { + vi.useFakeTimers(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true }); + + const button = toggle(); + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 16, + y: 16, + left: 16, + top: 16, + right: 56, + bottom: 56, + width: 40, + height: 40, + toJSON: () => ({}), + }); + + act(() => { + button.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 0, + clientX: 36, + clientY: 36, + }), + ); + button.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 1000, + clientY: 740, + }), + ); + button.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + clientX: 1000, + clientY: 740, + }), + ); + }); + + const wrap = button.parentElement!; + expect(wrap.style.left).toBe("968px"); + expect(wrap.style.top).toBe("712px"); + expect(wrap.style.transition).toContain("top"); + expect(JSON.parse(window.localStorage.getItem("openui.devtools.config") ?? "{}").position).toBe( + "bottom-right", + ); + + act(() => { + vi.advanceTimersByTime(SNAP_DURATION_MS); + }); + + expect(wrap.style.bottom).toBe("16px"); + expect(wrap.style.right).toBe("16px"); + expect(wrap.style.left).toBe(""); + expect(wrap.style.top).toBe(""); + }); + + it("opens Inspect from the left when the toggle is on the left, and places Debug beside it", () => { + window.localStorage.setItem( + "openui.devtools.config", + JSON.stringify({ position: "bottom-left" }), + ); + seedLibrary(); + render({ enabled: true }); + click(toggle()); + openDebugTray(); + + const inspect = tray("OpenUI Inspect"); + const debug = tray("OpenUI Debug"); + expect(inspect.style.left).toBe("12px"); + expect(inspect.style.right).toBe(""); + expect(inspect.style.transform).toBe("translateX(0)"); + expect(debug.style.left).toBe("504px"); + expect(debug.style.right).toBe(""); + + click(container.querySelector('button[aria-label="Close OpenUI Inspect"]')!); + expect(inspect.style.transform).toBe("translateX(calc(-100% - 12px))"); + expect(debug.style.left).toBe("12px"); + }); + + it("opens Inspect from the right when the toggle is on the right", () => { + render({ enabled: true }); + click(toggle()); + + const inspect = tray("OpenUI Inspect"); + expect(inspect.style.right).toBe("12px"); + expect(inspect.style.left).toBe(""); + expect(inspect.style.transform).toBe("translateX(0)"); + }); +}); + +describe("isLeftPosition", () => { + it("is true only for the left corners", () => { + expect(isLeftPosition("top-left")).toBe(true); + expect(isLeftPosition("bottom-left")).toBe(true); + expect(isLeftPosition("top-right")).toBe(false); + expect(isLeftPosition("bottom-right")).toBe(false); + }); +}); + +describe("nearestCorner", () => { + const viewport = { width: 1000, height: 800 }; + + it("snaps to the quadrant that contains the button center", () => { + expect(nearestCorner(0, 0, viewport)).toBe("top-left"); + expect(nearestCorner(960, 0, viewport)).toBe("top-right"); + expect(nearestCorner(0, 760, viewport)).toBe("bottom-left"); + expect(nearestCorner(960, 760, viewport)).toBe("bottom-right"); + }); +}); + +describe("cornerPoint", () => { + const viewport = { width: 1024, height: 768 }; + + it("matches the inset corners used when the toggle is at rest", () => { + expect(cornerPoint("top-left", viewport)).toEqual({ left: 16, top: 16 }); + expect(cornerPoint("top-right", viewport)).toEqual({ left: 968, top: 16 }); + expect(cornerPoint("bottom-left", viewport)).toEqual({ left: 16, top: 712 }); + expect(cornerPoint("bottom-right", viewport)).toEqual({ left: 968, top: 712 }); + }); }); diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6f7e490e0..f8925a97c 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -13,10 +13,14 @@ import { } from "./inspect"; import { addOrReplaceEvent, + DEFAULT_POSITION, + isLeftPosition, isLibraryEvent, useDevtoolsConfig, useDevtoolsSingleton, + useSnapCorner, type DevtoolsConfig, + type DevtoolsPosition, } from "./lib"; import { DEFAULT_COLOR_MODE, @@ -30,14 +34,16 @@ import { } from "./theme"; import { ErrorBoundary, IconButton, ShiroLogo, ThemeSegmented } from "./ui"; +export type { DevtoolsPosition }; + /** Uniform row height for the settings menu, set by its tallest control. */ const MENU_ROW_HEIGHT = 28; /** - * Tray geometry. The two trays together fill a block anchored to the bottom - * right — 85% of the viewport, capped so it stops growing on very large - * displays. Inspect keeps a fixed width and Debug takes whatever is left, - * overlapping Inspect rather than collapsing once it hits its floor. + * Tray geometry. The two trays together fill a block along the toggle's + * side — 85% of the viewport, capped so it stops growing on very large + * displays. Inspect keeps a fixed width on that edge; Debug takes whatever + * is left, overlapping Inspect rather than collapsing once it hits its floor. */ const TRAY_EDGE = 12; const TRAY_GAP = 12; @@ -46,12 +52,13 @@ const DEBUG_MIN_WIDTH = 360; const BLOCK_W = `min(85vw, 3456px)`; const BLOCK_H = `min(85vh, 2234px)`; -export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; - export interface OpenUIDevtoolsProps { /** Force the widget on/off. Defaults to on outside production builds. */ enabled?: boolean; - /** Corner for the floating toggle button. Defaults to "bottom-right". */ + /** + * @deprecated Drag the floating button to snap it to a corner. + * This prop is ignored. The position is persisted + */ position?: DevtoolsPosition; /** How many events to keep; oldest are dropped first. */ maxEvents?: number; @@ -80,12 +87,13 @@ export interface OpenUIDevtoolsProps { * stack trace. OpenUI Inspect and OpenUI Debug are independent tools on * independent trays: a stream's Debug button opens Debug beside Inspect, and * either closes without disturbing the other. Display filters and the theme - * live in the header settings menu. Renders nothing in production unless + * live in the header settings menu. Drag the floating button to snap it to a + * corner; the choice is remembered. Renders nothing in production unless * `enabled` is set explicitly. */ export function OpenUIDevtools({ enabled, - position = "bottom-right", + position: _position, maxEvents = 50, errorsOnly = false, autoOpenOnError = true, @@ -107,10 +115,16 @@ export function OpenUIDevtools({ theme: DEFAULT_COLOR_MODE, helpSeen: false, editorPct: DEFAULT_EDITOR_PCT, + position: DEFAULT_POSITION, }, { theme: themeProp }, ); - const { onlyErrors, theme: mode } = config; + const { onlyErrors, theme: mode, position } = config; + const snap = useSnapCorner({ + position, + onSnap: (next) => setConfig({ position: next }), + onActivate: () => setOpen(true), + }); const debug = useDebug({ theme: mode, helpSeen: config.helpSeen, @@ -119,6 +133,13 @@ export function OpenUIDevtools({ }); const styles = uiStyles(theme(mode)); + useEffect(() => { + if (_position == null) return; + console.warn( + "[@openuidev/devtools] The `position` prop is deprecated. Drag the toggle to snap it to a corner.", + ); + }, [_position]); + // Read configRef inside the (stable) subscription without re-subscribing. useEffect(() => { if (!isEnabled) return; @@ -148,22 +169,25 @@ export function OpenUIDevtools({ const errorCount = events.filter((event) => event.level === "error").length; const visibleEvents = onlyErrors ? events.filter((event) => event.level !== "info") : events; - // Inspect is pinned to the right edge; Debug fills the rest of the block and - // slides over to reclaim Inspect's slot whenever Inspect is out. + // Inspect pins to the toggle's side of the screen; Debug fills the rest of + // the block inward. Closing Inspect lets Debug reclaim that slot. + const fromLeft = isLeftPosition(position); const inspectSlot = open ? INSPECT_WIDTH + TRAY_GAP : 0; const inspectTray: CSSProperties = { - right: TRAY_EDGE, + ...(fromLeft ? { left: TRAY_EDGE } : { right: TRAY_EDGE }), bottom: TRAY_EDGE, height: BLOCK_H, width: `min(${INSPECT_WIDTH}px, calc(100vw - ${TRAY_EDGE * 2}px))`, - transform: open ? "translateX(0)" : `translateX(calc(100% + ${TRAY_EDGE}px))`, + transform: open + ? "translateX(0)" + : `translateX(calc(${fromLeft ? "-100%" : "100%"} ${fromLeft ? "-" : "+"} ${TRAY_EDGE}px))`, }; // Debug is a workspace rather than a peek at the app behind it: it fills // everything Inspect leaves (left/right edges rather than a width, so the // inset matches top and bottom) and cuts straight in instead of sliding. // Overrides the shared UI, so it is spread last. const debugTray: CSSProperties = { - right: TRAY_EDGE + inspectSlot, + ...(fromLeft ? { left: TRAY_EDGE + inspectSlot } : { right: TRAY_EDGE + inspectSlot }), bottom: TRAY_EDGE, height: BLOCK_H, width: `max(${DEBUG_MIN_WIDTH}px, calc(${BLOCK_W} - ${inspectSlot}px))`, @@ -174,16 +198,21 @@ export function OpenUIDevtools({ return ( - + 0 ? styles.toggleError : null), - ...(toggleHovered ? styles.toggleHover : null), + ...(toggleHovered && !snap.dragging ? styles.toggleHover : null), }} - onClick={() => setOpen(true)} + onPointerDown={snap.onPointerDown} + onPointerMove={snap.onPointerMove} + onPointerUp={snap.onPointerUp} + onPointerCancel={snap.onPointerCancel} + onClick={snap.onClick} onMouseEnter={() => setToggleHovered(true)} onMouseLeave={() => setToggleHovered(false)} + draggable={false} aria-label="Open OpenUI Inspect" aria-expanded={open} title={ @@ -389,13 +418,6 @@ function SettingsMenu({ ); } -const positionStyles: Record = { - "top-left": { top: 16, left: 16 }, - "top-right": { top: 16, right: 16 }, - "bottom-left": { bottom: 16, left: 16 }, - "bottom-right": { bottom: 16, right: 16 }, -}; - function uiStyles(t: ThemeTokens) { return { toggleWrap: { @@ -416,7 +438,9 @@ function uiStyles(t: ThemeTokens) { borderColor: t.toggleBorder, background: t.toggleBg, color: t.toggleFg, - cursor: "pointer", + cursor: "inherit", + touchAction: "none", + userSelect: "none", boxShadow: t.toggleShadow, fontFamily: FONT, padding: 0, @@ -446,9 +470,9 @@ function uiStyles(t: ThemeTokens) { fontWeight: 700, lineHeight: 1, }, - // Geometry (right/width/transform) is per-tray and set inline; this is the - // shared UI. Each tray hides itself when closed so the other can stay open - // without a retracted tray remaining focusable. + // Geometry (left/right/width/transform) is per-tray and set inline; this is + // the shared UI. Each tray hides itself when closed so the other can stay + // open without a retracted tray remaining focusable. drawer: { position: "fixed", // Max 32-bit signed int — sit above any app UI, including the toggle. @@ -467,13 +491,13 @@ function uiStyles(t: ThemeTokens) { fontSize: 13, visibility: "hidden", transition: - "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 220ms", + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), left 260ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 220ms", overflow: "hidden", }, drawerOpen: { visibility: "visible", transition: - "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s", + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), left 260ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s", }, debugHost: { flex: 1, diff --git a/packages/devtools/src/lib/index.ts b/packages/devtools/src/lib/index.ts index c2b86a207..5add24644 100644 --- a/packages/devtools/src/lib/index.ts +++ b/packages/devtools/src/lib/index.ts @@ -8,5 +8,7 @@ export { type LibraryLike, type RegisteredLibrary, } from "./libraryRegistry"; +export { DEFAULT_POSITION, isLeftPosition, type DevtoolsPosition } from "./position"; export { useDevtoolsSingleton } from "./singleton"; export { useDevtoolsConfig, type DevtoolsConfig } from "./useDevtoolsConfig"; +export { useSnapCorner } from "./useSnapCorner"; diff --git a/packages/devtools/src/lib/position.ts b/packages/devtools/src/lib/position.ts new file mode 100644 index 000000000..47fb9aecc --- /dev/null +++ b/packages/devtools/src/lib/position.ts @@ -0,0 +1,80 @@ +import type { CSSProperties } from "react"; + +export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +export const DEFAULT_POSITION: DevtoolsPosition = "bottom-right"; + +const POSITIONS: ReadonlySet = new Set([ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]); + +export function isDevtoolsPosition(value: unknown): value is DevtoolsPosition { + return typeof value === "string" && POSITIONS.has(value); +} + +/** Trays open from the same side as the toggle so they don't cover the page. */ +export function isLeftPosition(position: DevtoolsPosition): boolean { + return position === "top-left" || position === "bottom-left"; +} + +export const TOGGLE_SIZE = 40; +export const TOGGLE_EDGE = 16; +/** Pointer movement (px) before a press becomes a drag instead of a click. */ +export const DRAG_THRESHOLD = 5; +/** How long the toggle glides into a corner after release. */ +export const SNAP_DURATION_MS = 240; +export const SNAP_EASING = "cubic-bezier(0.22, 1, 0.36, 1)"; + +export function cornerStyle(position: DevtoolsPosition): CSSProperties { + switch (position) { + case "top-left": + return { top: TOGGLE_EDGE, left: TOGGLE_EDGE }; + case "top-right": + return { top: TOGGLE_EDGE, right: TOGGLE_EDGE }; + case "bottom-left": + return { bottom: TOGGLE_EDGE, left: TOGGLE_EDGE }; + case "bottom-right": + return { bottom: TOGGLE_EDGE, right: TOGGLE_EDGE }; + } +} + +/** Pixel origin for `position: fixed` so a drag can ease into a corner. */ +export function cornerPoint( + position: DevtoolsPosition, + viewport: { width: number; height: number }, +): { left: number; top: number } { + const left = position.endsWith("right") + ? Math.max(TOGGLE_EDGE, viewport.width - TOGGLE_SIZE - TOGGLE_EDGE) + : TOGGLE_EDGE; + const top = position.startsWith("bottom") + ? Math.max(TOGGLE_EDGE, viewport.height - TOGGLE_SIZE - TOGGLE_EDGE) + : TOGGLE_EDGE; + return { left, top }; +} + +/** Snap the button's center to the nearest viewport quadrant. */ +export function nearestCorner( + left: number, + top: number, + viewport: { width: number; height: number }, +): DevtoolsPosition { + const cx = left + TOGGLE_SIZE / 2; + const cy = top + TOGGLE_SIZE / 2; + const vertical = cy < viewport.height / 2 ? "top" : "bottom"; + const horizontal = cx < viewport.width / 2 ? "left" : "right"; + return `${vertical}-${horizontal}`; +} + +export function clampDrag( + left: number, + top: number, + viewport: { width: number; height: number }, +): { left: number; top: number } { + return { + left: Math.min(Math.max(0, left), Math.max(0, viewport.width - TOGGLE_SIZE)), + top: Math.min(Math.max(0, top), Math.max(0, viewport.height - TOGGLE_SIZE)), + }; +} diff --git a/packages/devtools/src/lib/useDevtoolsConfig.ts b/packages/devtools/src/lib/useDevtoolsConfig.ts index ad57ec705..5b9af9b35 100644 --- a/packages/devtools/src/lib/useDevtoolsConfig.ts +++ b/packages/devtools/src/lib/useDevtoolsConfig.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MAX_EDITOR_PCT, MIN_EDITOR_PCT } from "../debug/DebugUI"; import type { ColorMode } from "../theme"; +import { isDevtoolsPosition, type DevtoolsPosition } from "./position"; const STORAGE_KEY = "openui.devtools.config"; @@ -12,6 +13,8 @@ export type DevtoolsConfig = { helpSeen: boolean; /** Editor column width in OpenUI Debug, as a percentage of the split. */ editorPct: number; + /** Corner the floating toggle is snapped to. */ + position: DevtoolsPosition; }; function isColorMode(value: unknown): value is ColorMode { @@ -27,6 +30,7 @@ function sanitize(patch: Partial): Partial { if (typeof patch.editorPct === "number" && Number.isFinite(patch.editorPct)) { next.editorPct = Math.min(MAX_EDITOR_PCT, Math.max(MIN_EDITOR_PCT, patch.editorPct)); } + if (isDevtoolsPosition(patch.position)) next.position = patch.position; return next; } @@ -65,14 +69,15 @@ function merge(base: DevtoolsConfig, patch: Partial): DevtoolsCo * * Theme is arg-first: a passed `theme` wins over storage and is written * back so Settings stays in sync. With no arg, the stored theme is used. + * Position has no prop — stored snap corner, else the default. */ export function useDevtoolsConfig(defaults: DevtoolsConfig, provided: { theme?: ColorMode } = {}) { - const resolveTheme = (stored: Partial, fallback: ColorMode): ColorMode => - provided.theme ?? stored.theme ?? fallback; - const [config, setConfigState] = useState(() => { const stored = readStored(); - const next = { ...merge(defaults, stored), theme: resolveTheme(stored, defaults.theme) }; + const next = { + ...merge(defaults, stored), + theme: provided.theme ?? stored.theme ?? defaults.theme, + }; if (provided.theme) writeStored({ theme: provided.theme }); return next; }); @@ -85,7 +90,10 @@ export function useDevtoolsConfig(defaults: DevtoolsConfig, provided: { theme?: useEffect(() => { const stored = readStored(); setConfigState((prev) => { - const next = { ...merge(prev, stored), theme: resolveTheme(stored, prev.theme) }; + const next = { + ...merge(prev, stored), + theme: provided.theme ?? stored.theme ?? prev.theme, + }; configRef.current = next; return next; }); diff --git a/packages/devtools/src/lib/useSnapCorner.ts b/packages/devtools/src/lib/useSnapCorner.ts new file mode 100644 index 000000000..32e0c2047 --- /dev/null +++ b/packages/devtools/src/lib/useSnapCorner.ts @@ -0,0 +1,211 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type MouseEvent, + type PointerEvent, +} from "react"; +import { + clampDrag, + cornerPoint, + cornerStyle, + DRAG_THRESHOLD, + nearestCorner, + SNAP_DURATION_MS, + SNAP_EASING, + type DevtoolsPosition, +} from "./position"; + +type Gesture = { + pointerId: number; + startX: number; + startY: number; + grabX: number; + grabY: number; + originLeft: number; + originTop: number; + dragged: boolean; +}; + +type Drag = { left: number; top: number; snapping: boolean }; + +function viewport(): { width: number; height: number } { + return { width: window.innerWidth, height: window.innerHeight }; +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +interface UseSnapCornerOptions { + position: DevtoolsPosition; + onSnap: (position: DevtoolsPosition) => void; + onActivate: () => void; +} + +interface UseSnapCornerResult { + wrapStyle: CSSProperties; + dragging: boolean; + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerUp: (event: PointerEvent) => void; + onPointerCancel: (event: PointerEvent) => void; + onClick: (event: MouseEvent) => void; +} + +/** + * Next.js-style corner snap for the floating toggle: drag past a small + * threshold, follow the pointer, then ease into the nearest quadrant. + * A press that never crosses the threshold is a click (`onActivate`). + */ +export function useSnapCorner({ + position, + onSnap, + onActivate, +}: UseSnapCornerOptions): UseSnapCornerResult { + const [drag, setDrag] = useState(null); + const dragRef = useRef(drag); + dragRef.current = drag; + const gesture = useRef(null); + const skipClick = useRef(false); + const snapTimer = useRef | null>(null); + const onSnapRef = useRef(onSnap); + onSnapRef.current = onSnap; + const onActivateRef = useRef(onActivate); + onActivateRef.current = onActivate; + + useEffect( + () => () => { + if (snapTimer.current) clearTimeout(snapTimer.current); + }, + [], + ); + + const wrapStyle: CSSProperties = drag + ? { + left: drag.left, + top: drag.top, + right: "auto", + bottom: "auto", + cursor: drag.snapping ? "grab" : "grabbing", + transition: drag.snapping + ? `left ${SNAP_DURATION_MS}ms ${SNAP_EASING}, top ${SNAP_DURATION_MS}ms ${SNAP_EASING}` + : "none", + willChange: drag.snapping ? "left, top" : undefined, + } + : { ...cornerStyle(position), cursor: "grab" }; + + const clearSnapTimer = () => { + if (!snapTimer.current) return; + clearTimeout(snapTimer.current); + snapTimer.current = null; + }; + + const settle = useCallback(() => { + dragRef.current = null; + setDrag(null); + snapTimer.current = null; + }, []); + + const glideTo = useCallback( + (corner: DevtoolsPosition) => { + if (prefersReducedMotion()) { + settle(); + return; + } + const next = { ...cornerPoint(corner, viewport()), snapping: true }; + dragRef.current = next; + setDrag(next); + clearSnapTimer(); + snapTimer.current = setTimeout(settle, SNAP_DURATION_MS); + }, + [settle], + ); + + const onPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) return; + clearSnapTimer(); + const rect = event.currentTarget.getBoundingClientRect(); + gesture.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + grabX: event.clientX - rect.left, + grabY: event.clientY - rect.top, + originLeft: rect.left, + originTop: rect.top, + dragged: false, + }; + skipClick.current = false; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, []); + + const onPointerMove = useCallback((event: PointerEvent) => { + const g = gesture.current; + if (!g || event.pointerId !== g.pointerId) return; + const dx = event.clientX - g.startX; + const dy = event.clientY - g.startY; + if (!g.dragged && dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) return; + g.dragged = true; + skipClick.current = true; + const next = { + ...clampDrag(event.clientX - g.grabX, event.clientY - g.grabY, viewport()), + snapping: false, + }; + dragRef.current = next; + setDrag(next); + }, []); + + const finish = useCallback( + (event: PointerEvent, commit: boolean) => { + const g = gesture.current; + if (!g || event.pointerId !== g.pointerId) return; + gesture.current = null; + event.currentTarget.releasePointerCapture?.(event.pointerId); + if (!g.dragged) { + settle(); + return; + } + const point = dragRef.current ?? { left: g.originLeft, top: g.originTop }; + const corner = commit ? nearestCorner(point.left, point.top, viewport()) : position; + if (commit) onSnapRef.current(corner); + glideTo(corner); + }, + [glideTo, position, settle], + ); + + const onPointerUp = useCallback( + (event: PointerEvent) => finish(event, true), + [finish], + ); + + const onPointerCancel = useCallback( + (event: PointerEvent) => finish(event, false), + [finish], + ); + + const onClick = useCallback((event: MouseEvent) => { + if (skipClick.current) { + skipClick.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + onActivateRef.current(); + }, []); + + return { + wrapStyle, + dragging: drag !== null && !drag.snapping, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onClick, + }; +}