feat: switch between basic shapes

This commit is contained in:
Ryan Di 2025-03-14 19:54:18 +11:00
parent 70c3e921bb
commit 407d8ababb
9 changed files with 545 additions and 5 deletions

View file

@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
isCropping: false, isCropping: false,
croppingElementId: null, croppingElementId: null,
searchMatches: [], searchMatches: [],
showShapeSwitchPanel: false,
}; };
}; };
@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false }, isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
showShapeSwitchPanel: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View file

@ -166,6 +166,10 @@ import {
isElbowArrow, isElbowArrow,
isFlowchartNodeElement, isFlowchartNodeElement,
isBindableElement, isBindableElement,
isGenericSwitchableElement,
isGenericSwitchableToolType,
isLinearSwitchableElement,
isLinearSwitchableToolType,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@ -467,6 +471,7 @@ import {
getApproxMinLineHeight, getApproxMinLineHeight,
getMinTextElementWidth, getMinTextElementWidth,
} from "../element/textMeasurements"; } from "../element/textMeasurements";
import ShapeSwitch from "./ShapeSwitch";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -559,6 +564,7 @@ const gesture: Gesture = {
initialDistance: null, initialDistance: null,
initialScale: null, initialScale: null,
}; };
let textWysiwygSubmitHandler: (() => void) | null = null;
class App extends React.Component<AppProps, AppState> { class App extends React.Component<AppProps, AppState> {
canvas: AppClassProperties["canvas"]; canvas: AppClassProperties["canvas"];
@ -1805,6 +1811,7 @@ class App extends React.Component<AppProps, AppState> {
/> />
)} )}
{this.renderFrameNames()} {this.renderFrameNames()}
<ShapeSwitch app={this} />
</ExcalidrawActionManagerContext.Provider> </ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()} {this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider> </ExcalidrawElementsContext.Provider>
@ -4092,6 +4099,56 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (
selectedElements.length === 1 &&
(selectedElements[0].type === "rectangle" ||
selectedElements[0].type === "diamond" ||
selectedElements[0].type === "ellipse" ||
selectedElements[0].type === "arrow" ||
selectedElements[0].type === "line")
) {
if (this.state.showShapeSwitchPanel && event.key === KEYS.ESCAPE) {
this.setState({
showShapeSwitchPanel: false,
});
return;
}
if (event.key === KEYS.SLASH) {
if (!this.state.showShapeSwitchPanel) {
this.setState({
showShapeSwitchPanel: true,
});
} else if (
selectedElements[0].type === "rectangle" ||
selectedElements[0].type === "diamond" ||
selectedElements[0].type === "ellipse"
) {
const index = ["rectangle", "diamond", "ellipse"].indexOf(
selectedElements[0].type,
);
const nextType = ["rectangle", "diamond", "ellipse"][
(index + 1) % 3
] as ToolType;
this.setActiveTool({ type: nextType });
} else if (
selectedElements[0].type === "arrow" ||
selectedElements[0].type === "line"
) {
const index = ["arrow", "line"].indexOf(selectedElements[0].type);
const nextType = ["arrow", "line"][(index + 1) % 2] as ToolType;
this.setActiveTool({ type: nextType });
}
return;
}
this.setState({
showShapeSwitchPanel: false,
});
}
if ( if (
event.key === KEYS.ESCAPE && event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart this.flowChartCreator.isCreatingChart
@ -4742,6 +4799,95 @@ class App extends React.Component<AppProps, AppState> {
...commonResets, ...commonResets,
}; };
}); });
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElementsMap(),
this.state,
);
const firstElement = selectedElements[0];
if (
firstElement &&
selectedElements.length === 1 &&
isGenericSwitchableElement(firstElement) &&
isGenericSwitchableToolType(tool.type)
) {
ShapeCache.delete(firstElement);
mutateElement(firstElement, {
type: tool.type,
roundness:
tool.type === "diamond" && firstElement.roundness
? {
type: isUsingAdaptiveRadius(tool.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
value: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: firstElement.roundness,
});
this.setActiveTool({ type: "selection" });
if (firstElement.boundElements?.some((e) => e.type === "text")) {
this.startTextEditing({
sceneX: firstElement.x + firstElement.width / 2,
sceneY: firstElement.y + firstElement.height / 2,
container: firstElement as ExcalidrawTextContainer,
keepContainerDimensions: true,
});
}
textWysiwygSubmitHandler?.();
this.setState({
selectedElementIds: {
[firstElement.id]: true,
},
});
this.store.shouldCaptureIncrement();
}
if (
firstElement &&
selectedElements.length === 1 &&
isLinearSwitchableElement(firstElement) &&
isLinearSwitchableToolType(tool.type)
) {
ShapeCache.delete(firstElement);
mutateElement(firstElement as ExcalidrawLinearElement, {
type: tool.type,
startArrowhead: null,
endArrowhead: tool.type === "arrow" ? "arrow" : null,
});
this.setActiveTool({ type: "selection" });
if (
firstElement.boundElements?.some((e) => e.type === "text") &&
isArrowElement(firstElement)
) {
this.startTextEditing({
sceneX: firstElement.x + firstElement.width / 2,
sceneY: firstElement.y + firstElement.height / 2,
container: firstElement,
keepContainerDimensions: true,
});
}
textWysiwygSubmitHandler?.();
this.setState({
selectedElementIds: {
[firstElement.id]: true,
},
selectedLinearElement: new LinearElementEditor(
firstElement as ExcalidrawLinearElement,
),
});
}
}; };
setOpenDialog = (dialogType: AppState["openDialog"]) => { setOpenDialog = (dialogType: AppState["openDialog"]) => {
@ -4843,8 +4989,10 @@ class App extends React.Component<AppProps, AppState> {
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
isExistingElement = false, isExistingElement = false,
keepContainerDimensions = false,
}: { }: {
isExistingElement?: boolean; isExistingElement?: boolean;
keepContainerDimensions?: boolean;
}, },
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@ -4871,7 +5019,7 @@ class App extends React.Component<AppProps, AppState> {
]); ]);
}; };
textWysiwyg({ textWysiwygSubmitHandler = textWysiwyg({
id: element.id, id: element.id,
canvas: this.canvas, canvas: this.canvas,
getViewportCoords: (x, y) => { getViewportCoords: (x, y) => {
@ -4894,6 +5042,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
textWysiwygSubmitHandler = null;
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
@ -4949,6 +5098,7 @@ class App extends React.Component<AppProps, AppState> {
// the text on edit anyway (and users can select-all from contextmenu // the text on edit anyway (and users can select-all from contextmenu
// if needed) // if needed)
autoSelect: !this.device.isTouchScreen, autoSelect: !this.device.isTouchScreen,
keepContainerDimensions,
}); });
// deselect all other elements when inserting text // deselect all other elements when inserting text
this.deselectElements(); this.deselectElements();
@ -5181,6 +5331,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter = true, insertAtParentCenter = true,
container, container,
autoEdit = true, autoEdit = true,
keepContainerDimensions = false,
}: { }: {
/** X position to insert text at */ /** X position to insert text at */
sceneX: number; sceneX: number;
@ -5190,6 +5341,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter?: boolean; insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null; container?: ExcalidrawTextContainer | null;
autoEdit?: boolean; autoEdit?: boolean;
keepContainerDimensions?: boolean;
}) => { }) => {
let shouldBindToContainer = false; let shouldBindToContainer = false;
@ -5325,6 +5477,7 @@ class App extends React.Component<AppProps, AppState> {
if (autoEdit || existingTextElement || container) { if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, { this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement, isExistingElement: !!existingTextElement,
keepContainerDimensions,
}); });
} else { } else {
this.setState({ this.setState({
@ -8768,6 +8921,7 @@ class App extends React.Component<AppProps, AppState> {
cursorButton: "up", cursorButton: "up",
snapLines: updateStable(prevState.snapLines, []), snapLines: updateStable(prevState.snapLines, []),
originSnapOffset: null, originSnapOffset: null,
showShapeSwitchPanel: false,
})); }));
this.lastPointerMoveCoords = null; this.lastPointerMoveCoords = null;

