mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-26 10:49:57 +02:00
replace custom popup with popover
This commit is contained in:
18
packages/excalidraw/components/ToolPopover.scss
Normal file
18
packages/excalidraw/components/ToolPopover.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
117
packages/excalidraw/components/ToolPopover.tsx
Normal file
117
packages/excalidraw/components/ToolPopover.tsx
Normal file
@@ -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 (
|
||||||
|
<Popover.Root open={isPopupOpen}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx(className, {
|
||||||
|
fillable,
|
||||||
|
active: options.some((o) => 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content className="tool-popover-content" sideOffset={28}>
|
||||||
|
{options.map(({ type, icon, title }) => (
|
||||||
|
<ToolButton
|
||||||
|
className={clsx(className, {
|
||||||
|
active: currentType === type,
|
||||||
|
})}
|
||||||
|
key={type}
|
||||||
|
type="radio"
|
||||||
|
icon={icon}
|
||||||
|
checked={currentType === type}
|
||||||
|
name={`${namePrefix}-option`}
|
||||||
|
title={title || capitalizeString(type)}
|
||||||
|
keyBindingLabel=""
|
||||||
|
aria-label={title || capitalizeString(type)}
|
||||||
|
data-testid={`toolbar-${type}`}
|
||||||
|
onChange={() => {
|
||||||
|
if (app.state.activeTool.type !== type) {
|
||||||
|
trackEvent("toolbar", type, "ui");
|
||||||
|
}
|
||||||
|
app.setActiveTool({ type: type as any });
|
||||||
|
onToolChange?.(type);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
};
|
@@ -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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
tabIndex={-1}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: `${-16}px`,
|
|
||||||
left: `${panelPosition.x}px`,
|
|
||||||
zIndex: "var(--zIndex-popup)",
|
|
||||||
}}
|
|
||||||
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
|
|
||||||
>
|
|
||||||
{options.map(({ type, icon, title }) => (
|
|
||||||
<ToolButton
|
|
||||||
className={clsx(className, {
|
|
||||||
active: currentType === type,
|
|
||||||
})}
|
|
||||||
key={type}
|
|
||||||
type="radio"
|
|
||||||
icon={icon}
|
|
||||||
checked={currentType === type}
|
|
||||||
name={`${namePrefix}-option`}
|
|
||||||
title={title || capitalizeString(type)}
|
|
||||||
keyBindingLabel=""
|
|
||||||
aria-label={title || capitalizeString(type)}
|
|
||||||
data-testid={`toolbar-${type}`}
|
|
||||||
onChange={() => {
|
|
||||||
if (app.state.activeTool.type !== type) {
|
|
||||||
trackEvent("toolbar", type, "ui");
|
|
||||||
}
|
|
||||||
app.setActiveTool({ type: type as any });
|
|
||||||
onChange?.(type);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const displayedOption = getDisplayedOption();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<div ref={setTriggerRef}>
|
|
||||||
<ToolButton
|
|
||||||
className={clsx(className, {
|
|
||||||
fillable,
|
|
||||||
active:
|
|
||||||
isActive ||
|
|
||||||
isPopupOpen ||
|
|
||||||
options.some((o) => 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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToolTypePopup
|
|
||||||
app={app}
|
|
||||||
triggerElement={triggerRef}
|
|
||||||
isOpen={isPopupOpen}
|
|
||||||
onClose={() => setIsPopupOpen(false)}
|
|
||||||
options={options}
|
|
||||||
className={className}
|
|
||||||
namePrefix={namePrefix}
|
|
||||||
currentType={activeTool.type}
|
|
||||||
onChange={(type: string) => {
|
|
||||||
onToolChange(type);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
Reference in New Issue
Block a user