diff --git a/packages/excalidraw/components/ToolWithPopup.tsx b/packages/excalidraw/components/ToolWithPopup.tsx new file mode 100644 index 0000000000..1e4289f45a --- /dev/null +++ b/packages/excalidraw/components/ToolWithPopup.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; + +import { ToolButton } from "./ToolButton"; + +import type { AppClassProperties } from "../types"; +import { capitalizeString, CLASSES } from "@excalidraw/common"; +import { trackEvent } from "../analytics"; + +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.x - panelWidth / 2, + 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: (type: string) => 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(); + const isToolActive = options.some((option) => isActive(option.type)); + + return ( +
+
+ { + setIsPopupOpen((val) => !val); + onToolChange(defaultOption); + }} + /> +
+ + setIsPopupOpen(false)} + options={options} + className={className} + namePrefix={namePrefix} + currentType={activeTool.type} + onChange={(type: string) => { + onToolChange(type); + }} + /> +
+ ); +};