From 41a4dadaaf0b4e4cdddaf2651e0fcca9c649824e Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 24 Mar 2025 11:45:02 +1100 Subject: [PATCH] switch multi --- packages/excalidraw/components/App.tsx | 183 +++++++++-------- .../excalidraw/components/ShapeSwitch.tsx | 189 ++++++++++++++---- packages/excalidraw/element/textWysiwyg.tsx | 51 +---- packages/excalidraw/element/typeChecks.ts | 43 ++-- packages/excalidraw/element/types.ts | 3 + 5 files changed, 279 insertions(+), 190 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a6489ce3b..4c2aa7827 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -392,6 +392,7 @@ import { } from "../element/textMeasurements"; import ShapeSwitch, { + adjustBoundTextSize, GENERIC_SWITCHABLE_SHAPES, LINEAR_SWITCHABLE_SHAPES, shapeSwitchAtom, @@ -446,6 +447,8 @@ import type { MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, + GenericSwitchableToolType, + LinearSwitchableToolType, } from "../element/types"; import type { RenderInteractiveSceneCallback, @@ -573,7 +576,6 @@ const gesture: Gesture = { initialDistance: null, initialScale: null, }; -let textWysiwygSubmitHandler: (() => void) | null = null; class App extends React.Component { canvas: AppClassProperties["canvas"]; @@ -4108,28 +4110,24 @@ class App extends React.Component { return; } - const firstElement = selectedElements[0]; - const isGenericSwitchable = - firstElement && isGenericSwitchableElement(firstElement); - const isLinearSwitchable = - firstElement && isLinearSwitchableElement(firstElement); + const genericSwitchable = isGenericSwitchableElement(selectedElements); + const linearSwitchable = isLinearSwitchableElement(selectedElements); + + if (genericSwitchable || linearSwitchable) { + const firstElement = selectedElements[0]; - if ( - selectedElements.length === 1 && - (isGenericSwitchable || isLinearSwitchable) - ) { if (event.key === KEYS.ESCAPE) { editorJotaiStore.set(shapeSwitchAtom, null); } else if (event.key === KEYS.SLASH || event.key === KEYS.TAB) { event.preventDefault(); if (editorJotaiStore.get(shapeSwitchAtom)?.type === "panel") { - const index = isGenericSwitchable + const index = genericSwitchable ? GENERIC_SWITCHABLE_SHAPES.indexOf(selectedElements[0].type) : LINEAR_SWITCHABLE_SHAPES.indexOf(selectedElements[0].type); const nextType = ( - isGenericSwitchable + genericSwitchable ? GENERIC_SWITCHABLE_SHAPES[ (index + 1) % GENERIC_SWITCHABLE_SHAPES.length ] @@ -4145,16 +4143,20 @@ class App extends React.Component { type: "panel", }); if (!editorJotaiStore.get(shapeSwitchFontSizeAtom)) { - const boundText = getBoundTextElement( - firstElement, - this.scene.getNonDeletedElementsMap(), - ); - if (boundText && isGenericSwitchable) { - editorJotaiStore.set(shapeSwitchFontSizeAtom, { - fontSize: boundText.fontSize, - elementType: firstElement.type, - }); - } + selectedElements.forEach((element) => { + const boundText = getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ); + if (boundText && genericSwitchable && firstElement) { + editorJotaiStore.set(shapeSwitchFontSizeAtom, { + [element.id]: { + fontSize: boundText.fontSize, + elementType: element.type as GenericSwitchableToolType, + }, + }); + } + }); } } } @@ -4819,90 +4821,98 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), this.state, ); - const firstElement = selectedElements[0]; + const selectedElementIds = selectedElements.reduce( + (acc, element) => ({ ...acc, [element.id]: true }), + {}, + ); if ( - firstElement && - selectedElements.length === 1 && - isGenericSwitchableElement(firstElement) && + isGenericSwitchableElement(selectedElements) && isGenericSwitchableToolType(tool.type) ) { - ShapeCache.delete(firstElement); + selectedElements.forEach((element) => { + ShapeCache.delete(element); - 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, - }); + mutateElement( + element, + { + type: tool.type as GenericSwitchableToolType, + roundness: + tool.type === "diamond" && element.roundness + ? { + type: isUsingAdaptiveRadius(tool.type) + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + value: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : element.roundness, + }, + false, + ); - const boundText = getBoundTextElement( - firstElement, - this.scene.getNonDeletedElementsMap(), - ); - if (boundText) { - if ( - editorJotaiStore.get(shapeSwitchFontSizeAtom)?.elementType === - tool.type - ) { - mutateElement( + const boundText = getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ); + if (boundText) { + if ( + editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id] + ?.elementType === tool.type + ) { + mutateElement( + boundText, + { + fontSize: + editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id] + ?.fontSize ?? boundText.fontSize, + }, + false, + ); + } + + adjustBoundTextSize( + element, boundText, - { - fontSize: - editorJotaiStore.get(shapeSwitchFontSizeAtom)?.fontSize ?? - boundText.fontSize, - }, - false, + this.scene.getNonDeletedElementsMap(), ); } + }); - this.startTextEditing({ - sceneX: firstElement.x + firstElement.width / 2, - sceneY: firstElement.y + firstElement.height / 2, - container: firstElement as ExcalidrawTextContainer, - keepContainerDimensions: true, - }); - } - - this.setState((prevState) => ({ - selectedElementIds: { - [firstElement.id]: true, - }, - activeTool: updateActiveTool(prevState, { type: "selection" }), - })); - - textWysiwygSubmitHandler?.(); + this.setState((prevState) => { + return { + selectedElementIds, + activeTool: updateActiveTool(prevState, { type: "selection" }), + }; + }); this.store.shouldCaptureIncrement(); } if ( - firstElement && - selectedElements.length === 1 && - isLinearSwitchableElement(firstElement) && + isLinearSwitchableElement(selectedElements) && isLinearSwitchableToolType(tool.type) ) { - ShapeCache.delete(firstElement); + selectedElements.forEach((element) => { + ShapeCache.delete(element); - mutateElement(firstElement as ExcalidrawLinearElement, { - type: tool.type, - startArrowhead: null, - endArrowhead: tool.type === "arrow" ? "arrow" : null, + mutateElement( + element as ExcalidrawLinearElement, + { + type: tool.type as LinearSwitchableToolType, + startArrowhead: null, + endArrowhead: tool.type === "arrow" ? "arrow" : null, + }, + false, + ); }); + const firstElement = selectedElements[0]; this.setState((prevState) => ({ - selectedElementIds: { - [firstElement.id]: true, - }, - selectedLinearElement: new LinearElementEditor( - firstElement as ExcalidrawLinearElement, - ), + selectedElementIds, + selectedLinearElement: + selectedElements.length === 1 + ? new LinearElementEditor(firstElement as ExcalidrawLinearElement) + : null, activeTool: updateActiveTool(prevState, { type: "selection" }), })); } @@ -5037,7 +5047,7 @@ class App extends React.Component { ]); }; - textWysiwygSubmitHandler = textWysiwyg({ + textWysiwyg({ id: element.id, canvas: this.canvas, getViewportCoords: (x, y) => { @@ -5060,7 +5070,6 @@ 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 diff --git a/packages/excalidraw/components/ShapeSwitch.tsx b/packages/excalidraw/components/ShapeSwitch.tsx index 6444754ee..a1dfbcf64 100644 --- a/packages/excalidraw/components/ShapeSwitch.tsx +++ b/packages/excalidraw/components/ShapeSwitch.tsx @@ -6,15 +6,24 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { atom, useAtom } from "../editor-jotai"; -import { getElementAbsoluteCoords } from "../element"; -import { sceneCoordsToViewportCoords } from "../utils"; +import { getElementAbsoluteCoords, refreshTextDimensions } from "../element"; +import { getFontString, sceneCoordsToViewportCoords } from "../utils"; import { getSelectedElements } from "../scene"; import { trackEvent } from "../analytics"; import { isArrowElement, isLinearElement } from "../element/typeChecks"; import { t } from "../i18n"; -import "./ShapeSwitch.scss"; +import { + computeBoundTextPosition, + getBoundTextMaxHeight, + getBoundTextMaxWidth, +} from "../element/textElement"; +import { wrapText } from "../element/textWrapping"; +import { measureText } from "../element/textMeasurements"; +import { mutateElement } from "../element/mutateElement"; +import { getCommonBoundingBox } from "../element/bounds"; +import { ToolButton } from "./ToolButton"; import { ArrowIcon, DiamondIcon, @@ -22,10 +31,17 @@ import { LineIcon, RectangleIcon, } from "./icons"; -import { ToolButton } from "./ToolButton"; + +import "./ShapeSwitch.scss"; import type App from "./App"; -import type { ExcalidrawElement } from "../element/types"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawTextContainer, + ExcalidrawTextElementWithContainer, + GenericSwitchableToolType, +} from "../element/types"; import type { ToolType } from "../types"; const GAP_HORIZONTAL = 8; @@ -46,8 +62,10 @@ export const shapeSwitchAtom = atom< | null >(null); export const shapeSwitchFontSizeAtom = atom<{ - fontSize: number; - elementType: "rectangle" | "diamond" | "ellipse"; + [id: string]: { + fontSize: number; + elementType: GenericSwitchableToolType; + }; } | null>(null); const ShapeSwitch = ({ app }: { app: App }) => { @@ -64,22 +82,22 @@ const ShapeSwitch = ({ app }: { app: App }) => { app.scene.getNonDeletedElementsMap(), app.state, ); - const firstElement = selectedElements[0]; - - const isSingleSelected = firstElement && selectedElements.length === 1; // clear if hint target no longer matches - if (shapeSwitch.type === "hint" && firstElement?.id !== shapeSwitch.id) { + if ( + shapeSwitch.type === "hint" && + selectedElements?.[0]?.id !== shapeSwitch.id + ) { setShapeSwitch(null); return null; } - if (!isSingleSelected) { + if (selectedElements.length === 0) { setShapeSwitch(null); return null; } - const props = { app, element: firstElement }; + const props = { app, elements: selectedElements }; switch (shapeSwitch.type) { case "hint": @@ -91,7 +109,13 @@ const ShapeSwitch = ({ app }: { app: App }) => { } }; -const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => { +const Hint = ({ + app, + elements, +}: { + app: App; + elements: ExcalidrawElement[]; +}) => { const [, setShapeSwitch] = useAtom(shapeSwitchAtom); const hintRef = useRef(null); @@ -114,14 +138,14 @@ const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => { }, [setShapeSwitch]); const [x1, y1, , , cx, cy] = getElementAbsoluteCoords( - element, + elements[0], app.scene.getNonDeletedElementsMap(), ); const rotatedTopLeft = pointRotateRads( pointFrom(x1, y1), pointFrom(cx, cy), - element.angle, + elements[0].angle, ); const { x, y } = sceneCoordsToViewportCoords( @@ -135,7 +159,7 @@ const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => { return (
{ ); }; -const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => { - const [x1, , , y2, cx, cy] = getElementAbsoluteCoords( - element, - app.scene.getNonDeletedElementsMap(), - ); +const Panel = ({ + app, + elements, +}: { + app: App; + elements: ExcalidrawElement[]; +}) => { + let [x1, y2, cx, cy] = [0, 0, 0, 0]; + let rotatedBottomLeft = [0, 0]; - const rotatedBottomLeft = pointRotateRads( - pointFrom(x1, y2), - pointFrom(cx, cy), - element.angle, - ); + if (elements.length === 1) { + [x1, , , y2, cx, cy] = getElementAbsoluteCoords( + elements[0], + app.scene.getNonDeletedElementsMap(), + ); + + rotatedBottomLeft = pointRotateRads( + pointFrom(x1, y2), + pointFrom(cx, cy), + elements[0].angle, + ); + } else { + const { minX, maxY, midX, midY } = getCommonBoundingBox(elements); + x1 = minX; + y2 = maxY; + cx = midX; + cy = midY; + rotatedBottomLeft = pointFrom(x1, y2); + } const { x, y } = sceneCoordsToViewportCoords( { @@ -178,7 +220,7 @@ const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => { app.state, ); - const SHAPES: [string, string, ReactNode][] = isLinearElement(element) + const SHAPES: [string, string, ReactNode][] = isLinearElement(elements[0]) ? [ ["arrow", "5", ArrowIcon], ["line", "6", LineIcon], @@ -203,18 +245,22 @@ const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => { > {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 === elements[0].type || + (isArrowElement(elements[0]) && + elements[0].elbowed && + type === "elbow") || + (isArrowElement(elements[0]) && + elements[0].roundness && + type === "curve") || + (isArrowElement(elements[0]) && + !elements[0].elbowed && + !elements[0].roundness && type === "straight"); return ( { ); }; +export const adjustBoundTextSize = ( + container: ExcalidrawTextContainer, + boundText: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, +) => { + 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, + { + fontSize: nextFontSize, + width: metrics.width, + height: metrics.height, + }, + false, + ); + + const { x, y } = computeBoundTextPosition(container, boundText, elementsMap); + + mutateElement( + boundText, + { + x, + y, + }, + false, + ); + + mutateElement( + boundText, + { + ...refreshTextDimensions( + boundText, + container, + elementsMap, + boundText.originalText, + ), + containerId: container.id, + }, + false, + ); +}; + export default ShapeSwitch; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 4897f323e..c95c2e580 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -10,7 +10,6 @@ import { import { parseClipboard } from "../clipboard"; import { CLASSES, POINTER_BUTTON } from "../constants"; import { CODES, KEYS } from "../keys"; -import Scene from "../scene/Scene"; import { isWritableElement, getFontString, @@ -35,7 +34,7 @@ import { computeBoundTextPosition, getBoundTextElement, } from "./textElement"; -import { getTextWidth, measureText } from "./textMeasurements"; +import { getTextWidth } from "./textMeasurements"; import { normalizeText } from "./textMeasurements"; import { wrapText } from "./textWrapping"; import { @@ -86,7 +85,6 @@ export const textWysiwyg = ({ excalidrawContainer, app, autoSelect = true, - keepContainerDimensions = false, }: { id: ExcalidrawElement["id"]; /** @@ -127,8 +125,7 @@ export const textWysiwyg = ({ const updateWysiwygStyle = () => { const appState = app.state; - const updatedTextElement = - Scene.getScene(element)?.getElement(id); + const updatedTextElement = app.scene.getElement(id); if (!updatedTextElement) { return; @@ -190,48 +187,6 @@ export const textWysiwyg = ({ 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( @@ -578,7 +533,7 @@ export const textWysiwyg = ({ // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // wysiwyg on update cleanup(); - const updateElement = Scene.getScene(element)?.getElement( + const updateElement = app.scene.getElement( element.id, ) as ExcalidrawTextElement; if (!updateElement) { diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 988ce4497..dc72dc64b 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -30,6 +30,8 @@ import type { ExcalidrawRectangleElement, ExcalidrawEllipseElement, ExcalidrawDiamondElement, + GenericSwitchableToolType, + LinearSwitchableToolType, } from "./types"; export const isInitializedImageElement = ( @@ -341,22 +343,25 @@ export const isBounds = (box: unknown): box is Bounds => typeof box[2] === "number" && typeof box[3] === "number"; +type NonEmptyArray = [T, ...T[]]; + type ExcalidrawGenericSwitchableElement = | ExcalidrawRectangleElement | ExcalidrawEllipseElement | ExcalidrawDiamondElement; -type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond"; - -type LinearSwitchableToolType = "arrow" | "line"; - export const isGenericSwitchableElement = ( - element: ExcalidrawElement, -): element is ExcalidrawGenericSwitchableElement => { + elements: ExcalidrawElement[], +): elements is NonEmptyArray => { + if (elements.length === 0) { + return false; + } + const firstType = elements[0].type; return ( - element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond" + (firstType === "rectangle" || + firstType === "ellipse" || + firstType === "diamond") && + elements.every((element) => element.type === firstType) ); }; @@ -367,18 +372,16 @@ export const isGenericSwitchableToolType = ( }; 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; - } + elements: ExcalidrawElement[], +): elements is NonEmptyArray => { + if (elements.length === 0) { + return false; } - return false; + const firstType = elements[0].type; + return ( + (firstType === "arrow" || firstType === "line") && + elements.every((element) => element.type === firstType) + ); }; export const isLinearSwitchableToolType = ( diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 49ad800af..1902d9e2f 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -411,3 +411,6 @@ export type NonDeletedSceneElementsMap = Map< export type ElementsMapOrArray = | readonly ExcalidrawElement[] | Readonly; + +export type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond"; +export type LinearSwitchableToolType = "line" | "arrow";