feat: switch between basic shapes (#9270)

* feat: switch between basic shapes

* add tab for testing

* style tweaks

* only show hint when a new node is created

* fix panel state

* refactor

* combine captures into one

* keep original font size

* switch multi

* switch different types altogether

* use tab only

* fix font size atom

* do not switch from active tool change

* prefer generic when mixed

* provide an optional direction when shape switching

* adjust panel bg & shadow

* redraw to correctly position text

* remove redundant code

* only tab to switch if focusing on app container

* limit which linear elements can be switched

* add shape switch to command palette

* remove hint

* cache initial panel position

* bend line to elbow if needed

* remove debug logic

* clean switch of arrows using app state

* safe conversion between line, sharp, curved, and elbow

* cache linear when panel shows up

* type safe element conversion

* rename type

* respect initial type when switching between linears

* fix elbow segment indexing

* use latest linear

* merge converted elbow points if too close

* focus on panel after click

* set roudness to null to fix drag points offset for elbows

* remove Mutable

* add arrowBoundToElement check

* make it dependent on one signle state

* unmount when not showing

* simpler types, tidy up code

* can change linear when it's linear + non-generic

* fix popup component lifecycle

* move constant to CLASSES

* DRY out type detection

* file & variable renaming

* refactor

* throw in not-prod instead

* simplify

* semi-fix bindings on `generic` type conversion

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2025-04-30 18:07:31 +02:00
committed by GitHub
co-authored by dwelle
parent 4a60fe3d22
commit 195a743874
18 changed files with 1258 additions and 47 deletions
+64 -5
View File
@@ -100,6 +100,7 @@ import {
arrayToMap,
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
} from "@excalidraw/common";
import {
@@ -431,7 +432,7 @@ import {
} from "../components/hyperlink/Hyperlink";
import { Fonts } from "../fonts";
import { editorJotaiStore } from "../editor-jotai";
import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors";
import {
getSnapLinesAtPointer,
@@ -467,6 +468,12 @@ import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
convertElementTypePopupAtom,
convertElementTypes,
} from "./ConvertElementTypePopup";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@@ -498,7 +505,6 @@ import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
import type { ExcalidrawElementSkeleton } from "../data/transform";
import type {
AppClassProperties,
AppProps,
@@ -815,6 +821,15 @@ class App extends React.Component<AppProps, AppState> {
);
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
atom: WritableAtom<Value, Args, Result>,
...args: Args
): Result => {
const result = editorJotaiStore.set(atom, ...args);
this.triggerRender();
return result;
};
private onWindowMessage(event: MessageEvent) {
if (
event.origin !== "https://player.vimeo.com" &&
@@ -1583,6 +1598,9 @@ class App extends React.Component<AppProps, AppState> {
const firstSelectedElement = selectedElements[0];
const showShapeSwitchPanel =
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
return (
<div
className={clsx("excalidraw excalidraw-container", {
@@ -1857,6 +1875,9 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
{this.renderFrameNames()}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
@@ -2138,7 +2159,7 @@ class App extends React.Component<AppProps, AppState> {
};
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
editorJotaiStore.set(activeEyeDropperAtom, {
this.updateEditorAtom(activeEyeDropperAtom, {
swapPreviewOnAlt: true,
colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground",
@@ -4157,6 +4178,40 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// Shape switching
if (event.key === KEYS.ESCAPE) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
} else if (
event.key === KEYS.TAB &&
(document.activeElement === this.excalidrawContainerRef?.current ||
document.activeElement?.classList.contains(
CLASSES.CONVERT_ELEMENT_TYPE_POPUP,
))
) {
event.preventDefault();
const conversionType =
getConversionTypeFromElements(selectedElements);
if (
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"
) {
if (
convertElementTypes(this, {
conversionType,
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
}
}
if (conversionType) {
this.updateEditorAtom(convertElementTypePopupAtom, {
type: "panel",
});
}
}
if (
event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart
@@ -4615,7 +4670,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) {
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
}
// eye dropper
@@ -6364,7 +6419,11 @@ class App extends React.Component<AppProps, AppState> {
focus: false,
})),
}));
editorJotaiStore.set(searchItemInFocusAtom, null);
this.updateEditorAtom(searchItemInFocusAtom, null);
}
if (editorJotaiStore.get(convertElementTypePopupAtom)) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
}
// since contextMenu options are potentially evaluated on each render,
@@ -11,6 +11,8 @@ import {
isWritableElement,
} from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import {
@@ -410,6 +412,14 @@ function CommandPaletteInner({
actionManager.executeAction(actionToggleSearchMenu);
},
},
{
label: t("labels.shapeSwitch"),
category: DEFAULT_CATEGORIES.elements,
icon: boltIcon,
perform: () => {
actionManager.executeAction(actionToggleShapeSwitch);
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],
@@ -0,0 +1,18 @@
@import "../css//variables.module.scss";
.excalidraw {
.ConvertElementTypePopup {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
&:focus {
outline: none;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,10 @@ import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons";
import { updateBindings } from "../../../element/src/binding";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AppState } from "../../types";
+2 -21
View File
@@ -1,13 +1,8 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
@@ -27,6 +22,8 @@ import type {
import type Scene from "@excalidraw/element/Scene";
import { updateBindings } from "../../../element/src/binding";
import type { AppState } from "../../types";
export type StatsInputProperty =
@@ -194,19 +191,3 @@ export const getAtomicUnits = (
});
return _atomicUnits;
};
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, options);
}
};