mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: switch between basic shapes
This commit is contained in:
parent
70c3e921bb
commit
407d8ababb
9 changed files with 545 additions and 5 deletions
199
packages/excalidraw/components/ShapeSwitch.tsx
Normal file
199
packages/excalidraw/components/ShapeSwitch.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
import { getSelectedElements } from "../scene";
|
||||
import type App from "./App";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import {
|
||||
ArrowIcon,
|
||||
DiamondIcon,
|
||||
EllipseIcon,
|
||||
LineIcon,
|
||||
RectangleIcon,
|
||||
} from "./icons";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import type { ToolType } from "../types";
|
||||
import { sceneCoordsToViewportCoords } from "../utils";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import "./ShapeSwitch.scss";
|
||||
import clsx from "clsx";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import {
|
||||
isArrowElement,
|
||||
isGenericSwitchableElement,
|
||||
isLinearElement,
|
||||
isLinearSwitchableElement,
|
||||
} from "../element/typeChecks";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const GAP_HORIZONTAL = 8;
|
||||
const GAP_VERTICAL = 10;
|
||||
|
||||
const ShapeSwitch = ({ app }: { app: App }) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.state,
|
||||
);
|
||||
const firstElement = selectedElements[0];
|
||||
|
||||
useEffect(() => {
|
||||
return app.setState({ showShapeSwitchPanel: false });
|
||||
}, [app]);
|
||||
|
||||
if (
|
||||
firstElement &&
|
||||
selectedElements.length === 1 &&
|
||||
(isGenericSwitchableElement(firstElement) ||
|
||||
isLinearSwitchableElement(firstElement))
|
||||
) {
|
||||
return app.state.showShapeSwitchPanel ? (
|
||||
<Panel app={app} element={firstElement} />
|
||||
) : (
|
||||
<Hint app={app} element={firstElement} />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
|
||||
const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const { x, y } = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: rotatedTopLeft[0],
|
||||
sceneY: rotatedTopLeft[1],
|
||||
},
|
||||
app.state,
|
||||
);
|
||||
|
||||
const hintRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
hintRef.current?.classList.remove("animation");
|
||||
};
|
||||
|
||||
if (hintRef.current) {
|
||||
hintRef.current.addEventListener("animationend", listener);
|
||||
}
|
||||
}, [element.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hintRef}
|
||||
key={element.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: `${
|
||||
app.state.height +
|
||||
GAP_VERTICAL * app.state.zoom.value -
|
||||
y -
|
||||
app.state.offsetTop
|
||||
}px`,
|
||||
left: `${x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
|
||||
zIndex: 2,
|
||||
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
className={clsx("ShapeSwitch__Hint", "animation")}
|
||||
>
|
||||
<div className="key">/</div>
|
||||
<div className="text">{t("labels.slash")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
|
||||
const [x1, , , y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const rotatedBottomLeft = pointRotateRads(
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const { x, y } = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: rotatedBottomLeft[0],
|
||||
sceneY: rotatedBottomLeft[1],
|
||||
},
|
||||
app.state,
|
||||
);
|
||||
|
||||
const SHAPES: [string, string, ReactNode][] = isLinearElement(element)
|
||||
? [
|
||||
["arrow", "5", ArrowIcon],
|
||||
["line", "6", LineIcon],
|
||||
]
|
||||
: [
|
||||
["rectangle", "2", RectangleIcon],
|
||||
["diamond", "3", DiamondIcon],
|
||||
["ellipse", "4", EllipseIcon],
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${
|
||||
y + GAP_VERTICAL * app.state.zoom.value - app.state.offsetTop
|
||||
}px`,
|
||||
left: `${x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
|
||||
zIndex: 2,
|
||||
}}
|
||||
className="ShapeSwitch__Panel"
|
||||
>
|
||||
{SHAPES.map(([type, shortcut, icon]) => {
|
||||
const isSelected =
|
||||
type === element.type ||
|
||||
(isArrowElement(element) && element.elbowed && type === "elbow") ||
|
||||
(isArrowElement(element) && element.roundness && type === "curve") ||
|
||||
(isArrowElement(element) &&
|
||||
!element.elbowed &&
|
||||
!element.roundness &&
|
||||
type === "straight");
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
key={type}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={isSelected}
|
||||
name="editor-current-shape"
|
||||
title={type}
|
||||
keyBindingLabel={""}
|
||||
aria-label={type}
|
||||
data-testid={`toolbar-${type}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!app.state.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={() => {
|
||||
if (app.state.activeTool.type !== type) {
|
||||
trackEvent("shape-switch", type, "ui");
|
||||
}
|
||||
app.setActiveTool({ type: type as ToolType });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShapeSwitch;
|
Loading…
Add table
Add a link
Reference in a new issue