diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index f31d501e6..ce40d0cc5 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -32,6 +32,7 @@ interface ToolbarInfo { isCollapsed: boolean; position: { x: number; y: number } | null; snapEdge: string | null; + orientation: "horizontal" | "vertical" | null; } interface AgentSessionInfo { @@ -135,7 +136,6 @@ export interface ReactGrabPageObject { getRecentDropdownInfo: () => Promise; clickRecentItem: (index: number) => Promise; clickRecentCopyAll: () => Promise; - clickRecentClear: () => Promise; hoverRecentItem: (index: number) => Promise; getSelectionLabelInfo: () => Promise; @@ -699,6 +699,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { isCollapsed: false, position: null, snapEdge: null, + orientation: null, }; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) @@ -707,6 +708,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { isCollapsed: false, position: null, snapEdge: null, + orientation: null, }; const toolbar = root.querySelector( @@ -718,6 +720,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { isCollapsed: false, position: null, snapEdge: null, + orientation: null, }; const computedStyle = window.getComputedStyle(toolbar); @@ -734,7 +737,31 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const rect = toolbar.getBoundingClientRect(); let snapEdge: string | null = null; - if (position) { + const serializedToolbarState = localStorage.getItem( + "react-grab-toolbar-state", + ); + if (serializedToolbarState) { + try { + const parsedToolbarState = JSON.parse(serializedToolbarState); + if ( + parsedToolbarState && + typeof parsedToolbarState === "object" && + "edge" in parsedToolbarState + ) { + const edgeValue = parsedToolbarState.edge; + if ( + edgeValue === "top" || + edgeValue === "bottom" || + edgeValue === "left" || + edgeValue === "right" + ) { + snapEdge = edgeValue; + } + } + } catch {} + } + + if (!snapEdge && position) { const SNAP_THRESHOLD = 30; if (position.y <= SNAP_THRESHOLD) snapEdge = "top"; else if (position.y + rect.height >= viewportHeight - SNAP_THRESHOLD) @@ -745,12 +772,21 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { } const isCollapsed = computedStyle.cursor === "pointer"; + const orientationAttribute = toolbar.getAttribute( + "data-react-grab-toolbar-orientation", + ); + const orientation = + orientationAttribute === "vertical" || + orientationAttribute === "horizontal" + ? orientationAttribute + : null; return { isVisible: computedStyle.opacity !== "0", isCollapsed, position, snapEdge, + orientation, }; }, ATTRIBUTE_NAME); }; @@ -879,7 +915,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { "[data-react-grab-toolbar-recent]", ); if (!recentButton) return false; - const unreadDot = recentButton.querySelector('path[fill="#404040"]'); + const unreadDot = recentButton.querySelector('path[fill="currentColor"]'); return unreadDot !== null; }, ATTRIBUTE_NAME); }; @@ -2070,7 +2106,6 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { getRecentDropdownInfo, clickRecentItem, clickRecentCopyAll, - clickRecentClear, hoverRecentItem, getSelectionLabelInfo, diff --git a/packages/react-grab/e2e/recent-items.spec.ts b/packages/react-grab/e2e/recent-items.spec.ts index 18e6d8c78..bf920fe50 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 ({ @@ -83,6 +128,16 @@ test.describe("Recent Items", () => { }); test.describe("Dropdown Open/Close", () => { + test("should not open when pressing R with no recent items", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await reactGrab.pressKey("r"); + await reactGrab.page.waitForTimeout(100); + + expect(await reactGrab.isRecentDropdownVisible()).toBe(false); + }); + test("should open when clicking the recent button", async ({ reactGrab, }) => { @@ -93,6 +148,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, }) => { @@ -157,20 +222,6 @@ test.describe("Recent Items", () => { const dropdownInfo = await reactGrab.getRecentDropdownInfo(); expect(dropdownInfo.itemCount).toBe(2); }); - - test("should hide recent button after clearing all items", async ({ - reactGrab, - }) => { - await copyElement(reactGrab, "li:first-child"); - await reactGrab.clickRecentButton(); - await reactGrab.clickRecentClear(); - - await expect - .poll(() => reactGrab.isRecentButtonVisible(), { timeout: 2000 }) - .toBe(false); - - expect(await reactGrab.isRecentDropdownVisible()).toBe(false); - }); }); test.describe("Item Selection", () => { @@ -207,6 +258,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", () => { @@ -237,7 +358,9 @@ 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("")); @@ -248,49 +371,6 @@ test.describe("Recent Items", () => { const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); - }); - }); - - test.describe("Clear All", () => { - test("should remove all recent items", async ({ reactGrab }) => { - await copyElement(reactGrab, "li:first-child"); - await copyElement(reactGrab, "li:last-child"); - - await reactGrab.clickRecentButton(); - expect((await reactGrab.getRecentDropdownInfo()).itemCount).toBe(2); - - await reactGrab.clickRecentClear(); - - await expect - .poll(() => reactGrab.isRecentButtonVisible(), { timeout: 2000 }) - .toBe(false); - }); - - test("should hide the recent button in toolbar after clearing", async ({ - reactGrab, - }) => { - await copyElement(reactGrab, "li:first-child"); - - await expect - .poll(() => reactGrab.isRecentButtonVisible(), { timeout: 2000 }) - .toBe(true); - - await reactGrab.clickRecentButton(); - await reactGrab.clickRecentClear(); - - await expect - .poll(() => reactGrab.isRecentButtonVisible(), { timeout: 2000 }) - .toBe(false); - }); - - test("should close the dropdown after clearing", async ({ reactGrab }) => { - await copyElement(reactGrab, "li:first-child"); - await reactGrab.clickRecentButton(); - - expect(await reactGrab.isRecentDropdownVisible()).toBe(true); - - await reactGrab.clickRecentClear(); - expect(await reactGrab.isRecentDropdownVisible()).toBe(false); }); }); diff --git a/packages/react-grab/e2e/toolbar.spec.ts b/packages/react-grab/e2e/toolbar.spec.ts index d7aed6870..a5b83ead9 100644 --- a/packages/react-grab/e2e/toolbar.spec.ts +++ b/packages/react-grab/e2e/toolbar.spec.ts @@ -1,6 +1,118 @@ import { test, expect } from "./fixtures.js"; +import type { Page } from "@playwright/test"; test.describe("Toolbar", () => { + const overlayAttributeName = "data-react-grab"; + const toggleMeasureDurationMs = 220; + const maxEnabledToggleDriftPx = 1; + + 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, @@ -210,6 +322,20 @@ test.describe("Toolbar", () => { .toBe("top"); }); + test("should snap to bottom edge", async ({ reactGrab }) => { + await reactGrab.dragToolbar(0, 1200); + + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.snapEdge; + }, + { timeout: 3000 }, + ) + .toBe("bottom"); + }); + test("should snap to left edge", async ({ reactGrab }) => { await reactGrab.dragToolbar(-1000, -500); @@ -238,6 +364,58 @@ test.describe("Toolbar", () => { .toMatch(/^(right|top)$/); }); + test("should switch to vertical layout on left edge", async ({ + reactGrab, + }) => { + await reactGrab.dragToolbar(-1500, 0); + + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.snapEdge; + }, + { timeout: 3000 }, + ) + .toBe("left"); + + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.orientation; + }, + { timeout: 3000 }, + ) + .toBe("vertical"); + }); + + test("should switch to vertical layout on right edge", async ({ + reactGrab, + }) => { + await reactGrab.dragToolbar(1500, 0); + + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.snapEdge; + }, + { timeout: 3000 }, + ) + .toBe("right"); + + await expect + .poll( + async () => { + const info = await reactGrab.getToolbarInfo(); + return info.orientation; + }, + { timeout: 3000 }, + ) + .toBe("vertical"); + }); + test("should not drag when collapsed", async ({ reactGrab }) => { await reactGrab.clickToolbarCollapse(); await expect @@ -523,4 +701,134 @@ 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( + maxEnabledToggleDriftPx, + ); + expect(driftMetrics.maxVerticalDrift).toBeLessThan( + maxEnabledToggleDriftPx, + ); + + const metricsAfterToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsAfterToggle).not.toBeNull(); + + if (!metricsBeforeToggle || !metricsAfterToggle) return; + + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.x - + metricsBeforeToggle.enabledToggleCenter.x, + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.y - + metricsBeforeToggle.enabledToggleCenter.y, + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + expect( + Math.abs( + (metricsAfterToggle.toolbarPosition?.y ?? 0) - + (metricsBeforeToggle.toolbarPosition?.y ?? 0), + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + }); + + 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( + maxEnabledToggleDriftPx, + ); + expect(driftMetrics.maxVerticalDrift).toBeLessThan( + maxEnabledToggleDriftPx, + ); + + const metricsAfterToggle = await getToolbarEnabledToggleMetrics( + reactGrab.page, + ); + expect(metricsAfterToggle).not.toBeNull(); + + if (!metricsBeforeToggle || !metricsAfterToggle) return; + + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.x - + metricsBeforeToggle.enabledToggleCenter.x, + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + expect( + Math.abs( + metricsAfterToggle.enabledToggleCenter.y - + metricsBeforeToggle.enabledToggleCenter.y, + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + expect( + Math.abs( + (metricsAfterToggle.toolbarPosition?.x ?? 0) - + (metricsBeforeToggle.toolbarPosition?.x ?? 0), + ), + ).toBeLessThan(maxEnabledToggleDriftPx); + }); + }); }); diff --git a/packages/react-grab/src/components/icons/icon-comment.tsx b/packages/react-grab/src/components/icons/icon-comment.tsx index 38e767d9a..a7588051d 100644 --- a/packages/react-grab/src/components/icons/icon-comment.tsx +++ b/packages/react-grab/src/components/icons/icon-comment.tsx @@ -1,12 +1,15 @@ -import type { Component } from "solid-js"; +import type { Component, JSX } from "solid-js"; interface IconCommentProps { size?: number; class?: string; + isActive?: boolean; + style?: JSX.CSSProperties; } 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} + style={props.style} > - - + {isActive() ? ( + + ) : ( + + )} ); }; diff --git a/packages/react-grab/src/components/icons/icon-copy.tsx b/packages/react-grab/src/components/icons/icon-copy.tsx new file mode 100644 index 000000000..276802f4d --- /dev/null +++ b/packages/react-grab/src/components/icons/icon-copy.tsx @@ -0,0 +1,36 @@ +import type { Component } from "solid-js"; + +interface IconCopyProps { + size?: number; + class?: string; +} + +export const IconCopy: Component = (props) => { + const size = () => props.size ?? 12; + + return ( + + + + + ); +}; 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..3fc3ffc11 100644 --- a/packages/react-grab/src/components/icons/icon-select.tsx +++ b/packages/react-grab/src/components/icons/icon-select.tsx @@ -1,8 +1,9 @@ -import type { Component } from "solid-js"; +import type { Component, JSX } from "solid-js"; interface IconSelectProps { size?: number; class?: string; + style?: JSX.CSSProperties; } export const IconSelect: Component = (props) => { @@ -13,15 +14,29 @@ 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} + style={props.style} > + + - ); }; diff --git a/packages/react-grab/src/components/icons/icon-trash.tsx b/packages/react-grab/src/components/icons/icon-trash.tsx new file mode 100644 index 000000000..82afc100c --- /dev/null +++ b/packages/react-grab/src/components/icons/icon-trash.tsx @@ -0,0 +1,46 @@ +import type { Component } from "solid-js"; + +interface IconTrashProps { + size?: number; + class?: string; +} + +export const IconTrash: Component = (props) => { + const size = () => props.size ?? 12; + + return ( + + + + + + + ); +}; diff --git a/packages/react-grab/src/components/recent-dropdown.tsx b/packages/react-grab/src/components/recent-dropdown.tsx index 99c9fd1f4..be3d0be04 100644 --- a/packages/react-grab/src/components/recent-dropdown.tsx +++ b/packages/react-grab/src/components/recent-dropdown.tsx @@ -15,6 +15,10 @@ 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"; +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 }; @@ -40,9 +44,16 @@ 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 [isClearAllTooltipVisible, setIsClearAllTooltipVisible] = + createSignal(false); + const [isCopyAllTooltipVisible, setIsCopyAllTooltipVisible] = + createSignal(false); const isVisible = () => props.position !== null; @@ -94,6 +105,114 @@ 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( + "[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; + } + }); + + createEffect(() => { + if (!isVisible()) { + setIsClearAllTooltipVisible(false); + setIsCopyAllTooltipVisible(false); + } + }); + onMount(() => { measureContainer(); @@ -109,15 +228,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 === "Enter" && props.items.length > 0) { + + if (event.code === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + moveHighlightedRecentItem("forward"); + return; + } + + 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(); } }; @@ -169,76 +309,123 @@ export const RecentDropdown: Component = (props) => { )} >
- Recent + History 0}>
- - -
-
-
- -
-
- - {(item) => ( +
- )} - -
+ + Clear all + +
+
+ + + Copy all + +
+
+ + + +
+ 0} + fallback={ +
+ No copied elements yet +
+ } + > +
+ + {(item, itemIndex) => ( + + )} + +
+
diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index f4eb70b89..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} @@ -221,7 +222,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/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index c1c59c940..c1bb96c73 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -23,13 +23,23 @@ import { TOOLBAR_FADE_IN_DELAY_MS, TOOLBAR_SNAP_ANIMATION_DURATION_MS, TOOLBAR_DRAG_THRESHOLD_PX, - TOOLBAR_VELOCITY_MULTIPLIER_MS, TOOLBAR_COLLAPSED_SHORT_PX, TOOLBAR_COLLAPSED_LONG_PX, + TOOLBAR_DRAG_PREVIEW_SHORT_PX, + TOOLBAR_DRAG_PREVIEW_LONG_PX, + TOOLBAR_DRAG_PREVIEW_ROTATION_DURATION_MS, + TOOLBAR_DRAG_RELEASE_REVEAL_DURATION_MS, + TOOLBAR_DRAG_RELEASE_ICON_STAGGER_DELAY_MS, + TOOLBAR_DRAG_RELEASE_ICON_STAGGER_COUNT, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS, TOOLBAR_DEFAULT_WIDTH_PX, TOOLBAR_DEFAULT_HEIGHT_PX, TOOLBAR_SHAKE_TOOLTIP_DURATION_MS, + TOOLBAR_COMMENT_ICON_SIZE_PX, + TOOLBAR_SIDE_DOCK_THRESHOLD_PX, + TOOLBAR_DOCK_LAYOUT_ANIMATION_DURATION_MS, + TOOLBAR_DOCK_PREVIEW_DISTANCE_PX, + TOOLBAR_DOCK_PREVIEW_EDGE_SWITCH_HYSTERESIS_PX, PANEL_STYLES, } from "../../constants.js"; import { freezeUpdates } from "../../utils/freeze-updates.js"; @@ -48,6 +58,7 @@ interface ToolbarProps { isActive?: boolean; isCommentMode?: boolean; isContextMenuOpen?: boolean; + isHistoryOpen?: boolean; onToggle?: () => void; onComment?: () => void; enabled?: boolean; @@ -63,11 +74,40 @@ interface ToolbarProps { onToggleRecent?: (anchorPosition: { x: number; y: number }) => void; } +interface DragDockPreviewState { + edge: SnapEdge | null; +} + +interface EdgeDistanceMap { + top: number; + bottom: number; + left: number; + right: number; +} + +interface ProjectedDragPosition { + viewport: { + width: number; + height: number; + offsetLeft: number; + offsetTop: number; + }; + projectedX: number; + projectedY: number; + clampedProjectedX: number; + clampedProjectedY: number; +} + +interface ToolbarDimensions { + width: number; + height: number; +} + 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); @@ -77,8 +117,6 @@ export const Toolbar: Component = (props) => { const [snapEdge, setSnapEdge] = createSignal("bottom"); const [positionRatio, setPositionRatio] = createSignal(0.5); const [position, setPosition] = createSignal({ x: 0, y: 0 }); - const [dragOffset, setDragOffset] = createSignal({ x: 0, y: 0 }); - const [velocity, setVelocity] = createSignal({ x: 0, y: 0 }); const [hasDragMoved, setHasDragMoved] = createSignal(false); const [isShaking, setIsShaking] = createSignal(false); const [isCollapseAnimating, setIsCollapseAnimating] = createSignal(false); @@ -92,20 +130,300 @@ export const Toolbar: Component = (props) => { const [isToggleAnimating, setIsToggleAnimating] = createSignal(false); const [isRecentTooltipVisible, setIsRecentTooltipVisible] = createSignal(false); + const [expandableSectionMainAxisSizePx, setExpandableSectionMainAxisSizePx] = + createSignal(0); + const [ + isExpandableSectionMainAxisSizeLocked, + setIsExpandableSectionMainAxisSizeLocked, + ] = createSignal(false); + const [isDockLayoutAnimating, setIsDockLayoutAnimating] = createSignal(false); + const [isDragSnapTransitionActive, setIsDragSnapTransitionActive] = + createSignal(false); + const [isDragReleaseRevealPrepared, setIsDragReleaseRevealPrepared] = + createSignal(false); + const [isDragReleaseRevealAnimating, setIsDragReleaseRevealAnimating] = + createSignal(false); + const [dragDockPreviewEdge, setDragDockPreviewEdge] = + createSignal(null); + const [dragPointerPosition, setDragPointerPosition] = createSignal({ + x: 0, + y: 0, + }); let recentButtonRef: HTMLButtonElement | undefined; 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"); + const isSideDockedEdge = () => + snapEdge() === "left" || snapEdge() === "right"; + + const isVerticalOrientation = () => isSideDockedEdge(); + + const isVerticalLayout = () => !isCollapsed() && isVerticalOrientation(); + + const isSideEdge = (edge: SnapEdge) => edge === "left" || edge === "right"; + + const getMeasuredExpandableSectionMainAxisSizePx = (): number => { + const expandableSectionElement = expandableButtonsRef; + if (!expandableSectionElement) return 0; + return isVerticalLayout() + ? expandableSectionElement.scrollHeight + : expandableSectionElement.scrollWidth; + }; + + const cacheExpandableSectionMainAxisSizePx = () => { + const measuredExpandableSectionMainAxisSizePx = + getMeasuredExpandableSectionMainAxisSizePx(); + if (measuredExpandableSectionMainAxisSizePx <= 0) return; + setExpandableSectionMainAxisSizePx(measuredExpandableSectionMainAxisSizePx); + }; + + const getExpandableSectionVisibilityClasses = (): string => { + return props.enabled ? "opacity-100" : "opacity-0 pointer-events-none"; + }; + + const getExpandableSectionSizeStyle = () => { + const currentExpandableSectionMainAxisSizePx = + expandableSectionMainAxisSizePx(); + if (props.enabled) { + if ( + !isExpandableSectionMainAxisSizeLocked() || + currentExpandableSectionMainAxisSizePx <= 0 + ) { + return undefined; + } + if (isVerticalLayout()) { + return { + height: `${currentExpandableSectionMainAxisSizePx}px`, + }; + } + return { + width: `${currentExpandableSectionMainAxisSizePx}px`, + }; + } + if (isVerticalLayout()) { + return { + height: "0px", + }; + } + return { + width: "0px", + }; + }; + + const computeEdgeDistances = (pointerX: number, pointerY: number) => { + const viewport = getVisualViewport(); + const edgeDistanceMap: EdgeDistanceMap = { + top: Math.max(0, pointerY - viewport.offsetTop), + bottom: Math.max(0, viewport.offsetTop + viewport.height - pointerY), + left: Math.max(0, pointerX - viewport.offsetLeft), + right: Math.max(0, viewport.offsetLeft + viewport.width - pointerX), + }; + const nearestHorizontalEdge: SnapEdge = + edgeDistanceMap.left <= edgeDistanceMap.right ? "left" : "right"; + const nearestVerticalEdge: SnapEdge = + edgeDistanceMap.top <= edgeDistanceMap.bottom ? "top" : "bottom"; + const nearestHorizontalDistance = Math.min( + edgeDistanceMap.left, + edgeDistanceMap.right, + ); + const nearestVerticalDistance = Math.min( + edgeDistanceMap.top, + edgeDistanceMap.bottom, + ); + return { + edgeDistanceMap, + nearestHorizontalEdge, + nearestVerticalEdge, + nearestHorizontalDistance, + nearestVerticalDistance, + }; + }; + + const getClosestEdgeFromPointer = ( + pointerX: number, + pointerY: number, + ): SnapEdge => { + const { + nearestHorizontalEdge, + nearestVerticalEdge, + nearestHorizontalDistance, + nearestVerticalDistance, + } = computeEdgeDistances(pointerX, pointerY); + return nearestHorizontalDistance < nearestVerticalDistance + ? nearestHorizontalEdge + : nearestVerticalEdge; + }; + + const getDragPreviewEdge = (): SnapEdge => { + const previewEdge = dragDockPreviewEdge(); + if (previewEdge) return previewEdge; + const currentDragPointerPosition = dragPointerPosition(); + return getClosestEdgeFromPointer( + currentDragPointerPosition.x, + currentDragPointerPosition.y, + ); + }; + + const getDragPreviewDimensions = (): ToolbarDimensions => { + return { + width: TOOLBAR_DRAG_PREVIEW_LONG_PX, + height: TOOLBAR_DRAG_PREVIEW_SHORT_PX, + }; + }; + + const getDragPreviewRotationDegrees = (edge: SnapEdge): number => { + if (edge === "left" || edge === "right") return 90; + return 0; + }; + + const getDragReleaseItemTransform = ( + shouldScaleDuringReveal: boolean, + ): string | undefined => { + if (hasDragMoved() || !isDragReleaseRevealPrepared()) return undefined; + if (!shouldScaleDuringReveal) return undefined; + return "scale(0)"; + }; + + const getDragReleaseIconStaggerOrder = (slot: number): number => { + if (slot === -2) return 0; + if (slot === -1) return 1; + if (slot === 0) return 2; + return 0; + }; + + const getDragReleaseItemTransitionDelayMs = ( + slot: number, + shouldStagger: boolean, + ): number => { + if (!shouldStagger || !isDragReleaseRevealAnimating()) return 0; + return ( + getDragReleaseIconStaggerOrder(slot) * + TOOLBAR_DRAG_RELEASE_ICON_STAGGER_DELAY_MS + ); + }; + + const getDragReleaseRevealAnimationDurationMs = (): number => { + return ( + TOOLBAR_DRAG_RELEASE_REVEAL_DURATION_MS + + Math.max(0, TOOLBAR_DRAG_RELEASE_ICON_STAGGER_COUNT - 1) * + TOOLBAR_DRAG_RELEASE_ICON_STAGGER_DELAY_MS + ); + }; + + const getDragReleaseItemStyle = ( + slot: number, + shouldStagger = false, + shouldScaleDuringReveal = false, + ) => ({ + transform: getDragReleaseItemTransform(shouldScaleDuringReveal), + opacity: isDragReleaseRevealPrepared() ? "0" : undefined, + "transition-property": "transform,opacity", + "transition-duration": `${ + isDragReleaseRevealPrepared() + ? 0 + : TOOLBAR_DRAG_RELEASE_REVEAL_DURATION_MS + }ms`, + "transition-timing-function": "cubic-bezier(0.22,1,0.36,1)", + "transition-delay": `${ + isDragReleaseRevealPrepared() + ? 0 + : getDragReleaseItemTransitionDelayMs(slot, shouldStagger) + }ms`, + }); + + const getDragDockPreviewState = ( + pointerX: number, + pointerY: number, + dragDeltaX: number, + dragDeltaY: number, + previousPreviewEdge: SnapEdge | null, + ): DragDockPreviewState => { + const { + edgeDistanceMap, + nearestHorizontalEdge, + nearestVerticalEdge, + nearestHorizontalDistance, + nearestVerticalDistance, + } = computeEdgeDistances(pointerX, pointerY); + const nearestDistance = Math.min( + nearestHorizontalDistance, + nearestVerticalDistance, + ); + const canPreviewHorizontalEdge = + nearestHorizontalDistance <= TOOLBAR_DOCK_PREVIEW_DISTANCE_PX; + const canPreviewVerticalEdge = + nearestVerticalDistance <= TOOLBAR_DOCK_PREVIEW_DISTANCE_PX; + + if (nearestDistance > TOOLBAR_DOCK_PREVIEW_DISTANCE_PX) { + return { edge: null }; + } + + if (previousPreviewEdge) { + const previousEdgeDistance = edgeDistanceMap[previousPreviewEdge]; + const shouldRetainPreviousEdge = + previousEdgeDistance <= TOOLBAR_DOCK_PREVIEW_DISTANCE_PX && + previousEdgeDistance <= + nearestDistance + TOOLBAR_DOCK_PREVIEW_EDGE_SWITCH_HYSTERESIS_PX; + + if (shouldRetainPreviousEdge) { + return { edge: previousPreviewEdge }; + } + } + + const shouldPreferHorizontalAxis = + Math.abs(dragDeltaX) >= Math.abs(dragDeltaY); + + if (shouldPreferHorizontalAxis && canPreviewHorizontalEdge) { + return { edge: nearestHorizontalEdge }; + } + + if (!shouldPreferHorizontalAxis && canPreviewVerticalEdge) { + return { edge: nearestVerticalEdge }; + } + + const areAxisDistancesSimilar = + Math.abs(nearestHorizontalDistance - nearestVerticalDistance) <= + TOOLBAR_DOCK_PREVIEW_EDGE_SWITCH_HYSTERESIS_PX; + + if (areAxisDistancesSimilar) { + return { + edge: shouldPreferHorizontalAxis + ? nearestHorizontalEdge + : nearestVerticalEdge, + }; + } + + return { + edge: + nearestHorizontalDistance < nearestVerticalDistance + ? nearestHorizontalEdge + : nearestVerticalEdge, + }; + }; + const stopEventPropagation = (event: Event) => { event.stopPropagation(); event.stopImmediatePropagation(); }; + const suppressNextWindowClickAfterDrag = () => { + const handleWindowClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + window.addEventListener("click", handleWindowClick, true); + requestAnimationFrame(() => { + window.removeEventListener("click", handleWindowClick, true); + }); + }; + const createFreezeHandlers = ( setTooltipVisible: (visible: boolean) => void, ) => ({ @@ -121,7 +439,7 @@ export const Toolbar: Component = (props) => { onMouseLeave: () => { setTooltipVisible(false); props.onSelectHoverChange?.(false); - if (!props.isActive && !props.isContextMenuOpen) { + if (!props.isActive && !props.isContextMenuOpen && !props.isHistoryOpen) { unfreezeUpdatesCallback?.(); unfreezeUpdatesCallback = null; unfreezeGlobalAnimations(); @@ -130,18 +448,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; @@ -181,9 +493,33 @@ export const Toolbar: Component = (props) => { createEffect( on( - () => [props.isActive, props.isContextMenuOpen] as const, - ([isActive, isContextMenuOpen]) => { - if (!isActive && !isContextMenuOpen && unfreezeUpdatesCallback) { + () => + [ + isVerticalLayout(), + props.enabled, + props.recentItemCount, + Boolean(props.isHistoryOpen), + ] as const, + () => { + if (!props.enabled) return; + requestAnimationFrame(() => { + cacheExpandableSectionMainAxisSizePx(); + }); + }, + ), + ); + + createEffect( + on( + () => + [props.isActive, props.isContextMenuOpen, props.isHistoryOpen] as const, + ([isActive, isContextMenuOpen, isHistoryOpen]) => { + if ( + !isActive && + !isContextMenuOpen && + !isHistoryOpen && + unfreezeUpdatesCallback + ) { unfreezeUpdatesCallback(); unfreezeUpdatesCallback = null; } @@ -191,8 +527,59 @@ export const Toolbar: Component = (props) => { ), ); - let lastPointerPosition = { x: 0, y: 0, time: 0 }; + createEffect( + on( + () => [snapEdge(), isCollapsed()] as const, + ([nextEdge, nextIsCollapsed], previousLayoutState) => { + if (!previousLayoutState) return; + + const [previousEdge, previousIsCollapsed] = previousLayoutState; + const wasVerticalLayout = + !previousIsCollapsed && + (previousEdge === "left" || previousEdge === "right"); + const isVerticalLayoutNow = + !nextIsCollapsed && (nextEdge === "left" || nextEdge === "right"); + + if (wasVerticalLayout === isVerticalLayoutNow) return; + + setIsDockLayoutAnimating(true); + clearTimeout(dockLayoutAnimationTimeout); + dockLayoutAnimationTimeout = setTimeout(() => { + setIsDockLayoutAnimating(false); + if (isDragSnapTransitionActive()) return; + const rect = containerRef?.getBoundingClientRect(); + if (!rect || isCollapsed()) return; + + expandedDimensions = { width: rect.width, height: rect.height }; + const nextPosition = getPositionFromEdgeAndRatio( + snapEdge(), + positionRatio(), + rect.width, + rect.height, + ); + setPosition(nextPosition); + }, TOOLBAR_DOCK_LAYOUT_ANIMATION_DURATION_MS); + }, + ), + ); + + createEffect(() => { + setGlobalUserSelectDisabled(isDragging()); + }); + let pointerStartPosition = { x: 0, y: 0 }; + let dragReleaseRevealAnimationFrame: number | undefined; + let dragReleaseRevealAnimationTimeout: + | ReturnType + | undefined; + let toggleAnchorLockAnimationFrame: number | undefined; + let previousDocumentElementUserSelect = ""; + let previousDocumentBodyUserSelect = ""; + let isGlobalUserSelectDisabled = false; + let activeDragElementDimensions = { + width: TOOLBAR_DEFAULT_WIDTH_PX, + height: TOOLBAR_DEFAULT_HEIGHT_PX, + }; let expandedDimensions = { width: TOOLBAR_DEFAULT_WIDTH_PX, height: TOOLBAR_DEFAULT_HEIGHT_PX, @@ -205,6 +592,45 @@ export const Toolbar: Component = (props) => { const clampToViewport = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); + const clearDragReleaseRevealAnimationFrame = () => { + if (dragReleaseRevealAnimationFrame === undefined) return; + cancelAnimationFrame(dragReleaseRevealAnimationFrame); + dragReleaseRevealAnimationFrame = undefined; + }; + + const clearDragReleaseRevealAnimationTimeout = () => { + if (dragReleaseRevealAnimationTimeout === undefined) return; + clearTimeout(dragReleaseRevealAnimationTimeout); + dragReleaseRevealAnimationTimeout = undefined; + }; + + const clearToggleAnchorLockAnimationFrame = () => { + if (toggleAnchorLockAnimationFrame === undefined) return; + cancelAnimationFrame(toggleAnchorLockAnimationFrame); + toggleAnchorLockAnimationFrame = undefined; + }; + + const setGlobalUserSelectDisabled = (shouldDisable: boolean) => { + const documentElement = document.documentElement; + const documentBody = document.body; + if (!documentElement || !documentBody) return; + + if (shouldDisable && !isGlobalUserSelectDisabled) { + previousDocumentElementUserSelect = documentElement.style.userSelect; + previousDocumentBodyUserSelect = documentBody.style.userSelect; + documentElement.style.userSelect = "none"; + documentBody.style.userSelect = "none"; + isGlobalUserSelectDisabled = true; + return; + } + + if (!shouldDisable && isGlobalUserSelectDisabled) { + documentElement.style.userSelect = previousDocumentElementUserSelect; + documentBody.style.userSelect = previousDocumentBodyUserSelect; + isGlobalUserSelectDisabled = false; + } + }; + const getVisualViewport = () => { const visualViewport = window.visualViewport; if (visualViewport) { @@ -464,56 +890,38 @@ export const Toolbar: Component = (props) => { }); const handleToggleEnabled = createDragAwareHandler(() => { + cacheExpandableSectionMainAxisSizePx(); + setIsExpandableSectionMainAxisSizeLocked(true); const isCurrentlyEnabled = Boolean(props.enabled); const edge = snapEdge(); - const preTogglePosition = position(); - const expandableWidth = lastKnownExpandableWidth; - const shouldCompensatePosition = expandableWidth > 0 && edge !== "left"; - 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, - }; - } + clearTimeout(toggleAnimationTimeout); + toggleAnimationTimeout = setTimeout(() => { + clearToggleAnchorLockAnimationFrame(); + setIsToggleAnimating(false); + setIsExpandableSectionMainAxisSizeLocked(false); - 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, - ); + 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({ @@ -522,21 +930,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 = ( @@ -544,15 +939,100 @@ export const Toolbar: Component = (props) => { currentY: number, elementWidth: number, elementHeight: number, - velocityX: number, - velocityY: number, + preferredEdge: SnapEdge | null = null, ): { edge: SnapEdge; x: number; y: number } => { - const viewport = getVisualViewport(); + const getProjectedDragPosition = (): ProjectedDragPosition => { + const viewport = getVisualViewport(); + const projectedX = currentX; + const projectedY = currentY; + const minX = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; + const maxX = Math.max( + minX, + viewport.offsetLeft + + viewport.width - + elementWidth - + TOOLBAR_SNAP_MARGIN_PX, + ); + const minY = viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX; + const maxY = Math.max( + minY, + viewport.offsetTop + + viewport.height - + elementHeight - + TOOLBAR_SNAP_MARGIN_PX, + ); + + return { + viewport, + projectedX, + projectedY, + clampedProjectedX: clampToViewport(projectedX, minX, maxX), + clampedProjectedY: clampToViewport(projectedY, minY, maxY), + }; + }; + + const getSnapPositionForEdge = ( + edge: SnapEdge, + projectedDragPosition: ProjectedDragPosition, + ): { edge: SnapEdge; x: number; y: number } => { + const viewport = projectedDragPosition.viewport; + if (edge === "top") { + return { + edge, + x: projectedDragPosition.clampedProjectedX, + y: viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX, + }; + } + if (edge === "bottom") { + return { + edge, + x: projectedDragPosition.clampedProjectedX, + y: + viewport.offsetTop + + viewport.height - + elementHeight - + TOOLBAR_SNAP_MARGIN_PX, + }; + } + if (edge === "left") { + return { + edge, + x: viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX, + y: projectedDragPosition.clampedProjectedY, + }; + } + return { + edge, + x: + viewport.offsetLeft + + viewport.width - + elementWidth - + TOOLBAR_SNAP_MARGIN_PX, + y: projectedDragPosition.clampedProjectedY, + }; + }; + + const projectedDragPosition = getProjectedDragPosition(); + const viewport = projectedDragPosition.viewport; const viewportWidth = viewport.width; const viewportHeight = viewport.height; + const projectedX = projectedDragPosition.projectedX; + const projectedY = projectedDragPosition.projectedY; - const projectedX = currentX + velocityX * TOOLBAR_VELOCITY_MULTIPLIER_MS; - const projectedY = currentY + velocityY * TOOLBAR_VELOCITY_MULTIPLIER_MS; + if (preferredEdge) { + return getSnapPositionForEdge(preferredEdge, projectedDragPosition); + } + + if (projectedX <= viewport.offsetLeft + TOOLBAR_SIDE_DOCK_THRESHOLD_PX) { + return getSnapPositionForEdge("left", projectedDragPosition); + } + + if ( + projectedX + elementWidth >= + viewport.offsetLeft + viewportWidth - TOOLBAR_SIDE_DOCK_THRESHOLD_PX + ) { + return getSnapPositionForEdge("right", projectedDragPosition); + } const distanceToTop = projectedY - viewport.offsetTop + elementHeight / 2; const distanceToBottom = @@ -569,161 +1049,236 @@ export const Toolbar: Component = (props) => { ); if (minDistance === distanceToTop) { - return { - edge: "top", - x: Math.max( - viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX, - Math.min( - projectedX, - viewport.offsetLeft + - viewportWidth - - elementWidth - - TOOLBAR_SNAP_MARGIN_PX, - ), - ), - y: viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX, - }; + return getSnapPositionForEdge("top", projectedDragPosition); } if (minDistance === distanceToLeft) { - return { - edge: "left", - x: viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX, - y: Math.max( - viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX, - Math.min( - projectedY, - viewport.offsetTop + - viewportHeight - - elementHeight - - TOOLBAR_SNAP_MARGIN_PX, - ), - ), - }; + return getSnapPositionForEdge("left", projectedDragPosition); } if (minDistance === distanceToRight) { + return getSnapPositionForEdge("right", projectedDragPosition); + } + return getSnapPositionForEdge("bottom", projectedDragPosition); + }; + + const getAxisLockedSnapPosition = ( + edge: SnapEdge, + releasePosition: { x: number; y: number }, + snapPosition: { x: number; y: number }, + elementWidth: number, + elementHeight: number, + ) => { + const viewport = getVisualViewport(); + const minX = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; + const maxX = Math.max( + minX, + viewport.offsetLeft + + viewport.width - + elementWidth - + TOOLBAR_SNAP_MARGIN_PX, + ); + const minY = viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX; + const maxY = Math.max( + minY, + viewport.offsetTop + + viewport.height - + elementHeight - + TOOLBAR_SNAP_MARGIN_PX, + ); + + if (edge === "left" || edge === "right") { return { - edge: "right", - x: - viewport.offsetLeft + - viewportWidth - - elementWidth - - TOOLBAR_SNAP_MARGIN_PX, - y: Math.max( - viewport.offsetTop + TOOLBAR_SNAP_MARGIN_PX, - Math.min( - projectedY, - viewport.offsetTop + - viewportHeight - - elementHeight - - TOOLBAR_SNAP_MARGIN_PX, - ), - ), + x: snapPosition.x, + y: clampToViewport(releasePosition.y, minY, maxY), }; } + return { - edge: "bottom", - x: Math.max( - viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX, - Math.min( - projectedX, - viewport.offsetLeft + - viewportWidth - - elementWidth - - TOOLBAR_SNAP_MARGIN_PX, - ), - ), - y: - viewport.offsetTop + - viewportHeight - - elementHeight - - TOOLBAR_SNAP_MARGIN_PX, + x: clampToViewport(releasePosition.x, minX, maxX), + y: snapPosition.y, }; }; const handleWindowPointerMove = (event: PointerEvent) => { if (!isDragging()) return; + setDragPointerPosition({ x: event.clientX, y: event.clientY }); + const distanceMoved = Math.sqrt( Math.pow(event.clientX - pointerStartPosition.x, 2) + Math.pow(event.clientY - pointerStartPosition.y, 2), ); - if (distanceMoved > TOOLBAR_DRAG_THRESHOLD_PX) { + const hasExceededDragThreshold = distanceMoved > TOOLBAR_DRAG_THRESHOLD_PX; + if (hasExceededDragThreshold && !hasDragMoved()) { setHasDragMoved(true); } - if (!hasDragMoved()) return; - - const now = performance.now(); - const deltaTime = now - lastPointerPosition.time; + if (!hasDragMoved() && !hasExceededDragThreshold) return; - if (deltaTime > 0) { - const newVelocityX = (event.clientX - lastPointerPosition.x) / deltaTime; - const newVelocityY = (event.clientY - lastPointerPosition.y) / deltaTime; - setVelocity({ x: newVelocityX, y: newVelocityY }); - } - - lastPointerPosition = { x: event.clientX, y: event.clientY, time: now }; - - const newX = event.clientX - dragOffset().x; - const newY = event.clientY - dragOffset().y; + const previousPreviewEdge = dragDockPreviewEdge(); + const dragDockPreviewState = getDragDockPreviewState( + event.clientX, + event.clientY, + event.clientX - pointerStartPosition.x, + event.clientY - pointerStartPosition.y, + previousPreviewEdge, + ); + const dragPreviewDimensions = getDragPreviewDimensions(); + const newX = event.clientX - dragPreviewDimensions.width / 2; + const newY = event.clientY - dragPreviewDimensions.height / 2; setPosition({ x: newX, y: newY }); + if (dragDockPreviewState.edge !== previousPreviewEdge) { + setDragDockPreviewEdge(dragDockPreviewState.edge); + } }; - const handleWindowPointerUp = () => { + const handleWindowPointerUp = (event: PointerEvent) => { if (!isDragging()) return; window.removeEventListener("pointermove", handleWindowPointerMove); window.removeEventListener("pointerup", handleWindowPointerUp); const didMove = hasDragMoved(); - setIsDragging(false); + const dragDockPreviewEdgeAtRelease = dragDockPreviewEdge(); if (!didMove) { + setIsDragSnapTransitionActive(false); + setIsDragging(false); + setHasDragMoved(false); + setIsDragReleaseRevealPrepared(false); + setIsDragReleaseRevealAnimating(false); + clearDragReleaseRevealAnimationFrame(); + clearDragReleaseRevealAnimationTimeout(); + setDragDockPreviewEdge(null); return; } didDragOccur = true; + suppressNextWindowClickAfterDrag(); + setIsDragReleaseRevealPrepared(true); + setIsDragReleaseRevealAnimating(true); + clearDragReleaseRevealAnimationFrame(); + clearDragReleaseRevealAnimationTimeout(); + const dragPreviewEdgeAtRelease = + dragDockPreviewEdgeAtRelease ?? + getClosestEdgeFromPointer(event.clientX, event.clientY); + const dragPreviewDimensionsAtRelease = getDragPreviewDimensions(); + const releaseCenterX = + event.clientX ?? position().x + dragPreviewDimensionsAtRelease.width / 2; + const releaseCenterY = + event.clientY ?? position().y + dragPreviewDimensionsAtRelease.height / 2; + const releasePosition = { + x: releaseCenterX - activeDragElementDimensions.width / 2, + y: releaseCenterY - activeDragElementDimensions.height / 2, + }; + let snapDimensions: ToolbarDimensions = { + width: activeDragElementDimensions.width, + height: activeDragElementDimensions.height, + }; - const rect = containerRef?.getBoundingClientRect(); - if (!rect) return; - - const currentVelocity = velocity(); - const snap = getSnapPosition( - position().x, - position().y, - rect.width, - rect.height, - currentVelocity.x, - currentVelocity.y, + let snap = getSnapPosition( + releasePosition.x, + releasePosition.y, + snapDimensions.width, + snapDimensions.height, + dragDockPreviewEdgeAtRelease ?? dragPreviewEdgeAtRelease, + ); + const minimumDragDimension = Math.min( + activeDragElementDimensions.width, + activeDragElementDimensions.height, + ); + const maximumDragDimension = Math.max( + activeDragElementDimensions.width, + activeDragElementDimensions.height, + ); + const shouldUseSideDimensions = isSideEdge(snap.edge); + snapDimensions = { + width: shouldUseSideDimensions + ? minimumDragDimension + : maximumDragDimension, + height: shouldUseSideDimensions + ? maximumDragDimension + : minimumDragDimension, + }; + snap = getSnapPosition( + releasePosition.x, + releasePosition.y, + snapDimensions.width, + snapDimensions.height, + snap.edge, ); + + snap = { + edge: snap.edge, + ...getAxisLockedSnapPosition( + snap.edge, + releasePosition, + { x: snap.x, y: snap.y }, + snapDimensions.width, + snapDimensions.height, + ), + }; + const ratio = getRatioFromPosition( snap.edge, snap.x, snap.y, - rect.width, - rect.height, + snapDimensions.width, + snapDimensions.height, ); + expandedDimensions = { + width: snapDimensions.width, + height: snapDimensions.height, + }; + setIsDragSnapTransitionActive(true); + setDragDockPreviewEdge(snap.edge); setSnapEdge(snap.edge); setPositionRatio(ratio); + setIsDragging(false); setIsSnapping(true); requestAnimationFrame(() => { - requestAnimationFrame(() => { - setPosition({ x: snap.x, y: snap.y }); + setPosition({ x: snap.x, y: snap.y }); + setHasDragMoved(false); + dragReleaseRevealAnimationFrame = requestAnimationFrame(() => { + setIsDragReleaseRevealPrepared(false); + clearDragReleaseRevealAnimationTimeout(); + dragReleaseRevealAnimationTimeout = setTimeout(() => { + setIsDragReleaseRevealAnimating(false); + dragReleaseRevealAnimationTimeout = undefined; + }, getDragReleaseRevealAnimationDurationMs()); + dragReleaseRevealAnimationFrame = undefined; + }); + + snapAnimationTimeout = setTimeout(() => { + let nextRatio = ratio; + const measuredRect = containerRef?.getBoundingClientRect(); + if (measuredRect && !isCollapsed()) { + expandedDimensions = { + width: measuredRect.width, + height: measuredRect.height, + }; + nextRatio = getRatioFromPosition( + snap.edge, + position().x, + position().y, + measuredRect.width, + measuredRect.height, + ); + setPositionRatio(nextRatio); + } saveAndNotify({ edge: snap.edge, - ratio, + ratio: nextRatio, collapsed: isCollapsed(), enabled: props.enabled ?? true, }); - - snapAnimationTimeout = setTimeout(() => { - setIsSnapping(false); - }, TOOLBAR_SNAP_ANIMATION_DURATION_MS); - }); + setIsSnapping(false); + setIsDragSnapTransitionActive(false); + setDragDockPreviewEdge(null); + }, TOOLBAR_SNAP_ANIMATION_DURATION_MS); }); }; @@ -733,20 +1288,20 @@ export const Toolbar: Component = (props) => { const rect = containerRef?.getBoundingClientRect(); if (!rect) return; - pointerStartPosition = { x: event.clientX, y: event.clientY }; + activeDragElementDimensions = { width: rect.width, height: rect.height }; + setIsDragSnapTransitionActive(false); + setDragDockPreviewEdge(null); - setDragOffset({ - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }); + pointerStartPosition = { x: event.clientX, y: event.clientY }; + setDragPointerPosition({ x: event.clientX, y: event.clientY }); + setIsDragReleaseRevealPrepared(false); + setIsDragReleaseRevealAnimating(false); + clearDragReleaseRevealAnimationFrame(); + clearDragReleaseRevealAnimationTimeout(); + clearTimeout(snapAnimationTimeout); + setIsSnapping(false); setIsDragging(true); setHasDragMoved(false); - setVelocity({ x: 0, y: 0 }); - lastPointerPosition = { - x: event.clientX, - y: event.clientY, - time: performance.now(), - }; window.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("pointerup", handleWindowPointerUp); @@ -822,6 +1377,8 @@ export const Toolbar: Component = (props) => { let collapseAnimationTimeout: ReturnType | undefined; let snapAnimationTimeout: ReturnType | undefined; let toggleAnimationTimeout: ReturnType | undefined; + let dockLayoutAnimationTimeout: ReturnType | undefined; + let pendingSelfPublishedToolbarState: ToolbarState | null = null; const handleResize = () => { if (isDragging()) return; @@ -854,6 +1411,7 @@ export const Toolbar: Component = (props) => { }; const saveAndNotify = (state: ToolbarState) => { + pendingSelfPublishedToolbarState = state; saveToolbarState(state); props.onStateChange?.(state); }; @@ -912,18 +1470,27 @@ export const Toolbar: Component = (props) => { setPosition(defaultPosition); } - if (props.enabled && expandableButtonsRef) { - lastKnownExpandableWidth = expandableButtonsRef.offsetWidth; - } - if (props.onSubscribeToStateChanges) { const unsubscribe = props.onSubscribeToStateChanges( (state: ToolbarState) => { - if (isCollapseAnimating() || isToggleAnimating()) return; + if (state === pendingSelfPublishedToolbarState) { + pendingSelfPublishedToolbarState = null; + return; + } + + if ( + isCollapseAnimating() || + isToggleAnimating() || + isDragSnapTransitionActive() + ) { + return; + } const rect = containerRef?.getBoundingClientRect(); if (!rect) return; + const previousEdge = snapEdge(); + const didEdgeChange = previousEdge !== state.edge; const didCollapsedChange = isCollapsed() !== state.collapsed; setSnapEdge(state.edge); @@ -949,13 +1516,27 @@ export const Toolbar: Component = (props) => { }, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS); } setIsCollapsed(state.collapsed); + const currentExpandedPosition = position(); const newPosition = getPositionFromEdgeAndRatio( state.edge, state.ratio, expandedDimensions.width, expandedDimensions.height, ); - setPosition(newPosition); + const shouldPreserveCrossAxis = + !didEdgeChange && !state.collapsed && !didCollapsedChange; + const synchronizedPosition = shouldPreserveCrossAxis + ? state.edge === "top" || state.edge === "bottom" + ? { + x: newPosition.x, + y: currentExpandedPosition.y, + } + : { + x: currentExpandedPosition.x, + y: newPosition.y, + } + : newPosition; + setPosition(synchronizedPosition); setPositionRatio(state.ratio); } }, @@ -988,6 +1569,15 @@ export const Toolbar: Component = (props) => { clearTimeout(shakeTooltipTimeout); clearTimeout(snapAnimationTimeout); clearTimeout(toggleAnimationTimeout); + clearTimeout(dockLayoutAnimationTimeout); + clearDragReleaseRevealAnimationFrame(); + clearDragReleaseRevealAnimationTimeout(); + clearToggleAnchorLockAnimationFrame(); + setIsDragSnapTransitionActive(false); + setIsDragReleaseRevealPrepared(false); + setIsDragReleaseRevealAnimating(false); + setDragDockPreviewEdge(null); + setGlobalUserSelectDisabled(false); unfreezeUpdatesCallback?.(); }); @@ -1013,9 +1603,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"; }; @@ -1040,6 +1633,9 @@ export const Toolbar: Component = (props) => { ref={containerRef} data-react-grab-ignore-events data-react-grab-toolbar + data-react-grab-toolbar-orientation={ + isVerticalOrientation() ? "vertical" : "horizontal" + } class={cn( "fixed left-0 top-0 font-sans text-[13px] antialiased filter-[drop-shadow(0px_1px_2px_#51515140)] select-none", getCursorClass(), @@ -1055,17 +1651,48 @@ export const Toolbar: Component = (props) => { }px)`, "transform-origin": getTransformOrigin(), }} - onPointerDown={handlePointerDown} + on:pointerdown={(event) => { + stopEventPropagation(event); + handlePointerDown(event); + }} + on:mousedown={stopEventPropagation} >
setIsShaking(false)} onClick={(event) => { if (isCollapsed()) { @@ -1096,201 +1723,275 @@ export const Toolbar: Component = (props) => { >
-
-
+
+
-
- {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} - - + {/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */} + + + Select + +
+
+
- Select - +
+ {/* 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()} + +
+
-
- {/* 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()} - -
-
-
-
- + -
-
- - - {props.enabled ? "Disable" : "Enable"} - + {props.enabled ? "Disable" : "Enable"} +
+
-
- + style={getDragReleaseItemStyle(2, false, true)} + onClick={handleToggleCollapse} + > + + + + +
= (props) => { const edge = () => props.snapEdge ?? "bottom"; + const isVerticalLayout = () => + !props.isCollapsed && (edge() === "left" || edge() === "right"); - 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 = () => { @@ -57,7 +53,7 @@ export const ToolbarContent: Component = (props) => { }; const defaultSelectButton = () => ( -