diff --git a/packages/ui/components/ui/data-table.tsx b/packages/ui/components/ui/data-table.tsx index cba0ad6b4..17d7f222b 100644 --- a/packages/ui/components/ui/data-table.tsx +++ b/packages/ui/components/ui/data-table.tsx @@ -126,29 +126,27 @@ export function DataTable({ setResizingColumnId(header.column.id); setColumnWidth(header, startWidth); - // 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 originalCursor = document.body.style.cursor; + const originalUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; 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); - window.removeEventListener("blur", stopResize); - document.documentElement.removeAttribute("data-table-resizing"); + document.body.style.cursor = originalCursor; + document.body.style.userSelect = originalUserSelect; 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 b4eb528cc..001cc7c35 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -246,30 +246,6 @@ 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 deleted file mode 100644 index 43613db0a..000000000 --- a/packages/views/chat/components/use-chat-resize.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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 5cf2c12e3..01a53ddeb 100644 --- a/packages/views/chat/components/use-chat-resize.ts +++ b/packages/views/chat/components/use-chat-resize.ts @@ -84,9 +84,7 @@ export function useChatResize( const startDrag = useCallback( (e: React.PointerEvent, dir: DragDir) => { e.preventDefault(); - const captureEl = e.currentTarget as HTMLElement; - const { pointerId } = e; - captureEl.setPointerCapture(pointerId); + (e.target as HTMLElement).setPointerCapture(e.pointerId); dragRef.current = { startX: e.clientX, @@ -97,38 +95,9 @@ 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 || ev.pointerId !== pointerId) return; + if (!d) return; const { maxW: mw, maxH: mh } = boundsRef.current; @@ -143,21 +112,26 @@ export function useChatResize( setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh)); }; - 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(); + + const onPointerUp = () => { + dragRef.current = null; + setIsDragging(false); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); - document.addEventListener("pointercancel", onPointerCancel); - captureEl.addEventListener("lostpointercapture", onLostPointerCapture); - window.addEventListener("blur", finishDrag); + + const cursorMap: Record = { + left: "col-resize", + top: "row-resize", + corner: "nw-resize", + }; + document.body.style.cursor = cursorMap[dir]; + document.body.style.userSelect = "none"; }, [renderWidth, renderHeight, setChatSize], );