Browse Source

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 <[email protected]>
Ryan Di 2 months ago
parent
commit
195a743874

+ 1 - 0
packages/common/src/constants.ts

@@ -119,6 +119,7 @@ export const CLASSES = {
   SHAPE_ACTIONS_MENU: "App-menu__left",
   SHAPE_ACTIONS_MENU: "App-menu__left",
   ZOOM_ACTIONS: "zoom-actions",
   ZOOM_ACTIONS: "zoom-actions",
   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
+  CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
 };
 };
 
 
 export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
 export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";

+ 30 - 9
packages/element/src/binding.ts

@@ -81,7 +81,6 @@ import type {
   NonDeletedSceneElementsMap,
   NonDeletedSceneElementsMap,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
   ExcalidrawArrowElement,
   ExcalidrawArrowElement,
-  OrderedExcalidrawElement,
   ExcalidrawElbowArrowElement,
   ExcalidrawElbowArrowElement,
   FixedPoint,
   FixedPoint,
   FixedPointBinding,
   FixedPointBinding,
@@ -710,29 +709,32 @@ const calculateFocusAndGap = (
 
 
 // Supports translating, rotating and scaling `changedElement` with bound
 // Supports translating, rotating and scaling `changedElement` with bound
 // linear elements.
 // linear elements.
-// Because scaling involves moving the focus points as well, it is
-// done before the `changedElement` is updated, and the `newSize` is passed
-// in explicitly.
 export const updateBoundElements = (
 export const updateBoundElements = (
   changedElement: NonDeletedExcalidrawElement,
   changedElement: NonDeletedExcalidrawElement,
   scene: Scene,
   scene: Scene,
   options?: {
   options?: {
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
     newSize?: { width: number; height: number };
     newSize?: { width: number; height: number };
-    changedElements?: Map<string, OrderedExcalidrawElement>;
+    changedElements?: Map<string, ExcalidrawElement>;
   },
   },
 ) => {
 ) => {
+  if (!isBindableElement(changedElement)) {
+    return;
+  }
+
   const { newSize, simultaneouslyUpdated } = options ?? {};
   const { newSize, simultaneouslyUpdated } = options ?? {};
   const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
   const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
     simultaneouslyUpdated,
     simultaneouslyUpdated,
   );
   );
 
 
-  if (!isBindableElement(changedElement)) {
-    return;
+  let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
+  if (options?.changedElements) {
+    elementsMap = new Map(elementsMap) as typeof elementsMap;
+    options.changedElements.forEach((element) => {
+      elementsMap.set(element.id, element);
+    });
   }
   }
 
 
-  const elementsMap = scene.getNonDeletedElementsMap();
-
   boundElementsVisitor(elementsMap, changedElement, (element) => {
   boundElementsVisitor(elementsMap, changedElement, (element) => {
     if (!isLinearElement(element) || element.isDeleted) {
     if (!isLinearElement(element) || element.isDeleted) {
       return;
       return;
@@ -836,6 +838,25 @@ export const updateBoundElements = (
   });
   });
 };
 };
 
 
+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,
+      changedElements: new Map([[latestElement.id, latestElement]]),
+    });
+  }
+};
+
 const doesNeedUpdate = (
 const doesNeedUpdate = (
   boundElement: NonDeleted<ExcalidrawLinearElement>,
   boundElement: NonDeleted<ExcalidrawLinearElement>,
   changedElement: ExcalidrawBindableElement,
   changedElement: ExcalidrawBindableElement,

+ 1 - 2
packages/element/src/newElement.ts

@@ -44,7 +44,6 @@ import type {
   ExcalidrawIframeElement,
   ExcalidrawIframeElement,
   ElementsMap,
   ElementsMap,
   ExcalidrawArrowElement,
   ExcalidrawArrowElement,
-  FixedSegment,
   ExcalidrawElbowArrowElement,
   ExcalidrawElbowArrowElement,
 } from "./types";
 } from "./types";
 
 
@@ -478,7 +477,7 @@ export const newArrowElement = <T extends boolean>(
     endArrowhead?: Arrowhead | null;
     endArrowhead?: Arrowhead | null;
     points?: ExcalidrawArrowElement["points"];
     points?: ExcalidrawArrowElement["points"];
     elbowed?: T;
     elbowed?: T;
-    fixedSegments?: FixedSegment[] | null;
+    fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
   } & ElementConstructorOpts,
   } & ElementConstructorOpts,
 ): T extends true
 ): T extends true
   ? NonDeleted<ExcalidrawElbowArrowElement>
   ? NonDeleted<ExcalidrawElbowArrowElement>

+ 18 - 0
packages/element/src/typeChecks.ts

@@ -119,6 +119,20 @@ export const isElbowArrow = (
   return isArrowElement(element) && element.elbowed;
   return isArrowElement(element) && element.elbowed;
 };
 };
 
 
+export const isSharpArrow = (
+  element?: ExcalidrawElement,
+): element is ExcalidrawArrowElement => {
+  return isArrowElement(element) && !element.elbowed && !element.roundness;
+};
+
+export const isCurvedArrow = (
+  element?: ExcalidrawElement,
+): element is ExcalidrawArrowElement => {
+  return (
+    isArrowElement(element) && !element.elbowed && element.roundness !== null
+  );
+};
+
 export const isLinearElementType = (
 export const isLinearElementType = (
   elementType: ElementOrToolType,
   elementType: ElementOrToolType,
 ): boolean => {
 ): boolean => {
@@ -271,6 +285,10 @@ export const isBoundToContainer = (
   );
   );
 };
 };
 
 
+export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
+  return !!element.startBinding || !!element.endBinding;
+};
+
 export const isUsingAdaptiveRadius = (type: string) =>
 export const isUsingAdaptiveRadius = (type: string) =>
   type === "rectangle" ||
   type === "rectangle" ||
   type === "embeddable" ||
   type === "embeddable" ||

+ 8 - 0
packages/element/src/types.ts

@@ -412,3 +412,11 @@ export type NonDeletedSceneElementsMap = Map<
 export type ElementsMapOrArray =
 export type ElementsMapOrArray =
   | readonly ExcalidrawElement[]
   | readonly ExcalidrawElement[]
   | Readonly<ElementsMap>;
   | Readonly<ElementsMap>;
+
+export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
+export type ConvertibleLinearTypes =
+  | "line"
+  | "sharpArrow"
+  | "curvedArrow"
+  | "elbowArrow";
+export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

