From 407d8ababbd7ea616dfdc5e736854d3a25875255 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 14 Mar 2025 19:54:18 +1100 Subject: [PATCH] feat: switch between basic shapes --- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 156 +++++++++++++- .../excalidraw/components/ShapeSwitch.scss | 47 +++++ .../excalidraw/components/ShapeSwitch.tsx | 199 ++++++++++++++++++ .../excalidraw/components/ShapeSwitcher.tsx | 39 ++++ packages/excalidraw/element/textWysiwyg.tsx | 53 ++++- packages/excalidraw/element/typeChecks.ts | 49 +++++ packages/excalidraw/locales/en.json | 3 +- packages/excalidraw/types.ts | 2 + 9 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 packages/excalidraw/components/ShapeSwitch.scss create mode 100644 packages/excalidraw/components/ShapeSwitch.tsx create mode 100644 packages/excalidraw/components/ShapeSwitcher.tsx diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 644949e7c..38b85ce29 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit< isCropping: false, croppingElementId: null, searchMatches: [], + showShapeSwitchPanel: false, }; }; @@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (< isCropping: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false }, + showShapeSwitchPanel: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8eb2a9e2c..011ab6627 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -166,6 +166,10 @@ import { isElbowArrow, isFlowchartNodeElement, isBindableElement, + isGenericSwitchableElement, + isGenericSwitchableToolType, + isLinearSwitchableElement, + isLinearSwitchableToolType, } from "../element/typeChecks"; import type { ExcalidrawBindableElement, @@ -467,6 +471,7 @@ import { getApproxMinLineHeight, getMinTextElementWidth, } from "../element/textMeasurements"; +import ShapeSwitch from "./ShapeSwitch"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -559,6 +564,7 @@ const gesture: Gesture = { initialDistance: null, initialScale: null, }; +let textWysiwygSubmitHandler: (() => void) | null = null; class App extends React.Component { canvas: AppClassProperties["canvas"]; @@ -1805,6 +1811,7 @@ class App extends React.Component { /> )} {this.renderFrameNames()} + {this.renderEmbeddables()} @@ -4092,6 +4099,56 @@ class App extends React.Component { return; } + if ( + selectedElements.length === 1 && + (selectedElements[0].type === "rectangle" || + selectedElements[0].type === "diamond" || + selectedElements[0].type === "ellipse" || + selectedElements[0].type === "arrow" || + selectedElements[0].type === "line") + ) { + if (this.state.showShapeSwitchPanel && event.key === KEYS.ESCAPE) { + this.setState({ + showShapeSwitchPanel: false, + }); + + return; + } + + if (event.key === KEYS.SLASH) { + if (!this.state.showShapeSwitchPanel) { + this.setState({ + showShapeSwitchPanel: true, + }); + } else if ( + selectedElements[0].type === "rectangle" || + selectedElements[0].type === "diamond" || + selectedElements[0].type === "ellipse" + ) { + const index = ["rectangle", "diamond", "ellipse"].indexOf( + selectedElements[0].type, + ); + const nextType = ["rectangle", "diamond", "ellipse"][ + (index + 1) % 3 + ] as ToolType; + this.setActiveTool({ type: nextType }); + } else if ( + selectedElements[0].type === "arrow" || + selectedElements[0].type === "line" + ) { + const index = ["arrow", "line"].indexOf(selectedElements[0].type); + const nextType = ["arrow", "line"][(index + 1) % 2] as ToolType; + this.setActiveTool({ type: nextType }); + } + + return; + } + + this.setState({ + showShapeSwitchPanel: false, + }); + } + if ( event.key === KEYS.ESCAPE && this.flowChartCreator.isCreatingChart @@ -4742,6 +4799,95 @@ class App extends React.Component { ...commonResets, }; }); + + const selectedElements = getSelectedElements( + this.scene.getNonDeletedElementsMap(), + this.state, + ); + const firstElement = selectedElements[0]; + + if ( + firstElement && + selectedElements.length === 1 && + isGenericSwitchableElement(firstElement) && + isGenericSwitchableToolType(tool.type) + ) { + ShapeCache.delete(firstElement); + + mutateElement(firstElement, { + type: tool.type, + roundness: + tool.type === "diamond" && firstElement.roundness + ? { + type: isUsingAdaptiveRadius(tool.type) + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + value: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : firstElement.roundness, + }); + + this.setActiveTool({ type: "selection" }); + + if (firstElement.boundElements?.some((e) => e.type === "text")) { + this.startTextEditing({ + sceneX: firstElement.x + firstElement.width / 2, + sceneY: firstElement.y + firstElement.height / 2, + container: firstElement as ExcalidrawTextContainer, + keepContainerDimensions: true, + }); + } + + textWysiwygSubmitHandler?.(); + + this.setState({ + selectedElementIds: { + [firstElement.id]: true, + }, + }); + + this.store.shouldCaptureIncrement(); + } + + if ( + firstElement && + selectedElements.length === 1 && + isLinearSwitchableElement(firstElement) && + isLinearSwitchableToolType(tool.type) + ) { + ShapeCache.delete(firstElement); + + mutateElement(firstElement as ExcalidrawLinearElement, { + type: tool.type, + startArrowhead: null, + endArrowhead: tool.type === "arrow" ? "arrow" : null, + }); + + this.setActiveTool({ type: "selection" }); + + if ( + firstElement.boundElements?.some((e) => e.type === "text") && + isArrowElement(firstElement) + ) { + this.startTextEditing({ + sceneX: firstElement.x + firstElement.width / 2, + sceneY: firstElement.y + firstElement.height / 2, + container: firstElement, + keepContainerDimensions: true, + }); + } + + textWysiwygSubmitHandler?.(); + + this.setState({ + selectedElementIds: { + [firstElement.id]: true, + }, + selectedLinearElement: new LinearElementEditor( + firstElement as ExcalidrawLinearElement, + ), + }); + } }; setOpenDialog = (dialogType: AppState["openDialog"]) => { @@ -4843,8 +4989,10 @@ class App extends React.Component { element: ExcalidrawTextElement, { isExistingElement = false, + keepContainerDimensions = false, }: { isExistingElement?: boolean; + keepContainerDimensions?: boolean; }, ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); @@ -4871,7 +5019,7 @@ class App extends React.Component { ]); }; - textWysiwyg({ + textWysiwygSubmitHandler = textWysiwyg({ id: element.id, canvas: this.canvas, getViewportCoords: (x, y) => { @@ -4894,6 +5042,7 @@ class App extends React.Component { } }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { + textWysiwygSubmitHandler = null; const isDeleted = !nextOriginalText.trim(); updateElement(nextOriginalText, isDeleted); // select the created text element only if submitting via keyboard @@ -4949,6 +5098,7 @@ class App extends React.Component { // the text on edit anyway (and users can select-all from contextmenu // if needed) autoSelect: !this.device.isTouchScreen, + keepContainerDimensions, }); // deselect all other elements when inserting text this.deselectElements(); @@ -5181,6 +5331,7 @@ class App extends React.Component { insertAtParentCenter = true, container, autoEdit = true, + keepContainerDimensions = false, }: { /** X position to insert text at */ sceneX: number; @@ -5190,6 +5341,7 @@ class App extends React.Component { insertAtParentCenter?: boolean; container?: ExcalidrawTextContainer | null; autoEdit?: boolean; + keepContainerDimensions?: boolean; }) => { let shouldBindToContainer = false; @@ -5325,6 +5477,7 @@ class App extends React.Component { if (autoEdit || existingTextElement || container) { this.handleTextWysiwyg(element, { isExistingElement: !!existingTextElement, + keepContainerDimensions, }); } else { this.setState({ @@ -8768,6 +8921,7 @@ class App extends React.Component { cursorButton: "up", snapLines: updateStable(prevState.snapLines, []), originSnapOffset: null, + showShapeSwitchPanel: false, })); this.lastPointerMoveCoords = null; diff --git a/packages/excalidraw/components/ShapeSwitch.scss b/packages/excalidraw/components/ShapeSwitch.scss new file mode 100644 index 000000000..a43802008 --- /dev/null +++ b/packages/excalidraw/components/ShapeSwitch.scss @@ -0,0 +1,47 @@ +@import "../css//variables.module.scss"; + +@keyframes disappear { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.excalidraw { + .ShapeSwitch__Hint { + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + background: var(--default-bg-color); + + .key { + background: var(--color-primary-light); + padding: 0.4rem 0.8rem; + font-weight: bold; + border-radius: 0.5rem; + box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1); + } + + .text { + margin-left: 0.5rem; + } + } + + .animation { + opacity: 1; + animation: disappear 2s ease-out; + } + + .ShapeSwitch__Panel { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.2rem; + border-radius: 0.5rem; + background: var(--default-bg-color); + box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1); + } +} diff --git a/packages/excalidraw/components/ShapeSwitch.tsx b/packages/excalidraw/components/ShapeSwitch.tsx new file mode 100644 index 000000000..b2f7fba95 --- /dev/null +++ b/packages/excalidraw/components/ShapeSwitch.tsx @@ -0,0 +1,199 @@ +import { getSelectedElements } from "../scene"; +import type App from "./App"; +import { ToolButton } from "./ToolButton"; +import { + ArrowIcon, + DiamondIcon, + EllipseIcon, + LineIcon, + RectangleIcon, +} from "./icons"; +import { type ReactNode, useEffect, useRef } from "react"; +import { trackEvent } from "../analytics"; +import type { ToolType } from "../types"; +import { sceneCoordsToViewportCoords } from "../utils"; +import type { ExcalidrawElement } from "../element/types"; +import "./ShapeSwitch.scss"; +import clsx from "clsx"; +import { getElementAbsoluteCoords } from "../element"; +import { pointFrom, pointRotateRads } from "@excalidraw/math"; +import { + isArrowElement, + isGenericSwitchableElement, + isLinearElement, + isLinearSwitchableElement, +} from "../element/typeChecks"; +import { t } from "../i18n"; + +const GAP_HORIZONTAL = 8; +const GAP_VERTICAL = 10; + +const ShapeSwitch = ({ app }: { app: App }) => { + const selectedElements = getSelectedElements( + app.scene.getNonDeletedElementsMap(), + app.state, + ); + const firstElement = selectedElements[0]; + + useEffect(() => { + return app.setState({ showShapeSwitchPanel: false }); + }, [app]); + + if ( + firstElement && + selectedElements.length === 1 && + (isGenericSwitchableElement(firstElement) || + isLinearSwitchableElement(firstElement)) + ) { + return app.state.showShapeSwitchPanel ? ( + + ) : ( + + ); + } + + return null; +}; + +const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => { + const [x1, y1, , , cx, cy] = getElementAbsoluteCoords( + element, + app.scene.getNonDeletedElementsMap(), + ); + + const rotatedTopLeft = pointRotateRads( + pointFrom(x1, y1), + pointFrom(cx, cy), + element.angle, + ); + + const { x, y } = sceneCoordsToViewportCoords( + { + sceneX: rotatedTopLeft[0], + sceneY: rotatedTopLeft[1], + }, + app.state, + ); + + const hintRef = useRef(null); + + useEffect(() => { + const listener = () => { + hintRef.current?.classList.remove("animation"); + }; + + if (hintRef.current) { + hintRef.current.addEventListener("animationend", listener); + } + }, [element.id]); + + return ( +
+
/
+
{t("labels.slash")}
+
+ ); +}; + +const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => { + const [x1, , , y2, cx, cy] = getElementAbsoluteCoords( + element, + app.scene.getNonDeletedElementsMap(), + ); + + const rotatedBottomLeft = pointRotateRads( + pointFrom(x1, y2), + pointFrom(cx, cy), + element.angle, + ); + + const { x, y } = sceneCoordsToViewportCoords( + { + sceneX: rotatedBottomLeft[0], + sceneY: rotatedBottomLeft[1], + }, + app.state, + ); + + const SHAPES: [string, string, ReactNode][] = isLinearElement(element) + ? [ + ["arrow", "5", ArrowIcon], + ["line", "6", LineIcon], + ] + : [ + ["rectangle", "2", RectangleIcon], + ["diamond", "3", DiamondIcon], + ["ellipse", "4", EllipseIcon], + ]; + + return ( +
+ {SHAPES.map(([type, shortcut, icon]) => { + const isSelected = + type === element.type || + (isArrowElement(element) && element.elbowed && type === "elbow") || + (isArrowElement(element) && element.roundness && type === "curve") || + (isArrowElement(element) && + !element.elbowed && + !element.roundness && + type === "straight"); + + return ( + { + if (!app.state.penDetected && pointerType === "pen") { + app.togglePenMode(true); + } + }} + onChange={() => { + if (app.state.activeTool.type !== type) { + trackEvent("shape-switch", type, "ui"); + } + app.setActiveTool({ type: type as ToolType }); + }} + /> + ); + })} +
+ ); +}; + +export default ShapeSwitch; diff --git a/packages/excalidraw/components/ShapeSwitcher.tsx b/packages/excalidraw/components/ShapeSwitcher.tsx new file mode 100644 index 000000000..d408fad50 --- /dev/null +++ b/packages/excalidraw/components/ShapeSwitcher.tsx @@ -0,0 +1,39 @@ +// render the shape switcher div on top of the canvas at the selected element's position + +import { THEME } from "../constants"; +import type { ExcalidrawElement } from "../element/types"; +import type { AppState } from "../types"; +import { sceneCoordsToViewportCoords } from "../utils"; + +const ShapeSwitcher = ({ + appState, + element, +}: { + appState: AppState; + element: ExcalidrawElement; + elements: readonly ExcalidrawElement[]; +}) => { + const isDarkTheme = appState.theme === THEME.DARK; + const { x, y } = sceneCoordsToViewportCoords( + { + sceneX: element.x, + sceneY: element.y, + }, + appState, + ); + + return ( +
+ ); +}; + +export default ShapeSwitcher; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index a7570862d..01dfc600f 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -48,7 +48,7 @@ import { originalContainerCache, updateOriginalContainerCache, } from "./containerCache"; -import { getTextWidth } from "./textMeasurements"; +import { getTextWidth, measureText } from "./textMeasurements"; import { normalizeText } from "./textMeasurements"; const getTransform = ( @@ -72,6 +72,8 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; +type SubmitHandler = () => void; + export const textWysiwyg = ({ id, onChange, @@ -82,6 +84,7 @@ export const textWysiwyg = ({ excalidrawContainer, app, autoSelect = true, + keepContainerDimensions = false, }: { id: ExcalidrawElement["id"]; /** @@ -98,7 +101,8 @@ export const textWysiwyg = ({ excalidrawContainer: HTMLDivElement | null; app: App; autoSelect?: boolean; -}) => { + keepContainerDimensions?: boolean; +}): SubmitHandler => { const textPropertiesUpdated = ( updatedTextElement: ExcalidrawTextElement, editable: HTMLTextAreaElement, @@ -179,12 +183,53 @@ export const textWysiwyg = ({ } maxWidth = getBoundTextMaxWidth(container, updatedTextElement); - maxHeight = getBoundTextMaxHeight( container, updatedTextElement as ExcalidrawTextElementWithContainer, ); + if (keepContainerDimensions) { + const wrappedText = wrapText( + updatedTextElement.text, + getFontString(updatedTextElement), + maxWidth, + ); + + let metrics = measureText( + wrappedText, + getFontString(updatedTextElement), + updatedTextElement.lineHeight, + ); + + if (width > maxWidth || height > maxHeight) { + let nextFontSize = updatedTextElement.fontSize; + while ( + (metrics.width > maxWidth || metrics.height > maxHeight) && + nextFontSize > 0 + ) { + nextFontSize -= 1; + const _updatedTextElement = { + ...updatedTextElement, + fontSize: nextFontSize, + }; + metrics = measureText( + updatedTextElement.text, + getFontString(_updatedTextElement), + updatedTextElement.lineHeight, + ); + } + + mutateElement( + updatedTextElement, + { fontSize: nextFontSize }, + false, + ); + } + + width = metrics.width; + height = metrics.height; + } + // autogrow container height if text exceeds if (!isArrowElement(container) && height > maxHeight) { const targetContainerHeight = computeContainerDimensionForBoundText( @@ -727,4 +772,6 @@ export const textWysiwyg = ({ excalidrawContainer ?.querySelector(".excalidraw-textEditorContainer")! .appendChild(editable); + + return handleSubmit; }; diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 6bb4269f8..1c560d576 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -26,6 +26,9 @@ import type { PointBinding, FixedPointBinding, ExcalidrawFlowchartNodeElement, + ExcalidrawRectangleElement, + ExcalidrawEllipseElement, + ExcalidrawDiamondElement, } from "./types"; export const isInitializedImageElement = ( @@ -336,3 +339,49 @@ export const isBounds = (box: unknown): box is Bounds => typeof box[1] === "number" && typeof box[2] === "number" && typeof box[3] === "number"; + +type ExcalidrawGenericSwitchableElement = + | ExcalidrawRectangleElement + | ExcalidrawEllipseElement + | ExcalidrawDiamondElement; + +type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond"; + +type LinearSwitchableToolType = "arrow" | "line"; + +export const isGenericSwitchableElement = ( + element: ExcalidrawElement, +): element is ExcalidrawGenericSwitchableElement => { + return ( + element.type === "rectangle" || + element.type === "ellipse" || + element.type === "diamond" + ); +}; + +export const isGenericSwitchableToolType = ( + type: string, +): type is GenericSwitchableToolType => { + return type === "rectangle" || type === "ellipse" || type === "diamond"; +}; + +export const isLinearSwitchableElement = ( + element: ExcalidrawElement, +): element is ExcalidrawLinearElement => { + if (element.type === "arrow" || element.type === "line") { + if ( + (!element.boundElements || element.boundElements.length === 0) && + !element.startBinding && + !element.endBinding + ) { + return true; + } + } + return false; +}; + +export const isLinearSwitchableToolType = ( + type: string, +): type is LinearSwitchableToolType => { + return type === "arrow" || type === "line"; +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index f14b79705..97dfa34fa 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -165,7 +165,8 @@ "unCroppedDimension": "Uncropped dimension", "copyElementLink": "Copy link to object", "linkToElement": "Link to object", - "wrapSelectionInFrame": "Wrap selection in frame" + "wrapSelectionInFrame": "Wrap selection in frame", + "slash": "Slash" }, "elementLink": { "title": "Link to object", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 0562736cd..13523a269 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -410,6 +410,8 @@ export interface AppState { croppingElementId: ExcalidrawElement["id"] | null; searchMatches: readonly SearchMatch[]; + + showShapeSwitchPanel: boolean; } type SearchMatch = {