diff --git a/packages/excalidraw/components/ToolPopover.scss b/packages/excalidraw/components/ToolPopover.scss new file mode 100644 index 0000000000..d049704bb7 --- /dev/null +++ b/packages/excalidraw/components/ToolPopover.scss @@ -0,0 +1,18 @@ +@import "../css/variables.module.scss"; + +.excalidraw { + .tool-popover-content { + display: flex; + flex-direction: row; + gap: 0.25rem; + border-radius: 0.5rem; + background: var(--island-bg-color); + box-shadow: var(--shadow-island); + padding: 0.5rem; + z-index: var(--zIndex-layerUI); + } + + &:focus { + outline: none; + } +} diff --git a/packages/excalidraw/components/ToolPopover.tsx b/packages/excalidraw/components/ToolPopover.tsx new file mode 100644 index 0000000000..25614ba7ce --- /dev/null +++ b/packages/excalidraw/components/ToolPopover.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import clsx from "clsx"; + +import { capitalizeString } from "@excalidraw/common"; + +import * as Popover from "@radix-ui/react-popover"; + +import { trackEvent } from "../analytics"; + +import { ToolButton } from "./ToolButton"; + +import "./ToolPopover.scss"; + +import type { AppClassProperties } from "../types"; + +type ToolOption = { + type: string; + icon: React.ReactNode; + title?: string; +}; + +type ToolPopoverProps = { + app: AppClassProperties; + options: readonly ToolOption[]; + activeTool: { type: string }; + defaultOption: string; + className?: string; + namePrefix: string; + title: string; + "data-testid": string; + onToolChange: (type: string) => void; + displayedOption: ToolOption; + isActive: boolean; + fillable?: boolean; +}; + +export const ToolPopover = ({ + app, + options, + activeTool, + defaultOption, + className = "Shape", + namePrefix, + title, + "data-testid": dataTestId, + onToolChange, + isActive, + displayedOption, + fillable = false, +}: ToolPopoverProps) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const currentType = activeTool.type; + + // if currentType is not in options, close popup + if (!options.some((o) => o.type === currentType) && isPopupOpen) { + setIsPopupOpen(false); + } + + // Close popover when user starts interacting with the canvas (pointer down) + useEffect(() => { + // app.onPointerDownEmitter emits when pointer down happens on canvas area + const unsubscribe = app.onPointerDownEmitter.on(() => { + setIsPopupOpen(false); + }); + return () => unsubscribe?.(); + }, [app]); + + return ( + + + o.type === activeTool.type), + })} + type="radio" + icon={displayedOption.icon} + checked={isActive} + name="editor-current-shape" + title={title} + aria-label={title} + data-testid={dataTestId} + onPointerDown={() => { + setIsPopupOpen((v) => !v); + onToolChange(defaultOption); + }} + /> + + + + {options.map(({ type, icon, title }) => ( + { + if (app.state.activeTool.type !== type) { + trackEvent("toolbar", type, "ui"); + } + app.setActiveTool({ type: type as any }); + onToolChange?.(type); + }} + /> + ))} + + + ); +}; diff --git a/packages/excalidraw/components/ToolWithPopup.tsx b/packages/excalidraw/components/ToolWithPopup.tsx deleted file mode 100644 index 75474aff7f..0000000000 --- a/packages/excalidraw/components/ToolWithPopup.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import clsx from "clsx"; - -import { capitalizeString, CLASSES } from "@excalidraw/common"; - -import { trackEvent } from "../analytics"; - -import { ToolButton } from "./ToolButton"; - -import type { AppClassProperties } from "../types"; - -type ToolOption = { - type: string; - icon: React.ReactNode; - title?: string; -}; - -type ToolTypePopupProps = { - app: AppClassProperties; - triggerElement: HTMLElement | null; - isOpen: boolean; - onClose: () => void; - currentType: string; - onChange?: (type: string) => void; - options: readonly ToolOption[]; - className?: string; - namePrefix: string; -}; - -export const ToolTypePopup = ({ - app, - triggerElement, - isOpen, - onClose, - onChange, - currentType, - options, - className = "Shape", - namePrefix, -}: ToolTypePopupProps) => { - const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 }); - const panelRef = useRef(null); - - useEffect(() => { - if (!isOpen || !triggerElement) { - return; - } - - const updatePosition = () => { - const triggerRect = triggerElement.getBoundingClientRect(); - - const panelRect = panelRef.current?.getBoundingClientRect(); - const panelWidth = panelRect?.width ?? 0; - const panelHeight = panelRect?.height ?? 0; - setPanelPosition({ - x: triggerRect.left - panelWidth / 2 + 4, - y: panelHeight + 8, - }); - }; - - updatePosition(); - - // Outside click handling (capture pointer events for reliability on mobile) - const handlePointer = (event: PointerEvent) => { - const target = event.target as Node | null; - const panelEl = panelRef.current; - const triggerEl = triggerElement; - if (!target) { - onClose(); - return; - } - const insidePanel = !!panelEl && panelEl.contains(target); - const onTrigger = !!triggerEl && triggerEl.contains(target); - if (!insidePanel && !onTrigger) { - onClose(); - } - }; - document.addEventListener("pointerdown", handlePointer, true); - document.addEventListener("pointerup", handlePointer, true); - return () => { - document.removeEventListener("pointerdown", handlePointer, true); - document.removeEventListener("pointerup", handlePointer, true); - }; - }, [isOpen, triggerElement, onClose]); - - if (!isOpen) { - return null; - } - - return ( -
- {options.map(({ type, icon, title }) => ( - { - if (app.state.activeTool.type !== type) { - trackEvent("toolbar", type, "ui"); - } - app.setActiveTool({ type: type as any }); - onChange?.(type); - }} - /> - ))} -
- ); -}; - -type ToolWithPopupProps = { - app: AppClassProperties; - options: readonly ToolOption[]; - activeTool: { type: string }; - defaultOption: string; - className?: string; - namePrefix: string; - title: string; - "data-testid": string; - onToolChange: (type: string) => void; - getDisplayedOption: () => ToolOption; - isActive: boolean; - fillable?: boolean; -}; - -export const ToolWithPopup = ({ - app, - options, - activeTool, - defaultOption, - className = "Shape", - namePrefix, - title, - "data-testid": dataTestId, - onToolChange, - getDisplayedOption, - isActive, - fillable = false, -}: ToolWithPopupProps) => { - const [isPopupOpen, setIsPopupOpen] = useState(false); - const [triggerRef, setTriggerRef] = useState(null); - - const displayedOption = getDisplayedOption(); - - return ( -
-
- o.type === activeTool.type), - })} - type="radio" - icon={displayedOption.icon} - checked={isActive} - name="editor-current-shape" - title={title} - aria-label={title} - data-testid={dataTestId} - onPointerDown={() => { - setIsPopupOpen((val) => !val); - onToolChange(defaultOption); - }} - /> -
- - setIsPopupOpen(false)} - options={options} - className={className} - namePrefix={namePrefix} - currentType={activeTool.type} - onChange={(type: string) => { - onToolChange(type); - }} - /> -
- ); -};