From c8f3abded6b861f7253144eb41070488597b7b65 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 7 Feb 2026 17:50:48 -0800 Subject: [PATCH 01/29] recent --- .../src/components/recent-dropdown.tsx | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/packages/react-grab/src/components/recent-dropdown.tsx b/packages/react-grab/src/components/recent-dropdown.tsx index 99c9fd1f4..59c9d23d7 100644 --- a/packages/react-grab/src/components/recent-dropdown.tsx +++ b/packages/react-grab/src/components/recent-dropdown.tsx @@ -15,6 +15,7 @@ import { } from "../constants.js"; import { cn } from "../utils/cn.js"; import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; +import { IconComment } from "./icons/icon-comment.jsx"; const DEFAULT_OFFSCREEN_POSITION = { left: -9999, top: -9999 }; @@ -204,41 +205,64 @@ export const RecentDropdown: Component = (props) => {
-
0} + fallback={ +
+ No copied elements yet +
+ } > - - {(item) => ( - - )} - -
+ + + {item.commentText} + + + + + {formatRelativeTime(item.timestamp)} + + + )} + +
+ From bf036ec8b413e13089e9ab7e7e2cdac4280beba3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 02:22:03 +0000 Subject: [PATCH 02/29] fix: dismiss recent dropdown on overlay deactivation --- packages/react-grab/src/core/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 13893b64a..469fc1aea 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -1341,6 +1341,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.deactivate(); arrowNavigator.clearHistory(); keyboardSelectedElement = null; + dismissRecentDropdown(); if (wasDragging) { document.body.style.userSelect = ""; } From 8a2467aeaa7804542774f7f77a4863190281e168 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 02:34:52 +0000 Subject: [PATCH 03/29] Add recent tray keyboard controls --- packages/react-grab/e2e/recent-items.spec.ts | 10 ++++++ packages/react-grab/src/core/index.tsx | 31 +++++++++++++++++++ packages/react-grab/src/styles.css | 21 ++++++++++++- packages/react-grab/src/utils/mount-root.ts | 2 +- packages/website/app/globals.css | 32 ++++++++++++++++++++ packages/website/app/layout.tsx | 8 ++--- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/react-grab/e2e/recent-items.spec.ts b/packages/react-grab/e2e/recent-items.spec.ts index 18e6d8c78..bc587f5d1 100644 --- a/packages/react-grab/e2e/recent-items.spec.ts +++ b/packages/react-grab/e2e/recent-items.spec.ts @@ -93,6 +93,16 @@ test.describe("Recent Items", () => { expect(isDropdownVisible).toBe(true); }); + test("should open when pressing R while active", async ({ reactGrab }) => { + await copyElement(reactGrab, "li:first-child"); + await reactGrab.activate(); + await reactGrab.pressKey("r"); + + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(true); + }); + test("should close when clicking the recent button again", async ({ reactGrab, }) => { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 469fc1aea..3a459f10b 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -2014,6 +2014,36 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return true; }; + const getRecentDropdownAnchorPosition = (): { x: number; y: number } => { + const recentButtonElement = rendererRoot.querySelector( + "[data-react-grab-toolbar-recent]", + ); + if (!recentButtonElement) { + return { x: store.pointer.x, y: store.pointer.y }; + } + + const buttonRect = recentButtonElement.getBoundingClientRect(); + const anchorX = buttonRect.left + buttonRect.width / 2; + const toolbarEdge = currentToolbarState()?.edge; + const anchorY = toolbarEdge === "top" ? buttonRect.bottom : buttonRect.top; + + return { x: anchorX, y: anchorY }; + }; + + const handleRecentShortcut = (event: KeyboardEvent): boolean => { + if (event.key?.toLowerCase() !== "r" || isPromptMode()) return false; + if (event.metaKey || event.ctrlKey || event.altKey || event.repeat) { + return false; + } + if (!isActivated()) return false; + if (isKeyboardEventTriggeredByInput(event)) return false; + + event.preventDefault(); + event.stopPropagation(); + handleToggleRecent(getRecentDropdownAnchorPosition()); + return true; + }; + const handleScreenshotShortcut = (event: KeyboardEvent): boolean => { if (!isScreenshotSupported()) return false; if (store.contextMenuPosition !== null) return false; @@ -2453,6 +2483,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (handleActionCycleKey(event)) return; if (handleArrowNavigation(event)) return; if (handleEnterKeyActivation(event)) return; + if (handleRecentShortcut(event)) return; if (handleOpenFileShortcut(event)) return; if (handleScreenshotShortcut(event)) return; diff --git a/packages/react-grab/src/styles.css b/packages/react-grab/src/styles.css index 3c3c85616..46fef341a 100644 --- a/packages/react-grab/src/styles.css +++ b/packages/react-grab/src/styles.css @@ -1,7 +1,26 @@ @import "tailwindcss"; +:host { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-kerning: normal; + font-optical-sizing: auto; + font-synthesis: none; + font-weight: 460; + letter-spacing: -0.00563rem; + font-feature-settings: "cv01", "cv03", "cv04", "cv09", "cv11"; +} + +code, +kbd, +samp, +pre { + letter-spacing: normal; +} + @theme { - --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; diff --git a/packages/react-grab/src/utils/mount-root.ts b/packages/react-grab/src/utils/mount-root.ts index 062b3f2fb..94f5eb06f 100644 --- a/packages/react-grab/src/utils/mount-root.ts +++ b/packages/react-grab/src/utils/mount-root.ts @@ -4,7 +4,7 @@ export const ATTRIBUTE_NAME = "data-react-grab"; const FONT_LINK_ID = "react-grab-fonts"; const FONT_LINK_URL = - "https://fonts.googleapis.com/css2?family=Geist:wght@500&display=swap"; + "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"; const loadFonts = () => { if (document.getElementById(FONT_LINK_ID)) return; diff --git a/packages/website/app/globals.css b/packages/website/app/globals.css index 71b03b593..2d97eae7d 100644 --- a/packages/website/app/globals.css +++ b/packages/website/app/globals.css @@ -3,6 +3,15 @@ @custom-variant dark (&:is(.dark *)); +html { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-kerning: normal; + font-optical-sizing: auto; + font-synthesis: none; +} + html, body { background-color: #000; @@ -10,6 +19,28 @@ body { overflow-x: clip; } +body { + font-family: var(--font-inter), sans-serif; + font-weight: 460; + letter-spacing: -0.00563rem; + font-feature-settings: "cv01", "cv03", "cv04", "cv09", "cv11"; +} + +input, +textarea, +select, +button { + font-kerning: inherit; + font-optical-sizing: inherit; +} + +code, +kbd, +samp, +pre { + letter-spacing: normal; +} + @keyframes shimmer { 0% { background-position: 200% 0; @@ -270,6 +301,7 @@ body { } @theme inline { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/packages/website/app/layout.tsx b/packages/website/app/layout.tsx index 4c3c7b75a..bf0067635 100644 --- a/packages/website/app/layout.tsx +++ b/packages/website/app/layout.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; import { Analytics } from "@vercel/analytics/react"; import { NuqsAdapter } from "nuqs/adapters/next/app"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], display: "swap", preload: true, @@ -54,7 +54,7 @@ const RootLayout = ({ return ( {children} From 39cc6a7b0e24b9426072c7716fe5607c862e1493 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 02:47:27 +0000 Subject: [PATCH 04/29] Improve recents hover state --- packages/react-grab/e2e/recent-items.spec.ts | 118 +++++++++++++- .../src/components/recent-dropdown.tsx | 147 +++++++++++++++++- packages/react-grab/src/core/index.tsx | 7 + 3 files changed, 263 insertions(+), 9 deletions(-) diff --git a/packages/react-grab/e2e/recent-items.spec.ts b/packages/react-grab/e2e/recent-items.spec.ts index bc587f5d1..c968fd64a 100644 --- a/packages/react-grab/e2e/recent-items.spec.ts +++ b/packages/react-grab/e2e/recent-items.spec.ts @@ -16,6 +16,51 @@ const copyElement = async ( await reactGrab.page.waitForTimeout(300); }; +interface CopiedListItemContents { + firstCopiedContent: string; + secondCopiedContent: string; +} + +const copyThreeListItems = async ( + reactGrab: ReactGrabPageObject, +): Promise => { + await copyElement(reactGrab, "li:first-child"); + const firstCopiedContent = await reactGrab.getClipboardContent(); + + await copyElement(reactGrab, "li:nth-child(2)"); + const secondCopiedContent = await reactGrab.getClipboardContent(); + + await copyElement(reactGrab, "li:last-child"); + + return { + firstCopiedContent, + secondCopiedContent, + }; +}; + +const getHighlightedRecentItemIndex = async ( + reactGrab: ReactGrabPageObject, +): Promise => { + return reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const shadowRoot = host?.shadowRoot; + const root = shadowRoot?.querySelector("[data-react-grab]"); + const dropdown = root?.querySelector("[data-react-grab-recent-dropdown]"); + if (!dropdown) return null; + + const recentItemButtons = Array.from( + dropdown.querySelectorAll( + "[data-react-grab-recent-item]", + ), + ); + const highlightedIndex = recentItemButtons.findIndex((recentItemButton) => + recentItemButton.hasAttribute("data-react-grab-recent-item-highlighted"), + ); + + return highlightedIndex >= 0 ? highlightedIndex : null; + }); +}; + test.describe("Recent Items", () => { test.describe("Toolbar Recent Button", () => { test("should not be visible before any elements are copied", async ({ @@ -217,6 +262,76 @@ test.describe("Recent Items", () => { expect(await reactGrab.isRecentDropdownVisible()).toBe(false); }); + + test("should select the next item with ArrowDown then Enter", async ({ + reactGrab, + }) => { + const copiedListItemContents = await copyThreeListItems(reactGrab); + + await reactGrab.activate(); + await reactGrab.pressKey("r"); + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(true); + + await reactGrab.pressArrowDown(); + await reactGrab.pressEnter(); + + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(false); + await expect + .poll(() => reactGrab.getClipboardContent(), { timeout: 3000 }) + .toBe(copiedListItemContents.secondCopiedContent); + }); + + test("should show highlighted state while cycling with arrow keys", async ({ + reactGrab, + }) => { + await copyThreeListItems(reactGrab); + + await reactGrab.activate(); + await reactGrab.pressKey("r"); + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(true); + + await expect + .poll(() => getHighlightedRecentItemIndex(reactGrab), { timeout: 2000 }) + .toBe(0); + + await reactGrab.pressArrowDown(); + await expect + .poll(() => getHighlightedRecentItemIndex(reactGrab), { timeout: 2000 }) + .toBe(1); + + await reactGrab.pressArrowUp(); + await expect + .poll(() => getHighlightedRecentItemIndex(reactGrab), { timeout: 2000 }) + .toBe(0); + }); + + test("should select the previous item with ArrowUp then Enter", async ({ + reactGrab, + }) => { + const copiedListItemContents = await copyThreeListItems(reactGrab); + + await reactGrab.activate(); + await reactGrab.pressKey("r"); + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(true); + + await reactGrab.pressArrowUp(); + await reactGrab.pressEnter(); + + await expect + .poll(() => reactGrab.isRecentDropdownVisible(), { timeout: 2000 }) + .toBe(false); + await expect + .poll(() => reactGrab.getClipboardContent(), { timeout: 3000 }) + .toBe(copiedListItemContents.firstCopiedContent); + }); }); test.describe("Copy All", () => { @@ -247,7 +362,7 @@ test.describe("Recent Items", () => { expect(await reactGrab.isRecentDropdownVisible()).toBe(false); }); - test("should trigger copy all via Enter key", async ({ reactGrab }) => { + test("should select highlighted item via Enter key", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); @@ -258,6 +373,7 @@ test.describe("Recent Items", () => { const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); + expect(await reactGrab.isRecentDropdownVisible()).toBe(false); }); }); diff --git a/packages/react-grab/src/components/recent-dropdown.tsx b/packages/react-grab/src/components/recent-dropdown.tsx index 59c9d23d7..56bd1483a 100644 --- a/packages/react-grab/src/components/recent-dropdown.tsx +++ b/packages/react-grab/src/components/recent-dropdown.tsx @@ -41,9 +41,12 @@ const formatRelativeTime = (timestamp: number): string => { export const RecentDropdown: Component = (props) => { let containerRef: HTMLDivElement | undefined; + let lastHoveredRecentItemId: string | null = null; const [measuredWidth, setMeasuredWidth] = createSignal(0); const [measuredHeight, setMeasuredHeight] = createSignal(0); + const [highlightedRecentItemIndex, setHighlightedRecentItemIndex] = + createSignal(null); const isVisible = () => props.position !== null; @@ -95,6 +98,106 @@ export const RecentDropdown: Component = (props) => { event.stopImmediatePropagation(); }; + const notifyRecentItemHover = (recentItemId: string | null) => { + if (recentItemId === lastHoveredRecentItemId) return; + lastHoveredRecentItemId = recentItemId; + props.onItemHover?.(recentItemId); + }; + + const setHighlightedRecentItem = ( + nextHighlightedIndex: number | null, + shouldSyncHover: boolean, + ) => { + setHighlightedRecentItemIndex(nextHighlightedIndex); + + if (shouldSyncHover) { + if (nextHighlightedIndex === null) { + notifyRecentItemHover(null); + return; + } + + const highlightedRecentItem = props.items[nextHighlightedIndex] ?? null; + notifyRecentItemHover(highlightedRecentItem?.id ?? null); + + requestAnimationFrame(() => { + const highlightedItemButton = containerRef?.querySelectorAll< + HTMLButtonElement + >("[data-react-grab-recent-item]")[nextHighlightedIndex]; + highlightedItemButton?.scrollIntoView({ + block: "nearest", + }); + }); + } + }; + + const moveHighlightedRecentItem = (direction: "forward" | "backward") => { + const totalRecentItems = props.items.length; + if (totalRecentItems === 0) return; + + const currentHighlightedIndex = highlightedRecentItemIndex(); + + let nextHighlightedIndex = 0; + if ( + currentHighlightedIndex === null || + currentHighlightedIndex >= totalRecentItems + ) { + nextHighlightedIndex = direction === "forward" ? 0 : totalRecentItems - 1; + } else if (direction === "forward") { + nextHighlightedIndex = + currentHighlightedIndex + 1 >= totalRecentItems + ? 0 + : currentHighlightedIndex + 1; + } else { + nextHighlightedIndex = + currentHighlightedIndex - 1 < 0 + ? totalRecentItems - 1 + : currentHighlightedIndex - 1; + } + + setHighlightedRecentItem(nextHighlightedIndex, true); + }; + + const selectHighlightedRecentItem = () => { + if (props.items.length === 0) return; + + const currentHighlightedIndex = highlightedRecentItemIndex(); + const selectedRecentItem = + currentHighlightedIndex === null || + currentHighlightedIndex >= props.items.length + ? props.items[0] + : props.items[currentHighlightedIndex]; + + if (!selectedRecentItem) return; + props.onSelectItem?.(selectedRecentItem); + }; + + const isRecentItemHighlighted = (recentItemId: string): boolean => { + const currentHighlightedIndex = highlightedRecentItemIndex(); + if (currentHighlightedIndex === null) return false; + return props.items[currentHighlightedIndex]?.id === recentItemId; + }; + + const isBottomRecentItem = (recentItemIndex: number): boolean => { + return recentItemIndex === props.items.length - 1; + }; + + createEffect(() => { + const currentItems = props.items; + if (!isVisible() || currentItems.length === 0) { + setHighlightedRecentItem(null, true); + return; + } + + const currentHighlightedIndex = highlightedRecentItemIndex(); + if ( + currentHighlightedIndex === null || + currentHighlightedIndex >= currentItems.length + ) { + setHighlightedRecentItem(0, false); + return; + } + }); + onMount(() => { measureContainer(); @@ -110,15 +213,36 @@ export const RecentDropdown: Component = (props) => { const handleKeyDown = (event: KeyboardEvent) => { if (!isVisible()) return; + if (event.code === "Escape") { event.preventDefault(); event.stopPropagation(); + event.stopImmediatePropagation(); props.onDismiss?.(); + return; + } + + if (event.code === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + moveHighlightedRecentItem("forward"); + return; } - if (event.code === "Enter" && props.items.length > 0) { + + if (event.code === "ArrowUp") { event.preventDefault(); event.stopPropagation(); - props.onCopyAll?.(); + event.stopImmediatePropagation(); + moveHighlightedRecentItem("backward"); + return; + } + + if (event.code === "Enter") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + selectHighlightedRecentItem(); } }; @@ -189,7 +313,7 @@ export const RecentDropdown: Component = (props) => { @@ -218,18 +341,26 @@ export const RecentDropdown: Component = (props) => { style={{ "scrollbar-color": "rgba(0,0,0,0.15) transparent" }} > - {(item) => ( + {(item, itemIndex) => ( diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index f4eb70b89..ae8f78c40 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -221,7 +221,7 @@ export const ReactGrabRenderer: Component = (props) => { onSelectItem={props.onRecentItemSelect} onItemHover={props.onRecentItemHover} onCopyAll={props.onRecentCopyAll} - onClearAll={props.onRecentClear} + onClearAll={props.onRecentClearAll} onDismiss={props.onRecentDismiss} /> diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 0aa1a6b45..8c1f2484f 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -139,7 +139,7 @@ import { loadRecent, addRecentItem, removeRecentItem, - clearRecent, + clearRecentItems, } from "../utils/recent-storage.js"; import { copyContent } from "../utils/copy-content.js"; @@ -3432,6 +3432,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { dismissRecentDropdown(); }; + const handleRecentClearAll = () => { + if (recentItems().length === 0) return; + const clearedRecentItems = clearRecentItems(); + recentElementMap.clear(); + setRecentItems(clearedRecentItems); + setHasUnreadRecentItems(false); + dismissRecentDropdown(); + }; + const handleRecentItemHover = (recentItemId: string | null) => { clearRecentHoverBox(); if (recentItemId) { @@ -3469,14 +3478,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }; - const handleRecentClear = () => { - recentElementMap.clear(); - const updatedRecentItems = clearRecent(); - setRecentItems(updatedRecentItems); - setHasUnreadRecentItems(false); - dismissRecentDropdown(); - }; - const handleShowContextMenuSession = (sessionId: string) => { const session = agentManager.sessions().get(sessionId); if (!session) return; @@ -3638,7 +3639,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { onRecentItemSelect={handleRecentItemSelect} onRecentItemHover={handleRecentItemHover} onRecentCopyAll={handleRecentCopyAll} - onRecentClear={handleRecentClear} + onRecentClearAll={handleRecentClearAll} onRecentDismiss={dismissRecentDropdown} /> ); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 299c53b9d..1dd4450b8 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -536,7 +536,7 @@ export interface ReactGrabRendererProps { onRecentItemSelect?: (item: RecentItem) => void; onRecentItemHover?: (recentItemId: string | null) => void; onRecentCopyAll?: () => void; - onRecentClear?: () => void; + onRecentClearAll?: () => void; onRecentDismiss?: () => void; } diff --git a/packages/react-grab/src/utils/recent-storage.ts b/packages/react-grab/src/utils/recent-storage.ts index 340f95fc1..475805eb7 100644 --- a/packages/react-grab/src/utils/recent-storage.ts +++ b/packages/react-grab/src/utils/recent-storage.ts @@ -22,7 +22,7 @@ export const removeRecentItem = (itemId: string): RecentItem[] => { return recentItems; }; -export const clearRecent = (): RecentItem[] => { +export const clearRecentItems = (): RecentItem[] => { recentItems = []; return recentItems; }; From 7a60ff11d820ec6bf65768fdea105fac296e4065 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 16:39:01 +0000 Subject: [PATCH 10/29] Refine recent dropdown hover styles --- .../react-grab/src/components/recent-dropdown.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react-grab/src/components/recent-dropdown.tsx b/packages/react-grab/src/components/recent-dropdown.tsx index db05c07e8..0b8b0ddbf 100644 --- a/packages/react-grab/src/components/recent-dropdown.tsx +++ b/packages/react-grab/src/components/recent-dropdown.tsx @@ -303,31 +303,31 @@ export const RecentDropdown: Component = (props) => { data-react-grab-ignore-events data-react-grab-recent-clear aria-label="Clear all" - class="contain-layout shrink-0 flex items-center justify-center px-[3px] py-px rounded-sm bg-[#FEF2F2] cursor-pointer transition-all hover:bg-[#FEE2E2] press-scale h-[17px]" + class="contain-layout shrink-0 flex items-center justify-center w-[18px] h-[18px] rounded-sm bg-[#FEF2F2] border-none cursor-pointer transition-all hover:bg-black/[0.02] press-scale" onClick={(event) => { event.stopPropagation(); props.onClearAll?.(); }} > - + -
+
0} fallback={ @@ -348,9 +348,9 @@ export const RecentDropdown: Component = (props) => { data-react-grab-recent-item-highlighted={ isRecentItemHighlighted(item.id) ? "" : undefined } - class="contain-layout flex items-start justify-between w-full px-2 py-1 cursor-pointer transition-colors duration-150 text-left border border-transparent hover:bg-black/[0.035] hover:border-black/[0.08] gap-2" + class="contain-layout flex items-start justify-between w-full px-2 py-1 cursor-pointer transition-colors duration-150 text-left border border-transparent hover:bg-black/[0.02] hover:border-black/[0.05] gap-2" classList={{ - "bg-black/[0.075] border-black/[0.12]": + "bg-black/[0.02] border-black/[0.05]": isRecentItemHighlighted(item.id), "rounded-none": !isBottomRecentItem(itemIndex()), "rounded-b-[6px]": isBottomRecentItem(itemIndex()), From a99a6d8a5534f6656401f9aca612f016de06895b Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 17:29:18 +0000 Subject: [PATCH 11/29] Adjust toolbar icon styling --- .../src/components/icons/icon-comment.tsx | 38 ++++++--- .../src/components/icons/icon-inbox.tsx | 21 +++-- .../src/components/icons/icon-select.tsx | 23 ++++-- .../src/components/recent-dropdown.tsx | 78 +++++++++++++------ .../react-grab/src/components/renderer.tsx | 1 + .../src/components/toolbar/index.tsx | 38 ++++----- .../components/toolbar/toolbar-content.tsx | 26 +++---- .../src/utils/get-toolbar-icon-color.ts | 6 +- 8 files changed, 144 insertions(+), 87 deletions(-) diff --git a/packages/react-grab/src/components/icons/icon-comment.tsx b/packages/react-grab/src/components/icons/icon-comment.tsx index 38e767d9a..d3b02883b 100644 --- a/packages/react-grab/src/components/icons/icon-comment.tsx +++ b/packages/react-grab/src/components/icons/icon-comment.tsx @@ -3,10 +3,12 @@ import type { Component } from "solid-js"; interface IconCommentProps { size?: number; class?: string; + isActive?: boolean; } export const IconComment: Component = (props) => { const size = () => props.size ?? 14; + const isActive = () => Boolean(props.isActive); return ( = (props) => { width={size()} height={size()} viewBox="0 0 24 24" - fill="currentColor" + fill="none" class={props.class} > - - + {isActive() ? ( + + ) : ( + <> + + + + )} ); }; diff --git a/packages/react-grab/src/components/icons/icon-inbox.tsx b/packages/react-grab/src/components/icons/icon-inbox.tsx index 3ed814640..7ede77e64 100644 --- a/packages/react-grab/src/components/icons/icon-inbox.tsx +++ b/packages/react-grab/src/components/icons/icon-inbox.tsx @@ -14,11 +14,17 @@ export const IconInbox: Component = (props) => { width={size()} height={size()} viewBox="0 0 24 24" - fill="currentColor" + fill="none" class={props.class} > - - + + ); }; @@ -32,14 +38,15 @@ export const IconInboxUnread: Component = (props) => { width={size()} height={size()} viewBox="0 0 24 24" - fill="currentColor" + fill="none" class={props.class} > - ); }; diff --git a/packages/react-grab/src/components/icons/icon-select.tsx b/packages/react-grab/src/components/icons/icon-select.tsx index 573e8b111..c8dfc7d61 100644 --- a/packages/react-grab/src/components/icons/icon-select.tsx +++ b/packages/react-grab/src/components/icons/icon-select.tsx @@ -13,15 +13,28 @@ export const IconSelect: Component = (props) => { xmlns="http://www.w3.org/2000/svg" width={size()} height={size()} - viewBox="0 0 18 18" - fill="currentColor" + viewBox="0 0 24 24" + fill="none" class={props.class} > + + - ); }; diff --git a/packages/react-grab/src/components/recent-dropdown.tsx b/packages/react-grab/src/components/recent-dropdown.tsx index 0b8b0ddbf..c01d11ade 100644 --- a/packages/react-grab/src/components/recent-dropdown.tsx +++ b/packages/react-grab/src/components/recent-dropdown.tsx @@ -18,6 +18,7 @@ import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; import { IconComment } from "./icons/icon-comment.jsx"; import { IconCopy } from "./icons/icon-copy.jsx"; import { IconTrash } from "./icons/icon-trash.jsx"; +import { Tooltip } from "./tooltip.jsx"; const DEFAULT_OFFSCREEN_POSITION = { left: -9999, top: -9999 }; @@ -49,6 +50,10 @@ export const RecentDropdown: Component = (props) => { const [measuredHeight, setMeasuredHeight] = createSignal(0); const [highlightedRecentItemIndex, setHighlightedRecentItemIndex] = createSignal(null); + const [isClearAllTooltipVisible, setIsClearAllTooltipVisible] = + createSignal(false); + const [isCopyAllTooltipVisible, setIsCopyAllTooltipVisible] = + createSignal(false); const isVisible = () => props.position !== null; @@ -200,6 +205,13 @@ export const RecentDropdown: Component = (props) => { } }); + createEffect(() => { + if (!isVisible()) { + setIsClearAllTooltipVisible(false); + setIsCopyAllTooltipVisible(false); + } + }); + onMount(() => { measureContainer(); @@ -296,33 +308,49 @@ export const RecentDropdown: Component = (props) => { )} >
- Recent + History 0}>
- - +
+ + + Clear all + +
+
+ + + Copy all + +
diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index ae8f78c40..83bef4ceb 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -189,6 +189,7 @@ export const ReactGrabRenderer: Component = (props) => { isActive={props.isActive} isCommentMode={props.isCommentMode} isContextMenuOpen={props.contextMenuPosition !== null} + isHistoryOpen={props.recentDropdownPosition !== null} onToggle={props.onToggleActive} onComment={props.onComment} enabled={props.enabled} diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index c1c59c940..90d23df1c 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -48,6 +48,7 @@ interface ToolbarProps { isActive?: boolean; isCommentMode?: boolean; isContextMenuOpen?: boolean; + isHistoryOpen?: boolean; onToggle?: () => void; onComment?: () => void; enabled?: boolean; @@ -96,7 +97,7 @@ export const Toolbar: Component = (props) => { const recentTooltipLabel = () => { const count = props.recentItemCount ?? 0; - return count > 0 ? `Recent (${count})` : "Recent"; + return count > 0 ? `History (${count})` : "History"; }; const tooltipPosition = () => (snapEdge() === "top" ? "bottom" : "top"); @@ -130,18 +131,12 @@ export const Toolbar: Component = (props) => { }, }); - const collapsedEdgeClasses = () => { + const collapsedPaddingClasses = () => { if (!isCollapsed()) return ""; const edge = snapEdge(); - const roundedClass = { - top: "rounded-t-none rounded-b-[10px]", - bottom: "rounded-b-none rounded-t-[10px]", - left: "rounded-l-none rounded-r-[10px]", - right: "rounded-r-none rounded-l-[10px]", - }[edge]; const paddingClass = edge === "top" || edge === "bottom" ? "px-2 py-0.25" : "px-0.25 py-2"; - return `${roundedClass} ${paddingClass}`; + return paddingClass; }; let shakeTooltipTimeout: ReturnType | undefined; @@ -1059,10 +1054,10 @@ export const Toolbar: Component = (props) => { >
= (props) => { {...createFreezeHandlers(setIsCommentTooltipVisible)} > = (props) => { {...createFreezeHandlers(setIsRecentTooltipVisible)} > } > @@ -1257,12 +1253,12 @@ export const Toolbar: Component = (props) => {
@@ -1286,7 +1282,7 @@ export const Toolbar: Component = (props) => { diff --git a/packages/react-grab/src/components/toolbar/toolbar-content.tsx b/packages/react-grab/src/components/toolbar/toolbar-content.tsx index d979cdb7a..1172736e6 100644 --- a/packages/react-grab/src/components/toolbar/toolbar-content.tsx +++ b/packages/react-grab/src/components/toolbar/toolbar-content.tsx @@ -1,6 +1,5 @@ import type { Component, JSX } from "solid-js"; import { cn } from "../../utils/cn.js"; -import { PANEL_STYLES } from "../../constants.js"; import { IconSelect } from "../icons/icon-select.jsx"; import { IconComment } from "../icons/icon-comment.jsx"; import { IconChevron } from "../icons/icon-chevron.jsx"; @@ -27,17 +26,11 @@ export interface ToolbarContentProps { export const ToolbarContent: Component = (props) => { const edge = () => props.snapEdge ?? "bottom"; - const collapsedEdgeClasses = () => { + const collapsedPaddingClasses = () => { if (!props.isCollapsed) return ""; - const roundedClass = { - top: "rounded-t-none rounded-b-[10px]", - bottom: "rounded-b-none rounded-t-[10px]", - left: "rounded-l-none rounded-r-[10px]", - right: "rounded-r-none rounded-l-[10px]", - }[edge()]; const paddingClass = edge() === "top" || edge() === "bottom" ? "px-2 py-0.25" : "px-0.25 py-2"; - return `${roundedClass} ${paddingClass}`; + return paddingClass; }; const chevronRotation = () => { @@ -74,7 +67,8 @@ export const ToolbarContent: Component = (props) => { const defaultCommentButton = () => ( = (props) => { = (props) => { ref={recentButtonRef} data-react-grab-ignore-events data-react-grab-toolbar-recent - class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox mr-1.5" + class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox" on:pointerdown={(event) => { stopEventPropagation(event); handlePointerDown(event); @@ -1226,20 +1668,22 @@ export const Toolbar: Component = (props) => { }} {...createFreezeHandlers(setIsRecentTooltipVisible)} > - + + } + > + - } - > - - + + = (props) => { = (props) => {
= (props) => { const edge = () => props.snapEdge ?? "bottom"; + const isVerticalLayout = () => + !props.isCollapsed && (edge() === "left" || edge() === "right"); const collapsedPaddingClasses = () => { if (!props.isCollapsed) return ""; @@ -54,7 +56,7 @@ export const ToolbarContent: Component = (props) => { }; const defaultSelectButton = () => ( - - - Select - +
+
+ {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} + + + Select + +
-
-
-
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - - Comment - +
+
+ {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} + + + Comment + +
-
-
0 - ? "grid-cols-[1fr] opacity-100" - : "grid-cols-[0fr] opacity-0 pointer-events-none", - )} - > -
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - - {recentTooltipLabel()} - + + + + + {recentTooltipLabel()} + +
-
-
- - - {props.enabled ? "Disable" : "Enable"} - + > +
+
+ + + + {props.enabled ? "Disable" : "Enable"} + +
+
-
= (props) => { "flex items-center justify-center rounded-full antialiased transition-all duration-150 ease-out relative overflow-visible [font-synthesis:none] filter-[drop-shadow(0px_1px_2px_#51515140)] [corner-shape:superellipse(1.25)]", "bg-black", !props.isCollapsed && !isVerticalLayout() && "py-1.5 gap-1.5 px-2", - !props.isCollapsed && isVerticalLayout() && "flex-col py-2 px-1.5 gap-1.5", + !props.isCollapsed && + isVerticalLayout() && + "flex-col py-2 px-1.5 gap-1.5", collapsedPaddingClasses(), props.isShaking && "animate-shake", )} diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index f4e2cb863..a9d86b0f0 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -116,6 +116,10 @@ export const TOOLBAR_COLLAPSED_LONG_PX = 28; export const TOOLBAR_DRAG_PREVIEW_SHORT_PX = 28; export const TOOLBAR_DRAG_PREVIEW_LONG_PX = 56; export const TOOLBAR_DRAG_PREVIEW_ROTATION_DURATION_MS = 260; +export const TOOLBAR_DRAG_RELEASE_REVEAL_DURATION_MS = 180; +export const TOOLBAR_DRAG_RELEASE_ITEM_OFFSET_PX = 26; +export const TOOLBAR_DRAG_RELEASE_ICON_STAGGER_DELAY_MS = 50; +export const TOOLBAR_DRAG_RELEASE_ICON_STAGGER_COUNT = 3; export const TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS = 150; export const TOOLBAR_DEFAULT_WIDTH_PX = 78; export const TOOLBAR_DEFAULT_HEIGHT_PX = 28; diff --git a/packages/react-grab/src/styles.css b/packages/react-grab/src/styles.css index 77898a0a8..9a30f32ea 100644 --- a/packages/react-grab/src/styles.css +++ b/packages/react-grab/src/styles.css @@ -267,7 +267,8 @@ pre { } .animate-toolbar-dock-shift { - animation: toolbar-dock-shift var(--transition-slow) cubic-bezier(0.2, 0.8, 0.2, 1); + animation: toolbar-dock-shift var(--transition-slow) + cubic-bezier(0.2, 0.8, 0.2, 1); will-change: transform, opacity; } diff --git a/packages/website/app/layout.tsx b/packages/website/app/layout.tsx index bf0067635..6eee03bbb 100644 --- a/packages/website/app/layout.tsx +++ b/packages/website/app/layout.tsx @@ -53,9 +53,7 @@ const RootLayout = ({ }>) => { return ( - + {children} From 0e8e3b362a4467c9b0bcb40d59c642ae26e8f028 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 21:16:56 +0000 Subject: [PATCH 22/29] Adjust toolbar icon states --- .../src/components/toolbar/index.tsx | 23 ++++++++++++------- .../components/toolbar/toolbar-content.tsx | 5 ---- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index d82ea031e..520e49c38 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -1787,11 +1787,6 @@ export const Toolbar: Component = (props) => { = (props) => { > } > diff --git a/packages/react-grab/src/components/toolbar/toolbar-content.tsx b/packages/react-grab/src/components/toolbar/toolbar-content.tsx index a6442a649..14a25f63c 100644 --- a/packages/react-grab/src/components/toolbar/toolbar-content.tsx +++ b/packages/react-grab/src/components/toolbar/toolbar-content.tsx @@ -80,11 +80,6 @@ export const ToolbarContent: Component = (props) => { Date: Sun, 8 Feb 2026 21:30:26 +0000 Subject: [PATCH 23/29] fix: remove unused TOOLBAR_VELOCITY_MULTIPLIER_MS and clear snap timeout on drag start --- packages/react-grab/src/components/toolbar/index.tsx | 1 + packages/react-grab/src/constants.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 520e49c38..284ae6242 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -1268,6 +1268,7 @@ export const Toolbar: Component = (props) => { setDragReleaseOriginOffset({ x: 0, y: 0 }); clearDragReleaseRevealAnimationFrame(); clearDragReleaseRevealAnimationTimeout(); + clearTimeout(snapAnimationTimeout); setIsDragging(true); setHasDragMoved(false); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index a9d86b0f0..50d77fa4b 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -110,7 +110,6 @@ export const TOOLBAR_SNAP_MARGIN_PX = 16; export const TOOLBAR_FADE_IN_DELAY_MS = 500; export const TOOLBAR_SNAP_ANIMATION_DURATION_MS = 300; export const TOOLBAR_DRAG_THRESHOLD_PX = 5; -export const TOOLBAR_VELOCITY_MULTIPLIER_MS = 150; export const TOOLBAR_COLLAPSED_SHORT_PX = 14; export const TOOLBAR_COLLAPSED_LONG_PX = 28; export const TOOLBAR_DRAG_PREVIEW_SHORT_PX = 28; From c20d51acfa972b1b64bb37bdbe93111d5b856c02 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 8 Feb 2026 21:58:34 +0000 Subject: [PATCH 24/29] Adjust toolbar toggle animation --- packages/react-grab/e2e/toolbar.spec.ts | 229 ++++++++ .../src/components/toolbar/index.tsx | 487 ++++++++++-------- 2 files changed, 503 insertions(+), 213 deletions(-) diff --git a/packages/react-grab/e2e/toolbar.spec.ts b/packages/react-grab/e2e/toolbar.spec.ts index fea4b1cc4..5e3dadc0b 100644 --- a/packages/react-grab/e2e/toolbar.spec.ts +++ b/packages/react-grab/e2e/toolbar.spec.ts @@ -1,6 +1,113 @@ import { test, expect } from "./fixtures.js"; +import type { Page } from "@playwright/test"; test.describe("Toolbar", () => { + const overlayAttributeName = "data-react-grab"; + const toggleMeasureDurationMs = 220; + + const getToolbarEnabledToggleMetrics = async (page: Page) => { + return page.evaluate((attrName) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + if (!shadowRoot) return null; + const root = shadowRoot.querySelector(`[${attrName}]`); + if (!root) return null; + + const toolbar = root.querySelector( + "[data-react-grab-toolbar]", + ); + const enabledToggleButton = root.querySelector( + "[data-react-grab-toolbar-enabled]", + ); + if (!toolbar || !enabledToggleButton) return null; + + const enabledToggleButtonRect = enabledToggleButton.getBoundingClientRect(); + const transform = toolbar.style.transform; + const translateMatch = transform.match( + /translate\((-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)px\)/, + ); + const toolbarPosition = translateMatch + ? { + x: parseFloat(translateMatch[1]), + y: parseFloat(translateMatch[2]), + } + : null; + + return { + enabledToggleCenter: { + x: enabledToggleButtonRect.left + enabledToggleButtonRect.width / 2, + y: enabledToggleButtonRect.top + enabledToggleButtonRect.height / 2, + }, + toolbarPosition, + }; + }, overlayAttributeName); + }; + + const getToolbarEnabledToggleDriftMetrics = async (page: Page) => { + return page.evaluate( + async ({ attrName, measureDurationMs }) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + if (!shadowRoot) return null; + const root = shadowRoot.querySelector(`[${attrName}]`); + if (!root) return null; + const enabledToggleButton = root.querySelector( + "[data-react-grab-toolbar-enabled]", + ); + if (!enabledToggleButton) return null; + + const getEnabledToggleCenter = () => { + const enabledToggleButtonRect = enabledToggleButton.getBoundingClientRect(); + return { + x: enabledToggleButtonRect.left + enabledToggleButtonRect.width / 2, + y: enabledToggleButtonRect.top + enabledToggleButtonRect.height / 2, + }; + }; + + const initialEnabledToggleCenter = getEnabledToggleCenter(); + let maxHorizontalDrift = 0; + let maxVerticalDrift = 0; + const animationStartTime = performance.now(); + + enabledToggleButton.click(); + + await new Promise((resolve) => { + const trackDrift = (timestamp: number) => { + const currentEnabledToggleCenter = getEnabledToggleCenter(); + maxHorizontalDrift = Math.max( + maxHorizontalDrift, + Math.abs( + currentEnabledToggleCenter.x - initialEnabledToggleCenter.x, + ), + ); + maxVerticalDrift = Math.max( + maxVerticalDrift, + Math.abs(currentEnabledToggleCenter.y - initialEnabledToggleCenter.y), + ); + + if (timestamp - animationStartTime >= measureDurationMs) { + resolve(); + return; + } + + requestAnimationFrame(trackDrift); + }; + + requestAnimationFrame(trackDrift); + }); + + return { + maxHorizontalDrift, + maxVerticalDrift, + }; + }, + { + attrName: overlayAttributeName, + measureDurationMs: toggleMeasureDurationMs, + }, + ); + }; + test.describe("Visibility", () => { test("toolbar should be visible after initial load", async ({ reactGrab, @@ -589,4 +696,126 @@ test.describe("Toolbar", () => { await reactGrab.setViewportSize(1280, 720); }); }); + + test.describe("Enabled Toggle", () => { + test("enabled toggle should stay anchored on horizontal edges", async ({ + reactGrab, + }) => { + await expect + .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) + .toBe(true); + + await reactGrab.dragToolbar(0, 1200); + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.snapEdge; + }, + { timeout: 3000 }, + ) + .toBe("bottom"); + + const metricsBeforeToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsBeforeToggle).not.toBeNull(); + + const driftMetrics = await getToolbarEnabledToggleDriftMetrics( + reactGrab.page, + ); + expect(driftMetrics).not.toBeNull(); + + if (!driftMetrics) return; + + expect(driftMetrics.maxHorizontalDrift).toBeLessThan(2); + expect(driftMetrics.maxVerticalDrift).toBeLessThan(2); + + const metricsAfterToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsAfterToggle).not.toBeNull(); + + if (!metricsBeforeToggle || !metricsAfterToggle) return; + + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.x - + metricsBeforeToggle.enabledToggleCenter.x, + ), + ).toBeLessThan(2); + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.y - + metricsBeforeToggle.enabledToggleCenter.y, + ), + ).toBeLessThan(2); + expect( + Math.abs( + (metricsAfterToggle.toolbarPosition?.y ?? 0) - + (metricsBeforeToggle.toolbarPosition?.y ?? 0), + ), + ).toBeLessThan(2); + }); + + test("enabled toggle should stay anchored on vertical edges", async ({ + reactGrab, + }) => { + await expect + .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) + .toBe(true); + + await reactGrab.dragToolbar(-1500, 0); + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.snapEdge; + }, + { timeout: 3000 }, + ) + .toBe("left"); + + const metricsBeforeToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsBeforeToggle).not.toBeNull(); + + const driftMetrics = await getToolbarEnabledToggleDriftMetrics( + reactGrab.page, + ); + expect(driftMetrics).not.toBeNull(); + + if (!driftMetrics) return; + + expect(driftMetrics.maxHorizontalDrift).toBeLessThan(2); + expect(driftMetrics.maxVerticalDrift).toBeLessThan(2); + + const metricsAfterToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsAfterToggle).not.toBeNull(); + + if (!metricsBeforeToggle || !metricsAfterToggle) return; + + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.x - + metricsBeforeToggle.enabledToggleCenter.x, + ), + ).toBeLessThan(2); + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.y - + metricsBeforeToggle.enabledToggleCenter.y, + ), + ).toBeLessThan(2); + expect( + Math.abs( + (metricsAfterToggle.toolbarPosition?.x ?? 0) - + (metricsBeforeToggle.toolbarPosition?.x ?? 0), + ), + ).toBeLessThan(2); + }); + }); }); diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 284ae6242..24ea35942 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -108,8 +108,8 @@ interface ToolbarDimensions { export const Toolbar: Component = (props) => { let containerRef: HTMLDivElement | undefined; let expandableButtonsRef: HTMLDivElement | undefined; + let toggleEnabledButtonRef: HTMLButtonElement | undefined; let unfreezeUpdatesCallback: (() => void) | null = null; - let lastKnownExpandableWidth = 0; const [isVisible, setIsVisible] = createSignal(false); const [isCollapsed, setIsCollapsed] = createSignal(false); @@ -167,6 +167,18 @@ export const Toolbar: Component = (props) => { const isSideEdge = (edge: SnapEdge) => edge === "left" || edge === "right"; + const getExpandableSectionVisibilityClasses = (): string => { + return props.enabled ? "opacity-100" : "opacity-0 pointer-events-none"; + }; + + const getExpandableSectionSizeStyle = () => { + if (props.enabled) return undefined; + return { + width: "0px", + height: "0px", + }; + }; + const computeEdgeDistances = (pointerX: number, pointerY: number) => { const viewport = getVisualViewport(); const edgeDistanceMap: EdgeDistanceMap = { @@ -510,6 +522,7 @@ export const Toolbar: Component = (props) => { let dragReleaseRevealAnimationTimeout: | ReturnType | undefined; + let toggleAnchorLockAnimationFrame: number | undefined; let previousDocumentElementUserSelect = ""; let previousDocumentBodyUserSelect = ""; let isGlobalUserSelectDisabled = false; @@ -541,6 +554,12 @@ export const Toolbar: Component = (props) => { dragReleaseRevealAnimationTimeout = undefined; }; + const clearToggleAnchorLockAnimationFrame = () => { + if (toggleAnchorLockAnimationFrame === undefined) return; + cancelAnimationFrame(toggleAnchorLockAnimationFrame); + toggleAnchorLockAnimationFrame = undefined; + }; + const setGlobalUserSelectDisabled = (shouldDisable: boolean) => { const documentElement = document.documentElement; const documentBody = document.body; @@ -823,54 +842,101 @@ export const Toolbar: Component = (props) => { const handleToggleEnabled = createDragAwareHandler(() => { const isCurrentlyEnabled = Boolean(props.enabled); const edge = snapEdge(); - const preTogglePosition = position(); - const expandableWidth = lastKnownExpandableWidth; - const shouldCompensatePosition = expandableWidth > 0 && edge !== "left"; + const previousToggleRect = toggleEnabledButtonRef?.getBoundingClientRect(); + const previousToggleCenter = previousToggleRect + ? { + x: previousToggleRect.left + previousToggleRect.width / 2, + y: previousToggleRect.top + previousToggleRect.height / 2, + } + : null; + const shouldAdjustHorizontalAxis = edge === "top" || edge === "bottom"; - if (shouldCompensatePosition) { - setIsToggleAnimating(true); - } + setIsToggleAnimating(true); + clearToggleAnchorLockAnimationFrame(); props.onToggleEnabled?.(); - if (expandableWidth > 0) { - const widthChange = isCurrentlyEnabled - ? -expandableWidth - : expandableWidth; - expandedDimensions = { - width: expandedDimensions.width + widthChange, - height: expandedDimensions.height, + if (previousToggleCenter) { + const lockToggleCenterToPointer = () => { + if (!isToggleAnimating()) return; + + const toolbarRect = containerRef?.getBoundingClientRect(); + const toggleRect = toggleEnabledButtonRef?.getBoundingClientRect(); + if (!toolbarRect || !toggleRect) { + toggleAnchorLockAnimationFrame = requestAnimationFrame( + lockToggleCenterToPointer, + ); + return; + } + + const toggleCenterX = toggleRect.left + toggleRect.width / 2; + const toggleCenterY = toggleRect.top + toggleRect.height / 2; + const viewport = getVisualViewport(); + const minX = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; + const maxX = Math.max( + minX, + viewport.offsetLeft + + viewport.width - + toolbarRect.width - + TOOLBAR_SNAP_MARGIN_PX, + ); + const minY = viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX; + const maxY = Math.max( + minY, + viewport.offsetTop + + viewport.height - + toolbarRect.height - + TOOLBAR_SNAP_MARGIN_PX, + ); + + setPosition((previousPosition) => ({ + x: shouldAdjustHorizontalAxis + ? clampToViewport( + previousPosition.x + (previousToggleCenter.x - toggleCenterX), + minX, + maxX, + ) + : previousPosition.x, + y: shouldAdjustHorizontalAxis + ? previousPosition.y + : clampToViewport( + previousPosition.y + (previousToggleCenter.y - toggleCenterY), + minY, + maxY, + ), + })); + + toggleAnchorLockAnimationFrame = requestAnimationFrame( + lockToggleCenterToPointer, + ); }; - } - if (shouldCompensatePosition) { - const viewport = getVisualViewport(); - const positionOffset = isCurrentlyEnabled - ? expandableWidth - : -expandableWidth; - const clampMin = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; - const clampMax = - viewport.offsetLeft + - viewport.width - - expandedDimensions.width - - TOOLBAR_SNAP_MARGIN_PX; - const compensatedX = clampToViewport( - preTogglePosition.x + positionOffset, - clampMin, - clampMax, + toggleAnchorLockAnimationFrame = requestAnimationFrame( + lockToggleCenterToPointer, ); + } + + clearTimeout(toggleAnimationTimeout); + toggleAnimationTimeout = setTimeout(() => { + clearToggleAnchorLockAnimationFrame(); + setIsToggleAnimating(false); + + requestAnimationFrame(() => { + const nextContainerRect = containerRef?.getBoundingClientRect(); + if (!nextContainerRect) return; - setPosition({ x: compensatedX, y: preTogglePosition.y }); + expandedDimensions = { + width: nextContainerRect.width, + height: nextContainerRect.height, + }; - clearTimeout(toggleAnimationTimeout); - toggleAnimationTimeout = setTimeout(() => { - setIsToggleAnimating(false); + const nextPosition = position(); const newRatio = getRatioFromPosition( edge, - position().x, - position().y, - expandedDimensions.width, - expandedDimensions.height, + nextPosition.x, + nextPosition.y, + nextContainerRect.width, + nextContainerRect.height, ); setPositionRatio(newRatio); saveAndNotify({ @@ -879,21 +945,8 @@ export const Toolbar: Component = (props) => { collapsed: isCollapsed(), enabled: !isCurrentlyEnabled, }); - }, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS); - } else if (!isCurrentlyEnabled && lastKnownExpandableWidth === 0) { - // HACK: When toolbar mounts disabled, expandable buttons are hidden (grid-cols-[0fr]) - // so we can't measure their width. Learn it after the first enable animation completes. - clearTimeout(toggleAnimationTimeout); - toggleAnimationTimeout = setTimeout(() => { - if (expandableButtonsRef) { - lastKnownExpandableWidth = expandableButtonsRef.offsetWidth; - } - const rect = containerRef?.getBoundingClientRect(); - if (rect) { - expandedDimensions = { width: rect.width, height: rect.height }; - } - }, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS); - } + }); + }, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS); }); const getSnapPosition = ( @@ -1437,10 +1490,6 @@ export const Toolbar: Component = (props) => { setPosition(defaultPosition); } - if (props.enabled && expandableButtonsRef) { - lastKnownExpandableWidth = expandableButtonsRef.offsetWidth; - } - if (props.onSubscribeToStateChanges) { const unsubscribe = props.onSubscribeToStateChanges( (state: ToolbarState) => { @@ -1522,6 +1571,7 @@ export const Toolbar: Component = (props) => { clearTimeout(dockLayoutAnimationTimeout); clearDragReleaseRevealAnimationFrame(); clearDragReleaseRevealAnimationTimeout(); + clearToggleAnchorLockAnimationFrame(); setIsDragSnapTransitionActive(false); setIsDragReleaseRevealPrepared(false); setIsDragReleaseRevealAnimating(false); @@ -1552,9 +1602,12 @@ export const Toolbar: Component = (props) => { if (isSnapping()) { return "transition-[transform,opacity] duration-300 ease-out"; } - if (isCollapseAnimating() || isToggleAnimating()) { + if (isCollapseAnimating()) { return "transition-[transform,opacity] duration-150 ease-out"; } + if (isToggleAnimating()) { + return "transition-opacity duration-150 ease-out"; + } return "transition-opacity duration-300 ease-out"; }; @@ -1692,152 +1745,170 @@ export const Toolbar: Component = (props) => {
-
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - - Select - +
+
+ {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} + + + Select + +
-
-
-
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - - Comment - +
+
+ {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} + + + Comment + +
-
-
0 - ? "grid-cols-[1fr] opacity-100" - : "grid-cols-[0fr] opacity-0 pointer-events-none", - )} - style={getDragReleaseItemStyle(0, true, true)} - > -
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - - {recentTooltipLabel()} - + + + + + {recentTooltipLabel()} + +
@@ -1876,6 +1936,7 @@ export const Toolbar: Component = (props) => { style={getDragReleaseItemStyle(1, false, true)} >