diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1a82bae029008052a022729eec6bcb502e73db57..bd45f39ad2bdf08bc85d13af5caa4e3c821484be 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1212,6 +1212,7 @@ export default function ThreadTerminalDrawer({ pointerId: number; startY: number; startHeight: number; + drawer: HTMLElement | null; } | null>(null); const didResizeDuringDragRef = useRef(false); @@ -1418,26 +1419,26 @@ export default function ThreadTerminalDrawer({ pointerId: event.pointerId, startY: event.clientY, startHeight: drawerHeightRef.current, + drawer: event.currentTarget.parentElement, }; }, []); - const handleResizePointerMove = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, - [setDrawerHeight], - ); + // Writes the height straight to the drawer while dragging so the edge lands in + // the pointer's frame; React state commits once on release. + const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); + }, []); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -1450,10 +1451,11 @@ export default function ThreadTerminalDrawer({ if (!didResizeDuringDragRef.current) { return; } + setDrawerHeight(drawerHeightRef.current); syncHeight(drawerHeightRef.current); setResizeEpoch((value) => value + 1); }, - [syncHeight], + [setDrawerHeight, syncHeight], ); useEffect(() => { @@ -1510,6 +1512,7 @@ export default function ThreadTerminalDrawer({ onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} onPointerCancel={handleResizePointerEnd} + onLostPointerCapture={handleResizePointerEnd} /> ) : null}
@@ -1546,6 +1549,7 @@ export default function ThreadTerminalDrawer({ onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} onPointerCancel={handleResizePointerEnd} + onLostPointerCapture={handleResizePointerEnd} /> ) : null} diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 8969ebbf8d4b95508512ce9e41511d9410f12e7b..181a727c12338205cce8cadca326c2cac823b954 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -1,4 +1,11 @@ -import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"; +import { + type CSSProperties, + type ReactNode, + type RefObject, + useLayoutEffect, + useRef, + useState, +} from "react"; import { isElectron } from "~/env"; import { @@ -6,6 +13,7 @@ import { type PreviewPanelInlineSize, usePreviewPanelInlineSize, } from "~/hooks/usePreviewPanelInlineSize"; +import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth"; export { getPreviewPanelMaxWidth }; import { cn } from "~/lib/utils"; @@ -122,10 +130,15 @@ function PreviewPanelShellFrame( )} style={ isInline - ? { - width: maximized ? "100%" : collapsible && !open ? "0px" : `${width}px`, + ? ({ + [RESIZABLE_WIDTH_PROPERTY]: `${width}px`, + width: maximized + ? "100%" + : collapsible && !open + ? "0px" + : `var(${RESIZABLE_WIDTH_PROPERTY})`, transitionDuration: suppressWidthTransition ? "0ms" : undefined, - } + } as CSSProperties) : undefined } data-preview-panel-mode={props.mode} @@ -135,7 +148,11 @@ function PreviewPanelShellFrame(
{props.children}
diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 11ae6265de559c9479f626b0bceeb317393d9041..2d727eb79dcfd818f1d38e7b73f249a955ce4c2d 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -2,12 +2,19 @@ import { act, useLayoutEffect, type PointerEvent } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { useResizableWidth } from "./useResizableWidth"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; let renderer: ReactTestRenderer; let result: ReturnType; let captured = false; +const hostStyle = new Map(); const target = { + parentElement: { + style: { + setProperty: (property: string, value: string) => hostStyle.set(property, value), + removeProperty: (property: string) => hostStyle.delete(property), + }, + }, setPointerCapture: () => { captured = true; }, @@ -65,6 +72,7 @@ function Panel({ beforeEach(async () => { savedWidths.clear(); + hostStyle.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -102,7 +110,9 @@ describe("panel resize cleanup", () => { result.handlers.onPointerMove(pointer(50)); }); await act(() => frame?.(0)); - expect(result.width).toBe(450); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); + expect(hostStyle.get("transition-property")).toBe("none"); + expect(result.width).toBe(400); // Queue another move to check that interruption cancels pending work too. await act(() => result.handlers.onPointerMove(pointer(25))); expect(style.cursor).toBe("col-resize"); @@ -116,6 +126,7 @@ describe("panel resize cleanup", () => { expect(style.cursor).toBe(""); expect(style.userSelect).toBe(""); expect(captured).toBe(false); + expect(hostStyle.has("transition-property")).toBe(false); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); if (reason === "unmount") { expect(setItem).not.toHaveBeenCalled(); @@ -151,11 +162,11 @@ describe("panel resize cleanup", () => { result.handlers.onPointerMove(pointer(-200)); }); await act(() => frame?.(0)); - expect(result.width).toBe(Math.min(700, initialMax)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(700, initialMax)}px`); await act(() => renderer.update()); await act(() => result.handlers.onPointerMove(pointer(-250))); await act(() => frame?.(0)); - expect(result.width).toBe(Math.min(750, nextMax)); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(750, nextMax)}px`); await act(() => result.handlers.onPointerUp(pointer(-300))); expect(result.width).toBe(nextMax); expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax)); @@ -225,10 +236,11 @@ describe("panel width storage changes", () => { result.handlers.onPointerMove(pointer(50)); }); await act(() => frame?.(0)); - expect(result.width).toBe(450); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); await act(() => result.handlers.onPointerMove(pointer(25))); await act(() => renderer.update()); expect(result.width).toBe(650); + expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("650px"); expect(captured).toBe(false); expect(style.cursor).toBe(""); await act(() => { diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 22ab44bdead81bbec179ca6685efb3b0efb007cf..fc0d016c78b9705f230a495356ed1b3087008e10 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -12,6 +12,12 @@ import { useResizeDrag } from "./useResizeDrag"; const WidthSchema = Schema.Finite; +/** + * Custom property the host element must size itself from. The drag handle sits + * directly inside the host, and a drag writes this property on it each frame. + */ +export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width"; + export interface UseResizableWidthOptions { /** localStorage key the persisted width is stored under. */ readonly storageKey: string; @@ -39,9 +45,10 @@ export interface ResizableWidthHandlers { * specified edge. Width is read on mount or storage-key changes and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * - * The hook updates an internal `width` state during drag (so the panel - * follows the cursor live) and only commits to localStorage when the user - * lifts the pointer or the drag is interrupted. + * During a drag the hook writes `RESIZABLE_WIDTH_PROPERTY` on the handle's + * parent, so the panel follows the cursor in the same frame without + * re-rendering the owner. `width` state and localStorage commit once when the + * user lifts the pointer or the drag is interrupted. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; @@ -75,21 +82,26 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } const clampedWidth = clamp(widthState.width); - const latestOptions = useRef({ clamp, storageKey }); + const latestOptions = useRef({ clamp, storageKey, width: clampedWidth }); useLayoutEffect(() => { - latestOptions.current = { clamp, storageKey }; - }, [clamp, storageKey]); + latestOptions.current = { clamp, storageKey, width: clampedWidth }; + }, [clamp, clampedWidth, storageKey]); - const handlers = useResizeDrag( - () => ({ + const { refresh, ...handlers } = useResizeDrag((event) => { + const host = event.currentTarget.parentElement; + // A collapsible host animates width; live drag writes must not. The host + // renders its own transition-duration, so override a property it leaves alone. + host?.style.setProperty("transition-property", "none"); + return { width: clampedWidth, edge, resize(value) { const nextWidth = latestOptions.current.clamp(value); - setWidthState({ storageKey, width: nextWidth }); + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, finish(finalWidth) { + setWidthState({ storageKey, width: finalWidth }); // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); @@ -97,9 +109,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not persist panel width.", error); } }, - }), - storageKey, - ); + cleanup(committed) { + host?.style.removeProperty("transition-property"); + // React skips the write when the rendered width did not change. + if (!committed) { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${latestOptions.current.width}px`); + } + }, + }; + }, storageKey); + + // Bounds can change mid-drag (sidebar opens, window narrows) and the render + // rewrites the committed width, so re-apply the live pointer position. + useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index 6509a61767d01d1c4df4a07ac55b086f916717ee..797a94ed1ab5f2dc7436dab7a3d1102771edd12d 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -5,7 +5,8 @@ interface ResizeSession { edge: "left" | "right"; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; - cleanup?: () => void; + /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ + cleanup?: (committed: boolean) => void; } /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ @@ -49,7 +50,7 @@ export function useResizeDrag( } document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); - active.session.cleanup?.(); + active.session.cleanup?.(commit); if (commit) active.session.finish(active.width, active.moved); }, [flush], @@ -81,6 +82,8 @@ export function useResizeDrag( }; return { + /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ + refresh: flush, onPointerDown(event: PointerEvent) { if (event.button !== 0 || drag.current) return; const session = start(event); @@ -88,7 +91,7 @@ export function useResizeDrag( try { event.currentTarget.setPointerCapture(event.pointerId); } catch { - session.cleanup?.(); + session.cleanup?.(false); return; } event.preventDefault();