diff --git a/packages/ui/components/ui/data-table.tsx b/packages/ui/components/ui/data-table.tsx index 17d7f222b..cba0ad6b4 100644 --- a/packages/ui/components/ui/data-table.tsx +++ b/packages/ui/components/ui/data-table.tsx @@ -126,27 +126,29 @@ export function DataTable({ setResizingColumnId(header.column.id); setColumnWidth(header, startWidth); - const originalCursor = document.body.style.cursor; - const originalUserSelect = document.body.style.userSelect; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + // Lock the resize cursor globally so it survives the pointer leaving the + // narrow handle; the matching rule lives in packages/ui/styles/base.css. + document.documentElement.setAttribute("data-table-resizing", "true"); const handlePointerMove = (pointerEvent: PointerEvent) => { setColumnWidth(header, startWidth + pointerEvent.clientX - startX); }; + // stopResize is idempotent; wire it to every drag-end path (including a + // window blur mid-drag) so the global resize-cursor lock can never strand. const stopResize = () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", stopResize); window.removeEventListener("pointercancel", stopResize); - document.body.style.cursor = originalCursor; - document.body.style.userSelect = originalUserSelect; + window.removeEventListener("blur", stopResize); + document.documentElement.removeAttribute("data-table-resizing"); setResizingColumnId(null); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", stopResize); window.addEventListener("pointercancel", stopResize); + window.addEventListener("blur", stopResize); }, [setColumnWidth], ); diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 001cc7c35..b4eb528cc 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -246,6 +246,30 @@ html:has( user-select: none !important; } +html[data-table-resizing="true"], +html[data-table-resizing="true"] * { + cursor: col-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="left"], +html[data-chat-resizing="left"] * { + cursor: col-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="top"], +html[data-chat-resizing="top"] * { + cursor: row-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="corner"], +html[data-chat-resizing="corner"] * { + cursor: nw-resize !important; + user-select: none !important; +} + [data-sidebar-resizing="true"] [data-slot="sidebar-gap"], [data-sidebar-resizing="true"] [data-slot="sidebar-container"] { transition: none !important; diff --git a/packages/views/chat/components/use-chat-resize.test.tsx b/packages/views/chat/components/use-chat-resize.test.tsx new file mode 100644 index 000000000..43613db0a --- /dev/null +++ b/packages/views/chat/components/use-chat-resize.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render } from "@testing-library/react"; +import { useRef } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChatResizeHandles } from "./chat-resize-handles"; +import { useChatResize } from "./use-chat-resize"; + +// useChatResize only needs the size fields and setters from the chat store. +vi.mock("@multica/core/chat", () => { + const state = { + chatWidth: 360, + chatHeight: 480, + isExpanded: false, + setChatSize: vi.fn(), + setExpanded: vi.fn(), + }; + const useChatStore = Object.assign( + (selector?: (s: typeof state) => unknown) => + selector ? selector(state) : state, + { getState: () => state }, + ); + return { CHAT_MIN_W: 320, CHAT_MIN_H: 400, useChatStore }; +}); + +function Harness() { + const ref = useRef(null); + const { startDrag } = useChatResize(ref); + return ( +
+ +
+ ); +} + +const POINTER_ID = 11; + +function startLeftDrag(container: HTMLElement) { + // The left edge handle is the one with the col-resize cursor. + const handle = container.querySelector(".cursor-col-resize")!; + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + // jsdom elements don't implement the pointer-capture API. + handle.setPointerCapture = setPointerCapture; + handle.hasPointerCapture = vi.fn(() => true); + handle.releasePointerCapture = releasePointerCapture; + + fireEvent.pointerDown(handle, { + button: 0, + isPrimary: true, + pointerId: POINTER_ID, + clientX: 100, + clientY: 100, + }); + + return { handle, setPointerCapture, releasePointerCapture }; +} + +describe("chat resize cursor lock cleanup", () => { + beforeEach(() => { + document.documentElement.removeAttribute("data-chat-resizing"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.documentElement.removeAttribute("data-chat-resizing"); + }); + + it("locks the resize cursor on drag start and captures the pointer", () => { + const { container } = render(); + const { setPointerCapture } = startLeftDrag(container); + + expect(setPointerCapture).toHaveBeenCalledWith(POINTER_ID); + expect(document.documentElement).toHaveAttribute( + "data-chat-resizing", + "left", + ); + }); + + it("clears the lock and releases capture on pointerup", () => { + const { container } = render(); + const { releasePointerCapture } = startLeftDrag(container); + + fireEvent.pointerUp(document, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + expect(releasePointerCapture).toHaveBeenCalledWith(POINTER_ID); + }); + + it("clears the lock on pointercancel (no pointerup)", () => { + const { container } = render(); + startLeftDrag(container); + + fireEvent.pointerCancel(document, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); + + it("clears the lock on lostpointercapture (no pointerup)", () => { + const { container } = render(); + const { handle } = startLeftDrag(container); + + fireEvent.lostPointerCapture(handle, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); + + it("clears the lock when the window loses focus mid-drag", () => { + const { container } = render(); + startLeftDrag(container); + + fireEvent.blur(window); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); +}); diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts index 01a53ddeb..5cf2c12e3 100644 --- a/packages/views/chat/components/use-chat-resize.ts +++ b/packages/views/chat/components/use-chat-resize.ts @@ -84,7 +84,9 @@ export function useChatResize( const startDrag = useCallback( (e: React.PointerEvent, dir: DragDir) => { e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); + const captureEl = e.currentTarget as HTMLElement; + const { pointerId } = e; + captureEl.setPointerCapture(pointerId); dragRef.current = { startX: e.clientX, @@ -95,9 +97,38 @@ export function useChatResize( }; setIsDragging(true); + // Lock the resize cursor globally so it survives the pointer leaving the + // narrow handle; per-direction rules live in packages/ui/styles/base.css. + document.documentElement.setAttribute("data-chat-resizing", dir); + + // One idempotent cleanup wired to every way a pointer-capture drag can + // end. A drag started with setPointerCapture may terminate via + // pointercancel / lostpointercapture / window blur without a pointerup; + // missing any of those would strand data-chat-resizing and freeze the + // whole page in the resize cursor with text selection disabled. + let finished = false; + const finishDrag = () => { + if (finished) return; + finished = true; + + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); + captureEl.removeEventListener("lostpointercapture", onLostPointerCapture); + window.removeEventListener("blur", finishDrag); + + dragRef.current = null; + setIsDragging(false); + document.documentElement.removeAttribute("data-chat-resizing"); + + if (captureEl.hasPointerCapture?.(pointerId)) { + captureEl.releasePointerCapture?.(pointerId); + } + }; + const onPointerMove = (ev: PointerEvent) => { const d = dragRef.current; - if (!d) return; + if (!d || ev.pointerId !== pointerId) return; const { maxW: mw, maxH: mh } = boundsRef.current; @@ -112,26 +143,21 @@ export function useChatResize( setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh)); }; - - const onPointerUp = () => { - dragRef.current = null; - setIsDragging(false); - document.removeEventListener("pointermove", onPointerMove); - document.removeEventListener("pointerup", onPointerUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; + const onPointerUp = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); + }; + const onPointerCancel = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); + }; + const onLostPointerCapture = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); - - const cursorMap: Record = { - left: "col-resize", - top: "row-resize", - corner: "nw-resize", - }; - document.body.style.cursor = cursorMap[dir]; - document.body.style.userSelect = "none"; + document.addEventListener("pointercancel", onPointerCancel); + captureEl.addEventListener("lostpointercapture", onLostPointerCapture); + window.addEventListener("blur", finishDrag); }, [renderWidth, renderHeight, setChatSize], );