diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx index 08e69c359a158b6d646e4c1eb1853d7b4dbbb6a7..8c16061155c669fd9dafd5c084bc52cb3a400996 100644 --- a/apps/web/src/browser/BrowserViewportResizeHandles.tsx +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -30,7 +30,7 @@ interface Props { type HandleKind = "horizontal" | "vertical" | "corner"; const EDGE_BUTTON_CLASS = - "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]"; + "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]"; const EDGE_GRIP_CLASS = "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"; diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index ca2832bea6ce175495c51677fb4c1d90d88a7bf0..2b72ff7d1538ded043e3f0b22505b21be9625253 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => { expect( resolveBrowserDeviceViewportLayout( { width: 1200, height: 900 }, - { _tag: "freeform", width: 1180, height: 852 }, + { _tag: "freeform", width: 1168, height: 846 }, ), ).toEqual({ canvasWidth: 1200, canvasHeight: 900, - viewportX: 10, + viewportX: 16, viewportY: 38, - viewportWidth: 1180, - viewportHeight: 852, + viewportWidth: 1168, + viewportHeight: 846, viewportScale: 1, fillsPanel: false, }); @@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => { it("captures the available framed area when responsive mode is enabled", () => { expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ - width: 1180, - height: 852, + width: 1168, + height: 846, }); expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ - width: 590, - height: 426, + width: 584, + height: 423, }); }); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 489a8f16f3913064103b51659706340770582f29..d46a706589207e683173c5f9a346f823b8cdbd8e 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -23,7 +23,7 @@ export interface BrowserViewportLayout { // on both sides, inside the toolbar's 1px bottom border. The bar scrolls // horizontally, which also clips anything that overflows vertically. export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38; -export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 16; export type BrowserViewportResizeDirection = | "north" diff --git a/apps/web/src/browser/useBrowserViewportResize.test.tsx b/apps/web/src/browser/useBrowserViewportResize.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8bca7a01ae9a05f4e7610f766576bd1283c65e23 --- /dev/null +++ b/apps/web/src/browser/useBrowserViewportResize.test.tsx @@ -0,0 +1,124 @@ +import { act, type PointerEvent as ReactPointerEvent, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { commitBrowserViewportChange } from "./browserViewportActions"; +import { useBrowserViewportResize } from "./useBrowserViewportResize"; + +vi.mock("./browserViewportActions", () => ({ commitBrowserViewportChange: vi.fn(async () => {}) })); + +let renderer: ReactTestRenderer; +let result: ReturnType; +let events: EventTarget; +let captured = false; +const target = Object.assign(new EventTarget(), { + setPointerCapture: () => { + captured = true; + }, + hasPointerCapture: () => captured, + releasePointerCapture: () => { + captured = false; + }, +}); + +function pointer(clientX: number, options: { button?: number; pointerId?: number } = {}) { + return { + button: options.button ?? 0, + pointerId: options.pointerId ?? 1, + clientX, + clientY: 100, + target, + currentTarget: target, + preventDefault() {}, + stopPropagation() {}, + } as unknown as ReactPointerEvent; +} + +function dispatch(type: string, clientX = 100, destination = events) { + destination.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientX, clientY: 100 }), + ); +} + +function Viewport({ tabId = "one" }: { tabId?: string }) { + const resize = useBrowserViewportResize({ + tabId, + viewport: { _tag: "freeform", width: 800, height: 600 }, + zoomFactor: 1, + containerSize: { width: 1200, height: 900 }, + deviceToolbarVisible: true, + aspectRatio: null, + }); + useLayoutEffect(() => { + result = resize; + }); + return null; +} + +beforeEach(async () => { + events = new EventTarget(); + captured = false; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }); + await act(() => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it("commits the final release position even without a move frame", async () => { + await act(async () => { + result.handleResizePointerDown("east", pointer(100)); + dispatch("pointerup", 225); + }); + expect(commitBrowserViewportChange).toHaveBeenCalledExactlyOnceWith("one", { + _tag: "freeform", + width: 1050, + height: 600, + }); + expect(captured).toBe(false); +}); + +it.each(["cancel", "lost capture", "blur", "unmount", "tab switch"])( + "abandons the resize after %s", + async (reason) => { + await act(() => { + result.handleResizePointerDown("east", pointer(100)); + dispatch("pointermove", 175); + }); + expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 950, height: 600 }); + await act(() => { + if (reason === "cancel") dispatch("pointercancel"); + if (reason === "lost capture") dispatch("lostpointercapture", 100, target); + if (reason === "blur") dispatch("blur"); + if (reason === "unmount") renderer.unmount(); + if (reason === "tab switch") renderer.update(); + }); + await act(() => dispatch("pointerup", 300)); + expect(commitBrowserViewportChange).not.toHaveBeenCalled(); + expect(captured).toBe(false); + if (reason !== "unmount") + expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 800, height: 600 }); + }, +); + +it("ignores secondary buttons and selectable descendants", async () => { + await act(() => { + result.handleResizePointerDown("east", pointer(100, { button: 2 })); + result.handleResizePointerDown("east", { + ...pointer(100), + target: {}, + } as ReactPointerEvent); + dispatch("pointerup", 225); + }); + expect(commitBrowserViewportChange).not.toHaveBeenCalled(); + expect(captured).toBe(false); +}); diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index 4f05f6dd27d9f5c726295556d67014e4012e0a0c..fe2e63d9b5e7b23b40102440def13de0dd04887c 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -4,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: { const keyboardCommitTimerRef = useRef | null>(null); const keyboardViewportRef = useRef(null); const [dragViewport, setDragViewport] = useState(null); - const sourceViewportKey = browserViewportSettingKey(viewport); + const sourceViewportKey = `${tabId}:${browserViewportSettingKey(viewport)}`; const sourceViewportKeyRef = useRef(sourceViewportKey); sourceViewportKeyRef.current = sourceViewportKey; const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; @@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: { [], ); + useLayoutEffect( + () => () => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + }, + [tabId, sourceViewportKey], + ); + useEffect(() => { const pending = keyboardViewportRef.current; if (!pending || pending.sourceKey === sourceViewportKey) return; @@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: { direction: BrowserViewportResizeDirection, event: ReactPointerEvent, ) => { - if (effectiveViewport._tag === "fill") return; + if ( + effectiveViewport._tag === "fill" || + event.button !== 0 || + event.target !== event.currentTarget + ) + return; event.preventDefault(); event.stopPropagation(); if (keyboardCommitTimerRef.current !== null) { @@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", finish); window.removeEventListener("pointercancel", cancel); + window.removeEventListener("blur", interrupt); + target.removeEventListener("lostpointercapture", cancel); dragCleanupRef.current = null; try { target.releasePointerCapture(pointerId); @@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: { } function finish(upEvent: PointerEvent) { if (upEvent.pointerId !== pointerId) return; + if (!sourceChanged()) move(upEvent); cleanup(); if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { clearDrag(); @@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: { dragVersionRef.current += 1; clearDrag(); } + function interrupt() { + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + } dragCleanupRef.current = cleanup; window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", cancel); + window.addEventListener("blur", interrupt); + target.addEventListener("lostpointercapture", cancel); }; return { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index edcc4e7bf5fcd3978af51fb2f128020d26bff46d..9472500ea78f5b0fc766e2aee89ce1bbcdebf0db 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : ( )} - + {children} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4654f759e83250dd5a146ce723b8710ec0d1ad76..4499704635358dfd5977cf7439beea2f263ad5a6 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,3 +1,4 @@ +import { useResizeDrag } from "~/hooks/useResizeDrag"; import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, @@ -31,7 +32,6 @@ import { import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { - type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, useCallback, @@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({ [controlledDrawerHeight, threadId], ); const [resizeEpoch, setResizeEpoch] = useState(0); - const drawerHeightRef = useRef(drawerHeight); const preferredDrawerHeightRef = useRef(preferredDrawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); - const resizeStateRef = useRef<{ - pointerId: number; - startY: number; - startHeight: number; - drawer: HTMLElement | null; - } | null>(null); - const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = useMemo(() => { const normalizedIds: string[] = []; @@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({ }, [onHeightChange]); useLayoutEffect(() => { - // A render mid-drag (the window cap moved) must not replace the live height. - const resizeState = resizeStateRef.current; - if (resizeState) { - resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); - return; - } - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - useEffect(() => { preferredDrawerHeightRef.current = preferredDrawerHeight; }, [preferredDrawerHeight]); @@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({ lastSyncedHeightRef.current = controlledDrawerHeight; }, [controlledDrawerHeight, threadId]); - const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; - resizeStateRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - startHeight: drawerHeightRef.current, - drawer: event.currentTarget.parentElement, - }; - }, []); - - // 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) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - resizeStateRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - // A drag that ends where it started keeps the stored preference, which may - // be taller than the window-capped height the drag started from. - if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { - // React skips the write when the rendered height did not change, so put - // back the preference as the current window caps it. - const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); - drawerHeightRef.current = renderedHeight; - resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); - return; - } - setDrawerHeight(drawerHeightRef.current); - syncHeight(drawerHeightRef.current); - setResizeEpoch((value) => value + 1); + const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag( + (event) => { + const drawer = event.currentTarget.parentElement; + return { + size: drawerHeight, + edge: "top", + resize(value) { + const height = clampDrawerHeight(value); + drawer?.style.setProperty("height", `${height}px`); + return height; + }, + finish(height, moved) { + if (!moved || height === drawerHeight) { + drawer?.style.setProperty( + "height", + `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, + ); + return; + } + setDrawerHeight(height); + syncHeight(height); + setResizeEpoch((value) => value + 1); + }, + cleanup(committed) { + if (!committed) + drawer?.style.setProperty( + "height", + `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, + ); + }, + }; }, - [setDrawerHeight, syncHeight], + `${threadId}:${visible}:${isPanel}`, + ); + + useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]); + + const drawerResizeHandle = ( +
{ + const step = event.shiftKey ? 40 : 10; + const next = + event.key === "ArrowUp" + ? drawerHeight + step + : event.key === "ArrowDown" + ? drawerHeight - step + : event.key === "Home" + ? MIN_DRAWER_HEIGHT + : event.key === "End" + ? windowMaxDrawerHeight + : null; + if (next === null) return; + event.preventDefault(); + const height = clampDrawerHeight(next); + setDrawerHeight(height); + syncHeight(height); + setResizeEpoch((value) => value + 1); + }} + > + +
); useEffect(() => { @@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({ } const onWindowResize = () => { - // React only renders the stored preference, so clamp a live drag height here. - const resizeState = resizeStateRef.current; - const liveHeight = clampDrawerHeight(drawerHeightRef.current); - if (resizeState && liveHeight !== drawerHeightRef.current) { - drawerHeightRef.current = liveHeight; - resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); - } + refreshDrawerResize(); setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }; @@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [visible]); + }, [visible, refreshDrawerResize]); useEffect(() => { if (!visible) { @@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({ )} style={isPanel ? undefined : { height: `${drawerHeight}px` }} > - {!isPanel ? ( -
- ) : null} + {!isPanel ? drawerResizeHandle : null}

No terminal sessions for this thread yet.