| author | |
| committer | |
| log | e05b6a353e4701d919165a7a32ee30b2ec61dce0 |
| tree | b38ec38b04481f03ee50881213551960bafff9f6 |
| parent | 99e6ff629afe3d401757ea3b304d861b1b64f1e5 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover often missed drag handles and terminal text selection accidentally resized the sidebar. Reserve 12px sidebar and side-panel gutters and a 12px terminal divider outside selectable terminal content; expand browser viewport rails to 16px and floating preview targets to 12px edges and 20px corners. Add keyboard resizing and clean up pointer capture on interruption, route changes and unmount while preserving final release positions.
Assisted-by: gpt-6.1-sol14 files changed, 555 insertions(+), 174 deletions(-)
apps/web/src/browser/BrowserViewportResizeHandles.tsx+1-1| ... | ... | @@ -30,7 +30,7 @@ interface Props { |
| 30 | 30 | type HandleKind = "horizontal" | "vertical" | "corner"; |
| 31 | 31 | |
| 32 | 32 | const EDGE_BUTTON_CLASS = |
| 33 | "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]"; | |
| 33 | "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]"; | |
| 34 | 34 | const EDGE_GRIP_CLASS = |
| 35 | 35 | "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground"; |
| 36 | 36 |
apps/web/src/browser/browserViewportLayout.test.ts+8-8| ... | ... | @@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => { |
| 123 | 123 | expect( |
| 124 | 124 | resolveBrowserDeviceViewportLayout( |
| 125 | 125 | { width: 1200, height: 900 }, |
| 126 | { _tag: "freeform", width: 1180, height: 852 }, | |
| 126 | { _tag: "freeform", width: 1168, height: 846 }, | |
| 127 | 127 | ), |
| 128 | 128 | ).toEqual({ |
| 129 | 129 | canvasWidth: 1200, |
| 130 | 130 | canvasHeight: 900, |
| 131 | viewportX: 10, | |
| 131 | viewportX: 16, | |
| 132 | 132 | viewportY: 38, |
| 133 | viewportWidth: 1180, | |
| 134 | viewportHeight: 852, | |
| 133 | viewportWidth: 1168, | |
| 134 | viewportHeight: 846, | |
| 135 | 135 | viewportScale: 1, |
| 136 | 136 | fillsPanel: false, |
| 137 | 137 | }); |
| ... | ... | @@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => { |
| 139 | 139 | |
| 140 | 140 | it("captures the available framed area when responsive mode is enabled", () => { |
| 141 | 141 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ |
| 142 | width: 1180, | |
| 143 | height: 852, | |
| 142 | width: 1168, | |
| 143 | height: 846, | |
| 144 | 144 | }); |
| 145 | 145 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ |
| 146 | width: 590, | |
| 147 | height: 426, | |
| 146 | width: 584, | |
| 147 | height: 423, | |
| 148 | 148 | }); |
| 149 | 149 | }); |
| 150 | 150 |
apps/web/src/browser/browserViewportLayout.ts+1-1| ... | ... | @@ -23,7 +23,7 @@ export interface BrowserViewportLayout { |
| 23 | 23 | // on both sides, inside the toolbar's 1px bottom border. The bar scrolls |
| 24 | 24 | // horizontally, which also clips anything that overflows vertically. |
| 25 | 25 | export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38; |
| 26 | export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; | |
| 26 | export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 16; | |
| 27 | 27 | |
| 28 | 28 | export type BrowserViewportResizeDirection = |
| 29 | 29 | | "north" |
apps/web/src/browser/useBrowserViewportResize.test.tsx created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import { act, type PointerEvent as ReactPointerEvent, useLayoutEffect } from "react"; | |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 3 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { commitBrowserViewportChange } from "./browserViewportActions"; | |
| 6 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; | |
| 7 | ||
| 8 | vi.mock("./browserViewportActions", () => ({ commitBrowserViewportChange: vi.fn(async () => {}) })); | |
| 9 | ||
| 10 | let renderer: ReactTestRenderer; | |
| 11 | let result: ReturnType<typeof useBrowserViewportResize>; | |
| 12 | let events: EventTarget; | |
| 13 | let captured = false; | |
| 14 | const target = Object.assign(new EventTarget(), { | |
| 15 | setPointerCapture: () => { | |
| 16 | captured = true; | |
| 17 | }, | |
| 18 | hasPointerCapture: () => captured, | |
| 19 | releasePointerCapture: () => { | |
| 20 | captured = false; | |
| 21 | }, | |
| 22 | }); | |
| 23 | ||
| 24 | function pointer(clientX: number, options: { button?: number; pointerId?: number } = {}) { | |
| 25 | return { | |
| 26 | button: options.button ?? 0, | |
| 27 | pointerId: options.pointerId ?? 1, | |
| 28 | clientX, | |
| 29 | clientY: 100, | |
| 30 | target, | |
| 31 | currentTarget: target, | |
| 32 | preventDefault() {}, | |
| 33 | stopPropagation() {}, | |
| 34 | } as unknown as ReactPointerEvent<HTMLButtonElement>; | |
| 35 | } | |
| 36 | ||
| 37 | function dispatch(type: string, clientX = 100, destination = events) { | |
| 38 | destination.dispatchEvent( | |
| 39 | Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientX, clientY: 100 }), | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | function Viewport({ tabId = "one" }: { tabId?: string }) { | |
| 44 | const resize = useBrowserViewportResize({ | |
| 45 | tabId, | |
| 46 | viewport: { _tag: "freeform", width: 800, height: 600 }, | |
| 47 | zoomFactor: 1, | |
| 48 | containerSize: { width: 1200, height: 900 }, | |
| 49 | deviceToolbarVisible: true, | |
| 50 | aspectRatio: null, | |
| 51 | }); | |
| 52 | useLayoutEffect(() => { | |
| 53 | result = resize; | |
| 54 | }); | |
| 55 | return null; | |
| 56 | } | |
| 57 | ||
| 58 | beforeEach(async () => { | |
| 59 | events = new EventTarget(); | |
| 60 | captured = false; | |
| 61 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 62 | vi.stubGlobal("window", { | |
| 63 | addEventListener: events.addEventListener.bind(events), | |
| 64 | removeEventListener: events.removeEventListener.bind(events), | |
| 65 | }); | |
| 66 | await act(() => { | |
| 67 | renderer = create(<Viewport />); | |
| 68 | }); | |
| 69 | }); | |
| 70 | ||
| 71 | afterEach(async () => { | |
| 72 | await act(() => renderer.unmount()); | |
| 73 | vi.unstubAllGlobals(); | |
| 74 | vi.clearAllMocks(); | |
| 75 | }); | |
| 76 | ||
| 77 | it("commits the final release position even without a move frame", async () => { | |
| 78 | await act(async () => { | |
| 79 | result.handleResizePointerDown("east", pointer(100)); | |
| 80 | dispatch("pointerup", 225); | |
| 81 | }); | |
| 82 | expect(commitBrowserViewportChange).toHaveBeenCalledExactlyOnceWith("one", { | |
| 83 | _tag: "freeform", | |
| 84 | width: 1050, | |
| 85 | height: 600, | |
| 86 | }); | |
| 87 | expect(captured).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | it.each(["cancel", "lost capture", "blur", "unmount", "tab switch"])( | |
| 91 | "abandons the resize after %s", | |
| 92 | async (reason) => { | |
| 93 | await act(() => { | |
| 94 | result.handleResizePointerDown("east", pointer(100)); | |
| 95 | dispatch("pointermove", 175); | |
| 96 | }); | |
| 97 | expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 950, height: 600 }); | |
| 98 | await act(() => { | |
| 99 | if (reason === "cancel") dispatch("pointercancel"); | |
| 100 | if (reason === "lost capture") dispatch("lostpointercapture", 100, target); | |
| 101 | if (reason === "blur") dispatch("blur"); | |
| 102 | if (reason === "unmount") renderer.unmount(); | |
| 103 | if (reason === "tab switch") renderer.update(<Viewport tabId="two" />); | |
| 104 | }); | |
| 105 | await act(() => dispatch("pointerup", 300)); | |
| 106 | expect(commitBrowserViewportChange).not.toHaveBeenCalled(); | |
| 107 | expect(captured).toBe(false); | |
| 108 | if (reason !== "unmount") | |
| 109 | expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 800, height: 600 }); | |
| 110 | }, | |
| 111 | ); | |
| 112 | ||
| 113 | it("ignores secondary buttons and selectable descendants", async () => { | |
| 114 | await act(() => { | |
| 115 | result.handleResizePointerDown("east", pointer(100, { button: 2 })); | |
| 116 | result.handleResizePointerDown("east", { | |
| 117 | ...pointer(100), | |
| 118 | target: {}, | |
| 119 | } as ReactPointerEvent<HTMLButtonElement>); | |
| 120 | dispatch("pointerup", 225); | |
| 121 | }); | |
| 122 | expect(commitBrowserViewportChange).not.toHaveBeenCalled(); | |
| 123 | expect(captured).toBe(false); | |
| 124 | }); |
apps/web/src/browser/useBrowserViewportResize.ts+26-2| ... | ... | @@ -4,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr |
| 4 | 4 | import { |
| 5 | 5 | useCallback, |
| 6 | 6 | useEffect, |
| 7 | useLayoutEffect, | |
| 7 | 8 | useRef, |
| 8 | 9 | useState, |
| 9 | 10 | type KeyboardEvent as ReactKeyboardEvent, |
| ... | ... | @@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: { |
| 42 | 43 | const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 43 | 44 | const keyboardViewportRef = useRef<ViewportDrag | null>(null); |
| 44 | 45 | const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null); |
| 45 | const sourceViewportKey = browserViewportSettingKey(viewport); | |
| 46 | const sourceViewportKey = `${tabId}:${browserViewportSettingKey(viewport)}`; | |
| 46 | 47 | const sourceViewportKeyRef = useRef(sourceViewportKey); |
| 47 | 48 | sourceViewportKeyRef.current = sourceViewportKey; |
| 48 | 49 | const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; |
| ... | ... | @@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: { |
| 75 | 76 | [], |
| 76 | 77 | ); |
| 77 | 78 | |
| 79 | useLayoutEffect( | |
| 80 | () => () => { | |
| 81 | dragVersionRef.current += 1; | |
| 82 | dragCleanupRef.current?.(); | |
| 83 | }, | |
| 84 | [tabId, sourceViewportKey], | |
| 85 | ); | |
| 86 | ||
| 78 | 87 | useEffect(() => { |
| 79 | 88 | const pending = keyboardViewportRef.current; |
| 80 | 89 | if (!pending || pending.sourceKey === sourceViewportKey) return; |
| ... | ... | @@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: { |
| 159 | 168 | direction: BrowserViewportResizeDirection, |
| 160 | 169 | event: ReactPointerEvent<HTMLButtonElement>, |
| 161 | 170 | ) => { |
| 162 | if (effectiveViewport._tag === "fill") return; | |
| 171 | if ( | |
| 172 | effectiveViewport._tag === "fill" || | |
| 173 | event.button !== 0 || | |
| 174 | event.target !== event.currentTarget | |
| 175 | ) | |
| 176 | return; | |
| 163 | 177 | event.preventDefault(); |
| 164 | 178 | event.stopPropagation(); |
| 165 | 179 | if (keyboardCommitTimerRef.current !== null) { |
| ... | ... | @@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: { |
| 217 | 231 | window.removeEventListener("pointermove", move); |
| 218 | 232 | window.removeEventListener("pointerup", finish); |
| 219 | 233 | window.removeEventListener("pointercancel", cancel); |
| 234 | window.removeEventListener("blur", interrupt); | |
| 235 | target.removeEventListener("lostpointercapture", cancel); | |
| 220 | 236 | dragCleanupRef.current = null; |
| 221 | 237 | try { |
| 222 | 238 | target.releasePointerCapture(pointerId); |
| ... | ... | @@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: { |
| 226 | 242 | } |
| 227 | 243 | function finish(upEvent: PointerEvent) { |
| 228 | 244 | if (upEvent.pointerId !== pointerId) return; |
| 245 | if (!sourceChanged()) move(upEvent); | |
| 229 | 246 | cleanup(); |
| 230 | 247 | if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { |
| 231 | 248 | clearDrag(); |
| ... | ... | @@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: { |
| 243 | 260 | dragVersionRef.current += 1; |
| 244 | 261 | clearDrag(); |
| 245 | 262 | } |
| 263 | function interrupt() { | |
| 264 | cleanup(); | |
| 265 | dragVersionRef.current += 1; | |
| 266 | clearDrag(); | |
| 267 | } | |
| 246 | 268 | dragCleanupRef.current = cleanup; |
| 247 | 269 | window.addEventListener("pointermove", move, { passive: false }); |
| 248 | 270 | window.addEventListener("pointerup", finish); |
| 249 | 271 | window.addEventListener("pointercancel", cancel); |
| 272 | window.addEventListener("blur", interrupt); | |
| 273 | target.addEventListener("lostpointercapture", cancel); | |
| 250 | 274 | }; |
| 251 | 275 | |
| 252 | 276 | return { |
apps/web/src/components/AppSidebarLayout.tsx+1-1| ... | ... | @@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 338 | 338 | ) : ( |
| 339 | 339 | <ThreadSidebar /> |
| 340 | 340 | )} |
| 341 | <SidebarRail onDoubleClick={resetSidebarWidth} /> | |
| 341 | <SidebarRail aria-valuenow={sidebarWidth} onDoubleClick={resetSidebarWidth} /> | |
| 342 | 342 | </Sidebar> |
| 343 | 343 | {children} |
| 344 | 344 | <SidebarControl /> |
apps/web/src/components/ThreadTerminalDrawer.tsx+76-98| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useResizeDrag } from "~/hooks/useResizeDrag"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { |
| 3 | 4 | isAtomCommandInterrupted, |
| ... | ... | @@ -31,7 +32,6 @@ import { |
| 31 | 32 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 32 | 33 | import * as Schema from "effect/Schema"; |
| 33 | 34 | import { |
| 34 | type PointerEvent as ReactPointerEvent, | |
| 35 | 35 | type ReactNode, |
| 36 | 36 | type SetStateAction, |
| 37 | 37 | useCallback, |
| ... | ... | @@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({ |
| 1250 | 1250 | [controlledDrawerHeight, threadId], |
| 1251 | 1251 | ); |
| 1252 | 1252 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| 1253 | const drawerHeightRef = useRef(drawerHeight); | |
| 1254 | 1253 | const preferredDrawerHeightRef = useRef(preferredDrawerHeight); |
| 1255 | 1254 | const lastSyncedHeightRef = useRef(controlledDrawerHeight); |
| 1256 | 1255 | const onHeightChangeRef = useRef(onHeightChange); |
| 1257 | const resizeStateRef = useRef<{ | |
| 1258 | pointerId: number; | |
| 1259 | startY: number; | |
| 1260 | startHeight: number; | |
| 1261 | drawer: HTMLElement | null; | |
| 1262 | } | null>(null); | |
| 1263 | const didResizeDuringDragRef = useRef(false); | |
| 1264 | 1256 | |
| 1265 | 1257 | const normalizedTerminalIds = useMemo(() => { |
| 1266 | 1258 | const normalizedIds: string[] = []; |
| ... | ... | @@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({ |
| 1442 | 1434 | }, [onHeightChange]); |
| 1443 | 1435 | |
| 1444 | 1436 | useLayoutEffect(() => { |
| 1445 | // A render mid-drag (the window cap moved) must not replace the live height. | |
| 1446 | const resizeState = resizeStateRef.current; | |
| 1447 | if (resizeState) { | |
| 1448 | resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); | |
| 1449 | return; | |
| 1450 | } | |
| 1451 | drawerHeightRef.current = drawerHeight; | |
| 1452 | }, [drawerHeight]); | |
| 1453 | ||
| 1454 | useEffect(() => { | |
| 1455 | 1437 | preferredDrawerHeightRef.current = preferredDrawerHeight; |
| 1456 | 1438 | }, [preferredDrawerHeight]); |
| 1457 | 1439 | |
| ... | ... | @@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({ |
| 1465 | 1447 | lastSyncedHeightRef.current = controlledDrawerHeight; |
| 1466 | 1448 | }, [controlledDrawerHeight, threadId]); |
| 1467 | 1449 | |
| 1468 | const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | |
| 1469 | if (event.button !== 0) return; | |
| 1470 | event.preventDefault(); | |
| 1471 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 1472 | didResizeDuringDragRef.current = false; | |
| 1473 | resizeStateRef.current = { | |
| 1474 | pointerId: event.pointerId, | |
| 1475 | startY: event.clientY, | |
| 1476 | startHeight: drawerHeightRef.current, | |
| 1477 | drawer: event.currentTarget.parentElement, | |
| 1478 | }; | |
| 1479 | }, []); | |
| 1450 | const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag<HTMLDivElement>( | |
| 1451 | (event) => { | |
| 1452 | const drawer = event.currentTarget.parentElement; | |
| 1453 | return { | |
| 1454 | size: drawerHeight, | |
| 1455 | edge: "top", | |
| 1456 | resize(value) { | |
| 1457 | const height = clampDrawerHeight(value); | |
| 1458 | drawer?.style.setProperty("height", `${height}px`); | |
| 1459 | return height; | |
| 1460 | }, | |
| 1461 | finish(height, moved) { | |
| 1462 | if (!moved || height === drawerHeight) { | |
| 1463 | drawer?.style.setProperty( | |
| 1464 | "height", | |
| 1465 | `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, | |
| 1466 | ); | |
| 1467 | return; | |
| 1468 | } | |
| 1469 | setDrawerHeight(height); | |
| 1470 | syncHeight(height); | |
| 1471 | setResizeEpoch((value) => value + 1); | |
| 1472 | }, | |
| 1473 | cleanup(committed) { | |
| 1474 | if (!committed) | |
| 1475 | drawer?.style.setProperty( | |
| 1476 | "height", | |
| 1477 | `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, | |
| 1478 | ); | |
| 1479 | }, | |
| 1480 | }; | |
| 1481 | }, | |
| 1482 | `${threadId}:${visible}:${isPanel}`, | |
| 1483 | ); | |
| 1480 | 1484 | |
| 1481 | // Writes the height straight to the drawer while dragging so the edge lands in | |
| 1482 | // the pointer's frame; React state commits once on release. | |
| 1483 | const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | |
| 1484 | const resizeState = resizeStateRef.current; | |
| 1485 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | |
| 1486 | event.preventDefault(); | |
| 1487 | const clampedHeight = clampDrawerHeight( | |
| 1488 | resizeState.startHeight + (resizeState.startY - event.clientY), | |
| 1489 | ); | |
| 1490 | if (clampedHeight === drawerHeightRef.current) { | |
| 1491 | return; | |
| 1492 | } | |
| 1493 | didResizeDuringDragRef.current = true; | |
| 1494 | drawerHeightRef.current = clampedHeight; | |
| 1495 | resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); | |
| 1496 | }, []); | |
| 1485 | useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]); | |
| 1497 | 1486 | |
| 1498 | const handleResizePointerEnd = useCallback( | |
| 1499 | (event: ReactPointerEvent<HTMLDivElement>) => { | |
| 1500 | const resizeState = resizeStateRef.current; | |
| 1501 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | |
| 1502 | resizeStateRef.current = null; | |
| 1503 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| 1504 | event.currentTarget.releasePointerCapture(event.pointerId); | |
| 1505 | } | |
| 1506 | // A drag that ends where it started keeps the stored preference, which may | |
| 1507 | // be taller than the window-capped height the drag started from. | |
| 1508 | if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { | |
| 1509 | // React skips the write when the rendered height did not change, so put | |
| 1510 | // back the preference as the current window caps it. | |
| 1511 | const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); | |
| 1512 | drawerHeightRef.current = renderedHeight; | |
| 1513 | resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); | |
| 1514 | return; | |
| 1515 | } | |
| 1516 | setDrawerHeight(drawerHeightRef.current); | |
| 1517 | syncHeight(drawerHeightRef.current); | |
| 1518 | setResizeEpoch((value) => value + 1); | |
| 1519 | }, | |
| 1520 | [setDrawerHeight, syncHeight], | |
| 1487 | const drawerResizeHandle = ( | |
| 1488 | <div | |
| 1489 | role="separator" | |
| 1490 | aria-label="Resize terminal" | |
| 1491 | aria-orientation="horizontal" | |
| 1492 | aria-valuemin={MIN_DRAWER_HEIGHT} | |
| 1493 | aria-valuemax={windowMaxDrawerHeight} | |
| 1494 | aria-valuenow={drawerHeight} | |
| 1495 | tabIndex={0} | |
| 1496 | className="group relative h-3 shrink-0 cursor-row-resize touch-none select-none outline-none hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15" | |
| 1497 | {...drawerResizeHandlers} | |
| 1498 | onKeyDown={(event) => { | |
| 1499 | const step = event.shiftKey ? 40 : 10; | |
| 1500 | const next = | |
| 1501 | event.key === "ArrowUp" | |
| 1502 | ? drawerHeight + step | |
| 1503 | : event.key === "ArrowDown" | |
| 1504 | ? drawerHeight - step | |
| 1505 | : event.key === "Home" | |
| 1506 | ? MIN_DRAWER_HEIGHT | |
| 1507 | : event.key === "End" | |
| 1508 | ? windowMaxDrawerHeight | |
| 1509 | : null; | |
| 1510 | if (next === null) return; | |
| 1511 | event.preventDefault(); | |
| 1512 | const height = clampDrawerHeight(next); | |
| 1513 | setDrawerHeight(height); | |
| 1514 | syncHeight(height); | |
| 1515 | setResizeEpoch((value) => value + 1); | |
| 1516 | }} | |
| 1517 | > | |
| 1518 | <span | |
| 1519 | aria-hidden | |
| 1520 | className="pointer-events-none absolute left-1/2 top-1/2 h-0.5 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border group-hover:bg-primary/60 group-focus-visible:bg-primary/60" | |
| 1521 | /> | |
| 1522 | </div> | |
| 1521 | 1523 | ); |
| 1522 | 1524 | |
| 1523 | 1525 | useEffect(() => { |
| ... | ... | @@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({ |
| 1526 | 1528 | } |
| 1527 | 1529 | |
| 1528 | 1530 | const onWindowResize = () => { |
| 1529 | // React only renders the stored preference, so clamp a live drag height here. | |
| 1530 | const resizeState = resizeStateRef.current; | |
| 1531 | const liveHeight = clampDrawerHeight(drawerHeightRef.current); | |
| 1532 | if (resizeState && liveHeight !== drawerHeightRef.current) { | |
| 1533 | drawerHeightRef.current = liveHeight; | |
| 1534 | resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); | |
| 1535 | } | |
| 1531 | refreshDrawerResize(); | |
| 1536 | 1532 | setWindowMaxDrawerHeight(maxDrawerHeight()); |
| 1537 | 1533 | setResizeEpoch((value) => value + 1); |
| 1538 | 1534 | }; |
| ... | ... | @@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({ |
| 1540 | 1536 | return () => { |
| 1541 | 1537 | window.removeEventListener("resize", onWindowResize); |
| 1542 | 1538 | }; |
| 1543 | }, [visible]); | |
| 1539 | }, [visible, refreshDrawerResize]); | |
| 1544 | 1540 | |
| 1545 | 1541 | useEffect(() => { |
| 1546 | 1542 | if (!visible) { |
| ... | ... | @@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({ |
| 1567 | 1563 | )} |
| 1568 | 1564 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} |
| 1569 | 1565 | > |
| 1570 | {!isPanel ? ( | |
| 1571 | <div | |
| 1572 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | |
| 1573 | onPointerDown={handleResizePointerDown} | |
| 1574 | onPointerMove={handleResizePointerMove} | |
| 1575 | onPointerUp={handleResizePointerEnd} | |
| 1576 | onPointerCancel={handleResizePointerEnd} | |
| 1577 | onLostPointerCapture={handleResizePointerEnd} | |
| 1578 | /> | |
| 1579 | ) : null} | |
| 1566 | {!isPanel ? drawerResizeHandle : null} | |
| 1580 | 1567 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> |
| 1581 | 1568 | <p>No terminal sessions for this thread yet.</p> |
| 1582 | 1569 | <Button |
| ... | ... | @@ -1604,16 +1591,7 @@ export default function ThreadTerminalDrawer({ |
| 1604 | 1591 | )} |
| 1605 | 1592 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} |
| 1606 | 1593 | > |
| 1607 | {!isPanel ? ( | |
| 1608 | <div | |
| 1609 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | |
| 1610 | onPointerDown={handleResizePointerDown} | |
| 1611 | onPointerMove={handleResizePointerMove} | |
| 1612 | onPointerUp={handleResizePointerEnd} | |
| 1613 | onPointerCancel={handleResizePointerEnd} | |
| 1614 | onLostPointerCapture={handleResizePointerEnd} | |
| 1615 | /> | |
| 1616 | ) : null} | |
| 1594 | {!isPanel ? drawerResizeHandle : null} | |
| 1617 | 1595 | |
| 1618 | 1596 | {!hasTerminalSidebar && ( |
| 1619 | 1597 | <div className="pointer-events-none absolute right-2 top-2 z-20"> |
apps/web/src/components/preview/PreviewPanelShell.tsx+8-4| ... | ... | @@ -7,7 +7,6 @@ import { |
| 7 | 7 | useState, |
| 8 | 8 | } from "react"; |
| 9 | 9 | |
| 10 | import { isElectron } from "~/env"; | |
| 11 | 10 | import { |
| 12 | 11 | getPreviewPanelMaxWidth, |
| 13 | 12 | type PreviewPanelInlineSize, |
| ... | ... | @@ -68,7 +67,6 @@ function PreviewPanelShellFrame( |
| 68 | 67 | hostRef?: RefObject<HTMLDivElement | null>; |
| 69 | 68 | }, |
| 70 | 69 | ) { |
| 71 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; | |
| 72 | 70 | const isInline = props.mode === "inline"; |
| 73 | 71 | const collapsible = isInline && props.open !== undefined; |
| 74 | 72 | const open = props.open ?? true; |
| ... | ... | @@ -145,12 +143,18 @@ function PreviewPanelShellFrame( |
| 145 | 143 | data-preview-panel-maximized={maximized ? "true" : "false"} |
| 146 | 144 | > |
| 147 | 145 | {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null} |
| 148 | <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}> | |
| 146 | <div | |
| 147 | className={cn( | |
| 148 | "h-full min-h-0 w-full", | |
| 149 | isInline && !maximized && "pl-3", | |
| 150 | collapsible && "overflow-clip", | |
| 151 | )} | |
| 152 | > | |
| 149 | 153 | <div |
| 150 | 154 | className="flex h-full min-h-0 min-w-0 flex-col" |
| 151 | 155 | style={ |
| 152 | 156 | collapsible && !maximized |
| 153 | ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 1px)` } | |
| 157 | ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 13px)` } | |
| 154 | 158 | : undefined |
| 155 | 159 | } |
| 156 | 160 | > |
apps/web/src/components/preview/RightPanelResizeHandle.tsx+4-10| ... | ... | @@ -6,28 +6,22 @@ interface Props { |
| 6 | 6 | className?: string; |
| 7 | 7 | } |
| 8 | 8 | |
| 9 | /** | |
| 10 | * Hit target for resizing a right-anchored panel via its left edge. | |
| 11 | * | |
| 12 | * - Sits on top of the panel's border with a 4px overlap on each side so the | |
| 13 | * user can grab a few pixels off the edge without aiming. | |
| 14 | * - Visual indicator is a 1px line that lights up on hover/active to mirror | |
| 15 | * VS Code / Cursor. | |
| 16 | */ | |
| 17 | 9 | export function RightPanelResizeHandle({ handlers, className }: Props) { |
| 18 | 10 | return ( |
| 19 | 11 | <div |
| 20 | 12 | role="separator" |
| 21 | 13 | aria-orientation="vertical" |
| 14 | aria-label="Resize side panel" | |
| 15 | tabIndex={0} | |
| 22 | 16 | className={cn( |
| 23 | "group absolute inset-y-0 -left-1 z-20 w-2 cursor-col-resize select-none", | |
| 17 | "group absolute inset-y-0 left-0 z-20 w-3 cursor-col-resize touch-none select-none outline-none hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15", | |
| 24 | 18 | className, |
| 25 | 19 | )} |
| 26 | 20 | {...handlers} |
| 27 | 21 | > |
| 28 | 22 | <span |
| 29 | 23 | aria-hidden |
| 30 | className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-primary/60" | |
| 24 | className="pointer-events-none absolute inset-y-0 left-0 w-px bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-primary/60" | |
| 31 | 25 | /> |
| 32 | 26 | </div> |
| 33 | 27 | ); |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+74-17| ... | ... | @@ -63,6 +63,7 @@ import { |
| 63 | 63 | } from "./previewMiniPlayerLayout"; |
| 64 | 64 | |
| 65 | 65 | interface PointerGesture { |
| 66 | readonly target: HTMLElement; | |
| 66 | 67 | readonly pointerId: number; |
| 67 | 68 | readonly pointerType: string; |
| 68 | 69 | readonly pointerX: number; |
| ... | ... | @@ -82,19 +83,18 @@ interface Props { |
| 82 | 83 | |
| 83 | 84 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 84 | 85 | |
| 85 | // Invisible grab zones straddling each edge; the cursor is the only affordance. | |
| 86 | 86 | const RESIZE_HANDLES: ReadonlyArray<{ |
| 87 | 87 | readonly direction: BrowserViewportResizeDirection; |
| 88 | 88 | readonly className: string; |
| 89 | 89 | }> = [ |
| 90 | { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-resize" }, | |
| 91 | { direction: "south", className: "inset-x-0 -bottom-1 h-2 cursor-ns-resize" }, | |
| 92 | { direction: "west", className: "inset-y-0 -left-1 w-2 cursor-ew-resize" }, | |
| 93 | { direction: "east", className: "inset-y-0 -right-1 w-2 cursor-ew-resize" }, | |
| 94 | { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" }, | |
| 95 | { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" }, | |
| 96 | { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" }, | |
| 97 | { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, | |
| 90 | { direction: "north", className: "inset-x-0 -top-1.5 h-3 cursor-ns-resize" }, | |
| 91 | { direction: "south", className: "inset-x-0 -bottom-1.5 h-3 cursor-ns-resize" }, | |
| 92 | { direction: "west", className: "inset-y-0 -left-1.5 w-3 cursor-ew-resize" }, | |
| 93 | { direction: "east", className: "inset-y-0 -right-1.5 w-3 cursor-ew-resize" }, | |
| 94 | { direction: "northwest", className: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize" }, | |
| 95 | { direction: "northeast", className: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize" }, | |
| 96 | { direction: "southwest", className: "-bottom-2.5 -left-2.5 size-5 cursor-nesw-resize" }, | |
| 97 | { direction: "southeast", className: "-bottom-2.5 -right-2.5 size-5 cursor-nwse-resize" }, | |
| 98 | 98 | ]; |
| 99 | 99 | |
| 100 | 100 | /** Floats the thread's browser tab or device stream over chat. */ |
| ... | ... | @@ -383,6 +383,24 @@ function MiniPlayerShell({ |
| 383 | 383 | ]); |
| 384 | 384 | useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); |
| 385 | 385 | |
| 386 | const releaseGesture = useCallback(() => { | |
| 387 | const gesture = gestureRef.current; | |
| 388 | gestureRef.current = null; | |
| 389 | handleTappedRef.current = false; | |
| 390 | if (gesture?.target.hasPointerCapture(gesture.pointerId)) { | |
| 391 | gesture.target.releasePointerCapture(gesture.pointerId); | |
| 392 | } | |
| 393 | }, []); | |
| 394 | ||
| 395 | useEffect(() => { | |
| 396 | const onBlur = () => releaseGesture(); | |
| 397 | window.addEventListener("blur", onBlur); | |
| 398 | return () => { | |
| 399 | window.removeEventListener("blur", onBlur); | |
| 400 | releaseGesture(); | |
| 401 | }; | |
| 402 | }, [releaseGesture, sourceKey, threadRef.environmentId, threadRef.threadId]); | |
| 403 | ||
| 386 | 404 | const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 387 | 405 | // Inside a wide curve the default 8px inset would land on the clipped-away corner. |
| 388 | 406 | const pillInset = Math.max(8, Math.round(radius * 0.55)); |
| ... | ... | @@ -395,8 +413,10 @@ function MiniPlayerShell({ |
| 395 | 413 | event: ReactPointerEvent<HTMLElement>, |
| 396 | 414 | direction: BrowserViewportResizeDirection | null, |
| 397 | 415 | ) => { |
| 398 | if (event.button !== 0 || !frame) return; | |
| 416 | if (event.button !== 0 || !frame || gestureRef.current || event.target !== event.currentTarget) | |
| 417 | return; | |
| 399 | 418 | gestureRef.current = { |
| 419 | target: event.currentTarget, | |
| 400 | 420 | pointerId: event.pointerId, |
| 401 | 421 | pointerType: event.pointerType, |
| 402 | 422 | pointerX: event.clientX, |
| ... | ... | @@ -405,7 +425,12 @@ function MiniPlayerShell({ |
| 405 | 425 | direction, |
| 406 | 426 | moved: false, |
| 407 | 427 | }; |
| 408 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 428 | try { | |
| 429 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 430 | } catch { | |
| 431 | gestureRef.current = null; | |
| 432 | return; | |
| 433 | } | |
| 409 | 434 | event.preventDefault(); |
| 410 | 435 | event.stopPropagation(); |
| 411 | 436 | }; |
| ... | ... | @@ -444,15 +469,13 @@ function MiniPlayerShell({ |
| 444 | 469 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { |
| 445 | 470 | const gesture = gestureRef.current; |
| 446 | 471 | if (gesture?.pointerId !== event.pointerId) return; |
| 447 | gestureRef.current = null; | |
| 472 | if (event.type === "pointerup") handlePointerMove(event); | |
| 473 | releaseGesture(); | |
| 448 | 474 | handleTappedRef.current = |
| 449 | 475 | event.type === "pointerup" && |
| 450 | 476 | gesture.direction === null && |
| 451 | 477 | gesture.pointerType === "touch" && |
| 452 | 478 | !gesture.moved; |
| 453 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| 454 | event.currentTarget.releasePointerCapture(event.pointerId); | |
| 455 | } | |
| 456 | 479 | }; |
| 457 | 480 | |
| 458 | 481 | return ( |
| ... | ... | @@ -479,6 +502,7 @@ function MiniPlayerShell({ |
| 479 | 502 | onPointerMove={handlePointerMove} |
| 480 | 503 | onPointerUp={endGesture} |
| 481 | 504 | onPointerCancel={endGesture} |
| 505 | onLostPointerCapture={endGesture} | |
| 482 | 506 | onClick={() => { |
| 483 | 507 | if (!handleTappedRef.current) return; |
| 484 | 508 | handleTappedRef.current = false; |
| ... | ... | @@ -548,13 +572,46 @@ function MiniPlayerShell({ |
| 548 | 572 | {RESIZE_HANDLES.map(({ direction, className }) => ( |
| 549 | 573 | <div |
| 550 | 574 | key={direction} |
| 551 | role="presentation" | |
| 575 | role="button" | |
| 576 | tabIndex={0} | |
| 577 | aria-label={`Resize floating preview from ${direction}`} | |
| 552 | 578 | data-preview-mini-player-resize={direction} |
| 553 | className={cn("pointer-events-auto absolute z-[49] touch-none", className)} | |
| 579 | className={cn( | |
| 580 | "pointer-events-auto absolute z-[49] touch-none outline-none hover:bg-primary/15 focus-visible:bg-primary/20", | |
| 581 | className, | |
| 582 | )} | |
| 554 | 583 | onPointerDown={(event) => beginGesture(event, direction)} |
| 555 | 584 | onPointerMove={handlePointerMove} |
| 556 | 585 | onPointerUp={endGesture} |
| 557 | 586 | onPointerCancel={endGesture} |
| 587 | onLostPointerCapture={endGesture} | |
| 588 | onKeyDown={(event) => { | |
| 589 | if (!container) return; | |
| 590 | const step = event.shiftKey ? 40 : 10; | |
| 591 | const delta = | |
| 592 | event.key === "ArrowLeft" | |
| 593 | ? { x: -step, y: 0 } | |
| 594 | : event.key === "ArrowRight" | |
| 595 | ? { x: step, y: 0 } | |
| 596 | : event.key === "ArrowUp" | |
| 597 | ? { x: 0, y: -step } | |
| 598 | : event.key === "ArrowDown" | |
| 599 | ? { x: 0, y: step } | |
| 600 | : null; | |
| 601 | if (!delta) return; | |
| 602 | event.preventDefault(); | |
| 603 | const next = resizePreviewMiniPlayer({ | |
| 604 | start: frame, | |
| 605 | direction, | |
| 606 | delta, | |
| 607 | source: sourceSize, | |
| 608 | container, | |
| 609 | obstacles, | |
| 610 | }); | |
| 611 | usePreviewMiniPlayerStore | |
| 612 | .getState() | |
| 613 | .resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); | |
| 614 | }} | |
| 558 | 615 | /> |
| 559 | 616 | ))} |
| 560 | 617 | </section> |
apps/web/src/components/ui/sidebar.tsx+57-11| ... | ... | @@ -331,7 +331,10 @@ function Sidebar({ |
| 331 | 331 | {...props} |
| 332 | 332 | > |
| 333 | 333 | <div |
| 334 | className="flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5" | |
| 334 | className={cn( | |
| 335 | "flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5", | |
| 336 | resolvedResizable && (side === "left" ? "pr-3" : "pl-3"), | |
| 337 | )} | |
| 335 | 338 | data-sidebar="sidebar" |
| 336 | 339 | data-slot="sidebar-inner" |
| 337 | 340 | > |
| ... | ... | @@ -426,7 +429,7 @@ function SidebarRail({ |
| 426 | 429 | setTargetWidth(width); |
| 427 | 430 | |
| 428 | 431 | return { |
| 429 | width, | |
| 432 | size: width, | |
| 430 | 433 | edge: side === "left" ? "right" : "left", |
| 431 | 434 | resize(value) { |
| 432 | 435 | const options = latestResizable.current; |
| ... | ... | @@ -523,14 +526,9 @@ function SidebarRail({ |
| 523 | 526 | <button |
| 524 | 527 | aria-label={railLabel} |
| 525 | 528 | className={cn( |
| 526 | /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */ | |
| 527 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", | |
| 528 | "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out", | |
| 529 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | |
| 530 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | |
| 531 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | |
| 532 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | |
| 533 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | |
| 529 | "group absolute inset-y-0 z-20 hidden w-3 touch-none select-none outline-none sm:flex hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15 [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", | |
| 530 | "group-data-[side=left]:right-0 group-data-[side=right]:left-0 cursor-col-resize", | |
| 531 | "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border/50 group-data-[side=left]:after:right-0 group-data-[side=right]:after:left-0", | |
| 534 | 532 | className, |
| 535 | 533 | )} |
| 536 | 534 | data-sidebar="rail" |
| ... | ... | @@ -557,7 +555,55 @@ function SidebarRail({ |
| 557 | 555 | if (!event.defaultPrevented) resize.onPointerUp(event); |
| 558 | 556 | }} |
| 559 | 557 | ref={railRef} |
| 560 | tabIndex={-1} | |
| 558 | role={canResize ? "separator" : undefined} | |
| 559 | aria-orientation={canResize ? "vertical" : undefined} | |
| 560 | aria-valuemin={canResize ? resolvedResizable.minWidth : undefined} | |
| 561 | aria-valuemax={ | |
| 562 | canResize && Number.isFinite(resolvedResizable.maxWidth) | |
| 563 | ? resolvedResizable.maxWidth | |
| 564 | : undefined | |
| 565 | } | |
| 566 | onKeyDown={(event) => { | |
| 567 | if (!canResize) return; | |
| 568 | const rail = event.currentTarget; | |
| 569 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); | |
| 570 | const container = rail.closest<HTMLElement>("[data-slot='sidebar-container']"); | |
| 571 | if (!wrapper || !container) return; | |
| 572 | const currentWidth = container.getBoundingClientRect().width; | |
| 573 | const side = sidebarInstance?.side ?? "left"; | |
| 574 | const step = (event.shiftKey ? 40 : 10) * (side === "left" ? 1 : -1); | |
| 575 | const next = | |
| 576 | event.key === "ArrowRight" | |
| 577 | ? currentWidth + step | |
| 578 | : event.key === "ArrowLeft" | |
| 579 | ? currentWidth - step | |
| 580 | : event.key === "Home" | |
| 581 | ? resolvedResizable.minWidth | |
| 582 | : event.key === "End" && Number.isFinite(resolvedResizable.maxWidth) | |
| 583 | ? resolvedResizable.maxWidth | |
| 584 | : null; | |
| 585 | if (next === null) return; | |
| 586 | event.preventDefault(); | |
| 587 | const nextWidth = clampSidebarWidth(next, resolvedResizable); | |
| 588 | const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']"); | |
| 589 | if ( | |
| 590 | !sidebarRoot || | |
| 591 | resolvedResizable.shouldAcceptWidth?.({ | |
| 592 | currentWidth, | |
| 593 | nextWidth, | |
| 594 | wrapper, | |
| 595 | rail, | |
| 596 | side, | |
| 597 | sidebarRoot, | |
| 598 | }) === false | |
| 599 | ) | |
| 600 | return; | |
| 601 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 602 | if (resolvedResizable.storageKey) | |
| 603 | setLocalStorageItem(resolvedResizable.storageKey, nextWidth, Schema.Finite); | |
| 604 | resolvedResizable.onResize?.(nextWidth); | |
| 605 | }} | |
| 606 | tabIndex={canResize ? 0 : -1} | |
| 561 | 607 | type="button" |
| 562 | 608 | {...props} |
| 563 | 609 | /> |
apps/web/src/hooks/useResizableWidth.test.tsx+111-1| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { act, useLayoutEffect, type PointerEvent } from "react"; | |
| 1 | import { act, useLayoutEffect, useState, type KeyboardEvent, type PointerEvent } from "react"; | |
| 2 | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { useResizeDrag } from "./useResizeDrag"; | |
| 5 | 6 | import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; |
| 6 | 7 | |
| 7 | 8 | let renderer: ReactTestRenderer; |
| ... | ... | @@ -43,6 +44,7 @@ function pointer(clientX = 100) { |
| 43 | 44 | pointerId: 1, |
| 44 | 45 | clientX, |
| 45 | 46 | currentTarget: target, |
| 47 | target, | |
| 46 | 48 | preventDefault() {}, |
| 47 | 49 | stopPropagation() {}, |
| 48 | 50 | } as unknown as PointerEvent<HTMLElement>; |
| ... | ... | @@ -70,6 +72,33 @@ function Panel({ |
| 70 | 72 | return null; |
| 71 | 73 | } |
| 72 | 74 | |
| 75 | let verticalHandlers: ReturnType<typeof useResizeDrag<HTMLElement>>; | |
| 76 | let committedHeight = 400; | |
| 77 | ||
| 78 | function VerticalPanel() { | |
| 79 | const [height, setHeight] = useState(400); | |
| 80 | const handlers = useResizeDrag<HTMLElement>(() => ({ | |
| 81 | size: height, | |
| 82 | edge: "top", | |
| 83 | resize(size) { | |
| 84 | const next = Math.max(180, Math.min(600, size)); | |
| 85 | hostStyle.set("height", `${next}px`); | |
| 86 | return next; | |
| 87 | }, | |
| 88 | finish(size) { | |
| 89 | setHeight(size); | |
| 90 | committedHeight = size; | |
| 91 | }, | |
| 92 | cleanup(committed) { | |
| 93 | if (!committed) hostStyle.set("height", `${height}px`); | |
| 94 | }, | |
| 95 | })); | |
| 96 | useLayoutEffect(() => { | |
| 97 | verticalHandlers = handlers; | |
| 98 | }); | |
| 99 | return null; | |
| 100 | } | |
| 101 | ||
| 73 | 102 | beforeEach(async () => { |
| 74 | 103 | savedWidths.clear(); |
| 75 | 104 | hostStyle.clear(); |
| ... | ... | @@ -204,6 +233,87 @@ describe("panel resize cleanup", () => { |
| 204 | 233 | }); |
| 205 | 234 | }); |
| 206 | 235 | |
| 236 | describe("vertical drawer resize lifecycle", () => { | |
| 237 | it("uses the final vertical release position and ignores horizontal movement", async () => { | |
| 238 | committedHeight = 400; | |
| 239 | await act(() => renderer.update(<VerticalPanel />)); | |
| 240 | await act(() => { | |
| 241 | verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 }); | |
| 242 | verticalHandlers.onPointerMove({ ...pointer(-1000), clientY: 50 }); | |
| 243 | verticalHandlers.onPointerUp({ ...pointer(1000), clientY: 25 }); | |
| 244 | }); | |
| 245 | expect(committedHeight).toBe(475); | |
| 246 | expect(hostStyle.get("height")).toBe("475px"); | |
| 247 | expect(style.cursor).toBe(""); | |
| 248 | expect(captured).toBe(false); | |
| 249 | }); | |
| 250 | ||
| 251 | it("abandons an unfinished vertical drag on unmount without committing", async () => { | |
| 252 | committedHeight = 400; | |
| 253 | await act(() => renderer.update(<VerticalPanel />)); | |
| 254 | await act(() => { | |
| 255 | verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 }); | |
| 256 | verticalHandlers.onPointerMove({ ...pointer(), clientY: -1000 }); | |
| 257 | }); | |
| 258 | await act(() => frame?.(0)); | |
| 259 | expect(hostStyle.get("height")).toBe("600px"); | |
| 260 | expect(style.cursor).toBe("row-resize"); | |
| 261 | await act(() => renderer.unmount()); | |
| 262 | expect(committedHeight).toBe(400); | |
| 263 | expect(hostStyle.get("height")).toBe("400px"); | |
| 264 | expect(style.userSelect).toBe(""); | |
| 265 | expect(captured).toBe(false); | |
| 266 | }); | |
| 267 | }); | |
| 268 | ||
| 269 | describe("resize acquisition and keyboard", () => { | |
| 270 | it("does not acquire a drag that started in selectable descendant content", async () => { | |
| 271 | const event = { ...pointer(), target: {} } as PointerEvent<HTMLElement>; | |
| 272 | await act(() => { | |
| 273 | result.handlers.onPointerDown(event); | |
| 274 | result.handlers.onPointerMove(pointer(50)); | |
| 275 | result.handlers.onPointerUp(pointer(50)); | |
| 276 | }); | |
| 277 | expect(captured).toBe(false); | |
| 278 | expect(result.width).toBe(400); | |
| 279 | expect(style.userSelect).toBe(""); | |
| 280 | expect(setItem).not.toHaveBeenCalled(); | |
| 281 | }); | |
| 282 | ||
| 283 | it("ignores a secondary button and another pointer during an active drag", async () => { | |
| 284 | await act(() => result.handlers.onPointerDown({ ...pointer(), button: 2 })); | |
| 285 | expect(captured).toBe(false); | |
| 286 | await act(() => { | |
| 287 | result.handlers.onPointerDown(pointer()); | |
| 288 | result.handlers.onPointerMove({ ...pointer(-500), pointerId: 2 }); | |
| 289 | result.handlers.onPointerUp({ ...pointer(-500), pointerId: 2 }); | |
| 290 | }); | |
| 291 | expect(result.width).toBe(400); | |
| 292 | expect(captured).toBe(true); | |
| 293 | await act(() => result.handlers.onPointerUp(pointer(50))); | |
| 294 | expect(result.width).toBe(450); | |
| 295 | }); | |
| 296 | ||
| 297 | it("resizes from keyboard in edge direction, clamps and persists across reload", async () => { | |
| 298 | const key = (key: string, shiftKey = false) => | |
| 299 | ({ key, shiftKey, preventDefault() {} }) as KeyboardEvent<HTMLElement>; | |
| 300 | await act(() => result.handlers.onKeyDown(key("ArrowLeft"))); | |
| 301 | expect(result.width).toBe(410); | |
| 302 | await act(() => result.handlers.onKeyDown(key("ArrowLeft", true))); | |
| 303 | expect(result.width).toBe(450); | |
| 304 | await act(() => result.handlers.onKeyDown(key("Home"))); | |
| 305 | expect(result.width).toBe(200); | |
| 306 | await act(() => result.handlers.onKeyDown(key("ArrowRight"))); | |
| 307 | expect(result.width).toBe(200); | |
| 308 | await act(() => renderer.unmount()); | |
| 309 | await act(() => { | |
| 310 | renderer = create(<Panel />); | |
| 311 | }); | |
| 312 | expect(result.width).toBe(200); | |
| 313 | expect(captured).toBe(false); | |
| 314 | }); | |
| 315 | }); | |
| 316 | ||
| 207 | 317 | describe("panel width storage changes", () => { |
| 208 | 318 | it("restores separate thread widths without remounting and retains them after reload", async () => { |
| 209 | 319 | await act(() => { |
apps/web/src/hooks/useResizableWidth.ts+34-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import { |
| 3 | 3 | type PointerEvent as ReactPointerEvent, |
| 4 | type KeyboardEvent, | |
| 4 | 5 | useCallback, |
| 5 | 6 | useLayoutEffect, |
| 6 | 7 | useRef, |
| ... | ... | @@ -33,6 +34,10 @@ export interface UseResizableWidthOptions { |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | export interface ResizableWidthHandlers { |
| 37 | readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; | |
| 38 | readonly "aria-valuemin": number; | |
| 39 | readonly "aria-valuemax": number; | |
| 40 | readonly "aria-valuenow": number; | |
| 36 | 41 | readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void; |
| 37 | 42 | readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void; |
| 38 | 43 | readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void; |
| ... | ... | @@ -93,7 +98,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 93 | 98 | // renders its own transition-duration, so override a property it leaves alone. |
| 94 | 99 | host?.style.setProperty("transition-property", "none"); |
| 95 | 100 | return { |
| 96 | width: clampedWidth, | |
| 101 | size: clampedWidth, | |
| 97 | 102 | edge, |
| 98 | 103 | resize(value) { |
| 99 | 104 | const nextWidth = latestOptions.current.clamp(value); |
| ... | ... | @@ -123,5 +128,32 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 123 | 128 | // rewrites the committed width, so re-apply the live pointer position. |
| 124 | 129 | useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); |
| 125 | 130 | |
| 126 | return { width: clampedWidth, handlers }; | |
| 131 | return { | |
| 132 | width: clampedWidth, | |
| 133 | handlers: { | |
| 134 | ...handlers, | |
| 135 | "aria-valuemin": minWidth, | |
| 136 | "aria-valuemax": maxWidth, | |
| 137 | "aria-valuenow": clampedWidth, | |
| 138 | onKeyDown(event) { | |
| 139 | const step = event.shiftKey ? 40 : 10; | |
| 140 | const delta = edge === "left" ? -step : step; | |
| 141 | const next = | |
| 142 | event.key === "ArrowRight" | |
| 143 | ? clampedWidth + delta | |
| 144 | : event.key === "ArrowLeft" | |
| 145 | ? clampedWidth - delta | |
| 146 | : event.key === "Home" | |
| 147 | ? minWidth | |
| 148 | : event.key === "End" | |
| 149 | ? maxWidth | |
| 150 | : null; | |
| 151 | if (next === null) return; | |
| 152 | event.preventDefault(); | |
| 153 | const width = clamp(next); | |
| 154 | setWidthState({ storageKey, width }); | |
| 155 | setLocalStorageItem(storageKey, width, WidthSchema); | |
| 156 | }, | |
| 157 | }, | |
| 158 | }; | |
| 127 | 159 | } |
apps/web/src/hooks/useResizeDrag.ts+30-18| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 2 | 2 | |
| 3 | 3 | interface ResizeSession { |
| 4 | width: number; | |
| 5 | edge: "left" | "right"; | |
| 6 | resize: (width: number) => number; | |
| 7 | finish: (width: number, moved: boolean) => void; | |
| 4 | size: number; | |
| 5 | edge: "left" | "right" | "top" | "bottom"; | |
| 6 | resize: (size: number) => number; | |
| 7 | finish: (size: number, moved: boolean) => void; | |
| 8 | 8 | /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ |
| 9 | 9 | cleanup?: (committed: boolean) => void; |
| 10 | 10 | } |
| ... | ... | @@ -18,9 +18,9 @@ export function useResizeDrag<T extends HTMLElement>( |
| 18 | 18 | session: ResizeSession; |
| 19 | 19 | target: T; |
| 20 | 20 | pointerId: number; |
| 21 | startX: number; | |
| 22 | pendingX: number; | |
| 23 | width: number; | |
| 21 | startPosition: number; | |
| 22 | pendingPosition: number; | |
| 23 | size: number; | |
| 24 | 24 | moved: boolean; |
| 25 | 25 | frame: number | null; |
| 26 | 26 | } | null>(null); |
| ... | ... | @@ -28,9 +28,11 @@ export function useResizeDrag<T extends HTMLElement>( |
| 28 | 28 | const flush = useCallback(() => { |
| 29 | 29 | const active = drag.current; |
| 30 | 30 | if (!active) return; |
| 31 | const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); | |
| 31 | const delta = | |
| 32 | (active.pendingPosition - active.startPosition) * | |
| 33 | (active.session.edge === "left" || active.session.edge === "top" ? -1 : 1); | |
| 32 | 34 | active.moved ||= Math.abs(delta) > 2; |
| 33 | active.width = active.session.resize(active.session.width + delta); | |
| 35 | active.size = active.session.resize(active.session.size + delta); | |
| 34 | 36 | }, []); |
| 35 | 37 | |
| 36 | 38 | const finish = useCallback( |
| ... | ... | @@ -51,7 +53,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 51 | 53 | document.body.style.removeProperty("cursor"); |
| 52 | 54 | document.body.style.removeProperty("user-select"); |
| 53 | 55 | active.session.cleanup?.(commit); |
| 54 | if (commit) active.session.finish(active.width, active.moved); | |
| 56 | if (commit) active.session.finish(active.size, active.moved); | |
| 55 | 57 | }, |
| 56 | 58 | [flush], |
| 57 | 59 | ); |
| ... | ... | @@ -77,7 +79,11 @@ export function useResizeDrag<T extends HTMLElement>( |
| 77 | 79 | const active = drag.current; |
| 78 | 80 | if (!active || active.pointerId !== event.pointerId) return; |
| 79 | 81 | event.preventDefault(); |
| 80 | if (usePosition) active.pendingX = event.clientX; | |
| 82 | if (usePosition) | |
| 83 | active.pendingPosition = | |
| 84 | active.session.edge === "top" || active.session.edge === "bottom" | |
| 85 | ? event.clientY | |
| 86 | : event.clientX; | |
| 81 | 87 | finish(); |
| 82 | 88 | }; |
| 83 | 89 | |
| ... | ... | @@ -85,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 85 | 91 | /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ |
| 86 | 92 | refresh: flush, |
| 87 | 93 | onPointerDown(event: PointerEvent<T>) { |
| 88 | if (event.button !== 0 || drag.current) return; | |
| 94 | if (event.button !== 0 || drag.current || event.target !== event.currentTarget) return; | |
| 89 | 95 | const session = start(event); |
| 90 | 96 | if (!session) return; |
| 91 | 97 | try { |
| ... | ... | @@ -94,27 +100,33 @@ export function useResizeDrag<T extends HTMLElement>( |
| 94 | 100 | session.cleanup?.(false); |
| 95 | 101 | return; |
| 96 | 102 | } |
| 103 | const position = | |
| 104 | session.edge === "top" || session.edge === "bottom" ? event.clientY : event.clientX; | |
| 97 | 105 | event.preventDefault(); |
| 98 | 106 | event.stopPropagation(); |
| 99 | 107 | drag.current = { |
| 100 | 108 | session, |
| 101 | 109 | target: event.currentTarget, |
| 102 | 110 | pointerId: event.pointerId, |
| 103 | startX: event.clientX, | |
| 104 | pendingX: event.clientX, | |
| 105 | width: session.width, | |
| 111 | startPosition: position, | |
| 112 | pendingPosition: position, | |
| 113 | size: session.size, | |
| 106 | 114 | moved: false, |
| 107 | 115 | frame: null, |
| 108 | 116 | }; |
| 109 | document.body.style.cursor = "col-resize"; | |
| 117 | document.body.style.cursor = | |
| 118 | session.edge === "top" || session.edge === "bottom" ? "row-resize" : "col-resize"; | |
| 110 | 119 | document.body.style.userSelect = "none"; |
| 111 | 120 | }, |
| 112 | 121 | onPointerMove(event: PointerEvent<T>) { |
| 113 | 122 | const active = drag.current; |
| 114 | 123 | if (!active || active.pointerId !== event.pointerId) return; |
| 115 | 124 | event.preventDefault(); |
| 116 | active.pendingX = event.clientX; | |
| 117 | active.moved ||= Math.abs(event.clientX - active.startX) > 2; | |
| 125 | active.pendingPosition = | |
| 126 | active.session.edge === "top" || active.session.edge === "bottom" | |
| 127 | ? event.clientY | |
| 128 | : event.clientX; | |
| 129 | active.moved ||= Math.abs(active.pendingPosition - active.startPosition) > 2; | |
| 118 | 130 | if (active.frame !== null) return; |
| 119 | 131 | active.frame = requestAnimationFrame(() => { |
| 120 | 132 | active.frame = null; |