+ 34 - 0
packages/excalidraw/actions/actionToggleShapeSwitch.tsx

@@ -0,0 +1,34 @@
+import type { ExcalidrawElement } from "@excalidraw/element/types";
+
+import {
+  getConversionTypeFromElements,
+  convertElementTypePopupAtom,
+} from "../components/ConvertElementTypePopup";
+import { editorJotaiStore } from "../editor-jotai";
+import { CaptureUpdateAction } from "../store";
+
+import { register } from "./register";
+
+export const actionToggleShapeSwitch = register({
+  name: "toggleShapeSwitch",
+  label: "labels.shapeSwitch",
+  icon: () => null,
+  viewMode: true,
+  trackEvent: {
+    category: "shape_switch",
+    action: "toggle",
+  },
+  keywords: ["change", "switch", "swap"],
+  perform(elements, appState, _, app) {
+    editorJotaiStore.set(convertElementTypePopupAtom, {
+      type: "panel",
+    });
+
+    return {
+      captureUpdate: CaptureUpdateAction.NEVER,
+    };
+  },
+  checked: (appState) => appState.gridModeEnabled,
+  predicate: (elements, appState, props) =>
+    getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
+});

+ 4 - 2
packages/excalidraw/actions/types.ts

@@ -140,7 +140,8 @@ export type ActionName =
   | "linkToElement"
   | "linkToElement"
   | "cropEditor"
   | "cropEditor"
   | "wrapSelectionInFrame"
   | "wrapSelectionInFrame"