View file

@ -0,0 +1,47 @@
@import "../css//variables.module.scss";
@keyframes disappear {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.excalidraw {
.ShapeSwitch__Hint {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
background: var(--default-bg-color);
.key {
background: var(--color-primary-light);
padding: 0.4rem 0.8rem;
font-weight: bold;
border-radius: 0.5rem;
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
}
.text {
margin-left: 0.5rem;
}
}
.animation {
opacity: 1;
animation: disappear 2s ease-out;
}
.ShapeSwitch__Panel {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2rem;
border-radius: 0.5rem;
background: var(--default-bg-color);
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
}
}

View 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">&#47;</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;

View file

@ -0,0 +1,39 @@
// render the shape switcher div on top of the canvas at the selected element's position
import { THEME } from "../constants";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
import { sceneCoordsToViewportCoords } from "../utils";
const ShapeSwitcher = ({
appState,
element,
}: {
appState: AppState;
element: ExcalidrawElement;
elements: readonly ExcalidrawElement[];
}) => {
const isDarkTheme = appState.theme === THEME.DARK;
const { x, y } = sceneCoordsToViewportCoords(
{
sceneX: element.x,
sceneY: element.y,
},
appState,
);
return (
<div
style={{
position: "absolute",
bottom: `${appState.height - y + appState.offsetTop}px`,
left: `${x - appState.offsetLeft}px`,
backgroundColor: isDarkTheme
? "rgba(0, 0, 0, 0.9)"
: "rgba(255, 255, 255, 0.9)",
}}
></div>
);
};
export default ShapeSwitcher;

View file

@ -48,7 +48,7 @@ import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import { getTextWidth } from "./textMeasurements"; import { getTextWidth, measureText } from "./textMeasurements";
import { normalizeText } from "./textMeasurements"; import { normalizeText } from "./textMeasurements";
const getTransform = ( const getTransform = (
@ -72,6 +72,8 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@ -82,6 +84,7 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
keepContainerDimensions = false,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@ -98,7 +101,8 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
}) => { keepContainerDimensions?: boolean;
}): SubmitHandler => {
const textPropertiesUpdated = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@ -179,12 +183,53 @@ export const textWysiwyg = ({
} }
maxWidth = getBoundTextMaxWidth(container, updatedTextElement); maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight( maxHeight = getBoundTextMaxHeight(
container, container,
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
); );
if (keepContainerDimensions) {
const wrappedText = wrapText(
updatedTextElement.text,
getFontString(updatedTextElement),
maxWidth,
);
let metrics = measureText(
wrappedText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
if (width > maxWidth || height > maxHeight) {
let nextFontSize = updatedTextElement.fontSize;
while (
(metrics.width > maxWidth || metrics.height > maxHeight) &&
nextFontSize > 0
) {
nextFontSize -= 1;
const _updatedTextElement = {
...updatedTextElement,
fontSize: nextFontSize,
};
metrics = measureText(
updatedTextElement.text,
getFontString(_updatedTextElement),
updatedTextElement.lineHeight,
);
}
mutateElement(
updatedTextElement,
{ fontSize: nextFontSize },
false,
);
}
width = metrics.width;
height = metrics.height;
}
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) { if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight = computeContainerDimensionForBoundText(
@ -727,4 +772,6 @@ export const textWysiwyg = ({
excalidrawContainer excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")! ?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable); .appendChild(editable);
return handleSubmit;
}; };

View file

@ -26,6 +26,9 @@ import type {
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -336,3 +339,49 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
type ExcalidrawGenericSwitchableElement =
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement;
type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
type LinearSwitchableToolType = "arrow" | "line";
export const isGenericSwitchableElement = (
element: ExcalidrawElement,
): element is ExcalidrawGenericSwitchableElement => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
export const isGenericSwitchableToolType = (
type: string,
): type is GenericSwitchableToolType => {
return type === "rectangle" || type === "ellipse" || type === "diamond";
};
export const isLinearSwitchableElement = (
element: ExcalidrawElement,
): element is ExcalidrawLinearElement => {
if (element.type === "arrow" || element.type === "line") {
if (
(!element.boundElements || element.boundElements.length === 0) &&
!element.startBinding &&
!element.endBinding
) {
return true;
}
}
return false;
};
export const isLinearSwitchableToolType = (
type: string,
): type is LinearSwitchableToolType => {
return type === "arrow" || type === "line";
};

View file

@ -165,7 +165,8 @@
"unCroppedDimension": "Uncropped dimension", "unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object", "copyElementLink": "Copy link to object",
"linkToElement": "Link to object", "linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame" "wrapSelectionInFrame": "Wrap selection in frame",
"slash": "Slash"
}, },
"elementLink": { "elementLink": {
"title": "Link to object", "title": "Link to object",

View file

@ -410,6 +410,8 @@ export interface AppState {
croppingElementId: ExcalidrawElement["id"] | null; croppingElementId: ExcalidrawElement["id"] | null;
searchMatches: readonly SearchMatch[]; searchMatches: readonly SearchMatch[];
showShapeSwitchPanel: boolean;
} }
type SearchMatch = { type SearchMatch = {