| 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,7 +30,7 @@ interface Props { |
| 30 | type HandleKind = "horizontal" | "vertical" | "corner"; | 30 | type HandleKind = "horizontal" | "vertical" | "corner"; |
| 31 | 31 | ||
| 32 | const EDGE_BUTTON_CLASS = | 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 | const EDGE_GRIP_CLASS = | 34 | const EDGE_GRIP_CLASS = |
| 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"; | 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,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => { |
| 123 | expect( | 123 | expect( |
| 124 | resolveBrowserDeviceViewportLayout( | 124 | resolveBrowserDeviceViewportLayout( |
| 125 | { width: 1200, height: 900 }, | 125 | { width: 1200, height: 900 }, |
| 126 | { _tag: "freeform", width: 1180, height: 852 }, | 126 | { _tag: "freeform", width: 1168, height: 846 }, |
| 127 | ), | 127 | ), |
| 128 | ).toEqual({ | 128 | ).toEqual({ |
| 129 | canvasWidth: 1200, | 129 | canvasWidth: 1200, |
| 130 | canvasHeight: 900, | 130 | canvasHeight: 900, |
| 131 | viewportX: 10, | 131 | viewportX: 16, |
| 132 | viewportY: 38, | 132 | viewportY: 38, |
| 133 | viewportWidth: 1180, | 133 | viewportWidth: 1168, |
| 134 | viewportHeight: 852, | 134 | viewportHeight: 846, |
| 135 | viewportScale: 1, | 135 | viewportScale: 1, |
| 136 | fillsPanel: false, | 136 | fillsPanel: false, |
| 137 | }); | 137 | }); |
| ... | @@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => { | ... | @@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => { |
| 139 | 139 | ||
| 140 | it("captures the available framed area when responsive mode is enabled", () => { | 140 | it("captures the available framed area when responsive mode is enabled", () => { |
| 141 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ | 141 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ |
| 142 | width: 1180, | 142 | width: 1168, |
| 143 | height: 852, | 143 | height: 846, |
| 144 | }); | 144 | }); |
| 145 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ | 145 | expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ |
| 146 | width: 590, | 146 | width: 584, |
| 147 | height: 426, | 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,7 +23,7 @@ export interface BrowserViewportLayout { |
| 23 | // on both sides, inside the toolbar's 1px bottom border. The bar scrolls | 23 | // on both sides, inside the toolbar's 1px bottom border. The bar scrolls |
| 24 | // horizontally, which also clips anything that overflows vertically. | 24 | // horizontally, which also clips anything that overflows vertically. |
| 25 | export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38; | 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 | export type BrowserViewportResizeDirection = | 28 | export type BrowserViewportResizeDirection = |
| 29 | | "north" | 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,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr |
| 4 | import { | 4 | import { |
| 5 | useCallback, | 5 | useCallback, |
| 6 | useEffect, | 6 | useEffect, |
| 7 | useLayoutEffect, | ||
| 7 | useRef, | 8 | useRef, |
| 8 | useState, | 9 | useState, |
| 9 | type KeyboardEvent as ReactKeyboardEvent, | 10 | type KeyboardEvent as ReactKeyboardEvent, |
| ... | @@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: { | ... | @@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: { |
| 42 | const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 43 | const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 43 | const keyboardViewportRef = useRef<ViewportDrag | null>(null); | 44 | const keyboardViewportRef = useRef<ViewportDrag | null>(null); |
| 44 | const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null); | 45 | const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null); |
| 45 | const sourceViewportKey = browserViewportSettingKey(viewport); | 46 | const sourceViewportKey = `${tabId}:${browserViewportSettingKey(viewport)}`; |
| 46 | const sourceViewportKeyRef = useRef(sourceViewportKey); | 47 | const sourceViewportKeyRef = useRef(sourceViewportKey); |
| 47 | sourceViewportKeyRef.current = sourceViewportKey; | 48 | sourceViewportKeyRef.current = sourceViewportKey; |
| 48 | const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; | 49 | const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; |
| ... | @@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: { | ... | @@ -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 | useEffect(() => { | 87 | useEffect(() => { |
| 79 | const pending = keyboardViewportRef.current; | 88 | const pending = keyboardViewportRef.current; |
| 80 | if (!pending || pending.sourceKey === sourceViewportKey) return; | 89 | if (!pending || pending.sourceKey === sourceViewportKey) return; |
| ... | @@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: { | ... | @@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: { |
| 159 | direction: BrowserViewportResizeDirection, | 168 | direction: BrowserViewportResizeDirection, |
| 160 | event: ReactPointerEvent<HTMLButtonElement>, | 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 | event.preventDefault(); | 177 | event.preventDefault(); |
| 164 | event.stopPropagation(); | 178 | event.stopPropagation(); |
| 165 | if (keyboardCommitTimerRef.current !== null) { | 179 | if (keyboardCommitTimerRef.current !== null) { |
| ... | @@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: { | ... | @@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: { |
| 217 | window.removeEventListener("pointermove", move); | 231 | window.removeEventListener("pointermove", move); |
| 218 | window.removeEventListener("pointerup", finish); | 232 | window.removeEventListener("pointerup", finish); |
| 219 | window.removeEventListener("pointercancel", cancel); | 233 | window.removeEventListener("pointercancel", cancel); |
| 234 | window.removeEventListener("blur", interrupt); | ||
| 235 | target.removeEventListener("lostpointercapture", cancel); | ||
| 220 | dragCleanupRef.current = null; | 236 | dragCleanupRef.current = null; |
| 221 | try { | 237 | try { |
| 222 | target.releasePointerCapture(pointerId); | 238 | target.releasePointerCapture(pointerId); |
| ... | @@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: { | ... | @@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: { |
| 226 | } | 242 | } |
| 227 | function finish(upEvent: PointerEvent) { | 243 | function finish(upEvent: PointerEvent) { |
| 228 | if (upEvent.pointerId !== pointerId) return; | 244 | if (upEvent.pointerId !== pointerId) return; |
| 245 | if (!sourceChanged()) move(upEvent); | ||
| 229 | cleanup(); | 246 | cleanup(); |
| 230 | if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { | 247 | if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { |
| 231 | clearDrag(); | 248 | clearDrag(); |
| ... | @@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: { | ... | @@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: { |
| 243 | dragVersionRef.current += 1; | 260 | dragVersionRef.current += 1; |
| 244 | clearDrag(); | 261 | clearDrag(); |
| 245 | } | 262 | } |
| 263 | function interrupt() { | ||
| 264 | cleanup(); | ||
| 265 | dragVersionRef.current += 1; | ||
| 266 | clearDrag(); | ||
| 267 | } | ||
| 246 | dragCleanupRef.current = cleanup; | 268 | dragCleanupRef.current = cleanup; |
| 247 | window.addEventListener("pointermove", move, { passive: false }); | 269 | window.addEventListener("pointermove", move, { passive: false }); |
| 248 | window.addEventListener("pointerup", finish); | 270 | window.addEventListener("pointerup", finish); |
| 249 | window.addEventListener("pointercancel", cancel); | 271 | window.addEventListener("pointercancel", cancel); |
| 272 | window.addEventListener("blur", interrupt); | ||
| 273 | target.addEventListener("lostpointercapture", cancel); | ||
| 250 | }; | 274 | }; |
| 251 | 275 | ||
| 252 | return { | 276 | return { |
apps/web/src/components/AppSidebarLayout.tsx+1-1| ... | @@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 338 | ) : ( | 338 | ) : ( |
| 339 | <ThreadSidebar /> | 339 | <ThreadSidebar /> |
| 340 | )} | 340 | )} |
| 341 | <SidebarRail onDoubleClick={resetSidebarWidth} /> | 341 | <SidebarRail aria-valuenow={sidebarWidth} onDoubleClick={resetSidebarWidth} /> |
| 342 | </Sidebar> | 342 | </Sidebar> |
| 343 | {children} | 343 | {children} |
| 344 | <SidebarControl /> | 344 | <SidebarControl /> |
apps/web/src/components/ThreadTerminalDrawer.tsx+76-98| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useResizeDrag } from "~/hooks/useResizeDrag"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 3 | import { |
| 3 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| ... | @@ -31,7 +32,6 @@ import { | ... | @@ -31,7 +32,6 @@ import { |
| 31 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 32 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 32 | import * as Schema from "effect/Schema"; | 33 | import * as Schema from "effect/Schema"; |
| 33 | import { | 34 | import { |
| 34 | type PointerEvent as ReactPointerEvent, | ||
| 35 | type ReactNode, | 35 | type ReactNode, |
| 36 | type SetStateAction, | 36 | type SetStateAction, |
| 37 | useCallback, | 37 | useCallback, |
| ... | @@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({ |
| 1250 | [controlledDrawerHeight, threadId], | 1250 | [controlledDrawerHeight, threadId], |
| 1251 | ); | 1251 | ); |
| 1252 | const [resizeEpoch, setResizeEpoch] = useState(0); | 1252 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| 1253 | const drawerHeightRef = useRef(drawerHeight); | ||
| 1254 | const preferredDrawerHeightRef = useRef(preferredDrawerHeight); | 1253 | const preferredDrawerHeightRef = useRef(preferredDrawerHeight); |
| 1255 | const lastSyncedHeightRef = useRef(controlledDrawerHeight); | 1254 | const lastSyncedHeightRef = useRef(controlledDrawerHeight); |
| 1256 | const onHeightChangeRef = useRef(onHeightChange); | 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 | const normalizedTerminalIds = useMemo(() => { | 1257 | const normalizedTerminalIds = useMemo(() => { |
| 1266 | const normalizedIds: string[] = []; | 1258 | const normalizedIds: string[] = []; |
| ... | @@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({ |
| 1442 | }, [onHeightChange]); | 1434 | }, [onHeightChange]); |
| 1443 | 1435 | ||
| 1444 | useLayoutEffect(() => { | 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 | preferredDrawerHeightRef.current = preferredDrawerHeight; | 1437 | preferredDrawerHeightRef.current = preferredDrawerHeight; |
| 1456 | }, [preferredDrawerHeight]); | 1438 | }, [preferredDrawerHeight]); |
| 1457 | 1439 | ||
| ... | @@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({ |
| 1465 | lastSyncedHeightRef.current = controlledDrawerHeight; | 1447 | lastSyncedHeightRef.current = controlledDrawerHeight; |
| 1466 | }, [controlledDrawerHeight, threadId]); | 1448 | }, [controlledDrawerHeight, threadId]); |
| 1467 | 1449 | ||
| 1468 | const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | 1450 | const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag<HTMLDivElement>( |
| 1469 | if (event.button !== 0) return; | 1451 | (event) => { |
| 1470 | event.preventDefault(); | 1452 | const drawer = event.currentTarget.parentElement; |
| 1471 | event.currentTarget.setPointerCapture(event.pointerId); | 1453 | return { |
| 1472 | didResizeDuringDragRef.current = false; | 1454 | size: drawerHeight, |
| 1473 | resizeStateRef.current = { | 1455 | edge: "top", |
| 1474 | pointerId: event.pointerId, | 1456 | resize(value) { |
| 1475 | startY: event.clientY, | 1457 | const height = clampDrawerHeight(value); |
| 1476 | startHeight: drawerHeightRef.current, | 1458 | drawer?.style.setProperty("height", `${height}px`); |
| 1477 | drawer: event.currentTarget.parentElement, | 1459 | return height; |
| 1478 | }; | 1460 | }, |
| 1479 | }, []); | 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 | 1485 | useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]); |
| 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 | }, []); | ||
| 1497 | 1486 | ||
| 1498 | const handleResizePointerEnd = useCallback( | 1487 | const drawerResizeHandle = ( |
| 1499 | (event: ReactPointerEvent<HTMLDivElement>) => { | 1488 | <div |
| 1500 | const resizeState = resizeStateRef.current; | 1489 | role="separator" |
| 1501 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | 1490 | aria-label="Resize terminal" |
| 1502 | resizeStateRef.current = null; | 1491 | aria-orientation="horizontal" |
| 1503 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | 1492 | aria-valuemin={MIN_DRAWER_HEIGHT} |
| 1504 | event.currentTarget.releasePointerCapture(event.pointerId); | 1493 | aria-valuemax={windowMaxDrawerHeight} |
| 1505 | } | 1494 | aria-valuenow={drawerHeight} |
| 1506 | // A drag that ends where it started keeps the stored preference, which may | 1495 | tabIndex={0} |
| 1507 | // be taller than the window-capped height the drag started from. | 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" |
| 1508 | if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { | 1497 | {...drawerResizeHandlers} |
| 1509 | // React skips the write when the rendered height did not change, so put | 1498 | onKeyDown={(event) => { |
| 1510 | // back the preference as the current window caps it. | 1499 | const step = event.shiftKey ? 40 : 10; |
| 1511 | const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); | 1500 | const next = |
| 1512 | drawerHeightRef.current = renderedHeight; | 1501 | event.key === "ArrowUp" |
| 1513 | resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); | 1502 | ? drawerHeight + step |
| 1514 | return; | 1503 | : event.key === "ArrowDown" |
| 1515 | } | 1504 | ? drawerHeight - step |
| 1516 | setDrawerHeight(drawerHeightRef.current); | 1505 | : event.key === "Home" |
| 1517 | syncHeight(drawerHeightRef.current); | 1506 | ? MIN_DRAWER_HEIGHT |
| 1518 | setResizeEpoch((value) => value + 1); | 1507 | : event.key === "End" |
| 1519 | }, | 1508 | ? windowMaxDrawerHeight |
| 1520 | [setDrawerHeight, syncHeight], | 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 | useEffect(() => { | 1525 | useEffect(() => { |
| ... | @@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({ |
| 1526 | } | 1528 | } |
| 1527 | 1529 | ||
| 1528 | const onWindowResize = () => { | 1530 | const onWindowResize = () => { |
| 1529 | // React only renders the stored preference, so clamp a live drag height here. | 1531 | refreshDrawerResize(); |
| 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 | } | ||
| 1536 | setWindowMaxDrawerHeight(maxDrawerHeight()); | 1532 | setWindowMaxDrawerHeight(maxDrawerHeight()); |
| 1537 | setResizeEpoch((value) => value + 1); | 1533 | setResizeEpoch((value) => value + 1); |
| 1538 | }; | 1534 | }; |
| ... | @@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({ |
| 1540 | return () => { | 1536 | return () => { |
| 1541 | window.removeEventListener("resize", onWindowResize); | 1537 | window.removeEventListener("resize", onWindowResize); |
| 1542 | }; | 1538 | }; |
| 1543 | }, [visible]); | 1539 | }, [visible, refreshDrawerResize]); |
| 1544 | 1540 | ||
| 1545 | useEffect(() => { | 1541 | useEffect(() => { |
| 1546 | if (!visible) { | 1542 | if (!visible) { |
| ... | @@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({ |
| 1567 | )} | 1563 | )} |
| 1568 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} | 1564 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} |
| 1569 | > | 1565 | > |
| 1570 | {!isPanel ? ( | 1566 | {!isPanel ? drawerResizeHandle : null} |
| 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} | ||
| 1580 | <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"> | 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 | <p>No terminal sessions for this thread yet.</p> | 1568 | <p>No terminal sessions for this thread yet.</p> |
| 1582 | <Button | 1569 | <Button |
| ... | @@ -1604,16 +1591,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1604,16 +1591,7 @@ export default function ThreadTerminalDrawer({ |
| 1604 | )} | 1591 | )} |
| 1605 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} | 1592 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} |
| 1606 | > | 1593 | > |
| 1607 | {!isPanel ? ( | 1594 | {!isPanel ? drawerResizeHandle : null} |
| 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} | ||
| 1617 | 1595 | ||
| 1618 | {!hasTerminalSidebar && ( | 1596 | {!hasTerminalSidebar && ( |
| 1619 | <div className="pointer-events-none absolute right-2 top-2 z-20"> | 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 +7,6 @@ import { |
| 7 | useState, | 7 | useState, |
| 8 | } from "react"; | 8 | } from "react"; |
| 9 | 9 | ||
| 10 | import { isElectron } from "~/env"; | ||
| 11 | import { | 10 | import { |
| 12 | getPreviewPanelMaxWidth, | 11 | getPreviewPanelMaxWidth, |
| 13 | type PreviewPanelInlineSize, | 12 | type PreviewPanelInlineSize, |
| ... | @@ -68,7 +67,6 @@ function PreviewPanelShellFrame( | ... | @@ -68,7 +67,6 @@ function PreviewPanelShellFrame( |
| 68 | hostRef?: RefObject<HTMLDivElement | null>; | 67 | hostRef?: RefObject<HTMLDivElement | null>; |
| 69 | }, | 68 | }, |
| 70 | ) { | 69 | ) { |
| 71 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; | ||
| 72 | const isInline = props.mode === "inline"; | 70 | const isInline = props.mode === "inline"; |
| 73 | const collapsible = isInline && props.open !== undefined; | 71 | const collapsible = isInline && props.open !== undefined; |
| 74 | const open = props.open ?? true; | 72 | const open = props.open ?? true; |
| ... | @@ -145,12 +143,18 @@ function PreviewPanelShellFrame( | ... | @@ -145,12 +143,18 @@ function PreviewPanelShellFrame( |
| 145 | data-preview-panel-maximized={maximized ? "true" : "false"} | 143 | data-preview-panel-maximized={maximized ? "true" : "false"} |
| 146 | > | 144 | > |
| 147 | {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null} | 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 | <div | 153 | <div |
| 150 | className="flex h-full min-h-0 min-w-0 flex-col" | 154 | className="flex h-full min-h-0 min-w-0 flex-col" |
| 151 | style={ | 155 | style={ |
| 152 | collapsible && !maximized | 156 | collapsible && !maximized |
| 153 | ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 1px)` } | 157 | ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 13px)` } |
| 154 | : undefined | 158 | : undefined |
| 155 | } | 159 | } |
| 156 | > | 160 | > |
apps/web/src/components/preview/RightPanelResizeHandle.tsx+4-10| ... | @@ -6,28 +6,22 @@ interface Props { | ... | @@ -6,28 +6,22 @@ interface Props { |
| 6 | className?: string; | 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 | export function RightPanelResizeHandle({ handlers, className }: Props) { | 9 | export function RightPanelResizeHandle({ handlers, className }: Props) { |
| 18 | return ( | 10 | return ( |
| 19 | <div | 11 | <div |
| 20 | role="separator" | 12 | role="separator" |
| 21 | aria-orientation="vertical" | 13 | aria-orientation="vertical" |
| 14 | aria-label="Resize side panel" | ||
| 15 | tabIndex={0} | ||
| 22 | className={cn( | 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 | className, | 18 | className, |
| 25 | )} | 19 | )} |
| 26 | {...handlers} | 20 | {...handlers} |
| 27 | > | 21 | > |
| 28 | <span | 22 | <span |
| 29 | aria-hidden | 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 | </div> | 26 | </div> |
| 33 | ); | 27 | ); |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+74-17| ... | @@ -63,6 +63,7 @@ import { | ... | @@ -63,6 +63,7 @@ import { |
| 63 | } from "./previewMiniPlayerLayout"; | 63 | } from "./previewMiniPlayerLayout"; |
| 64 | 64 | ||
| 65 | interface PointerGesture { | 65 | interface PointerGesture { |
| 66 | readonly target: HTMLElement; | ||
| 66 | readonly pointerId: number; | 67 | readonly pointerId: number; |
| 67 | readonly pointerType: string; | 68 | readonly pointerType: string; |
| 68 | readonly pointerX: number; | 69 | readonly pointerX: number; |
| ... | @@ -82,19 +83,18 @@ interface Props { | ... | @@ -82,19 +83,18 @@ interface Props { |
| 82 | 83 | ||
| 83 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; | 84 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 84 | 85 | ||
| 85 | // Invisible grab zones straddling each edge; the cursor is the only affordance. | ||
| 86 | const RESIZE_HANDLES: ReadonlyArray<{ | 86 | const RESIZE_HANDLES: ReadonlyArray<{ |
| 87 | readonly direction: BrowserViewportResizeDirection; | 87 | readonly direction: BrowserViewportResizeDirection; |
| 88 | readonly className: string; | 88 | readonly className: string; |
| 89 | }> = [ | 89 | }> = [ |
| 90 | { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-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 h-2 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 w-2 cursor-ew-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 w-2 cursor-ew-resize" }, | 93 | { direction: "east", className: "inset-y-0 -right-1.5 w-3 cursor-ew-resize" }, |
| 94 | { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" }, | 94 | { direction: "northwest", className: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize" }, |
| 95 | { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" }, | 95 | { direction: "northeast", className: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize" }, |
| 96 | { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" }, | 96 | { direction: "southwest", className: "-bottom-2.5 -left-2.5 size-5 cursor-nesw-resize" }, |
| 97 | { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, | 97 | { direction: "southeast", className: "-bottom-2.5 -right-2.5 size-5 cursor-nwse-resize" }, |
| 98 | ]; | 98 | ]; |
| 99 | 99 | ||
| 100 | /** Floats the thread's browser tab or device stream over chat. */ | 100 | /** Floats the thread's browser tab or device stream over chat. */ |
| ... | @@ -383,6 +383,24 @@ function MiniPlayerShell({ | ... | @@ -383,6 +383,24 @@ function MiniPlayerShell({ |
| 383 | ]); | 383 | ]); |
| 384 | useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); | 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 | const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; | 404 | const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 387 | // Inside a wide curve the default 8px inset would land on the clipped-away corner. | 405 | // Inside a wide curve the default 8px inset would land on the clipped-away corner. |
| 388 | const pillInset = Math.max(8, Math.round(radius * 0.55)); | 406 | const pillInset = Math.max(8, Math.round(radius * 0.55)); |
| ... | @@ -395,8 +413,10 @@ function MiniPlayerShell({ | ... | @@ -395,8 +413,10 @@ function MiniPlayerShell({ |
| 395 | event: ReactPointerEvent<HTMLElement>, | 413 | event: ReactPointerEvent<HTMLElement>, |
| 396 | direction: BrowserViewportResizeDirection | null, | 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 | gestureRef.current = { | 418 | gestureRef.current = { |
| 419 | target: event.currentTarget, | ||
| 400 | pointerId: event.pointerId, | 420 | pointerId: event.pointerId, |
| 401 | pointerType: event.pointerType, | 421 | pointerType: event.pointerType, |
| 402 | pointerX: event.clientX, | 422 | pointerX: event.clientX, |
| ... | @@ -405,7 +425,12 @@ function MiniPlayerShell({ | ... | @@ -405,7 +425,12 @@ function MiniPlayerShell({ |
| 405 | direction, | 425 | direction, |
| 406 | moved: false, | 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 | event.preventDefault(); | 434 | event.preventDefault(); |
| 410 | event.stopPropagation(); | 435 | event.stopPropagation(); |
| 411 | }; | 436 | }; |
| ... | @@ -444,15 +469,13 @@ function MiniPlayerShell({ | ... | @@ -444,15 +469,13 @@ function MiniPlayerShell({ |
| 444 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { | 469 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { |
| 445 | const gesture = gestureRef.current; | 470 | const gesture = gestureRef.current; |
| 446 | if (gesture?.pointerId !== event.pointerId) return; | 471 | if (gesture?.pointerId !== event.pointerId) return; |
| 447 | gestureRef.current = null; | 472 | if (event.type === "pointerup") handlePointerMove(event); |
| 473 | releaseGesture(); | ||
| 448 | handleTappedRef.current = | 474 | handleTappedRef.current = |
| 449 | event.type === "pointerup" && | 475 | event.type === "pointerup" && |
| 450 | gesture.direction === null && | 476 | gesture.direction === null && |
| 451 | gesture.pointerType === "touch" && | 477 | gesture.pointerType === "touch" && |
| 452 | !gesture.moved; | 478 | !gesture.moved; |
| 453 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | ||
| 454 | event.currentTarget.releasePointerCapture(event.pointerId); | ||
| 455 | } | ||
| 456 | }; | 479 | }; |
| 457 | 480 | ||
| 458 | return ( | 481 | return ( |
| ... | @@ -479,6 +502,7 @@ function MiniPlayerShell({ | ... | @@ -479,6 +502,7 @@ function MiniPlayerShell({ |
| 479 | onPointerMove={handlePointerMove} | 502 | onPointerMove={handlePointerMove} |
| 480 | onPointerUp={endGesture} | 503 | onPointerUp={endGesture} |
| 481 | onPointerCancel={endGesture} | 504 | onPointerCancel={endGesture} |
| 505 | onLostPointerCapture={endGesture} | ||
| 482 | onClick={() => { | 506 | onClick={() => { |
| 483 | if (!handleTappedRef.current) return; | 507 | if (!handleTappedRef.current) return; |
| 484 | handleTappedRef.current = false; | 508 | handleTappedRef.current = false; |
| ... | @@ -548,13 +572,46 @@ function MiniPlayerShell({ | ... | @@ -548,13 +572,46 @@ function MiniPlayerShell({ |
| 548 | {RESIZE_HANDLES.map(({ direction, className }) => ( | 572 | {RESIZE_HANDLES.map(({ direction, className }) => ( |
| 549 | <div | 573 | <div |
| 550 | key={direction} | 574 | key={direction} |
| 551 | role="presentation" | 575 | role="button" |
| 576 | tabIndex={0} | ||
| 577 | aria-label={`Resize floating preview from ${direction}`} | ||
| 552 | data-preview-mini-player-resize={direction} | 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 | onPointerDown={(event) => beginGesture(event, direction)} | 583 | onPointerDown={(event) => beginGesture(event, direction)} |
| 555 | onPointerMove={handlePointerMove} | 584 | onPointerMove={handlePointerMove} |
| 556 | onPointerUp={endGesture} | 585 | onPointerUp={endGesture} |
| 557 | onPointerCancel={endGesture} | 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 | </section> | 617 | </section> |
apps/web/src/components/ui/sidebar.tsx+57-11| ... | @@ -331,7 +331,10 @@ function Sidebar({ | ... | @@ -331,7 +331,10 @@ function Sidebar({ |
| 331 | {...props} | 331 | {...props} |
| 332 | > | 332 | > |
| 333 | <div | 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 | data-sidebar="sidebar" | 338 | data-sidebar="sidebar" |
| 336 | data-slot="sidebar-inner" | 339 | data-slot="sidebar-inner" |
| 337 | > | 340 | > |
| ... | @@ -426,7 +429,7 @@ function SidebarRail({ | ... | @@ -426,7 +429,7 @@ function SidebarRail({ |
| 426 | setTargetWidth(width); | 429 | setTargetWidth(width); |
| 427 | 430 | ||
| 428 | return { | 431 | return { |
| 429 | width, | 432 | size: width, |
| 430 | edge: side === "left" ? "right" : "left", | 433 | edge: side === "left" ? "right" : "left", |
| 431 | resize(value) { | 434 | resize(value) { |
| 432 | const options = latestResizable.current; | 435 | const options = latestResizable.current; |
| ... | @@ -523,14 +526,9 @@ function SidebarRail({ | ... | @@ -523,14 +526,9 @@ function SidebarRail({ |
| 523 | <button | 526 | <button |
| 524 | aria-label={railLabel} | 527 | aria-label={railLabel} |
| 525 | className={cn( | 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. */ | 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", |
| 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", | 530 | "group-data-[side=left]:right-0 group-data-[side=right]:left-0 cursor-col-resize", |
| 528 | "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out", | 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", |
| 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", | ||
| 534 | className, | 532 | className, |
| 535 | )} | 533 | )} |
| 536 | data-sidebar="rail" | 534 | data-sidebar="rail" |
| ... | @@ -557,7 +555,55 @@ function SidebarRail({ | ... | @@ -557,7 +555,55 @@ function SidebarRail({ |
| 557 | if (!event.defaultPrevented) resize.onPointerUp(event); | 555 | if (!event.defaultPrevented) resize.onPointerUp(event); |
| 558 | }} | 556 | }} |
| 559 | ref={railRef} | 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 | type="button" | 607 | type="button" |
| 562 | {...props} | 608 | {...props} |
| 563 | /> | 609 | /> |
apps/web/src/hooks/useResizableWidth.test.tsx+111-1| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { act, useLayoutEffect, type PointerEvent } from "react"; | 1 | import { act, useLayoutEffect, useState, type KeyboardEvent, type PointerEvent } from "react"; |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { useResizeDrag } from "./useResizeDrag"; | ||
| 5 | import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; | 6 | import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; |
| 6 | 7 | ||
| 7 | let renderer: ReactTestRenderer; | 8 | let renderer: ReactTestRenderer; |
| ... | @@ -43,6 +44,7 @@ function pointer(clientX = 100) { | ... | @@ -43,6 +44,7 @@ function pointer(clientX = 100) { |
| 43 | pointerId: 1, | 44 | pointerId: 1, |
| 44 | clientX, | 45 | clientX, |
| 45 | currentTarget: target, | 46 | currentTarget: target, |
| 47 | target, | ||
| 46 | preventDefault() {}, | 48 | preventDefault() {}, |
| 47 | stopPropagation() {}, | 49 | stopPropagation() {}, |
| 48 | } as unknown as PointerEvent<HTMLElement>; | 50 | } as unknown as PointerEvent<HTMLElement>; |
| ... | @@ -70,6 +72,33 @@ function Panel({ | ... | @@ -70,6 +72,33 @@ function Panel({ |
| 70 | return null; | 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 | beforeEach(async () => { | 102 | beforeEach(async () => { |
| 74 | savedWidths.clear(); | 103 | savedWidths.clear(); |
| 75 | hostStyle.clear(); | 104 | hostStyle.clear(); |
| ... | @@ -204,6 +233,87 @@ describe("panel resize cleanup", () => { | ... | @@ -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 | describe("panel width storage changes", () => { | 317 | describe("panel width storage changes", () => { |
| 208 | it("restores separate thread widths without remounting and retains them after reload", async () => { | 318 | it("restores separate thread widths without remounting and retains them after reload", async () => { |
| 209 | await act(() => { | 319 | await act(() => { |
apps/web/src/hooks/useResizableWidth.ts+34-2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { | 2 | import { |
| 3 | type PointerEvent as ReactPointerEvent, | 3 | type PointerEvent as ReactPointerEvent, |
| 4 | type KeyboardEvent, | ||
| 4 | useCallback, | 5 | useCallback, |
| 5 | useLayoutEffect, | 6 | useLayoutEffect, |
| 6 | useRef, | 7 | useRef, |
| ... | @@ -33,6 +34,10 @@ export interface UseResizableWidthOptions { | ... | @@ -33,6 +34,10 @@ export interface UseResizableWidthOptions { |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | export interface ResizableWidthHandlers { | 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 | readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void; | 41 | readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void; |
| 37 | readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void; | 42 | readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void; |
| 38 | readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void; | 43 | readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void; |
| ... | @@ -93,7 +98,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -93,7 +98,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 93 | // renders its own transition-duration, so override a property it leaves alone. | 98 | // renders its own transition-duration, so override a property it leaves alone. |
| 94 | host?.style.setProperty("transition-property", "none"); | 99 | host?.style.setProperty("transition-property", "none"); |
| 95 | return { | 100 | return { |
| 96 | width: clampedWidth, | 101 | size: clampedWidth, |
| 97 | edge, | 102 | edge, |
| 98 | resize(value) { | 103 | resize(value) { |
| 99 | const nextWidth = latestOptions.current.clamp(value); | 104 | const nextWidth = latestOptions.current.clamp(value); |
| ... | @@ -123,5 +128,32 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -123,5 +128,32 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 123 | // rewrites the committed width, so re-apply the live pointer position. | 128 | // rewrites the committed width, so re-apply the live pointer position. |
| 124 | useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); | 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,10 +1,10 @@ |
| 1 | import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; | 1 | import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 2 | 2 | ||
| 3 | interface ResizeSession { | 3 | interface ResizeSession { |
| 4 | width: number; | 4 | size: number; |
| 5 | edge: "left" | "right"; | 5 | edge: "left" | "right" | "top" | "bottom"; |
| 6 | resize: (width: number) => number; | 6 | resize: (size: number) => number; |
| 7 | finish: (width: number, moved: boolean) => void; | 7 | finish: (size: number, moved: boolean) => void; |
| 8 | /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ | 8 | /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ |
| 9 | cleanup?: (committed: boolean) => void; | 9 | cleanup?: (committed: boolean) => void; |
| 10 | } | 10 | } |
| ... | @@ -18,9 +18,9 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -18,9 +18,9 @@ export function useResizeDrag<T extends HTMLElement>( |
| 18 | session: ResizeSession; | 18 | session: ResizeSession; |
| 19 | target: T; | 19 | target: T; |
| 20 | pointerId: number; | 20 | pointerId: number; |
| 21 | startX: number; | 21 | startPosition: number; |
| 22 | pendingX: number; | 22 | pendingPosition: number; |
| 23 | width: number; | 23 | size: number; |
| 24 | moved: boolean; | 24 | moved: boolean; |
| 25 | frame: number | null; | 25 | frame: number | null; |
| 26 | } | null>(null); | 26 | } | null>(null); |
| ... | @@ -28,9 +28,11 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -28,9 +28,11 @@ export function useResizeDrag<T extends HTMLElement>( |
| 28 | const flush = useCallback(() => { | 28 | const flush = useCallback(() => { |
| 29 | const active = drag.current; | 29 | const active = drag.current; |
| 30 | if (!active) return; | 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 | active.moved ||= Math.abs(delta) > 2; | 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 | const finish = useCallback( | 38 | const finish = useCallback( |
| ... | @@ -51,7 +53,7 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -51,7 +53,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 51 | document.body.style.removeProperty("cursor"); | 53 | document.body.style.removeProperty("cursor"); |
| 52 | document.body.style.removeProperty("user-select"); | 54 | document.body.style.removeProperty("user-select"); |
| 53 | active.session.cleanup?.(commit); | 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 | [flush], | 58 | [flush], |
| 57 | ); | 59 | ); |
| ... | @@ -77,7 +79,11 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -77,7 +79,11 @@ export function useResizeDrag<T extends HTMLElement>( |
| 77 | const active = drag.current; | 79 | const active = drag.current; |
| 78 | if (!active || active.pointerId !== event.pointerId) return; | 80 | if (!active || active.pointerId !== event.pointerId) return; |
| 79 | event.preventDefault(); | 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 | finish(); | 87 | finish(); |
| 82 | }; | 88 | }; |
| 83 | 89 | ||
| ... | @@ -85,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -85,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 85 | /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ | 91 | /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ |
| 86 | refresh: flush, | 92 | refresh: flush, |
| 87 | onPointerDown(event: PointerEvent<T>) { | 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 | const session = start(event); | 95 | const session = start(event); |
| 90 | if (!session) return; | 96 | if (!session) return; |
| 91 | try { | 97 | try { |
| ... | @@ -94,27 +100,33 @@ export function useResizeDrag<T extends HTMLElement>( | ... | @@ -94,27 +100,33 @@ export function useResizeDrag<T extends HTMLElement>( |
| 94 | session.cleanup?.(false); | 100 | session.cleanup?.(false); |
| 95 | return; | 101 | return; |
| 96 | } | 102 | } |
| 103 | const position = | ||
| 104 | session.edge === "top" || session.edge === "bottom" ? event.clientY : event.clientX; | ||
| 97 | event.preventDefault(); | 105 | event.preventDefault(); |
| 98 | event.stopPropagation(); | 106 | event.stopPropagation(); |
| 99 | drag.current = { | 107 | drag.current = { |
| 100 | session, | 108 | session, |
| 101 | target: event.currentTarget, | 109 | target: event.currentTarget, |
| 102 | pointerId: event.pointerId, | 110 | pointerId: event.pointerId, |
| 103 | startX: event.clientX, | 111 | startPosition: position, |
| 104 | pendingX: event.clientX, | 112 | pendingPosition: position, |
| 105 | width: session.width, | 113 | size: session.size, |
| 106 | moved: false, | 114 | moved: false, |
| 107 | frame: null, | 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 | document.body.style.userSelect = "none"; | 119 | document.body.style.userSelect = "none"; |
| 111 | }, | 120 | }, |
| 112 | onPointerMove(event: PointerEvent<T>) { | 121 | onPointerMove(event: PointerEvent<T>) { |
| 113 | const active = drag.current; | 122 | const active = drag.current; |
| 114 | if (!active || active.pointerId !== event.pointerId) return; | 123 | if (!active || active.pointerId !== event.pointerId) return; |
| 115 | event.preventDefault(); | 124 | event.preventDefault(); |
| 116 | active.pendingX = event.clientX; | 125 | active.pendingPosition = |
| 117 | active.moved ||= Math.abs(event.clientX - active.startX) > 2; | 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 | if (active.frame !== null) return; | 130 | if (active.frame !== null) return; |
| 119 | active.frame = requestAnimationFrame(() => { | 131 | active.frame = requestAnimationFrame(() => { |
| 120 | active.frame = null; | 132 | active.frame = null; |