-  | "toggleLassoTool";
+  | "toggleLassoTool"
+  | "toggleShapeSwitch";
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
@@ -195,7 +196,8 @@ export interface Action {
           | "menu"
           | "menu"
           | "collab"
           | "collab"
           | "hyperlink"
           | "hyperlink"
-          | "search_menu";
+          | "search_menu"
+          | "shape_switch";
         action?: string;
         action?: string;
         predicate?: (
         predicate?: (
           appState: Readonly<AppState>,
           appState: Readonly<AppState>,

+ 64 - 5
packages/excalidraw/components/App.tsx

@@ -100,6 +100,7 @@ import {
   arrayToMap,
   arrayToMap,
   type EXPORT_IMAGE_TYPES,
   type EXPORT_IMAGE_TYPES,
   randomInteger,
   randomInteger,
+  CLASSES,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 
 
 import {
 import {
@@ -431,7 +432,7 @@ import {
 } from "../components/hyperlink/Hyperlink";
 } from "../components/hyperlink/Hyperlink";
 
 
 import { Fonts } from "../fonts";
 import { Fonts } from "../fonts";
-import { editorJotaiStore } from "../editor-jotai";
+import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
 import { ImageSceneDataError } from "../errors";
 import { ImageSceneDataError } from "../errors";
 import {
 import {
   getSnapLinesAtPointer,
   getSnapLinesAtPointer,
@@ -467,6 +468,12 @@ import { LassoTrail } from "../lasso";
 
 
 import { EraserTrail } from "../eraser";
 import { EraserTrail } from "../eraser";
 
 
+import ConvertElementTypePopup, {
+  getConversionTypeFromElements,
+  convertElementTypePopupAtom,
+  convertElementTypes,
+} from "./ConvertElementTypePopup";
+
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
 import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@@ -498,7 +505,6 @@ import type { ExportedElements } from "../data";
 import type { ContextMenuItems } from "./ContextMenu";
 import type { ContextMenuItems } from "./ContextMenu";
 import type { FileSystemHandle } from "../data/filesystem";
 import type { FileSystemHandle } from "../data/filesystem";
 import type { ExcalidrawElementSkeleton } from "../data/transform";
 import type { ExcalidrawElementSkeleton } from "../data/transform";
-
 import type {
 import type {
   AppClassProperties,
   AppClassProperties,
   AppProps,
   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) {
   private onWindowMessage(event: MessageEvent) {
     if (
     if (
       event.origin !== "https://player.vimeo.com" &&
       event.origin !== "https://player.vimeo.com" &&
@@ -1583,6 +1598,9 @@ class App extends React.Component<AppProps, AppState> {
 
 
     const firstSelectedElement = selectedElements[0];
     const firstSelectedElement = selectedElements[0];
 
 
+    const showShapeSwitchPanel =
+      editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
+
     return (
     return (
       <div
       <div
         className={clsx("excalidraw excalidraw-container", {
         className={clsx("excalidraw excalidraw-container", {
@@ -1857,6 +1875,9 @@ class App extends React.Component<AppProps, AppState> {
                           />
                           />
                         )}
                         )}
                         {this.renderFrameNames()}
                         {this.renderFrameNames()}
+                        {showShapeSwitchPanel && (
+                          <ConvertElementTypePopup app={this} />
+                        )}
                       </ExcalidrawActionManagerContext.Provider>
                       </ExcalidrawActionManagerContext.Provider>
                       {this.renderEmbeddables()}
                       {this.renderEmbeddables()}
                     </ExcalidrawElementsContext.Provider>
                     </ExcalidrawElementsContext.Provider>
@@ -2138,7 +2159,7 @@ class App extends React.Component<AppProps, AppState> {
   };
   };
 
 
   private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
   private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
-    editorJotaiStore.set(activeEyeDropperAtom, {
+    this.updateEditorAtom(activeEyeDropperAtom, {
       swapPreviewOnAlt: true,
       swapPreviewOnAlt: true,
       colorPickerType:
       colorPickerType:
         type === "stroke" ? "elementStroke" : "elementBackground",
         type === "stroke" ? "elementStroke" : "elementBackground",
@@ -4157,6 +4178,40 @@ class App extends React.Component<AppProps, AppState> {
           return;
           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 (
         if (
           event.key === KEYS.ESCAPE &&
           event.key === KEYS.ESCAPE &&
           this.flowChartCreator.isCreatingChart
           this.flowChartCreator.isCreatingChart
@@ -4615,7 +4670,7 @@ class App extends React.Component<AppProps, AppState> {
         event[KEYS.CTRL_OR_CMD] &&
         event[KEYS.CTRL_OR_CMD] &&
         (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
         (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
       ) {
       ) {
-        editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
+        this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
       }
       }
 
 
       // eye dropper
       // eye dropper
@@ -6364,7 +6419,11 @@ class App extends React.Component<AppProps, AppState> {
           focus: false,
           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,
     // since contextMenu options are potentially evaluated on each render,

+ 10 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -11,6 +11,8 @@ import {
   isWritableElement,
   isWritableElement,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 
 
+import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
+
 import type { MarkRequired } from "@excalidraw/common/utility-types";
 import type { MarkRequired } from "@excalidraw/common/utility-types";
 
 
 import {
 import {
@@ -410,6 +412,14 @@ function CommandPaletteInner({
             actionManager.executeAction(actionToggleSearchMenu);
             actionManager.executeAction(actionToggleSearchMenu);
           },
           },
         },
         },
+        {
+          label: t("labels.shapeSwitch"),
+          category: DEFAULT_CATEGORIES.elements,
+          icon: boltIcon,
+          perform: () => {
+            actionManager.executeAction(actionToggleShapeSwitch);
+          },
+        },
         {
         {
           label: t("labels.changeStroke"),
           label: t("labels.changeStroke"),
           keywords: ["color", "outline"],
           keywords: ["color", "outline"],

+ 18 - 0
packages/excalidraw/components/ConvertElementTypePopup.scss

@@ -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;
+    }
+  }
+}

+ 1047 - 0
packages/excalidraw/components/ConvertElementTypePopup.tsx

@@ -0,0 +1,1047 @@
+import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
+
+import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
+
+import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
+
+import {
+  hasBoundTextElement,
+  isArrowBoundToElement,
+  isArrowElement,
+  isCurvedArrow,
+  isElbowArrow,
+  isLinearElement,
+  isSharpArrow,
+  isUsingAdaptiveRadius,
+} from "@excalidraw/element/typeChecks";
+
+import {
+  getCommonBoundingBox,
+  getElementAbsoluteCoords,
+} from "@excalidraw/element/bounds";
+
+import {
+  getBoundTextElement,
+  getBoundTextMaxHeight,
+  getBoundTextMaxWidth,
+  redrawTextBoundingBox,
+} from "@excalidraw/element/textElement";
+
+import { wrapText } from "@excalidraw/element/textWrapping";
+
+import {
+  assertNever,
+  CLASSES,
+  getFontString,
+  isProdEnv,
+  updateActiveTool,
+} from "@excalidraw/common";
+
+import { measureText } from "@excalidraw/element/textMeasurements";
+
+import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
+
+import {
+  newArrowElement,
+  newElement,
+  newLinearElement,
+} from "@excalidraw/element/newElement";
+
+import { ShapeCache } from "@excalidraw/element/ShapeCache";
+
+import type {
+  ConvertibleGenericTypes,
+  ConvertibleLinearTypes,
+  ConvertibleTypes,
+  ExcalidrawArrowElement,
+  ExcalidrawDiamondElement,
+  ExcalidrawElbowArrowElement,
+  ExcalidrawElement,
+  ExcalidrawEllipseElement,
+  ExcalidrawLinearElement,
+  ExcalidrawRectangleElement,
+  ExcalidrawSelectionElement,
+  ExcalidrawTextContainer,
+  ExcalidrawTextElementWithContainer,
+  FixedSegment,
+} from "@excalidraw/element/types";
+
+import type Scene from "@excalidraw/element/Scene";
+
+import {
+  bumpVersion,
+  mutateElement,
+  ROUNDNESS,
+  sceneCoordsToViewportCoords,
+} from "..";
+import { trackEvent } from "../analytics";
+import { atom, editorJotaiStore, useSetAtom } from "../editor-jotai";
+import { updateBindings } from "../../element/src/binding";
+
+import "./ConvertElementTypePopup.scss";
+import { ToolButton } from "./ToolButton";
+import {
+  DiamondIcon,
+  elbowArrowIcon,
+  EllipseIcon,
+  LineIcon,
+  RectangleIcon,
+  roundArrowIcon,
+  sharpArrowIcon,
+} from "./icons";
+
+import type App from "./App";
+
+import type { AppClassProperties } from "../types";
+
+const GAP_HORIZONTAL = 8;
+const GAP_VERTICAL = 10;
+
+// indicates order of switching
+const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const;
+// indicates order of switching
+const LINEAR_TYPES = [
+  "line",
+  "sharpArrow",
+  "curvedArrow",
+  "elbowArrow",
+] as const;
+
+const CONVERTIBLE_GENERIC_TYPES: ReadonlySet<ConvertibleGenericTypes> = new Set(
+  GENERIC_TYPES,
+);
+
+const CONVERTIBLE_LINEAR_TYPES: ReadonlySet<ConvertibleLinearTypes> = new Set(
+  LINEAR_TYPES,
+);
+
+const isConvertibleGenericType = (
+  elementType: string,
+): elementType is ConvertibleGenericTypes =>
+  CONVERTIBLE_GENERIC_TYPES.has(elementType as ConvertibleGenericTypes);
+
+const isConvertibleLinearType = (
+  elementType: string,
+): elementType is ConvertibleLinearTypes =>
+  elementType === "arrow" ||
+  CONVERTIBLE_LINEAR_TYPES.has(elementType as ConvertibleLinearTypes);
+
+export const convertElementTypePopupAtom = atom<{
+  type: "panel";
+} | null>(null);
+
+// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
+export const fontSize_conversionCacheAtom = atom<{
+  [id: string]: {
+    fontSize: number;
+    elementType: ConvertibleGenericTypes;
+  };
+} | null>(null);
+
+// NOTE doesn't need to be an atom. Review once we integrate with properties panel.
+export const linearElement_conversionCacheAtom = atom<{
+  [id: string]: {
+    properties:
+      | Partial<ExcalidrawLinearElement>
+      | Partial<ExcalidrawElbowArrowElement>;
+    initialType: ConvertibleLinearTypes;
+  };
+} | null>(null);
+
+const ConvertElementTypePopup = ({ app }: { app: App }) => {
+  const setFontSizeCache = useSetAtom(fontSize_conversionCacheAtom);
+  const setLinearElementCache = useSetAtom(linearElement_conversionCacheAtom);
+
+  const selectedElements = app.scene.getSelectedElements(app.state);
+  const elementsCategoryRef = useRef<ConversionType>(null);
+
+  // close shape switch panel if selecting different "types" of elements
+  useEffect(() => {
+    if (selectedElements.length === 0) {
+      app.updateEditorAtom(convertElementTypePopupAtom, null);
+      return;
+    }
+
+    const conversionType = getConversionTypeFromElements(selectedElements);
+
+    if (conversionType && !elementsCategoryRef.current) {
+      elementsCategoryRef.current = conversionType;
+    } else if (
+      (elementsCategoryRef.current && !conversionType) ||
+      (elementsCategoryRef.current &&
+        conversionType !== elementsCategoryRef.current)
+    ) {
+      app.updateEditorAtom(convertElementTypePopupAtom, null);
+      elementsCategoryRef.current = null;
+    }
+  }, [selectedElements, app]);
+
+  useEffect(() => {
+    return () => {
+      setFontSizeCache(null);
+      setLinearElementCache(null);
+    };
+  }, [setFontSizeCache, setLinearElementCache]);
+
+  return <Panel app={app} elements={selectedElements} />;
+};
+
+const Panel = ({
+  app,
+  elements,
+}: {
+  app: App;
+  elements: ExcalidrawElement[];
+}) => {
+  const conversionType = getConversionTypeFromElements(elements);
+
+  const genericElements = useMemo(() => {
+    return conversionType === "generic"
+      ? filterGenericConvetibleElements(elements)
+      : [];
+  }, [conversionType, elements]);
+  const linearElements = useMemo(() => {
+    return conversionType === "linear"
+      ? filterLinearConvertibleElements(elements)
+      : [];
+  }, [conversionType, elements]);
+
+  const sameType =
+    conversionType === "generic"
+      ? genericElements.every(
+          (element) => element.type === genericElements[0].type,
+        )
+      : conversionType === "linear"
+      ? linearElements.every(
+          (element) =>
+            getArrowType(element) === getArrowType(linearElements[0]),
+        )
+      : false;
+
+  const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
+  const positionRef = useRef("");
+  const panelRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const elements = [...genericElements, ...linearElements].sort((a, b) =>
+      a.id.localeCompare(b.id),
+    );
+    const newPositionRef = `
+      ${app.state.scrollX}${app.state.scrollY}${app.state.offsetTop}${
+      app.state.offsetLeft
+    }${app.state.zoom.value}${elements.map((el) => el.id).join(",")}`;
+
+    if (newPositionRef === positionRef.current) {
+      return;
+    }
+
+    positionRef.current = newPositionRef;
+
+    let bottomLeft;
+
+    if (elements.length === 1) {
+      const [x1, , , y2, cx, cy] = getElementAbsoluteCoords(
+        elements[0],
+        app.scene.getNonDeletedElementsMap(),
+      );
+      bottomLeft = pointRotateRads(
+        pointFrom(x1, y2),
+        pointFrom(cx, cy),
+        elements[0].angle,
+      );
+    } else {
+      const { minX, maxY } = getCommonBoundingBox(elements);
+      bottomLeft = pointFrom(minX, maxY);
+    }
+
+    const { x, y } = sceneCoordsToViewportCoords(
+      { sceneX: bottomLeft[0], sceneY: bottomLeft[1] },
+      app.state,
+    );
+
+    setPanelPosition({ x, y });
+  }, [genericElements, linearElements, app.scene, app.state]);
+
+  useEffect(() => {
+    if (editorJotaiStore.get(linearElement_conversionCacheAtom)) {
+      return;
+    }
+
+    for (const linearElement of linearElements) {
+      const initialType = getArrowType(linearElement);
+      const cachedProperties =
+        initialType === "line"
+          ? getLineProperties(linearElement)
+          : initialType === "sharpArrow"
+          ? getSharpArrowProperties(linearElement)
+          : initialType === "curvedArrow"
+          ? getCurvedArrowProperties(linearElement)
+          : initialType === "elbowArrow"
+          ? getElbowArrowProperties(linearElement)
+          : {};
+
+      editorJotaiStore.set(linearElement_conversionCacheAtom, {
+        ...editorJotaiStore.get(linearElement_conversionCacheAtom),
+        [linearElement.id]: {
+          properties: cachedProperties,
+          initialType,
+        },
+      });
+    }
+  }, [linearElements]);
+
+  useEffect(() => {
+    if (editorJotaiStore.get(fontSize_conversionCacheAtom)) {
+      return;
+    }
+
+    for (const element of genericElements) {
+      const boundText = getBoundTextElement(
+        element,
+        app.scene.getNonDeletedElementsMap(),
+      );
+      if (boundText) {
+        editorJotaiStore.set(fontSize_conversionCacheAtom, {
+          ...editorJotaiStore.get(fontSize_conversionCacheAtom),
+          [element.id]: {
+            fontSize: boundText.fontSize,
+            elementType: element.type as ConvertibleGenericTypes,
+          },
+        });
+      }
+    }
+  }, [genericElements, app.scene]);
+
+  const SHAPES: [string, ReactNode][] =
+    conversionType === "linear"
+      ? [
+          ["line", LineIcon],
+          ["sharpArrow", sharpArrowIcon],
+          ["curvedArrow", roundArrowIcon],
+          ["elbowArrow", elbowArrowIcon],
+        ]
+      : conversionType === "generic"
+      ? [
+          ["rectangle", RectangleIcon],
+          ["diamond", DiamondIcon],
+          ["ellipse", EllipseIcon],
+        ]
+      : [];
+
+  return (
+    <div
+      ref={panelRef}
+      tabIndex={-1}
+      style={{
+        position: "absolute",
+        top: `${
+          panelPosition.y +
+          (GAP_VERTICAL + 8) * app.state.zoom.value -
+          app.state.offsetTop
+        }px`,
+        left: `${panelPosition.x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
+        zIndex: 2,
+      }}
+      className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
+    >
+      {SHAPES.map(([type, icon]) => {
+        const isSelected =
+          sameType &&
+          ((conversionType === "generic" && genericElements[0].type === type) ||
+            (conversionType === "linear" &&
+              getArrowType(linearElements[0]) === type));
+
+        return (
+          <ToolButton
+            className="Shape"
+            key={`${elements[0].id}${elements[0].version}_${type}`}
+            type="radio"
+            icon={icon}
+            checked={isSelected}
+            name="convertElementType-option"
+            title={type}
+            keyBindingLabel={""}
+            aria-label={type}
+            data-testid={`toolbar-${type}`}
+            onChange={() => {
+              if (app.state.activeTool.type !== type) {
+                trackEvent("convertElementType", type, "ui");
+              }
+              convertElementTypes(app, {
+                conversionType,
+                nextType: type as
+                  | ConvertibleGenericTypes
+                  | ConvertibleLinearTypes,
+              });
+              panelRef.current?.focus();
+            }}
+          />
+        );
+      })}
+    </div>
+  );
+};
+
+export const adjustBoundTextSize = (
+  container: ExcalidrawTextContainer,
+  boundText: ExcalidrawTextElementWithContainer,
+  scene: Scene,
+) => {
+  const maxWidth = getBoundTextMaxWidth(container, boundText);
+  const maxHeight = getBoundTextMaxHeight(container, boundText);
+
+  const wrappedText = wrapText(
+    boundText.text,
+    getFontString(boundText),
+    maxWidth,
+  );
+
+  let metrics = measureText(
+    wrappedText,
+    getFontString(boundText),
+    boundText.lineHeight,
+  );
+
+  let nextFontSize = boundText.fontSize;
+  while (
+    (metrics.width > maxWidth || metrics.height > maxHeight) &&
+    nextFontSize > 0
+  ) {
+    nextFontSize -= 1;
+    const _updatedTextElement = {
+      ...boundText,
+      fontSize: nextFontSize,
+    };
+    metrics = measureText(
+      boundText.text,
+      getFontString(_updatedTextElement),
+      boundText.lineHeight,
+    );
+  }
+
+  mutateElement(boundText, scene.getNonDeletedElementsMap(), {
+    fontSize: nextFontSize,
+    width: metrics.width,
+    height: metrics.height,
+  });
+
+  redrawTextBoundingBox(boundText, container, scene);
+};
+
+type ConversionType = "generic" | "linear" | null;
+
+export const convertElementTypes = (
+  app: App,
+  {
+    conversionType,
+    nextType,
+    direction = "right",
+  }: {
+    conversionType: ConversionType;
+    nextType?: ConvertibleTypes;
+    direction?: "left" | "right";
+  },
+): boolean => {
+  if (!conversionType) {
+    return false;
+  }
+
+  const selectedElements = app.scene.getSelectedElements(app.state);
+
+  const selectedElementIds = selectedElements.reduce(
+    (acc, element) => ({ ...acc, [element.id]: true }),
+    {},
+  );
+
+  const advancement = direction === "right" ? 1 : -1;
+
+  if (conversionType === "generic") {
+    const convertibleGenericElements =
+      filterGenericConvetibleElements(selectedElements);
+
+    const sameType = convertibleGenericElements.every(
+      (element) => element.type === convertibleGenericElements[0].type,
+    );
+
+    const index = sameType
+      ? GENERIC_TYPES.indexOf(convertibleGenericElements[0].type)
+      : -1;
+
+    nextType =
+      nextType ??
+      GENERIC_TYPES[
+        (index + GENERIC_TYPES.length + advancement) % GENERIC_TYPES.length
+      ];
+
+    if (nextType && isConvertibleGenericType(nextType)) {
+      const convertedElements: Record<string, ExcalidrawElement> = {};
+
+      for (const element of convertibleGenericElements) {
+        const convertedElement = convertElementType(element, nextType, app);
+        convertedElements[convertedElement.id] = convertedElement;
+      }
+
+      const nextElements = [];
+
+      for (const element of app.scene.getElementsIncludingDeleted()) {
+        if (convertedElements[element.id]) {
+          nextElements.push(convertedElements[element.id]);
+        } else {
+          nextElements.push(element);
+        }
+      }
+
+      app.scene.replaceAllElements(nextElements);
+
+      for (const element of Object.values(convertedElements)) {
+        const boundText = getBoundTextElement(
+          element,
+          app.scene.getNonDeletedElementsMap(),
+        );
+        if (boundText) {
+          if (
+            editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
+              ?.elementType === nextType
+          ) {
+            mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
+              fontSize:
+                editorJotaiStore.get(fontSize_conversionCacheAtom)?.[element.id]
+                  ?.fontSize ?? boundText.fontSize,
+            });
+          }
+
+          adjustBoundTextSize(
+            element as ExcalidrawTextContainer,
+            boundText,
+            app.scene,
+          );
+        }
+      }
+
+      app.setState((prevState) => {
+        return {
+          selectedElementIds,
+          activeTool: updateActiveTool(prevState, {
+            type: "selection",
+          }),
+        };
+      });
+    }
+  }
+
+  if (conversionType === "linear") {
+    const convertibleLinearElements = filterLinearConvertibleElements(
+      selectedElements,
+    ) as ExcalidrawLinearElement[];
+
+    const arrowType = getArrowType(convertibleLinearElements[0]);
+    const sameType = convertibleLinearElements.every(
+      (element) => getArrowType(element) === arrowType,
+    );
+
+    const index = sameType ? LINEAR_TYPES.indexOf(arrowType) : -1;
+    nextType =
+      nextType ??
+      LINEAR_TYPES[
+        (index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
+      ];
+
+    if (nextType && isConvertibleLinearType(nextType)) {
+      const convertedElements: Record<string, ExcalidrawElement> = {};
+      for (const element of convertibleLinearElements) {
+        const { properties, initialType } =
+          editorJotaiStore.get(linearElement_conversionCacheAtom)?.[
+            element.id
+          ] || {};
+
+        // If the initial type is not elbow, and when we switch to elbow,
+        // the linear line might be "bent" and the points would likely be different.
+        // When we then switch to other non elbow types from this converted elbow,
+        // we still want to use the original points instead.
+        if (
+          initialType &&
+          properties &&
+          isElbowArrow(element) &&
+          initialType !== "elbowArrow" &&
+          nextType !== "elbowArrow"
+        ) {
+          // first convert back to the original type
+          const originalType = convertElementType(
+            element,
+            initialType,
+            app,
+          ) as ExcalidrawLinearElement;
+          // then convert to the target type
+          const converted = convertElementType(
+            initialType === "line"
+              ? newLinearElement({
+                  ...originalType,
+                  ...properties,
+                  type: "line",
+                })
+              : newArrowElement({
+                  ...originalType,
+                  ...properties,
+                  type: "arrow",
+                }),
+            nextType,
+            app,
+          );
+          convertedElements[converted.id] = converted;
+        } else {
+          const converted = convertElementType(element, nextType, app);
+          convertedElements[converted.id] = converted;
+        }
+      }
+
+      const nextElements = [];
+
+      for (const element of app.scene.getElementsIncludingDeleted()) {
+        if (convertedElements[element.id]) {
+          nextElements.push(convertedElements[element.id]);
+        } else {
+          nextElements.push(element);
+        }
+      }
+
+      app.scene.replaceAllElements(nextElements);
+
+      for (const element of Object.values(convertedElements)) {
+        const cachedLinear = editorJotaiStore.get(
+          linearElement_conversionCacheAtom,
+        )?.[element.id];
+
+        if (cachedLinear) {
+          const { properties, initialType } = cachedLinear;
+
+          if (initialType === nextType) {
+            mutateElement(
+              element,
+              app.scene.getNonDeletedElementsMap(),
+              properties,
+            );
+            continue;
+          }
+        }
+
+        if (isElbowArrow(element)) {
+          const nextPoints = convertLineToElbow(element);
+          if (nextPoints.length < 2) {
+            // skip if not enough points to form valid segments
+            continue;
+          }
+          const fixedSegments: FixedSegment[] = [];
+          for (let i = 0; i < nextPoints.length - 1; i++) {
+            fixedSegments.push({
+              start: nextPoints[i],
+              end: nextPoints[i + 1],
+              index: i + 1,
+            });
+          }
+          const updates = updateElbowArrowPoints(
+            element,
+            app.scene.getNonDeletedElementsMap(),
+            {
+              points: nextPoints,
+              fixedSegments,
+            },
+          );
+          mutateElement(element, app.scene.getNonDeletedElementsMap(), {
+            ...updates,
+          });
+        }
+      }
+    }
+    const convertedSelectedLinearElements = filterLinearConvertibleElements(
+      app.scene.getSelectedElements(app.state),
+    );
+
+    app.setState((prevState) => ({
+      selectedElementIds,
+      selectedLinearElement:
+        convertedSelectedLinearElements.length === 1
+          ? new LinearElementEditor(
+              convertedSelectedLinearElements[0],
+              app.scene.getNonDeletedElementsMap(),
+            )
+          : null,
+      activeTool: updateActiveTool(prevState, {
+        type: "selection",
+      }),
+    }));
+  }
+
+  return true;
+};
+
+export const getConversionTypeFromElements = (
+  elements: ExcalidrawElement[],
+): ConversionType => {
+  if (elements.length === 0) {
+    return null;
+  }
+
+  let canBeLinear = false;
+  for (const element of elements) {
+    if (isConvertibleGenericType(element.type)) {
+      // generic type conversion have preference
+      return "generic";
+    }
+    if (isEligibleLinearElement(element)) {
+      canBeLinear = true;
+    }
+  }
+
+  if (canBeLinear) {
+    return "linear";
+  }
+
+  return null;
+};
+
+const isEligibleLinearElement = (element: ExcalidrawElement) => {
+  return (
+    isLinearElement(element) &&
+    (!isArrowElement(element) ||
+      (!isArrowBoundToElement(element) && !hasBoundTextElement(element)))
+  );
+};
+
+const getArrowType = (element: ExcalidrawLinearElement) => {
+  if (isSharpArrow(element)) {
+    return "sharpArrow";
+  }
+  if (isCurvedArrow(element)) {
+    return "curvedArrow";
+  }
+  if (isElbowArrow(element)) {
+    return "elbowArrow";
+  }
+  return "line";
+};
+
+const getLineProperties = (
+  element: ExcalidrawLinearElement,
+): Partial<ExcalidrawLinearElement> => {
+  if (element.type === "line") {
+    return {
+      points: element.points,
+      roundness: element.roundness,
+    };
+  }
+  return {};
+};
+
+const getSharpArrowProperties = (
+  element: ExcalidrawLinearElement,
+): Partial<ExcalidrawArrowElement> => {
+  if (isSharpArrow(element)) {
+    return {
+      points: element.points,
+      startArrowhead: element.startArrowhead,
+      endArrowhead: element.endArrowhead,
+      startBinding: element.startBinding,
+      endBinding: element.endBinding,
+      roundness: null,
+    };
+  }
+
+  return {};
+};
+
+const getCurvedArrowProperties = (
+  element: ExcalidrawLinearElement,
+): Partial<ExcalidrawArrowElement> => {
+  if (isCurvedArrow(element)) {
+    return {
+      points: element.points,
+      startArrowhead: element.startArrowhead,
+      endArrowhead: element.endArrowhead,
+      startBinding: element.startBinding,
+      endBinding: element.endBinding,
+      roundness: element.roundness,
+    };
+  }
+
+  return {};
+};
+
+const getElbowArrowProperties = (
+  element: ExcalidrawLinearElement,
+): Partial<ExcalidrawElbowArrowElement> => {
+  if (isElbowArrow(element)) {
+    return {
+      points: element.points,
+      startArrowhead: element.startArrowhead,
+      endArrowhead: element.endArrowhead,
+      startBinding: element.startBinding,
+      endBinding: element.endBinding,
+      roundness: null,
+      fixedSegments: element.fixedSegments,
+      startIsSpecial: element.startIsSpecial,
+      endIsSpecial: element.endIsSpecial,
+    };
+  }
+
+  return {};
+};
+
+const filterGenericConvetibleElements = (elements: ExcalidrawElement[]) =>
+  elements.filter((element) => isConvertibleGenericType(element.type)) as Array<
+    | ExcalidrawRectangleElement
+    | ExcalidrawDiamondElement
+    | ExcalidrawEllipseElement
+  >;
+
+const filterLinearConvertibleElements = (elements: ExcalidrawElement[]) =>
+  elements.filter((element) =>
+    isEligibleLinearElement(element),
+  ) as ExcalidrawLinearElement[];
+
+const THRESHOLD = 20;
+const isVert = (a: LocalPoint, b: LocalPoint) => a[0] === b[0];
+const isHorz = (a: LocalPoint, b: LocalPoint) => a[1] === b[1];
+const dist = (a: LocalPoint, b: LocalPoint) =>
+  isVert(a, b) ? Math.abs(a[1] - b[1]) : Math.abs(a[0] - b[0]);
+
+const convertLineToElbow = (line: ExcalidrawLinearElement): LocalPoint[] => {
+  // 1. build an *orthogonal* route, snapping offsets < SNAP
+  const ortho: LocalPoint[] = [line.points[0]];
+  const src = sanitizePoints(line.points);
+
+  for (let i = 1; i < src.length; ++i) {
+    const start = ortho[ortho.length - 1];
+    const end = [...src[i]] as LocalPoint; // clone
+
+    // snap tiny offsets onto the current axis
+    if (Math.abs(end[0] - start[0]) < THRESHOLD) {
+      end[0] = start[0];
+    } else if (Math.abs(end[1] - start[1]) < THRESHOLD) {
+      end[1] = start[1];
+    }
+
+    // straight or needs a 90 ° bend?
+    if (isVert(start, end) || isHorz(start, end)) {
+      ortho.push(end);
+    } else {
+      ortho.push(pointFrom<LocalPoint>(start[0], end[1]));
+      ortho.push(end);
+    }
+  }
+
+  // 2. drop obviously colinear middle points
+  const trimmed: LocalPoint[] = [ortho[0]];
+  for (let i = 1; i < ortho.length - 1; ++i) {
+    if (
+      !(
+        (isVert(ortho[i - 1], ortho[i]) && isVert(ortho[i], ortho[i + 1])) ||
+        (isHorz(ortho[i - 1], ortho[i]) && isHorz(ortho[i], ortho[i + 1]))
+      )
+    ) {
+      trimmed.push(ortho[i]);
+    }
+  }
+  trimmed.push(ortho[ortho.length - 1]);
+
+  // 3. collapse micro “jogs” (V-H-V / H-V-H whose short leg < SNAP)
+  const clean: LocalPoint[] = [trimmed[0]];
+  for (let i = 1; i < trimmed.length - 1; ++i) {
+    const a = clean[clean.length - 1];
+    const b = trimmed[i];
+    const c = trimmed[i + 1];
+
+    const v1 = isVert(a, b);
+    const v2 = isVert(b, c);
+    if (v1 !== v2) {
+      const d1 = dist(a, b);
+      const d2 = dist(b, c);
+
+      if (d1 < THRESHOLD || d2 < THRESHOLD) {
+        // pick the shorter leg to remove
+        if (d2 < d1) {
+          // … absorb leg 2 – pull *c* onto axis of *a-b*
+          if (v1) {
+            c[0] = a[0];
+          } else {
+            c[1] = a[1];
+          }
+        } else {
+          // … absorb leg 1 – slide the whole first leg onto *b-c* axis
+          // eslint-disable-next-line no-lonely-if
+          if (v2) {
+            for (
+              let k = clean.length - 1;
+              k >= 0 && clean[k][0] === a[0];
+              --k
+            ) {
+              clean[k][0] = b[0];
+            }
+          } else {
+            for (
+              let k = clean.length - 1;
+              k >= 0 && clean[k][1] === a[1];
+              --k
+            ) {
+              clean[k][1] = b[1];
+            }
+          }
+        }
+        // *b* is gone, don’t add it
+        continue;
+      }
+    }
+    clean.push(b);
+  }
+  clean.push(trimmed[trimmed.length - 1]);
+  return clean;
+};
+
+const sanitizePoints = (points: readonly LocalPoint[]): LocalPoint[] => {
+  if (points.length === 0) {
+    return [];
+  }
+
+  const sanitized: LocalPoint[] = [points[0]];
+
+  for (let i = 1; i < points.length; i++) {
+    const [x1, y1] = sanitized[sanitized.length - 1];
+    const [x2, y2] = points[i];
+
+    if (x1 !== x2 || y1 !== y2) {
+      sanitized.push(points[i]);
+    }
+  }
+
+  return sanitized;
+};
+
+/**
+ * Converts an element to a new type, adding or removing properties as needed
+ * so that the element object is always valid.
+ *
+ * Valid conversions at this point:
+ * - switching between generic elements
+ *   e.g. rectangle -> diamond
+ * - switching between linear elements
+ *   e.g. elbow arrow -> line
+ */
+const convertElementType = <
+  TElement extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+>(
+  element: TElement,
+  targetType: ConvertibleTypes,
+  app: AppClassProperties,
+): ExcalidrawElement => {
+  if (!isValidConversion(element.type, targetType)) {
+    if (!isProdEnv()) {
+      throw Error(`Invalid conversion from ${element.type} to ${targetType}.`);
+    }
+    return element;
+  }
+
+  if (element.type === targetType) {
+    return element;
+  }
+
+  ShapeCache.delete(element);
+
+  if (isConvertibleGenericType(targetType)) {
+    const nextElement = bumpVersion(
+      newElement({
+        ...element,
+        type: targetType,
+        roundness:
+          targetType === "diamond" && element.roundness
+            ? {
+                type: isUsingAdaptiveRadius(targetType)
+                  ? ROUNDNESS.ADAPTIVE_RADIUS
+                  : ROUNDNESS.PROPORTIONAL_RADIUS,
+              }
+            : element.roundness,
+      }),
+    ) as typeof element;
+
+    updateBindings(nextElement, app.scene);
+
+    return nextElement;
+  }
+
+  if (isConvertibleLinearType(targetType)) {
+    switch (targetType) {
+      case "line": {
+        return bumpVersion(
+          newLinearElement({
+            ...element,
+            type: "line",
+          }),
+        );
+      }
+      case "sharpArrow": {
+        return bumpVersion(
+          newArrowElement({
+            ...element,
+            type: "arrow",
+            elbowed: false,
+            roundness: null,
+            startArrowhead: app.state.currentItemStartArrowhead,
+            endArrowhead: app.state.currentItemEndArrowhead,
+          }),
+        );
+      }
+      case "curvedArrow": {
+        return bumpVersion(
+          newArrowElement({
+            ...element,
+            type: "arrow",
+            elbowed: false,
+            roundness: {
+              type: ROUNDNESS.PROPORTIONAL_RADIUS,
+            },
+            startArrowhead: app.state.currentItemStartArrowhead,
+            endArrowhead: app.state.currentItemEndArrowhead,
+          }),
+        );
+      }
+      case "elbowArrow": {
+        return bumpVersion(
+          newArrowElement({
+            ...element,
+            type: "arrow",
+            elbowed: true,
+            fixedSegments: null,
+            roundness: null,
+          }),
+        );
+      }
+    }
+  }
+
+  assertNever(targetType, `unhandled conversion type: ${targetType}`);
+
+  return element;
+};
+
+const isValidConversion = (
+  startType: string,
+  targetType: ConvertibleTypes,
+): startType is ConvertibleTypes => {
+  if (
+    isConvertibleGenericType(startType) &&
+    isConvertibleGenericType(targetType)
+  ) {
+    return true;
+  }
+
+  if (
+    isConvertibleLinearType(startType) &&
+    isConvertibleLinearType(targetType)
+  ) {
+    return true;
+  }
+
+  // NOTE: add more conversions when needed
+
+  return false;
+};
+
+export default ConvertElementTypePopup;

+ 3 - 1
packages/excalidraw/components/Stats/Angle.tsx

@@ -11,8 +11,10 @@ import type Scene from "@excalidraw/element/Scene";
 
 
 import { angleIcon } from "../icons";
 import { angleIcon } from "../icons";
 
 
+import { updateBindings } from "../../../element/src/binding";
+
 import DragInput from "./DragInput";
 import DragInput from "./DragInput";
-import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
 
 
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { AppState } from "../../types";
 import type { AppState } from "../../types";

+ 2 - 21
packages/excalidraw/components/Stats/utils.ts

@@ -1,13 +1,8 @@
 import { pointFrom, pointRotateRads } from "@excalidraw/math";
 import { pointFrom, pointRotateRads } from "@excalidraw/math";
 
 
-import {
-  bindOrUnbindLinearElements,
-  updateBoundElements,
-} from "@excalidraw/element/binding";
 import { getBoundTextElement } from "@excalidraw/element/textElement";
 import { getBoundTextElement } from "@excalidraw/element/textElement";
 import {
 import {
   isFrameLikeElement,
   isFrameLikeElement,
-  isLinearElement,
   isTextElement,
   isTextElement,
 } from "@excalidraw/element/typeChecks";
 } from "@excalidraw/element/typeChecks";
 
 
@@ -27,6 +22,8 @@ import type {
 
 
 import type Scene from "@excalidraw/element/Scene";
 import type Scene from "@excalidraw/element/Scene";
 
 
+import { updateBindings } from "../../../element/src/binding";
+
 import type { AppState } from "../../types";
 import type { AppState } from "../../types";
 
 
 export type StatsInputProperty =
 export type StatsInputProperty =
@@ -194,19 +191,3 @@ export const getAtomicUnits = (
     });
     });
   return _atomicUnits;
   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);
-  }
-};

+ 2 - 2
packages/excalidraw/data/restore.ts

@@ -29,6 +29,7 @@ import { bumpVersion } from "@excalidraw/element/mutateElement";
 import { getContainerElement } from "@excalidraw/element/textElement";
 import { getContainerElement } from "@excalidraw/element/textElement";
 import { detectLineHeight } from "@excalidraw/element/textMeasurements";
 import { detectLineHeight } from "@excalidraw/element/textMeasurements";
 import {
 import {
+  isArrowBoundToElement,
   isArrowElement,
   isArrowElement,
   isElbowArrow,
   isElbowArrow,
   isFixedPointBinding,
   isFixedPointBinding,
@@ -594,8 +595,7 @@ export const restoreElements = (
   return restoredElements.map((element) => {
   return restoredElements.map((element) => {
     if (
     if (
       isElbowArrow(element) &&
       isElbowArrow(element) &&
-      element.startBinding == null &&
-      element.endBinding == null &&
+      !isArrowBoundToElement(element) &&
       !validateElbowPoints(element.points)
       !validateElbowPoints(element.points)
     ) {
     ) {
       return {
       return {

+ 7 - 2
packages/excalidraw/editor-jotai.ts

@@ -1,10 +1,15 @@
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
-import { atom, createStore, type PrimitiveAtom } from "jotai";
+import {
+  atom,
+  createStore,
+  type PrimitiveAtom,
+  type WritableAtom,
+} from "jotai";
 import { createIsolation } from "jotai-scope";
 import { createIsolation } from "jotai-scope";
 
 
 const jotai = createIsolation();
 const jotai = createIsolation();
 
 
-export { atom, PrimitiveAtom };
+export { atom, PrimitiveAtom, WritableAtom };
 export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
 export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
 export const EditorJotaiProvider: ReturnType<
 export const EditorJotaiProvider: ReturnType<
   typeof createIsolation
   typeof createIsolation

+ 3 - 1
packages/excalidraw/locales/en.json

@@ -165,7 +165,9 @@
     "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",
+    "tab": "Tab",
+    "shapeSwitch": "Switch shape"
   },
   },
   "elementLink": {
   "elementLink": {
     "title": "Link to object",
     "title": "Link to object",

+ 1 - 0
packages/excalidraw/types.ts

@@ -714,6 +714,7 @@ export type AppClassProperties = {
   excalidrawContainerValue: App["excalidrawContainerValue"];
   excalidrawContainerValue: App["excalidrawContainerValue"];
 
 
   onPointerUpEmitter: App["onPointerUpEmitter"];
   onPointerUpEmitter: App["onPointerUpEmitter"];
+  updateEditorAtom: App["updateEditorAtom"];
 };
 };
 
 
 export type PointerDownState = Readonly<{
 export type PointerDownState = Readonly<{

+ 5 - 2
packages/excalidraw/wysiwyg/textWysiwyg.tsx

@@ -80,6 +80,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,
@@ -106,7 +108,7 @@ export const textWysiwyg = ({
   excalidrawContainer: HTMLDivElement | null;
   excalidrawContainer: HTMLDivElement | null;
   app: App;
   app: App;
   autoSelect?: boolean;
   autoSelect?: boolean;
-}) => {
+}): SubmitHandler => {
   const textPropertiesUpdated = (
   const textPropertiesUpdated = (
     updatedTextElement: ExcalidrawTextElement,
     updatedTextElement: ExcalidrawTextElement,
     editable: HTMLTextAreaElement,
     editable: HTMLTextAreaElement,
@@ -186,7 +188,6 @@ export const textWysiwyg = ({
         }
         }
 
 
         maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
         maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
-
         maxHeight = getBoundTextMaxHeight(
         maxHeight = getBoundTextMaxHeight(
           container,
           container,
           updatedTextElement as ExcalidrawTextElementWithContainer,
           updatedTextElement as ExcalidrawTextElementWithContainer,
@@ -735,4 +736,6 @@ export const textWysiwyg = ({
   excalidrawContainer
   excalidrawContainer
     ?.querySelector(".excalidraw-textEditorContainer")!
     ?.querySelector(".excalidraw-textEditorContainer")!
     .appendChild(editable);
     .appendChild(editable);
+
+  return handleSubmit;
 };
 };