compact bottom toolbar
This commit is contained in:
parent
204e06b77b
commit
12644eb347
122
packages/excalidraw/components/LinearElementTypePopup.tsx
Normal file
122
packages/excalidraw/components/LinearElementTypePopup.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { CLASSES, capitalizeString } from "@excalidraw/common";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { ArrowIcon, LineIcon } from "./icons";
|
||||||
|
|
||||||
|
import type { AppClassProperties } from "../types";
|
||||||
|
|
||||||
|
import "./ConvertElementTypePopup.scss";
|
||||||
|
|
||||||
|
const LINEAR_ELEMENT_TYPES = [
|
||||||
|
{ type: "arrow", icon: ArrowIcon },
|
||||||
|
{ type: "line", icon: LineIcon },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type LinearElementType = "arrow" | "line";
|
||||||
|
|
||||||
|
type LinearElementTypePopupProps = {
|
||||||
|
app: AppClassProperties;
|
||||||
|
triggerElement: HTMLElement | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentType: LinearElementType;
|
||||||
|
onChange?: (type: LinearElementType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinearElementTypePopup = ({
|
||||||
|
app,
|
||||||
|
triggerElement,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
currentType,
|
||||||
|
}: LinearElementTypePopupProps) => {
|
||||||
|
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.x + triggerRect.width / 2 - panelWidth / 2,
|
||||||
|
y: triggerRect.top - panelHeight - 16,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
const panelEl = panelRef.current;
|
||||||
|
const triggerEl = triggerElement;
|
||||||
|
if (!target) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clickedInsidePanel = !!panelEl && panelEl.contains(target);
|
||||||
|
const clickedTrigger = !!triggerEl && triggerEl.contains(target);
|
||||||
|
if (!clickedInsidePanel && !clickedTrigger) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// use capture to ensure we run before potential re-renders hide elements
|
||||||
|
document.addEventListener("pointerdown", handleClick, true);
|
||||||
|
document.addEventListener("pointerup", handleClick, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handleClick, true);
|
||||||
|
document.removeEventListener("pointerup", handleClick, true);
|
||||||
|
};
|
||||||
|
}, [isOpen, triggerElement, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: `${panelPosition.y}px`,
|
||||||
|
left: `${panelPosition.x}px`,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
|
||||||
|
>
|
||||||
|
{LINEAR_ELEMENT_TYPES.map(({ type, icon }) => (
|
||||||
|
<ToolButton
|
||||||
|
className="LinearElement"
|
||||||
|
key={type}
|
||||||
|
type="radio"
|
||||||
|
icon={icon}
|
||||||
|
checked={currentType === type}
|
||||||
|
name="linearElementType-option"
|
||||||
|
title={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
keyBindingLabel=""
|
||||||
|
aria-label={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
data-testid={`toolbar-${type}`}
|
||||||
|
onChange={() => {
|
||||||
|
if (app.state.activeTool.type !== type) {
|
||||||
|
trackEvent("toolbar", type, "ui");
|
||||||
|
}
|
||||||
|
app.setActiveTool({ type: type as any });
|
||||||
|
onChange?.(type);
|
||||||
|
// Intentionally NOT calling onClose here; popup should stay open
|
||||||
|
// until user clicks outside trigger or popup per requirements.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,6 +11,7 @@ import { calculateScrollCenter } from "../scene";
|
|||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
|
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
|
import { MobileToolBar } from "./MobileToolBar";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
@ -78,60 +79,16 @@ export const MobileMenu = ({
|
|||||||
} = useTunnels();
|
} = useTunnels();
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top" className="App-top-bar">
|
<div>
|
||||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
{/* {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} */}
|
||||||
<Section heading="shapes">
|
<MobileToolBar
|
||||||
{(heading: React.ReactNode) => (
|
|
||||||
<Stack.Col gap={4} align="center">
|
|
||||||
<Stack.Row gap={1} className="App-toolbar-container">
|
|
||||||
<Island padding={1} className="App-toolbar App-toolbar--mobile">
|
|
||||||
{heading}
|
|
||||||
<Stack.Row gap={1}>
|
|
||||||
<ShapesSwitcher
|
|
||||||
appState={appState}
|
|
||||||
activeTool={appState.activeTool}
|
|
||||||
UIOptions={UIOptions}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
</Island>
|
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
|
||||||
<div className="mobile-misc-tools-container">
|
|
||||||
{!appState.viewModeEnabled &&
|
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
|
||||||
<DefaultSidebarTriggerTunnel.Out />
|
|
||||||
)}
|
|
||||||
<PenModeButton
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={() => onPenModeToggle(null)}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
isMobile
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
|
||||||
<LockButton
|
|
||||||
checked={appState.activeTool.locked}
|
|
||||||
onChange={onLockToggle}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
<HandButton
|
|
||||||
checked={isHandToolActive(appState)}
|
|
||||||
onChange={() => onHandToolToggle()}
|
|
||||||
title={t("toolBar.hand")}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Stack.Row>
|
|
||||||
</Stack.Col>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
<HintViewer
|
|
||||||
appState={appState}
|
appState={appState}
|
||||||
isMobile={true}
|
|
||||||
device={device}
|
|
||||||
app={app}
|
app={app}
|
||||||
|
actionManager={actionManager}
|
||||||
|
onHandToolToggle={onHandToolToggle}
|
||||||
|
UIOptions={UIOptions}
|
||||||
/>
|
/>
|
||||||
</FixedSideContainer>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -166,9 +123,14 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled &&
|
<FixedSideContainer side="top" className="App-top-bar">
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
<HintViewer
|
||||||
renderToolbar()}
|
appState={appState}
|
||||||
|
isMobile={true}
|
||||||
|
device={device}
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
</FixedSideContainer>
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@ -192,7 +154,9 @@ export const MobileMenu = ({
|
|||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
<footer className="App-toolbar">
|
<footer className="App-toolbar">
|
||||||
{renderAppToolbar()}
|
{!appState.viewModeEnabled &&
|
||||||
|
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||||
|
renderToolbar()}
|
||||||
{appState.scrolledOutside &&
|
{appState.scrolledOutside &&
|
||||||
!appState.openMenu &&
|
!appState.openMenu &&
|
||||||
!appState.openSidebar && (
|
!appState.openSidebar && (
|
||||||
|
|||||||
78
packages/excalidraw/components/MobileToolBar.scss
Normal file
78
packages/excalidraw/components/MobileToolBar.scss
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.mobile-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media screen and (min-width: 350px) {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a media query to increase the gaps on larger mobile devices
|
||||||
|
@media screen and (min-width: 400px) {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar .ToolIcon {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse existing dropdown styles */
|
||||||
|
.mobile-toolbar .App-toolbar__extra-tools-dropdown {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--default-border-color);
|
||||||
|
margin: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-undo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-undo .ToolIcon {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
429
packages/excalidraw/components/MobileToolBar.tsx
Normal file
429
packages/excalidraw/components/MobileToolBar.tsx
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { KEYS, capitalizeString } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { HandButton } from "./HandButton";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { ShapesSwitcher } from "./Actions";
|
||||||
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
|
import { ShapeTypePopup } from "./ShapeTypePopup";
|
||||||
|
import { SelectionTypePopup } from "./SelectionTypePopup";
|
||||||
|
import { LinearElementTypePopup } from "./LinearElementTypePopup";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SelectionIcon,
|
||||||
|
FreedrawIcon,
|
||||||
|
EraserIcon,
|
||||||
|
RectangleIcon,
|
||||||
|
ArrowIcon,
|
||||||
|
extraToolsIcon,
|
||||||
|
DiamondIcon,
|
||||||
|
EllipseIcon,
|
||||||
|
LineIcon,
|
||||||
|
TextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
frameToolIcon,
|
||||||
|
EmbedIcon,
|
||||||
|
laserPointerToolIcon,
|
||||||
|
LassoIcon,
|
||||||
|
mermaidLogoIcon,
|
||||||
|
MagicIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { isHandToolActive } from "../appState";
|
||||||
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
|
||||||
|
import type { ActionManager } from "../actions/manager";
|
||||||
|
import type { AppClassProperties, AppProps, UIAppState } from "../types";
|
||||||
|
|
||||||
|
import "./ToolIcon.scss";
|
||||||
|
import "./MobileToolBar.scss";
|
||||||
|
|
||||||
|
type MobileToolBarProps = {
|
||||||
|
appState: UIAppState;
|
||||||
|
app: AppClassProperties;
|
||||||
|
actionManager: ActionManager;
|
||||||
|
onHandToolToggle: () => void;
|
||||||
|
UIOptions: AppProps["UIOptions"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileToolBar = ({
|
||||||
|
appState,
|
||||||
|
app,
|
||||||
|
actionManager,
|
||||||
|
onHandToolToggle,
|
||||||
|
UIOptions,
|
||||||
|
}: MobileToolBarProps) => {
|
||||||
|
const activeTool = appState.activeTool;
|
||||||
|
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
|
||||||
|
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
|
||||||
|
"rectangle" | "diamond" | "ellipse"
|
||||||
|
>("rectangle");
|
||||||
|
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
|
||||||
|
"arrow" | "line"
|
||||||
|
>("arrow");
|
||||||
|
const [isShapeTypePopupOpen, setIsShapeTypePopupOpen] = useState(false);
|
||||||
|
const [rectangleTriggerRef, setRectangleTriggerRef] =
|
||||||
|
useState<HTMLElement | null>(null);
|
||||||
|
const [isLinearElementTypePopupOpen, setIsLinearElementTypePopupOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [linearElementTriggerRef, setLinearElementTriggerRef] =
|
||||||
|
useState<HTMLElement | null>(null);
|
||||||
|
const [isSelectionTypePopupOpen, setIsSelectionTypePopupOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [selectionTriggerRef, setSelectionTriggerRef] =
|
||||||
|
useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// keep lastActiveGenericShape in sync with active tool if user switches via other UI
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
activeTool.type === "rectangle" ||
|
||||||
|
activeTool.type === "diamond" ||
|
||||||
|
activeTool.type === "ellipse"
|
||||||
|
) {
|
||||||
|
setLastActiveGenericShape(activeTool.type);
|
||||||
|
}
|
||||||
|
}, [activeTool.type]);
|
||||||
|
|
||||||
|
// keep lastActiveLinearElement in sync with active tool if user switches via other UI
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||||
|
setLastActiveLinearElement(activeTool.type);
|
||||||
|
}
|
||||||
|
}, [activeTool.type]);
|
||||||
|
|
||||||
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
|
const lassoToolSelected =
|
||||||
|
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||||
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
|
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||||
|
|
||||||
|
const handleToolChange = (toolType: string, pointerType?: string) => {
|
||||||
|
if (appState.activeTool.type !== toolType) {
|
||||||
|
trackEvent("toolbar", toolType, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolType === "selection") {
|
||||||
|
if (appState.activeTool.type === "selection") {
|
||||||
|
// Toggle selection tool behavior if needed
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: "selection" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: toolType as any });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-toolbar">
|
||||||
|
{/* Hand Tool */}
|
||||||
|
<HandButton
|
||||||
|
checked={isHandToolActive(appState)}
|
||||||
|
onChange={onHandToolToggle}
|
||||||
|
title={t("toolBar.hand")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Selection Tool */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<div ref={setSelectionTriggerRef}>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("Shape", { fillable: false })}
|
||||||
|
type="radio"
|
||||||
|
icon={
|
||||||
|
app.defaultSelectionTool === "selection"
|
||||||
|
? SelectionIcon
|
||||||
|
: LassoIcon
|
||||||
|
}
|
||||||
|
checked={
|
||||||
|
activeTool.type === "lasso" || activeTool.type === "selection"
|
||||||
|
}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.selection"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.selection"))}
|
||||||
|
data-testid="toolbar-selection"
|
||||||
|
onPointerDown={() => {
|
||||||
|
setIsSelectionTypePopupOpen((val) => !val);
|
||||||
|
app.setActiveTool({ type: app.defaultSelectionTool });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectionTypePopup
|
||||||
|
app={app}
|
||||||
|
triggerElement={selectionTriggerRef}
|
||||||
|
isOpen={isSelectionTypePopupOpen}
|
||||||
|
onClose={() => setIsSelectionTypePopupOpen(false)}
|
||||||
|
onChange={(type) => {
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
app.defaultSelectionTool = type;
|
||||||
|
}}
|
||||||
|
currentType={activeTool.type === "lasso" ? "lasso" : "selection"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Free Draw */}
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("Shape", { fillable: false })}
|
||||||
|
type="radio"
|
||||||
|
icon={FreedrawIcon}
|
||||||
|
checked={activeTool.type === "freedraw"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.freedraw"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.freedraw"))}
|
||||||
|
data-testid="toolbar-freedraw"
|
||||||
|
onChange={() => handleToolChange("freedraw")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Eraser */}
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("Shape", { fillable: true })}
|
||||||
|
type="radio"
|
||||||
|
icon={EraserIcon}
|
||||||
|
checked={activeTool.type === "eraser"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.eraser"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.eraser"))}
|
||||||
|
data-testid="toolbar-eraser"
|
||||||
|
onChange={() => handleToolChange("eraser")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rectangle */}
|
||||||
|
<div
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
ref={(el) => setRectangleTriggerRef(el as HTMLElement | null)}
|
||||||
|
>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("Shape", { fillable: false })}
|
||||||
|
type="radio"
|
||||||
|
icon={
|
||||||
|
lastActiveGenericShape === "rectangle"
|
||||||
|
? RectangleIcon
|
||||||
|
: lastActiveGenericShape === "diamond"
|
||||||
|
? DiamondIcon
|
||||||
|
: lastActiveGenericShape === "ellipse"
|
||||||
|
? EllipseIcon
|
||||||
|
: RectangleIcon
|
||||||
|
}
|
||||||
|
checked={["rectangle", "diamond", "ellipse"].includes(
|
||||||
|
activeTool.type,
|
||||||
|
)}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveGenericShape === "rectangle"
|
||||||
|
? "toolBar.rectangle"
|
||||||
|
: lastActiveGenericShape === "diamond"
|
||||||
|
? "toolBar.diamond"
|
||||||
|
: lastActiveGenericShape === "ellipse"
|
||||||
|
? "toolBar.ellipse"
|
||||||
|
: "toolBar.rectangle",
|
||||||
|
),
|
||||||
|
)}`}
|
||||||
|
aria-label={capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveGenericShape === "rectangle"
|
||||||
|
? "toolBar.rectangle"
|
||||||
|
: lastActiveGenericShape === "diamond"
|
||||||
|
? "toolBar.diamond"
|
||||||
|
: lastActiveGenericShape === "ellipse"
|
||||||
|
? "toolBar.ellipse"
|
||||||
|
: "toolBar.rectangle",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
data-testid="toolbar-rectangle"
|
||||||
|
onPointerDown={() => {
|
||||||
|
setIsShapeTypePopupOpen((val) => !val);
|
||||||
|
app.setActiveTool({ type: lastActiveGenericShape });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShapeTypePopup
|
||||||
|
app={app}
|
||||||
|
triggerElement={rectangleTriggerRef}
|
||||||
|
isOpen={isShapeTypePopupOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsShapeTypePopupOpen(false);
|
||||||
|
}}
|
||||||
|
onChange={(type) => {
|
||||||
|
setLastActiveGenericShape(type);
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
}}
|
||||||
|
currentType={activeTool.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow/Line */}
|
||||||
|
<div
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
ref={(el) => setLinearElementTriggerRef(el as HTMLElement | null)}
|
||||||
|
>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("Shape", { fillable: true })}
|
||||||
|
type="radio"
|
||||||
|
icon={lastActiveLinearElement === "arrow" ? ArrowIcon : LineIcon}
|
||||||
|
checked={["arrow", "line"].includes(activeTool.type)}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveLinearElement === "arrow"
|
||||||
|
? "toolBar.arrow"
|
||||||
|
: "toolBar.line",
|
||||||
|
),
|
||||||
|
)}`}
|
||||||
|
aria-label={capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveLinearElement === "arrow"
|
||||||
|
? "toolBar.arrow"
|
||||||
|
: "toolBar.line",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
data-testid="toolbar-arrow"
|
||||||
|
onPointerDown={() => {
|
||||||
|
setIsLinearElementTypePopupOpen((val) => !val);
|
||||||
|
app.setActiveTool({ type: lastActiveLinearElement });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinearElementTypePopup
|
||||||
|
app={app}
|
||||||
|
triggerElement={linearElementTriggerRef}
|
||||||
|
isOpen={isLinearElementTypePopupOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsLinearElementTypePopupOpen(false);
|
||||||
|
}}
|
||||||
|
onChange={(type) => {
|
||||||
|
setLastActiveLinearElement(type);
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
}}
|
||||||
|
currentType={activeTool.type === "line" ? "line" : "arrow"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Other Shapes */}
|
||||||
|
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||||
|
"App-toolbar__extra-tools-trigger--selected":
|
||||||
|
frameToolSelected ||
|
||||||
|
embeddableToolSelected ||
|
||||||
|
lassoToolSelected ||
|
||||||
|
activeTool.type === "text" ||
|
||||||
|
activeTool.type === "image" ||
|
||||||
|
(laserToolSelected && !app.props.isCollaborating),
|
||||||
|
})}
|
||||||
|
onToggle={() => setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen)}
|
||||||
|
title={t("toolBar.extraTools")}
|
||||||
|
>
|
||||||
|
{frameToolSelected
|
||||||
|
? frameToolIcon
|
||||||
|
: embeddableToolSelected
|
||||||
|
? EmbedIcon
|
||||||
|
: activeTool.type === "text"
|
||||||
|
? TextIcon
|
||||||
|
: activeTool.type === "image"
|
||||||
|
? ImageIcon
|
||||||
|
: laserToolSelected && !app.props.isCollaborating
|
||||||
|
? laserPointerToolIcon
|
||||||
|
: lassoToolSelected
|
||||||
|
? LassoIcon
|
||||||
|
: extraToolsIcon}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
|
||||||
|
onSelect={() => setIsOtherShapesMenuOpen(false)}
|
||||||
|
className="App-toolbar__extra-tools-dropdown"
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "text" })}
|
||||||
|
icon={TextIcon}
|
||||||
|
shortcut={KEYS.T.toLocaleUpperCase()}
|
||||||
|
data-testid="toolbar-text"
|
||||||
|
selected={activeTool.type === "text"}
|
||||||
|
>
|
||||||
|
{t("toolBar.text")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "image" })}
|
||||||
|
icon={ImageIcon}
|
||||||
|
data-testid="toolbar-image"
|
||||||
|
selected={activeTool.type === "image"}
|
||||||
|
>
|
||||||
|
{t("toolBar.image")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||||
|
icon={frameToolIcon}
|
||||||
|
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||||
|
data-testid="toolbar-frame"
|
||||||
|
selected={frameToolSelected}
|
||||||
|
>
|
||||||
|
{t("toolBar.frame")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||||
|
icon={EmbedIcon}
|
||||||
|
data-testid="toolbar-embeddable"
|
||||||
|
selected={embeddableToolSelected}
|
||||||
|
>
|
||||||
|
{t("toolBar.embeddable")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||||
|
icon={laserPointerToolIcon}
|
||||||
|
data-testid="toolbar-laser"
|
||||||
|
selected={laserToolSelected}
|
||||||
|
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||||
|
>
|
||||||
|
{t("toolBar.laser")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{app.defaultSelectionTool !== "lasso" && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||||
|
icon={LassoIcon}
|
||||||
|
data-testid="toolbar-lasso"
|
||||||
|
selected={lassoToolSelected}
|
||||||
|
>
|
||||||
|
{t("toolBar.lasso")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
|
Generate
|
||||||
|
</div>
|
||||||
|
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
|
||||||
|
icon={mermaidLogoIcon}
|
||||||
|
data-testid="toolbar-embeddable"
|
||||||
|
>
|
||||||
|
{t("toolBar.mermaidToExcalidraw")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
|
icon={MagicIcon}
|
||||||
|
data-testid="toolbar-magicframe"
|
||||||
|
>
|
||||||
|
{t("toolBar.magicframe")}
|
||||||
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="mobile-toolbar-separator" />
|
||||||
|
|
||||||
|
{/* Undo Button */}
|
||||||
|
<div className="mobile-toolbar-undo">
|
||||||
|
{actionManager.renderAction("undo")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
packages/excalidraw/components/SelectionTypePopup.tsx
Normal file
122
packages/excalidraw/components/SelectionTypePopup.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { CLASSES, capitalizeString } from "@excalidraw/common";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { SelectionIcon, LassoIcon } from "./icons";
|
||||||
|
|
||||||
|
import type { AppClassProperties } from "../types";
|
||||||
|
|
||||||
|
import "./ConvertElementTypePopup.scss";
|
||||||
|
|
||||||
|
const SELECTION_TYPES = [
|
||||||
|
{ type: "selection", icon: SelectionIcon },
|
||||||
|
{ type: "lasso", icon: LassoIcon },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SelectionType = "selection" | "lasso";
|
||||||
|
|
||||||
|
type SelectionTypePopupProps = {
|
||||||
|
app: AppClassProperties;
|
||||||
|
triggerElement: HTMLElement | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentType: SelectionType;
|
||||||
|
onChange?: (type: SelectionType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectionTypePopup = ({
|
||||||
|
app,
|
||||||
|
triggerElement,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
currentType,
|
||||||
|
}: SelectionTypePopupProps) => {
|
||||||
|
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.x + triggerRect.width / 2 - panelWidth / 2,
|
||||||
|
y: triggerRect.top - panelHeight - 16,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
const panelEl = panelRef.current;
|
||||||
|
const triggerEl = triggerElement;
|
||||||
|
if (!target) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clickedInsidePanel = !!panelEl && panelEl.contains(target);
|
||||||
|
const clickedTrigger = !!triggerEl && triggerEl.contains(target);
|
||||||
|
if (!clickedInsidePanel && !clickedTrigger) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// use capture to ensure we run before potential re-renders hide elements
|
||||||
|
document.addEventListener("pointerdown", handleClick, true);
|
||||||
|
document.addEventListener("pointerup", handleClick, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handleClick, true);
|
||||||
|
document.removeEventListener("pointerup", handleClick, true);
|
||||||
|
};
|
||||||
|
}, [isOpen, triggerElement, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: `${panelPosition.y}px`,
|
||||||
|
left: `${panelPosition.x}px`,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
|
||||||
|
>
|
||||||
|
{SELECTION_TYPES.map(({ type, icon }) => (
|
||||||
|
<ToolButton
|
||||||
|
className="Selection"
|
||||||
|
key={type}
|
||||||
|
type="radio"
|
||||||
|
icon={icon}
|
||||||
|
checked={currentType === type}
|
||||||
|
name="selectionType-option"
|
||||||
|
title={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
keyBindingLabel=""
|
||||||
|
aria-label={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
data-testid={`toolbar-${type}`}
|
||||||
|
onChange={() => {
|
||||||
|
if (app.state.activeTool.type !== type) {
|
||||||
|
trackEvent("toolbar", type, "ui");
|
||||||
|
}
|
||||||
|
app.setActiveTool({ type: type as any });
|
||||||
|
onChange?.(type);
|
||||||
|
// Intentionally NOT calling onClose here; popup should stay open
|
||||||
|
// until user clicks outside trigger or popup per requirements.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
123
packages/excalidraw/components/ShapeTypePopup.tsx
Normal file
123
packages/excalidraw/components/ShapeTypePopup.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { CLASSES, capitalizeString } from "@excalidraw/common";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { DiamondIcon, EllipseIcon, RectangleIcon } from "./icons";
|
||||||
|
|
||||||
|
import type { AppClassProperties } from "../types";
|
||||||
|
|
||||||
|
import "./ConvertElementTypePopup.scss";
|
||||||
|
|
||||||
|
const GAP_HORIZONTAL = 44;
|
||||||
|
const GAP_VERTICAL = -94;
|
||||||
|
|
||||||
|
const GENERIC_SHAPES = [
|
||||||
|
{ type: "rectangle", icon: RectangleIcon },
|
||||||
|
{ type: "diamond", icon: DiamondIcon },
|
||||||
|
{ type: "ellipse", icon: EllipseIcon },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ShapeTypePopupProps = {
|
||||||
|
app: AppClassProperties;
|
||||||
|
triggerElement: HTMLElement | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentType: string;
|
||||||
|
onChange?: (type: "rectangle" | "diamond" | "ellipse") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShapeTypePopup = ({
|
||||||
|
app,
|
||||||
|
triggerElement,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
currentType,
|
||||||
|
}: ShapeTypePopupProps) => {
|
||||||
|
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.x + triggerRect.width / 2 - panelWidth / 2,
|
||||||
|
y: triggerRect.top - panelHeight - 16,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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: `${panelPosition.y}px`,
|
||||||
|
left: `${panelPosition.x}px`,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
|
||||||
|
>
|
||||||
|
{GENERIC_SHAPES.map(({ type, icon }) => (
|
||||||
|
<ToolButton
|
||||||
|
className="Shape"
|
||||||
|
key={type}
|
||||||
|
type="radio"
|
||||||
|
icon={icon}
|
||||||
|
checked={currentType === type}
|
||||||
|
name="shapeType-option"
|
||||||
|
title={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
keyBindingLabel=""
|
||||||
|
aria-label={capitalizeString(t(`toolBar.${type}`))}
|
||||||
|
data-testid={`toolbar-${type}`}
|
||||||
|
onChange={() => {
|
||||||
|
if (app.state.activeTool.type !== type) {
|
||||||
|
trackEvent("toolbar", type, "ui");
|
||||||
|
}
|
||||||
|
app.setActiveTool({ type: type as any });
|
||||||
|
onChange?.(type);
|
||||||
|
// Do NOT close here; keep popup open until user clicks outside (parity with SelectionTypePopup)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,6 +6,13 @@
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
&--placement-top {
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
&--mobile {
|
&--mobile {
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -17,16 +17,27 @@ import "./DropdownMenu.scss";
|
|||||||
const DropdownMenu = ({
|
const DropdownMenu = ({
|
||||||
children,
|
children,
|
||||||
open,
|
open,
|
||||||
|
placement,
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
placement?: "top" | "bottom";
|
||||||
}) => {
|
}) => {
|
||||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||||
const MenuContentComp = getMenuContentComponent(children);
|
const MenuContentComp = getMenuContentComponent(children);
|
||||||
|
|
||||||
|
// clone the MenuContentComp to pass the placement prop
|
||||||
|
const MenuContentCompWithPlacement =
|
||||||
|
MenuContentComp && React.isValidElement(MenuContentComp)
|
||||||
|
? React.cloneElement(MenuContentComp as React.ReactElement<any>, {
|
||||||
|
placement,
|
||||||
|
})
|
||||||
|
: MenuContentComp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{MenuTriggerComp}
|
{MenuTriggerComp}
|
||||||
{open && MenuContentComp}
|
{open && MenuContentCompWithPlacement}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const MenuContent = ({
|
|||||||
className = "",
|
className = "",
|
||||||
onSelect,
|
onSelect,
|
||||||
style,
|
style,
|
||||||
|
placement = "bottom",
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClickOutside?: () => void;
|
onClickOutside?: () => void;
|
||||||
@ -26,6 +27,7 @@ const MenuContent = ({
|
|||||||
*/
|
*/
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
placement?: "top" | "bottom";
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@ -58,6 +60,7 @@ const MenuContent = ({
|
|||||||
|
|
||||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||||
"dropdown-menu--mobile": device.editor.isMobile,
|
"dropdown-menu--mobile": device.editor.isMobile,
|
||||||
|
"dropdown-menu--placement-top": placement === "top",
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -249,13 +249,13 @@ body.excalidraw-cursor-resize * {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
> .Island {
|
> .Island {
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 100%;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
@ -264,6 +264,8 @@ body.excalidraw-cursor-resize * {
|
|||||||
|
|
||||||
.App-toolbar {
|
.App-toolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
.eraser {
|
.eraser {
|
||||||
&.ToolIcon:hover {
|
&.ToolIcon:hover {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user