diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index 2694a97852..b88348e2b6 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -4,6 +4,7 @@ import { type FocusEvent, type PointerEvent as ReactPointerEvent, type ReactNode, + type RefObject, type TransitionEvent, useCallback, useContext, @@ -27,7 +28,6 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import { PANEL_COLLAPSE_TRANSITION_CLASS, - PANEL_RESIZE_HIT_AREA_MARGINS, PANEL_RESIZE_HANDLE_LAYER_CLASS, PANEL_RESIZE_HIT_TARGET_CLASS, } from "./panelTransitionTokens"; @@ -67,10 +67,7 @@ import { useDiffFilesCollapseControls, } from "./git-diff/diffFilesStore"; import { buildGitDiffIdentity } from "./git-diff/gitDiffPanelHelpers"; -import { - type SecondaryPanelDraggingHandler, - useSecondaryPanelResize, -} from "./useSecondaryPanelResize"; +import { useSecondaryPanelResize } from "./useSecondaryPanelResize"; import { threadSecondaryPanelResizingAtom } from "./threadSecondaryPanelAtoms"; import { GitDiffToolbar } from "./GitDiffToolbar"; import { GitDiffTabContent } from "./ThreadSecondaryPanelTabContent"; @@ -275,26 +272,16 @@ function ThreadSecondaryPanelContent({ handleSecondaryPanelWidthChange, } = useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: isOpen }); const { - handleSecondaryPanelDragging: handleResizeDragging, handleSecondaryPanelResize, - handleSecondaryPanelResizePointerDownCapture, + resizeHitTargetRef, persistedWidthPercent, secondaryPanelRef: panelRef, secondaryResizablePanelRef: resizablePanelRef, } = useSecondaryPanelResize({ isSecondaryPanelOpen: isOpen, onPanelWidthChange: handleSecondaryPanelWidthChange, + onResizeStart: handleSecondaryPanelResizeStart, }); - const handleSecondaryPanelDragging: SecondaryPanelDraggingHandler = - useCallback( - (isDragging) => { - if (isDragging) { - handleSecondaryPanelResizeStart(); - } - handleResizeDragging(isDragging); - }, - [handleResizeDragging, handleSecondaryPanelResizeStart], - ); const hasPanelExpandedRef = useRef(false); useLayoutEffect(() => { hasPanelExpandedRef.current = false; @@ -1029,8 +1016,7 @@ function ThreadSecondaryPanelContent({ isOpen={isOpen} isConversationCollapsed={isConversationCollapsed} matchesSplitDividers={hostLayout !== null} - onDragging={handleSecondaryPanelDragging} - onPointerDown={handleSecondaryPanelResizePointerDownCapture} + hitTargetRef={resizeHitTargetRef} /> void; + hitTargetRef: RefObject; } function SecondaryPanelResizeHandle({ isOpen, isConversationCollapsed, matchesSplitDividers, - onDragging, - onPointerDown, + hitTargetRef, }: SecondaryPanelResizeHandleProps) { const isResizing = useAtomValue(threadSecondaryPanelResizingAtom); return ( onPointerDown(event.nativeEvent)} data-panel-resize-snap-handle="" - hitAreaMargins={PANEL_RESIZE_HIT_AREA_MARGINS} + hitAreaMargins={{ coarse: 0, fine: 0 }} className={cn( "group relative shrink-0 overflow-visible transition-[width,opacity,background-color]", PANEL_RESIZE_HANDLE_LAYER_CLASS, @@ -1198,6 +1180,7 @@ function SecondaryPanelResizeHandle({ > diff --git a/apps/app/src/components/secondary-panel/panelTransitionTokens.ts b/apps/app/src/components/secondary-panel/panelTransitionTokens.ts index 6c5335add8..e9abf0e91d 100644 --- a/apps/app/src/components/secondary-panel/panelTransitionTokens.ts +++ b/apps/app/src/components/secondary-panel/panelTransitionTokens.ts @@ -38,9 +38,7 @@ export function usePanelCollapseTransitionsReady( return !enabled || readyKey === resetKey; } -export const PANEL_RESIZE_HIT_AREA_MARGINS = { coarse: 15, fine: 8 }; - export const PANEL_RESIZE_HANDLE_LAYER_CLASS = "z-[25]"; export const PANEL_RESIZE_HIT_TARGET_CLASS = - "absolute inset-y-0 left-1/2 z-10 w-3 -translate-x-1/2 touch-none cursor-col-resize bg-transparent"; + "absolute inset-y-0 left-1/2 z-10 w-4 -translate-x-1/2 touch-none cursor-col-resize pointer-coarse:w-8 bg-transparent"; diff --git a/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx b/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx index caff728e00..544a324562 100644 --- a/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx +++ b/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx @@ -1,212 +1,256 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createRef, useRef } from "react"; +import { + Panel, + PanelGroup, + PanelResizeHandle, + type ImperativePanelGroupHandle, + type ImperativePanelHandle, +} from "react-resizable-panels"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePanelResizeSnap } from "./usePanelResizeSnap"; +vi.mock("react-resizable-panels", async () => { + const { createRequire } = await import("node:module"); + const { dirname, join } = await import("node:path"); + const require = createRequire(import.meta.url); + const root = dirname(require.resolve("react-resizable-panels/package.json")); + return require(join(root, "dist/react-resizable-panels.browser.development.cjs.js")); +}); + +const frames = new Map(); +let nextFrameId = 0; + +beforeEach(() => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + const id = ++nextFrameId; + frames.set(id, callback); + return id; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => { + frames.delete(id); + }); +}); + +afterEach(() => { + cleanup(); + frames.clear(); + vi.restoreAllMocks(); +}); + +function advanceFrame() { + const callbacks = [...frames.values()]; + frames.clear(); + act(() => callbacks.forEach((callback) => callback(0))); +} + function rect(left: number, width: number): DOMRect { - return { - bottom: 600, - height: 600, - left, - right: left + width, - top: 0, - width, - x: left, - y: 0, - toJSON: () => ({}), - }; + return new DOMRect(left, 0, width, 600); } -function SnapHarness({ onResize }: { onResize: (fraction: number) => void }) { - const { onPointerDownCapture } = usePanelResizeSnap({ - axis: "x", - onResize, - target: { boundaryIndex: 1, childCount: 2 }, - }); - return ( -
-
-
- onPointerDownCapture(event.nativeEvent) - } +function setup(secondarySize = 50) { + const group = createRef(); + const onResize = vi.fn(); + const onDragging = vi.fn(); + function Harness() { + const panel = useRef(null); + const hitTargetRef = usePanelResizeSnap({ + onResize: (fraction) => { + onResize(fraction); + panel.current?.resize((1 - fraction) * 100); + }, + onDragging, + }); + return ( + - -
-
-
- ); + + + + + + + ); + } + const { unmount } = render(); + const previous = screen.getByTestId("previous"); + const divider = screen.getByTestId("divider"); + Object.defineProperties(divider, { + setPointerCapture: { value: vi.fn() }, + hasPointerCapture: { value: () => true }, + releasePointerCapture: { value: vi.fn() }, + }); + const next = screen.getByTestId("next"); + const grid = screen.getByTestId("grid"); + grid.getBoundingClientRect = () => rect(100, 800); + previous.getBoundingClientRect = () => rect(100, (group.current?.getLayout()[0] ?? 0) * 8); + divider.getBoundingClientRect = () => rect(previous.getBoundingClientRect().right, 0); + next.getBoundingClientRect = () => rect(divider.getBoundingClientRect().right, (group.current?.getLayout()[1] ?? 0) * 8); + const down = () => fireEvent.pointerDown(divider, { + clientX: divider.getBoundingClientRect().left, + clientY: 100, + button: 0, + buttons: 1, + pointerId: 40, + }); + const move = (clientX: number, buttons = 1) => fireEvent.pointerMove(document.body, { + buttons, + clientX, + pointerId: 40, + }); + const release = () => fireEvent.pointerUp(window, { pointerId: 40 }); + const expectLayout = (leading: number, trailing: number) => { + const layout = group.current?.getLayout(); + expect(layout?.[0]).toBeCloseTo(leading); + expect(layout?.[1]).toBeCloseTo(trailing); + expect(Number(previous.style.flexGrow)).toBeCloseTo(leading, 0); + expect(Number(next.style.flexGrow)).toBeCloseTo(trailing, 0); + }; + return { divider, down, expectLayout, grid, group, move, onDragging, onResize, release, unmount }; } -afterEach(() => cleanup()); - describe("usePanelResizeSnap", () => { - it("previews pointer movement locally and commits once at drag end", () => { - const onResize = vi.fn(); - render(); - const grid = screen.getByTestId("grid"); - const previous = screen.getByTestId("previous"); - const divider = screen.getByTestId("divider"); - const hitTarget = screen.getByTestId("hit-target"); - const next = screen.getByTestId("next"); - grid.getBoundingClientRect = () => rect(100, 800); - previous.getBoundingClientRect = () => rect(100, 370); - divider.getBoundingClientRect = () => rect(470, 1); - next.getBoundingClientRect = () => rect(471, 429); - const rawPanelMove = vi.fn(); - document.body.addEventListener("pointermove", rawPanelMove, true); + it("updates the panel library once per frame without a separate DOM preview", () => { + const h = setup(); + h.down(); + h.move(450); + h.move(440); + h.expectLayout(50, 50); + expect(h.onResize).not.toHaveBeenCalled(); + advanceFrame(); + h.expectLayout(42.5, 57.5); + expect(h.onResize).toHaveBeenCalledExactlyOnceWith(0.425); - try { - fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 40 }); - fireEvent.pointerMove(document.body, { - buttons: 1, - clientX: 450, - pointerId: 40, - }); - - expect(rawPanelMove).not.toHaveBeenCalled(); - expect(onResize).not.toHaveBeenCalled(); - expect(previous.style.flex).toBe("0.4375 1 0px"); - expect(next.style.flex).toBe("0.5625 1 0px"); - - fireEvent.pointerUp(window, { clientX: 450, pointerId: 40 }); - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenLastCalledWith(0.4375); - } finally { - fireEvent.pointerUp(window, { clientX: 450, pointerId: 40 }); - document.body.removeEventListener("pointermove", rawPanelMove, true); - } + h.move(430); + h.move(420); + h.move(410); + expect(h.onResize).toHaveBeenCalledOnce(); + advanceFrame(); + h.expectLayout(38.75, 61.25); + expect(h.onResize).toHaveBeenCalledTimes(2); + expect(h.onResize).toHaveBeenLastCalledWith(0.3875); + h.release(); + expect(h.onResize).toHaveBeenCalledTimes(2); }); - it("disables panel-size transitions only for the active pointer drag", () => { - const onResize = vi.fn(); - render(); - const grid = screen.getByTestId("grid"); - const previous = screen.getByTestId("previous"); - const divider = screen.getByTestId("divider"); - const hitTarget = screen.getByTestId("hit-target"); - const next = screen.getByTestId("next"); - grid.getBoundingClientRect = () => rect(100, 800); - previous.getBoundingClientRect = () => rect(100, 370); - divider.getBoundingClientRect = () => rect(470, 1); - next.getBoundingClientRect = () => rect(471, 429); - grid.style.setProperty("--panel-collapse-duration", "220ms"); - - fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 42 }); - expect(grid.style.getPropertyValue("--panel-collapse-duration")).toBe( - "0ms", - ); + it.each([ + { secondary: 70, outside: 100, inside: 420, leading: 40 }, + { secondary: 24, outside: 1000, inside: 628, leading: 66 }, + ])("keeps the $secondary% limit and follows the pointer back inside", ({ secondary, outside, inside, leading }) => { + const h = setup(secondary); + h.down(); + h.move(outside); + advanceFrame(); + h.expectLayout(100 - secondary, secondary); + h.move(inside); + advanceFrame(); + h.expectLayout(leading, 100 - leading); + h.release(); + h.expectLayout(leading, 100 - leading); + }); - fireEvent.pointerUp(window, { clientX: 470, pointerId: 42 }); - expect(grid.style.getPropertyValue("--panel-collapse-duration")).toBe( - "220ms", - ); + it.each([false, true])("preserves a keyboard layout after a pointer update: %s", (moveFirst) => { + const h = setup(); + h.down(); + if (moveFirst) { + h.move(450); + h.move(440); + advanceFrame(); + } + act(() => h.group.current?.setLayout([60, 40])); + h.release(); + advanceFrame(); + h.expectLayout(60, 40); + }); + + it("applies queued pointer input before a newer keyboard resize", () => { + const h = setup(); + h.down(); + h.move(450); + h.move(440); + fireEvent.keyDown(h.divider, { key: "ArrowRight" }); + h.expectLayout(52.5, 47.5); + advanceFrame(); + h.release(); + h.expectLayout(52.5, 47.5); }); - it("releases when the panel library consumes pointerup before the snap handler", () => { - const onResize = vi.fn(); - render(); - const grid = screen.getByTestId("grid"); - const previous = screen.getByTestId("previous"); - const divider = screen.getByTestId("divider"); - const hitTarget = screen.getByTestId("hit-target"); - const next = screen.getByTestId("next"); - grid.getBoundingClientRect = () => rect(100, 800); - previous.getBoundingClientRect = () => rect(100, 370); - divider.getBoundingClientRect = () => rect(470, 1); - next.getBoundingClientRect = () => rect(471, 429); - grid.style.setProperty("--panel-collapse-duration", "220ms"); - const consumePointerUp = (event: PointerEvent) => - event.stopImmediatePropagation(); - window.addEventListener("pointerup", consumePointerUp, true); + it.each(["pointerup", "pointercancel", "mouseup", "blur", "buttons", "lostpointercapture"]) ("flushes the last position before %s cleanup", (end) => { + const h = setup(); + h.grid.style.setProperty("--panel-collapse-duration", "220ms"); + h.down(); + h.move(450); + h.move(440); + h.onResize.mockImplementation(() => { + expect(h.grid.style.getPropertyValue("--panel-collapse-duration")).toBe("0ms"); + }); + if (end === "buttons") h.move(440, 0); + else if (end === "lostpointercapture") fireEvent(h.divider, new PointerEvent("lostpointercapture", { pointerId: 40 })); + else if (end === "blur") fireEvent.blur(window); + else if (end === "mouseup") fireEvent.mouseUp(window); + else if (end === "pointercancel") fireEvent.pointerCancel(window, { pointerId: 40 }); + else h.release(); + h.expectLayout(42.5, 57.5); + expect(h.onResize).toHaveBeenCalledExactlyOnceWith(0.425); + expect(h.onDragging.mock.calls).toEqual([[true], [false]]); + expect(h.onResize.mock.invocationCallOrder[0]).toBeLessThan(h.onDragging.mock.invocationCallOrder[1]); + expect(h.grid.style.getPropertyValue("--panel-collapse-duration")).toBe("220ms"); + advanceFrame(); + expect(h.onResize).toHaveBeenCalledOnce(); + }); + it("owns pointer input without starting the library drag session", () => { + const h = setup(); + const rawDown = vi.fn(); + const rawMove = vi.fn(); + document.body.addEventListener("pointermove", rawMove, true); + document.body.addEventListener("pointerdown", rawDown, true); try { - fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 43 }); - fireEvent.pointerMove(document.body, { - buttons: 1, - clientX: 450, - pointerId: 43, - }); - fireEvent.pointerUp(document.body, { clientX: 450, pointerId: 43 }); - fireEvent.mouseUp(window, { clientX: 450 }); - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenLastCalledWith(0.4375); - const resizeCountAfterRelease = onResize.mock.calls.length; - - fireEvent.pointerMove(document.body, { - buttons: 0, - clientX: 430, - pointerId: 43, - }); - - expect(onResize).toHaveBeenCalledTimes(resizeCountAfterRelease); - expect(grid.style.getPropertyValue("--panel-collapse-duration")).toBe( - "220ms", - ); + h.down(); + h.move(450); + h.move(440); + expect(rawMove).not.toHaveBeenCalled(); + expect(rawDown).not.toHaveBeenCalled(); + expect(h.divider.getAttribute("data-resize-handle-state")).not.toBe("drag"); + advanceFrame(); + h.expectLayout(42.5, 57.5); + h.release(); + h.move(440, 0); + expect(rawMove).toHaveBeenCalledOnce(); } finally { - window.removeEventListener("pointerup", consumePointerUp, true); + document.body.removeEventListener("pointermove", rawMove, true); + document.body.removeEventListener("pointerdown", rawDown, true); } }); - it("releases when pointer movement reports that no buttons remain held", () => { - const onResize = vi.fn(); - render(); - const grid = screen.getByTestId("grid"); - const previous = screen.getByTestId("previous"); - const divider = screen.getByTestId("divider"); - const hitTarget = screen.getByTestId("hit-target"); - const next = screen.getByTestId("next"); - grid.getBoundingClientRect = () => rect(100, 800); - previous.getBoundingClientRect = () => rect(100, 370); - divider.getBoundingClientRect = () => rect(470, 1); - next.getBoundingClientRect = () => rect(471, 429); - grid.style.setProperty("--panel-collapse-duration", "220ms"); - - fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 44 }); - fireEvent.pointerMove(document.body, { - buttons: 0, - clientX: 450, - pointerId: 44, - }); - - expect(onResize).not.toHaveBeenCalled(); - expect(grid.style.getPropertyValue("--panel-collapse-duration")).toBe( - "220ms", - ); + it("drops queued updates when the owner unmounts", () => { + const h = setup(); + h.down(); + h.move(450); + h.move(440); + h.unmount(); + advanceFrame(); + expect(h.onResize).not.toHaveBeenCalled(); }); - it("bridges a fast outer-panel crossing into the shared two-pane grid", () => { - const onResize = vi.fn(); - render(); - const grid = screen.getByTestId("grid"); - const previous = screen.getByTestId("previous"); - const divider = screen.getByTestId("divider"); - const hitTarget = screen.getByTestId("hit-target"); - const next = screen.getByTestId("next"); - grid.getBoundingClientRect = () => rect(100, 800); - previous.getBoundingClientRect = () => rect(100, 370); - divider.getBoundingClientRect = () => rect(470, 1); - next.getBoundingClientRect = () => rect(471, 429); - - fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 41 }); - fireEvent.pointerMove(document.body, { - buttons: 1, - clientX: 560, - pointerId: 41, - }); - - expect(onResize).not.toHaveBeenCalled(); - expect(previous.style.flex).toBe("0.5 1 0px"); - expect(next.style.flex).toBe("0.5 1 0px"); - expect( - document.querySelector("[data-split-resize-snap-guide]"), - ).not.toBeNull(); - - fireEvent.pointerUp(window, { clientX: 560, pointerId: 41 }); - expect(onResize).toHaveBeenLastCalledWith(0.5); + it("keeps the shared snap guide until the gesture ends", () => { + const h = setup(60); + h.down(); + h.move(560); + advanceFrame(); + h.expectLayout(50, 50); + expect(document.querySelector("[data-split-resize-snap-guide]")).not.toBeNull(); + h.release(); expect(document.querySelector("[data-split-resize-snap-guide]")).toBeNull(); }); }); diff --git a/apps/app/src/components/secondary-panel/usePanelResizeSnap.ts b/apps/app/src/components/secondary-panel/usePanelResizeSnap.ts index 6b4077f02d..3948d81676 100644 --- a/apps/app/src/components/secondary-panel/usePanelResizeSnap.ts +++ b/apps/app/src/components/secondary-panel/usePanelResizeSnap.ts @@ -1,56 +1,39 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; +import { flushSync } from "react-dom"; +import { createSplitResizeSnapSession } from "@/lib/split-resize-snap"; import { - createSplitResizeFlexPair, - createSplitResizeSnapSession, - type SplitResizeAxis, - type SplitResizeGridTarget, -} from "@/lib/split-resize-snap"; + THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT, + THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT, +} from "./secondaryPanelSizing"; interface UsePanelResizeSnapArgs { - axis: SplitResizeAxis; onResize: (leadingFraction: number) => void; - target: SplitResizeGridTarget; -} - -interface PanelResizeSnapDrag { - cancel: () => void; - finish: () => void; -} - -export interface PanelResizeSnapController { - finish: () => void; - onPointerDownCapture: (event: PointerEvent) => void; + onDragging: (isDragging: boolean) => void; } export function usePanelResizeSnap({ - axis, onResize, - target, -}: UsePanelResizeSnapArgs): PanelResizeSnapController { - const { boundaryIndex, childCount } = target; - const activeDragRef = useRef(null); - const finish = useCallback(() => { - const activeDrag = activeDragRef.current; - activeDragRef.current = null; - activeDrag?.finish(); - }, []); - const cancel = useCallback(() => { - const activeDrag = activeDragRef.current; - activeDragRef.current = null; - activeDrag?.cancel(); - }, []); + onDragging, +}: UsePanelResizeSnapArgs) { + const hitTargetRef = useRef(null); + const activeDragRef = useRef<((commit: boolean) => void) | null>(null); - useEffect(() => cancel, [cancel]); + useEffect(() => () => activeDragRef.current?.(false), []); - const onPointerDownCapture = useCallback( - (event: PointerEvent) => { - finish(); + useEffect(() => { + const onPointerDownCapture = (event: PointerEvent) => { const eventTarget = event.target; if (!(eventTarget instanceof HTMLElement)) return; const divider = eventTarget.closest( "[data-panel-resize-snap-handle]", ); - if (divider === null) return; + if ( + divider === null || + hitTargetRef.current?.parentElement !== divider || + divider.getAttribute("data-panel-resize-handle-enabled") !== "true" || + event.button !== 0 + ) return; + activeDragRef.current?.(true); const previous = divider.previousElementSibling; const next = divider.nextElementSibling; if ( @@ -59,18 +42,18 @@ export function usePanelResizeSnap({ ) { return; } - const previousRect = previous.getBoundingClientRect(); - const nextRect = next.getBoundingClientRect(); - const start = axis === "x" ? previousRect.left : previousRect.top; - const end = axis === "x" ? nextRect.right : nextRect.bottom; + const start = previous.getBoundingClientRect().left; + const end = next.getBoundingClientRect().right; if (end <= start) return; const ownerWindow = divider.ownerDocument.defaultView; if (ownerWindow === null) return; - const pair = createSplitResizeFlexPair(previous, next, ownerWindow); - const snapSession = createSplitResizeSnapSession(divider, axis, { - boundaryIndex, - childCount, + event.preventDefault(); + event.stopPropagation(); + divider.focus({ preventScroll: true }); + const snapSession = createSplitResizeSnapSession(divider, "x", { + boundaryIndex: 1, + childCount: 2, }); const grid = divider.closest( "[data-split-resize-grid-root]", @@ -83,28 +66,46 @@ export function usePanelResizeSnap({ ); grid?.style.setProperty("--panel-collapse-duration", "0ms"); const pointerId = event.pointerId; - const pointer = axis === "x" ? event.clientX : event.clientY; - snapSession.resolve({ end, pointer, start }); + divider.setPointerCapture(pointerId); + divider.dataset.dragging = "true"; + snapSession.resolve({ end, pointer: event.clientX, start }); let finished = false; let pendingFraction: number | null = null; + let frame: number | null = null; + const applyResize = () => { + frame = null; + const fraction = pendingFraction; + pendingFraction = null; + if (fraction !== null) onResize(fraction); + }; + const flushResize = () => { + if (frame !== null) ownerWindow.cancelAnimationFrame(frame); + flushSync(applyResize); + }; const move = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) return; if (moveEvent.buttons === 0) { - finish(); + complete(true); return; } moveEvent.preventDefault(); moveEvent.stopPropagation(); - const nextPointer = - axis === "x" ? moveEvent.clientX : moveEvent.clientY; const result = snapSession.resolve({ end, - pointer: nextPointer, + pointer: moveEvent.clientX, start, }); - pendingFraction = result.fraction; - pair.apply(result.fraction); + pendingFraction = Math.max( + (100 - THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT) / 100, + Math.min( + (100 - THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT) / 100, + result.fraction, + ), + ); + if (frame === null) { + frame = ownerWindow.requestAnimationFrame(applyResize); + } }; const complete = (commit: boolean) => { if (finished) return; @@ -116,9 +117,25 @@ export function usePanelResizeSnap({ finishForPointer, true, ); - ownerWindow.removeEventListener("mouseup", finishOnMouseUp, true); - ownerWindow.removeEventListener("blur", finishOnBlur); + ownerWindow.removeEventListener("mouseup", commitDrag, true); + ownerWindow.removeEventListener("blur", commitDrag); + divider.removeEventListener("keydown", flushResize, true); + divider.removeEventListener("lostpointercapture", finishForPointer); + delete divider.dataset.dragging; + if (divider.hasPointerCapture(pointerId)) { + divider.releasePointerCapture(pointerId); + } snapSession.clear(); + if (activeDragRef.current === complete) { + activeDragRef.current = null; + } + if (commit) { + flushResize(); + previous.getBoundingClientRect(); + } else if (frame !== null) { + ownerWindow.cancelAnimationFrame(frame); + } + onDragging(false); if (grid !== null) { if (transitionDuration === "" || transitionDuration === undefined) { grid.style.removeProperty("--panel-collapse-duration"); @@ -130,38 +147,26 @@ export function usePanelResizeSnap({ ); } } - if ( - activeDragRef.current?.finish === commitDrag || - activeDragRef.current?.cancel === cancelDrag - ) { - activeDragRef.current = null; - } - if (commit && pendingFraction !== null) { - onResize(pendingFraction); - return; - } - if (!commit) { - pair.restore(); - } }; const commitDrag = () => complete(true); - const cancelDrag = () => complete(false); const finishForPointer = (finishEvent: PointerEvent) => { if (finishEvent.pointerId !== pointerId) return; commitDrag(); }; - const finishOnMouseUp = () => commitDrag(); - const finishOnBlur = () => commitDrag(); - - activeDragRef.current = { cancel: cancelDrag, finish: commitDrag }; + activeDragRef.current = complete; ownerWindow.addEventListener("pointermove", move, true); ownerWindow.addEventListener("pointerup", finishForPointer, true); ownerWindow.addEventListener("pointercancel", finishForPointer, true); - ownerWindow.addEventListener("mouseup", finishOnMouseUp, true); - ownerWindow.addEventListener("blur", finishOnBlur); - }, - [axis, boundaryIndex, childCount, finish, onResize], - ); + ownerWindow.addEventListener("mouseup", commitDrag, true); + ownerWindow.addEventListener("blur", commitDrag); + divider.addEventListener("keydown", flushResize, true); + divider.addEventListener("lostpointercapture", finishForPointer); + onDragging(true); + }; + + window.addEventListener("pointerdown", onPointerDownCapture, true); + return () => window.removeEventListener("pointerdown", onPointerDownCapture, true); + }, [onDragging, onResize]); - return { finish, onPointerDownCapture }; + return hitTargetRef; } diff --git a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts index 663e379e05..31b9baecc8 100644 --- a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts +++ b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useAtomValue, useSetAtom } from "jotai"; import type { ImperativePanelHandle } from "react-resizable-panels"; import { useResizeObserver } from "usehooks-ts"; @@ -8,7 +8,6 @@ import { } from "./threadSecondaryPanelAtoms"; import { usePanelResizeSnap } from "./usePanelResizeSnap"; -export type SecondaryPanelDraggingHandler = (isDragging: boolean) => void; export type SecondaryPanelWidthChangeHandler = ( width: number | undefined, ) => void; @@ -18,20 +17,19 @@ type SecondaryPanelResizeHandler = (size: number) => void; interface UseSecondaryPanelResizeArgs { isSecondaryPanelOpen: boolean; onPanelWidthChange: SecondaryPanelWidthChangeHandler; + onResizeStart: () => void; } export function useSecondaryPanelResize({ isSecondaryPanelOpen, onPanelWidthChange, + onResizeStart, }: UseSecondaryPanelResizeArgs) { - const [isSecondaryPanelDragging, setIsSecondaryPanelDragging] = - useState(false); const persistedWidthPercent = useAtomValue(secondaryPanelWidthPercentAtom); const setPersistedWidthPercent = useSetAtom(secondaryPanelWidthPercentAtom); const setIsResizing = useSetAtom(threadSecondaryPanelResizingAtom); const secondaryPanelRef = useRef(null!); const secondaryResizablePanelRef = useRef(null); - const isSecondaryPanelDraggingRef = useRef(false); const lastSecondaryPanelSizeRef = useRef(persistedWidthPercent); const handleSecondaryPanelPointerResize = useCallback( (leadingFraction: number) => { @@ -39,13 +37,20 @@ export function useSecondaryPanelResize({ }, [], ); - const { - finish: finishSecondaryPanelResizeSnap, - onPointerDownCapture: handleSecondaryPanelResizePointerDownCapture, - } = usePanelResizeSnap({ - axis: "x", + const handleSecondaryPanelDragging = useCallback( + (isDragging: boolean) => { + setIsResizing(isDragging); + if (isDragging) { + onResizeStart(); + } else if (lastSecondaryPanelSizeRef.current > 0) { + setPersistedWidthPercent(lastSecondaryPanelSizeRef.current); + } + }, + [onResizeStart, setIsResizing, setPersistedWidthPercent], + ); + const resizeHitTargetRef = usePanelResizeSnap({ onResize: handleSecondaryPanelPointerResize, - target: { boundaryIndex: 1, childCount: 2 }, + onDragging: handleSecondaryPanelDragging, }); const prevOpenRef = useRef(isSecondaryPanelOpen); @@ -79,77 +84,6 @@ export function useSecondaryPanelResize({ }, }); - const finishSecondaryPanelDragging = useCallback(() => { - isSecondaryPanelDraggingRef.current = false; - setIsSecondaryPanelDragging(false); - setIsResizing(false); - - if (lastSecondaryPanelSizeRef.current > 0) { - setPersistedWidthPercent(lastSecondaryPanelSizeRef.current); - } - }, [setIsResizing, setPersistedWidthPercent]); - - const handleSecondaryPanelDragging = - useCallback( - (isDragging) => { - if (isDragging) { - isSecondaryPanelDraggingRef.current = true; - setIsSecondaryPanelDragging(true); - setIsResizing(true); - return; - } - - finishSecondaryPanelResizeSnap(); - finishSecondaryPanelDragging(); - }, - [ - finishSecondaryPanelDragging, - finishSecondaryPanelResizeSnap, - setIsResizing, - ], - ); - - useEffect( - () => () => { - if (!isSecondaryPanelDraggingRef.current) { - return; - } - isSecondaryPanelDraggingRef.current = false; - setIsResizing(false); - }, - [setIsResizing], - ); - - useEffect(() => { - if (!isSecondaryPanelDragging) { - return; - } - - window.addEventListener("pointerup", finishSecondaryPanelDragging, true); - window.addEventListener("mouseup", finishSecondaryPanelDragging, true); - window.addEventListener( - "pointercancel", - finishSecondaryPanelDragging, - true, - ); - window.addEventListener("blur", finishSecondaryPanelDragging); - - return () => { - window.removeEventListener( - "pointerup", - finishSecondaryPanelDragging, - true, - ); - window.removeEventListener("mouseup", finishSecondaryPanelDragging, true); - window.removeEventListener( - "pointercancel", - finishSecondaryPanelDragging, - true, - ); - window.removeEventListener("blur", finishSecondaryPanelDragging); - }; - }, [finishSecondaryPanelDragging, isSecondaryPanelDragging]); - const handleSecondaryPanelResize = useCallback( (size) => { if (size <= 0) { @@ -166,9 +100,8 @@ export function useSecondaryPanelResize({ ); return { - handleSecondaryPanelDragging, handleSecondaryPanelResize, - handleSecondaryPanelResizePointerDownCapture, + resizeHitTargetRef, persistedWidthPercent, secondaryPanelRef, secondaryResizablePanelRef, diff --git a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx index 63a5f7d53c..629f0e7cfb 100644 --- a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx +++ b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx @@ -37,7 +37,6 @@ import { RIGHT_PANEL_TOGGLE_ICON_NAME } from "@/components/secondary-panel/panel import { getPanelCollapseTransitionStyle, PANEL_COLLAPSE_TRANSITION_CLASS, - PANEL_RESIZE_HIT_AREA_MARGINS, PANEL_RESIZE_HANDLE_LAYER_CLASS, PANEL_RESIZE_HIT_TARGET_CLASS, } from "@/components/secondary-panel/panelTransitionTokens"; @@ -158,23 +157,18 @@ export function SplitWorkspaceSecondaryPanelHost({ }, [], ); - const { - finish: finishEmptyPanelResizeSnap, - onPointerDownCapture: handleEmptyPanelResizePointerDownCapture, - } = usePanelResizeSnap({ - axis: "x", - onResize: handleEmptyPanelPointerResize, - target: { boundaryIndex: 1, childCount: 2 }, - }); const handleEmptyPanelResize = (size: number) => { if (size > 0) lastEmptyPanelSizeRef.current = size; }; const handleEmptyPanelDragging = (isDragging: boolean) => { if (isDragging) return; - finishEmptyPanelResizeSnap(); if (lastEmptyPanelSizeRef.current <= 0) return; setPanelWidthPercent(lastEmptyPanelSizeRef.current); }; + const emptyPanelHitTargetRef = usePanelResizeSnap({ + onResize: handleEmptyPanelPointerResize, + onDragging: handleEmptyPanelDragging, + }); const handleEmptyPanelCollapse = () => { if (lastEmptyPanelSizeRef.current <= 0) return; setIsPanelVisible(false); @@ -258,14 +252,10 @@ export function SplitWorkspaceSecondaryPanelHost({ - handleEmptyPanelResizePointerDownCapture(event.nativeEvent) - } data-panel-resize-snap-handle="" - hitAreaMargins={PANEL_RESIZE_HIT_AREA_MARGINS} + hitAreaMargins={{ coarse: 0, fine: 0 }} className={cn( - "relative shrink-0 overflow-visible bg-border-seam transition-[width,opacity,background-color] hover:bg-ring/40 data-[resize-handle-state=drag]:bg-ring/40", + "relative shrink-0 overflow-visible bg-border-seam transition-[width,opacity,background-color] hover:bg-ring/40 data-[dragging=true]:bg-ring/40", PANEL_RESIZE_HANDLE_LAYER_CLASS, PANEL_COLLAPSE_TRANSITION_CLASS, isOpen @@ -276,6 +266,7 @@ export function SplitWorkspaceSecondaryPanelHost({ >