diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index cd3bd7a157..f50d86e4fc 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -119,6 +119,7 @@ export const CLASSES = { SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", + CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", }; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 1e9daee188..ee5d037a8c 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -81,7 +81,6 @@ import type { NonDeletedSceneElementsMap, ExcalidrawTextElement, ExcalidrawArrowElement, - OrderedExcalidrawElement, ExcalidrawElbowArrowElement, FixedPoint, FixedPointBinding, @@ -710,29 +709,32 @@ const calculateFocusAndGap = ( // Supports translating, rotating and scaling `changedElement` with bound // 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 = ( changedElement: NonDeletedExcalidrawElement, scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; - changedElements?: Map; + changedElements?: Map; }, ) => { + if (!isBindableElement(changedElement)) { + return; + } + const { newSize, simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( 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) => { if (!isLinearElement(element) || element.isDeleted) { 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 = ( boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index 53a2f05aed..2ce9f306cc 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -44,7 +44,6 @@ import type { ExcalidrawIframeElement, ElementsMap, ExcalidrawArrowElement, - FixedSegment, ExcalidrawElbowArrowElement, } from "./types"; @@ -478,7 +477,7 @@ export const newArrowElement = ( endArrowhead?: Arrowhead | null; points?: ExcalidrawArrowElement["points"]; elbowed?: T; - fixedSegments?: FixedSegment[] | null; + fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null; } & ElementConstructorOpts, ): T extends true ? NonDeleted diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index 54619726df..aed06c812d 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -119,6 +119,20 @@ export const isElbowArrow = ( 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 = ( elementType: ElementOrToolType, ): boolean => { @@ -271,6 +285,10 @@ export const isBoundToContainer = ( ); }; +export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => { + return !!element.startBinding || !!element.endBinding; +}; + export const isUsingAdaptiveRadius = (type: string) => type === "rectangle" || type === "embeddable" || diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 3b40135d5e..1c8c22811c 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -412,3 +412,11 @@ export type NonDeletedSceneElementsMap = Map< export type ElementsMapOrArray = | readonly ExcalidrawElement[] | Readonly; + +export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse"; +export type ConvertibleLinearTypes = + | "line" + | "sharpArrow" + | "curvedArrow" + | "elbowArrow"; +export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes; diff --git a/packages/excalidraw/actions/actionToggleShapeSwitch.tsx b/packages/excalidraw/actions/actionToggleShapeSwitch.tsx new file mode 100644 index 0000000000..39e7566fb7 --- /dev/null +++ b/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, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c63a122e04..c4a4d2cce5 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -140,7 +140,8 @@ export type ActionName = | "linkToElement" | "cropEditor" | "wrapSelectionInFrame" - | "toggleLassoTool"; + | "toggleLassoTool" + | "toggleShapeSwitch"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -195,7 +196,8 @@ export interface Action { | "menu" | "collab" | "hyperlink" - | "search_menu"; + | "search_menu" + | "shape_switch"; action?: string; predicate?: ( appState: Readonly, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0381a2e393..ddb071981f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { ); } + updateEditorAtom = ( + atom: WritableAtom, + ...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 { const firstSelectedElement = selectedElements[0]; + const showShapeSwitchPanel = + editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"; + return (
{ /> )} {this.renderFrameNames()} + {showShapeSwitchPanel && ( + + )} {this.renderEmbeddables()} @@ -2138,7 +2159,7 @@ class App extends React.Component { }; 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 { 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 { 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 { 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, diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 8b45e3377e..60289cfa18 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -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"], diff --git a/packages/excalidraw/components/ConvertElementTypePopup.scss b/packages/excalidraw/components/ConvertElementTypePopup.scss new file mode 100644 index 0000000000..414ca9533b --- /dev/null +++ b/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; + } + } +} diff --git a/packages/excalidraw/components/ConvertElementTypePopup.tsx b/packages/excalidraw/components/ConvertElementTypePopup.tsx new file mode 100644 index 0000000000..9ff1719880 --- /dev/null +++ b/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 = new Set( + GENERIC_TYPES, +); + +const CONVERTIBLE_LINEAR_TYPES: ReadonlySet = 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 + | Partial; + 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(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 ; +}; + +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(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 ( +
+ {SHAPES.map(([type, icon]) => { + const isSelected = + sameType && + ((conversionType === "generic" && genericElements[0].type === type) || + (conversionType === "linear" && + getArrowType(linearElements[0]) === type)); + + return ( + { + if (app.state.activeTool.type !== type) { + trackEvent("convertElementType", type, "ui"); + } + convertElementTypes(app, { + conversionType, + nextType: type as + | ConvertibleGenericTypes + | ConvertibleLinearTypes, + }); + panelRef.current?.focus(); + }} + /> + ); + })} +
+ ); +}; + +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 = {}; + + 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 = {}; + 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 => { + if (element.type === "line") { + return { + points: element.points, + roundness: element.roundness, + }; + } + return {}; +}; + +const getSharpArrowProperties = ( + element: ExcalidrawLinearElement, +): Partial => { + 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 => { + 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 => { + 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(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, +>( + 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; diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index d0cb187dac..76cb4876d5 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -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"; diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 79e7ed18b2..9aefa5544f 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -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); - } -}; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 1811cbb57c..cafc0bdd6a 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -29,6 +29,7 @@ import { bumpVersion } from "@excalidraw/element/mutateElement"; import { getContainerElement } from "@excalidraw/element/textElement"; import { detectLineHeight } from "@excalidraw/element/textMeasurements"; import { + isArrowBoundToElement, isArrowElement, isElbowArrow, isFixedPointBinding, @@ -594,8 +595,7 @@ export const restoreElements = ( return restoredElements.map((element) => { if ( isElbowArrow(element) && - element.startBinding == null && - element.endBinding == null && + !isArrowBoundToElement(element) && !validateElbowPoints(element.points) ) { return { diff --git a/packages/excalidraw/editor-jotai.ts b/packages/excalidraw/editor-jotai.ts index 28bc69306c..464accfb37 100644 --- a/packages/excalidraw/editor-jotai.ts +++ b/packages/excalidraw/editor-jotai.ts @@ -1,10 +1,15 @@ // 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"; const jotai = createIsolation(); -export { atom, PrimitiveAtom }; +export { atom, PrimitiveAtom, WritableAtom }; export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai; export const EditorJotaiProvider: ReturnType< typeof createIsolation diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 381f2b67f8..8f0e88c6b9 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -165,7 +165,9 @@ "unCroppedDimension": "Uncropped dimension", "copyElementLink": "Copy link to object", "linkToElement": "Link to object", - "wrapSelectionInFrame": "Wrap selection in frame" + "wrapSelectionInFrame": "Wrap selection in frame", + "tab": "Tab", + "shapeSwitch": "Switch shape" }, "elementLink": { "title": "Link to object", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index ebc31029c6..bd6e21fdc9 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -714,6 +714,7 @@ export type AppClassProperties = { excalidrawContainerValue: App["excalidrawContainerValue"]; onPointerUpEmitter: App["onPointerUpEmitter"]; + updateEditorAtom: App["updateEditorAtom"]; }; export type PointerDownState = Readonly<{ diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index a7ddf659ee..f9c4536d62 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -80,6 +80,8 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; +type SubmitHandler = () => void; + export const textWysiwyg = ({ id, onChange, @@ -106,7 +108,7 @@ export const textWysiwyg = ({ excalidrawContainer: HTMLDivElement | null; app: App; autoSelect?: boolean; -}) => { +}): SubmitHandler => { const textPropertiesUpdated = ( updatedTextElement: ExcalidrawTextElement, editable: HTMLTextAreaElement, @@ -186,7 +188,6 @@ export const textWysiwyg = ({ } maxWidth = getBoundTextMaxWidth(container, updatedTextElement); - maxHeight = getBoundTextMaxHeight( container, updatedTextElement as ExcalidrawTextElementWithContainer, @@ -735,4 +736,6 @@ export const textWysiwyg = ({ excalidrawContainer ?.querySelector(".excalidraw-textEditorContainer")! .appendChild(editable); + + return handleSubmit; };