diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index ea7e425d4..23cae2b6b 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -1,67 +1,77 @@ import { mutateElement } from "../../element/mutateElement"; import { getBoundTextElement } from "../../element/textElement"; import { isArrowElement } from "../../element/typeChecks"; -import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import type { ExcalidrawElement } from "../../element/types"; import { degreeToRadian, radianToDegree } from "../../math"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, isPropertyEditable } from "./utils"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; interface AngleProps { element: ExcalidrawElement; - elementsMap: ElementsMap; + scene: Scene; + appState: AppState; + property: "angle"; } const STEP_SIZE = 15; -const Angle = ({ element, elementsMap }: AngleProps) => { - const handleDegreeChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - shouldChangeByStepSize, - nextValue, - }) => { - const origElement = originalElements[0]; - if (origElement) { - if (nextValue !== undefined) { - const nextAngle = degreeToRadian(nextValue); - mutateElement(element, { - angle: nextAngle, - }); - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement && !isArrowElement(element)) { - mutateElement(boundTextElement, { angle: nextAngle }); - } +const handleDegreeChange: DragInputCallbackType = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const origElement = originalElements[0]; + if (origElement) { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + return; + } + if (nextValue !== undefined) { + const nextAngle = degreeToRadian(nextValue); + mutateElement(latestElement, { + angle: nextAngle, + }); - return; + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + if (boundTextElement && !isArrowElement(latestElement)) { + mutateElement(boundTextElement, { angle: nextAngle }); } - const originalAngleInDegrees = - Math.round(radianToDegree(origElement.angle) * 100) / 100; - const changeInDegrees = Math.round(accumulatedChange); - let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; - if (shouldChangeByStepSize) { - nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE); - } + return; + } - nextAngleInDegrees = - nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; + const originalAngleInDegrees = + Math.round(radianToDegree(origElement.angle) * 100) / 100; + const changeInDegrees = Math.round(accumulatedChange); + let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; + if (shouldChangeByStepSize) { + nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE); + } - const nextAngle = degreeToRadian(nextAngleInDegrees); + nextAngleInDegrees = + nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; - mutateElement(element, { - angle: nextAngle, - }); + const nextAngle = degreeToRadian(nextAngleInDegrees); - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement && !isArrowElement(element)) { - mutateElement(boundTextElement, { angle: nextAngle }); - } + mutateElement(latestElement, { + angle: nextAngle, + }); + + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + if (boundTextElement && !isArrowElement(latestElement)) { + mutateElement(boundTextElement, { angle: nextAngle }); } - }; + } +}; +const Angle = ({ element, scene, appState, property }: AngleProps) => { return ( { elements={[element]} dragInputCallback={handleDegreeChange} editable={isPropertyEditable(element, "angle")} + scene={scene} + appState={appState} + property={property} /> ); }; diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index 845e2e8f6..4c3d97bc2 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -1,13 +1,16 @@ -import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import type { ExcalidrawElement } from "../../element/types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; interface DimensionDragInputProps { property: "width" | "height"; element: ExcalidrawElement; - elementsMap: ElementsMap; + scene: Scene; + appState: AppState; } const STEP_SIZE = 10; @@ -15,99 +18,101 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => { return element.type === "image"; }; -const DimensionDragInput = ({ +const handleDimensionChange: DragInputCallbackType< + DimensionDragInputProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldKeepAspectRatio, + shouldChangeByStepSize, + nextValue, property, - element, - elementsMap, -}: DimensionDragInputProps) => { - const handleDimensionChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - originalElementsMap, - shouldKeepAspectRatio, - shouldChangeByStepSize, - nextValue, - }) => { - const origElement = originalElements[0]; - if (origElement) { - const keepAspectRatio = - shouldKeepAspectRatio || _shouldKeepAspectRatio(element); - const aspectRatio = origElement.width / origElement.height; + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const origElement = originalElements[0]; + if (origElement) { + const keepAspectRatio = + shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); + const aspectRatio = origElement.width / origElement.height; - if (nextValue !== undefined) { - const nextWidth = Math.max( - property === "width" - ? nextValue - : keepAspectRatio - ? nextValue * aspectRatio - : origElement.width, - MIN_WIDTH_OR_HEIGHT, - ); - const nextHeight = Math.max( - property === "height" - ? nextValue - : keepAspectRatio - ? nextValue / aspectRatio - : origElement.height, - MIN_WIDTH_OR_HEIGHT, - ); + if (nextValue !== undefined) { + const nextWidth = Math.max( + property === "width" + ? nextValue + : keepAspectRatio + ? nextValue * aspectRatio + : origElement.width, + MIN_WIDTH_OR_HEIGHT, + ); + const nextHeight = Math.max( + property === "height" + ? nextValue + : keepAspectRatio + ? nextValue / aspectRatio + : origElement.height, + MIN_WIDTH_OR_HEIGHT, + ); - resizeElement( - nextWidth, - nextHeight, - keepAspectRatio, - element, - origElement, - elementsMap, - originalElementsMap, - ); + resizeElement( + nextWidth, + nextHeight, + keepAspectRatio, + origElement, + elementsMap, + ); - return; - } - const changeInWidth = property === "width" ? accumulatedChange : 0; - const changeInHeight = property === "height" ? accumulatedChange : 0; + return; + } + const changeInWidth = property === "width" ? accumulatedChange : 0; + const changeInHeight = property === "height" ? accumulatedChange : 0; - let nextWidth = Math.max(0, origElement.width + changeInWidth); - if (property === "width") { - if (shouldChangeByStepSize) { - nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); - } else { - nextWidth = Math.round(nextWidth); - } + let nextWidth = Math.max(0, origElement.width + changeInWidth); + if (property === "width") { + if (shouldChangeByStepSize) { + nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); + } else { + nextWidth = Math.round(nextWidth); } + } - let nextHeight = Math.max(0, origElement.height + changeInHeight); - if (property === "height") { - if (shouldChangeByStepSize) { - nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); - } else { - nextHeight = Math.round(nextHeight); - } + let nextHeight = Math.max(0, origElement.height + changeInHeight); + if (property === "height") { + if (shouldChangeByStepSize) { + nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); + } else { + nextHeight = Math.round(nextHeight); } + } - if (keepAspectRatio) { - if (property === "width") { - nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; - } else { - nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; - } + if (keepAspectRatio) { + if (property === "width") { + nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; + } else { + nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; } + } - nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); + nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); + nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); - resizeElement( - nextWidth, - nextHeight, - keepAspectRatio, - element, - origElement, - elementsMap, - originalElementsMap, - ); - } - }; + resizeElement( + nextWidth, + nextHeight, + keepAspectRatio, + origElement, + elementsMap, + ); + } +}; +const DimensionDragInput = ({ + property, + element, + scene, + appState, +}: DimensionDragInputProps) => { const value = Math.round((property === "width" ? element.width : element.height) * 100) / 100; @@ -119,6 +124,9 @@ const DimensionDragInput = ({ dragInputCallback={handleDimensionChange} value={value} editable={isPropertyEditable(element, property)} + scene={scene} + appState={appState} + property={property} /> ); }; diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 36d92e9d3..0218e8369 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -3,23 +3,19 @@ import { EVENT } from "../../constants"; import { KEYS } from "../../keys"; import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import { deepCopyElement } from "../../element/newElement"; - -import "./DragInput.scss"; import clsx from "clsx"; import { useApp } from "../App"; import { InlineIcon } from "../InlineIcon"; +import type { StatsInputProperty } from "./utils"; import { SMALLEST_DELTA } from "./utils"; import { StoreAction } from "../../store"; +import type Scene from "../../scene/Scene"; -export type DragInputCallbackType = ({ - accumulatedChange, - instantChange, - originalElements, - originalElementsMap, - shouldKeepAspectRatio, - shouldChangeByStepSize, - nextValue, -}: { +import "./DragInput.scss"; +import type { AppState } from "../../types"; +import { cloneJSON } from "../../utils"; + +export type DragInputCallbackType = (props: { accumulatedChange: number; instantChange: number; originalElements: readonly ExcalidrawElement[]; @@ -27,19 +23,25 @@ export type DragInputCallbackType = ({ shouldKeepAspectRatio: boolean; shouldChangeByStepSize: boolean; nextValue?: number; + property: T; + scene: Scene; + originalAppState: AppState; }) => void; -interface StatsDragInputProps { +interface StatsDragInputProps { label: string | React.ReactNode; icon?: React.ReactNode; value: number | "Mixed"; elements: readonly ExcalidrawElement[]; editable?: boolean; shouldKeepAspectRatio?: boolean; - dragInputCallback: DragInputCallbackType; + dragInputCallback: DragInputCallbackType; + property: T; + scene: Scene; + appState: AppState; } -const StatsDragInput = ({ +const StatsDragInput = ({ label, icon, dragInputCallback, @@ -47,19 +49,48 @@ const StatsDragInput = ({ elements, editable = true, shouldKeepAspectRatio, -}: StatsDragInputProps) => { + property, + scene, + appState, +}: StatsDragInputProps) => { const app = useApp(); const inputRef = useRef(null); const labelRef = useRef(null); const [inputValue, setInputValue] = useState(value.toString()); + const stateRef = useRef<{ + originalAppState: AppState; + originalElements: readonly ExcalidrawElement[]; + lastUpdatedValue: string; + updatePending: boolean; + }>(null!); + if (!stateRef.current) { + stateRef.current = { + originalAppState: cloneJSON(appState), + originalElements: elements, + lastUpdatedValue: inputValue, + updatePending: false, + }; + } + useEffect(() => { - setInputValue(value.toString()); - }, [value, elements]); + const inputValue = value.toString(); + setInputValue(inputValue); + stateRef.current.lastUpdatedValue = inputValue; + }, [value]); + + const handleInputValue = ( + updatedValue: string, + elements: readonly ExcalidrawElement[], + appState: AppState, + ) => { + if (!stateRef.current.updatePending) { + return false; + } + stateRef.current.updatePending = false; - const handleInputValue = (v: string) => { - const parsed = Number(v); + const parsed = Number(updatedValue); if (isNaN(parsed)) { setInputValue(value.toString()); return; @@ -74,6 +105,7 @@ const StatsDragInput = ({ // than the smallest delta allowed, which is 0.01 // reason: idempotent to avoid unnecessary if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) { + stateRef.current.lastUpdatedValue = updatedValue; dragInputCallback({ accumulatedChange: 0, instantChange: 0, @@ -82,6 +114,9 @@ const StatsDragInput = ({ shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldChangeByStepSize: false, nextValue: rounded, + property, + scene, + originalAppState: appState, }); app.syncActionResult({ storeAction: StoreAction.CAPTURE }); } @@ -97,12 +132,28 @@ const StatsDragInput = ({ return () => { const nextValue = input?.value; if (nextValue) { - handleInputValueRef.current(nextValue); + handleInputValueRef.current( + nextValue, + stateRef.current.originalElements, + stateRef.current.originalAppState, + ); } }; - }, []); + }, [ + // we need to track change of `editable` state as mount/unmount + // because react doesn't trigger `blur` when a an input is blurred due + // to being disabled (https://github.com/facebook/react/issues/9142). + // As such, if we keep rendering disabled inputs, then change in selection + // to an element that has a given property as non-editable would not trigger + // blur/unmount and wouldn't update the value. + editable, + ]); - return editable ? ( + if (!editable) { + return null; + } + + return (
| null = null; + const originalAppState: AppState = cloneJSON(appState); let accumulatedChange: number | null = null; @@ -165,6 +217,9 @@ const StatsDragInput = ({ originalElementsMap, shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldChangeByStepSize: event.shiftKey, + property, + scene, + originalAppState, }); } @@ -216,7 +271,7 @@ const StatsDragInput = ({ eventTarget instanceof HTMLInputElement && event.key === KEYS.ENTER ) { - handleInputValue(eventTarget.value); + handleInputValue(eventTarget.value, elements, appState); app.focusContainer(); } } @@ -224,23 +279,28 @@ const StatsDragInput = ({ ref={inputRef} value={inputValue} onChange={(event) => { + stateRef.current.updatePending = true; setInputValue(event.target.value); }} onFocus={(event) => { event.target.select(); + stateRef.current.originalElements = elements; + stateRef.current.originalAppState = cloneJSON(appState); }} onBlur={(event) => { if (!inputValue) { setInputValue(value.toString()); } else if (editable) { - handleInputValue(event.target.value); + handleInputValue( + event.target.value, + stateRef.current.originalElements, + stateRef.current.originalAppState, + ); } }} disabled={!editable} />
- ) : ( - <> ); }; diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx index 43ef6f44a..8ed136f4f 100644 --- a/packages/excalidraw/components/Stats/FontSize.tsx +++ b/packages/excalidraw/components/Stats/FontSize.tsx @@ -1,66 +1,80 @@ -import type { ElementsMap, ExcalidrawTextElement } from "../../element/types"; +import type { ExcalidrawTextElement } from "../../element/types"; import { refreshTextDimensions } from "../../element/newElement"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { mutateElement } from "../../element/mutateElement"; import { getStepSizedValue } from "./utils"; import { fontSizeIcon } from "../icons"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; +import { isTextElement } from "../../element"; interface FontSizeProps { element: ExcalidrawTextElement; - elementsMap: ElementsMap; + scene: Scene; + appState: AppState; + property: "fontSize"; } const MIN_FONT_SIZE = 4; const STEP_SIZE = 4; -const FontSize = ({ element, elementsMap }: FontSizeProps) => { - const handleFontSizeChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - shouldChangeByStepSize, - nextValue, - }) => { - const origElement = originalElements[0]; - if (origElement) { - if (nextValue !== undefined) { - const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); +const handleFontSizeChange: DragInputCallbackType< + FontSizeProps["property"] +> = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); - const newElement = { - ...element, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); - mutateElement(element, { - ...updates, - fontSize: nextFontSize, - }); - return; - } + const origElement = originalElements[0]; + if (origElement) { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement || !isTextElement(latestElement)) { + return; + } + if (nextValue !== undefined) { + const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); - if (origElement.type === "text") { - const originalFontSize = Math.round(origElement.fontSize); - const changeInFontSize = Math.round(accumulatedChange); - let nextFontSize = Math.max( - originalFontSize + changeInFontSize, - MIN_FONT_SIZE, - ); - if (shouldChangeByStepSize) { - nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); - } - const newElement = { - ...element, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); - mutateElement(element, { - ...updates, - fontSize: nextFontSize, - }); + const newElement = { + ...latestElement, + fontSize: nextFontSize, + }; + const updates = refreshTextDimensions(newElement, null, elementsMap); + mutateElement(latestElement, { + ...updates, + fontSize: nextFontSize, + }); + return; + } + + if (origElement.type === "text") { + const originalFontSize = Math.round(origElement.fontSize); + const changeInFontSize = Math.round(accumulatedChange); + let nextFontSize = Math.max( + originalFontSize + changeInFontSize, + MIN_FONT_SIZE, + ); + if (shouldChangeByStepSize) { + nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); } + const newElement = { + ...latestElement, + fontSize: nextFontSize, + }; + const updates = refreshTextDimensions(newElement, null, elementsMap); + mutateElement(latestElement, { + ...updates, + fontSize: nextFontSize, + }); } - }; + } +}; +const FontSize = ({ element, scene, appState, property }: FontSizeProps) => { return ( { elements={[element]} dragInputCallback={handleFontSizeChange} icon={fontSizeIcon} + appState={appState} + scene={scene} + property={property} /> ); }; diff --git a/packages/excalidraw/components/Stats/MultiAngle.tsx b/packages/excalidraw/components/Stats/MultiAngle.tsx index 0bb60c5d5..5e420a467 100644 --- a/packages/excalidraw/components/Stats/MultiAngle.tsx +++ b/packages/excalidraw/components/Stats/MultiAngle.tsx @@ -1,7 +1,7 @@ import { mutateElement } from "../../element/mutateElement"; import { getBoundTextElement } from "../../element/textElement"; import { isArrowElement } from "../../element/typeChecks"; -import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import type { ExcalidrawElement } from "../../element/types"; import { isInGroup } from "../../groups"; import { degreeToRadian, radianToDegree } from "../../math"; import type Scene from "../../scene/Scene"; @@ -9,84 +9,102 @@ import { angleIcon } from "../icons"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, isPropertyEditable } from "./utils"; +import type { AppState } from "../../types"; interface MultiAngleProps { elements: readonly ExcalidrawElement[]; - elementsMap: ElementsMap; scene: Scene; + appState: AppState; + property: "angle"; } const STEP_SIZE = 15; -const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => { - const handleDegreeChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - shouldChangeByStepSize, - nextValue, - }) => { - const editableLatestIndividualElements = elements.filter( - (el) => !isInGroup(el) && isPropertyEditable(el, "angle"), - ); - const editableOriginalIndividualElements = originalElements.filter( - (el) => !isInGroup(el) && isPropertyEditable(el, "angle"), - ); - - if (nextValue !== undefined) { - const nextAngle = degreeToRadian(nextValue); - - for (const element of editableLatestIndividualElements) { - mutateElement( - element, - { - angle: nextAngle, - }, - false, - ); - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement && !isArrowElement(element)) { - mutateElement(boundTextElement, { angle: nextAngle }, false); - } - } +const handleDegreeChange: DragInputCallbackType< + MultiAngleProps["property"] +> = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + property, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const editableLatestIndividualElements = originalElements + .map((el) => elementsMap.get(el.id)) + .filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property)); + const editableOriginalIndividualElements = originalElements.filter( + (el) => !isInGroup(el) && isPropertyEditable(el, property), + ); - scene.triggerUpdate(); + if (nextValue !== undefined) { + const nextAngle = degreeToRadian(nextValue); - return; - } - - for (let i = 0; i < editableLatestIndividualElements.length; i++) { - const latestElement = editableLatestIndividualElements[i]; - const originalElement = editableOriginalIndividualElements[i]; - const originalAngleInDegrees = - Math.round(radianToDegree(originalElement.angle) * 100) / 100; - const changeInDegrees = Math.round(accumulatedChange); - let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; - if (shouldChangeByStepSize) { - nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE); + for (const element of editableLatestIndividualElements) { + if (!element) { + continue; } - - nextAngleInDegrees = - nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; - - const nextAngle = degreeToRadian(nextAngleInDegrees); - mutateElement( - latestElement, + element, { angle: nextAngle, }, false, ); - const boundTextElement = getBoundTextElement(latestElement, elementsMap); - if (boundTextElement && !isArrowElement(latestElement)) { + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement && !isArrowElement(element)) { mutateElement(boundTextElement, { angle: nextAngle }, false); } } + scene.triggerUpdate(); - }; + return; + } + + for (let i = 0; i < editableLatestIndividualElements.length; i++) { + const latestElement = editableLatestIndividualElements[i]; + if (!latestElement) { + continue; + } + const originalElement = editableOriginalIndividualElements[i]; + const originalAngleInDegrees = + Math.round(radianToDegree(originalElement.angle) * 100) / 100; + const changeInDegrees = Math.round(accumulatedChange); + let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; + if (shouldChangeByStepSize) { + nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE); + } + + nextAngleInDegrees = + nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; + + const nextAngle = degreeToRadian(nextAngleInDegrees); + + mutateElement( + latestElement, + { + angle: nextAngle, + }, + false, + ); + + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + if (boundTextElement && !isArrowElement(latestElement)) { + mutateElement(boundTextElement, { angle: nextAngle }, false); + } + } + scene.triggerUpdate(); +}; + +const MultiAngle = ({ + elements, + scene, + appState, + property, +}: MultiAngleProps) => { const editableLatestIndividualElements = elements.filter( (el) => !isInGroup(el) && isPropertyEditable(el, "angle"), ); @@ -107,6 +125,9 @@ const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => { elements={elements} dragInputCallback={handleDegreeChange} editable={editable} + appState={appState} + scene={scene} + property={property} /> ); }; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 78cd5e788..e6fd715e9 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -9,10 +9,10 @@ import { } from "../../element/textElement"; import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type Scene from "../../scene/Scene"; -import type { Point } from "../../types"; +import type { AppState, Point } from "../../types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; -import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils"; import type { AtomicUnit } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; @@ -23,6 +23,7 @@ interface MultiDimensionProps { elementsMap: ElementsMap; atomicUnits: AtomicUnit[]; scene: Scene; + appState: AppState; } const STEP_SIZE = 10; @@ -131,143 +132,21 @@ const resizeGroup = ( } }; -const MultiDimension = ({ - property, - elements, - elementsMap, - atomicUnits, +const handleDimensionChange: DragInputCallbackType< + MultiDimensionProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + originalAppState, + shouldChangeByStepSize, + nextValue, scene, -}: MultiDimensionProps) => { - const sizes = useMemo( - () => - atomicUnits.map((atomicUnit) => { - const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap); - - if (elementsInUnit.length > 1) { - const [x1, y1, x2, y2] = getCommonBounds( - elementsInUnit.map((el) => el.latest), - ); - return ( - Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100 - ); - } - const [el] = elementsInUnit; - - return ( - Math.round( - (property === "width" ? el.latest.width : el.latest.height) * 100, - ) / 100 - ); - }), - [elementsMap, atomicUnits, property], - ); - - const value = - new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed"; - - const editable = sizes.length > 0; - - const handleDimensionChange: DragInputCallbackType = ({ - accumulatedChange, - originalElementsMap, - shouldChangeByStepSize, - nextValue, - }) => { - if (nextValue !== undefined) { - for (const atomicUnit of atomicUnits) { - const elementsInUnit = getElementsInAtomicUnit( - atomicUnit, - elementsMap, - originalElementsMap, - ); - - if (elementsInUnit.length > 1) { - const latestElements = elementsInUnit.map((el) => el.latest!); - const originalElements = elementsInUnit.map((el) => el.original!); - const [x1, y1, x2, y2] = getCommonBounds(originalElements); - const initialWidth = x2 - x1; - const initialHeight = y2 - y1; - const aspectRatio = initialWidth / initialHeight; - const nextWidth = Math.max( - MIN_WIDTH_OR_HEIGHT, - property === "width" ? Math.max(0, nextValue) : initialWidth, - ); - const nextHeight = Math.max( - MIN_WIDTH_OR_HEIGHT, - property === "height" ? Math.max(0, nextValue) : initialHeight, - ); - - resizeGroup( - nextWidth, - nextHeight, - initialHeight, - aspectRatio, - [x1, y1], - property, - latestElements, - originalElements, - elementsMap, - originalElementsMap, - ); - } else { - const [el] = elementsInUnit; - const latestElement = el?.latest; - const origElement = el?.original; - - if ( - latestElement && - origElement && - isPropertyEditable(latestElement, property) - ) { - let nextWidth = - property === "width" - ? Math.max(0, nextValue) - : latestElement.width; - if (property === "width") { - if (shouldChangeByStepSize) { - nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); - } else { - nextWidth = Math.round(nextWidth); - } - } - - let nextHeight = - property === "height" - ? Math.max(0, nextValue) - : latestElement.height; - if (property === "height") { - if (shouldChangeByStepSize) { - nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); - } else { - nextHeight = Math.round(nextHeight); - } - } - - nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); - nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - - resizeElement( - nextWidth, - nextHeight, - false, - latestElement, - origElement, - elementsMap, - originalElementsMap, - false, - ); - } - } - } - - scene.triggerUpdate(); - - return; - } - - const changeInWidth = property === "width" ? accumulatedChange : 0; - const changeInHeight = property === "height" ? accumulatedChange : 0; - + property, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const atomicUnits = getAtomicUnits(originalElements, originalAppState); + if (nextValue !== undefined) { for (const atomicUnit of atomicUnits) { const elementsInUnit = getElementsInAtomicUnit( atomicUnit, @@ -278,31 +157,18 @@ const MultiDimension = ({ if (elementsInUnit.length > 1) { const latestElements = elementsInUnit.map((el) => el.latest!); const originalElements = elementsInUnit.map((el) => el.original!); - const [x1, y1, x2, y2] = getCommonBounds(originalElements); const initialWidth = x2 - x1; const initialHeight = y2 - y1; const aspectRatio = initialWidth / initialHeight; - let nextWidth = Math.max(0, initialWidth + changeInWidth); - if (property === "width") { - if (shouldChangeByStepSize) { - nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); - } else { - nextWidth = Math.round(nextWidth); - } - } - - let nextHeight = Math.max(0, initialHeight + changeInHeight); - if (property === "height") { - if (shouldChangeByStepSize) { - nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); - } else { - nextHeight = Math.round(nextHeight); - } - } - - nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); - nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); + const nextWidth = Math.max( + MIN_WIDTH_OR_HEIGHT, + property === "width" ? Math.max(0, nextValue) : initialWidth, + ); + const nextHeight = Math.max( + MIN_WIDTH_OR_HEIGHT, + property === "height" ? Math.max(0, nextValue) : initialHeight, + ); resizeGroup( nextWidth, @@ -326,7 +192,8 @@ const MultiDimension = ({ origElement && isPropertyEditable(latestElement, property) ) { - let nextWidth = Math.max(0, origElement.width + changeInWidth); + let nextWidth = + property === "width" ? Math.max(0, nextValue) : latestElement.width; if (property === "width") { if (shouldChangeByStepSize) { nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); @@ -335,7 +202,10 @@ const MultiDimension = ({ } } - let nextHeight = Math.max(0, origElement.height + changeInHeight); + let nextHeight = + property === "height" + ? Math.max(0, nextValue) + : latestElement.height; if (property === "height") { if (shouldChangeByStepSize) { nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); @@ -351,17 +221,145 @@ const MultiDimension = ({ nextWidth, nextHeight, false, - latestElement, origElement, elementsMap, - originalElementsMap, + false, ); } } } scene.triggerUpdate(); - }; + + return; + } + + const changeInWidth = property === "width" ? accumulatedChange : 0; + const changeInHeight = property === "height" ? accumulatedChange : 0; + + for (const atomicUnit of atomicUnits) { + const elementsInUnit = getElementsInAtomicUnit( + atomicUnit, + elementsMap, + originalElementsMap, + ); + + if (elementsInUnit.length > 1) { + const latestElements = elementsInUnit.map((el) => el.latest!); + const originalElements = elementsInUnit.map((el) => el.original!); + + const [x1, y1, x2, y2] = getCommonBounds(originalElements); + const initialWidth = x2 - x1; + const initialHeight = y2 - y1; + const aspectRatio = initialWidth / initialHeight; + let nextWidth = Math.max(0, initialWidth + changeInWidth); + if (property === "width") { + if (shouldChangeByStepSize) { + nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); + } else { + nextWidth = Math.round(nextWidth); + } + } + + let nextHeight = Math.max(0, initialHeight + changeInHeight); + if (property === "height") { + if (shouldChangeByStepSize) { + nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); + } else { + nextHeight = Math.round(nextHeight); + } + } + + nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); + nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); + + resizeGroup( + nextWidth, + nextHeight, + initialHeight, + aspectRatio, + [x1, y1], + property, + latestElements, + originalElements, + elementsMap, + originalElementsMap, + ); + } else { + const [el] = elementsInUnit; + const latestElement = el?.latest; + const origElement = el?.original; + + if ( + latestElement && + origElement && + isPropertyEditable(latestElement, property) + ) { + let nextWidth = Math.max(0, origElement.width + changeInWidth); + if (property === "width") { + if (shouldChangeByStepSize) { + nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); + } else { + nextWidth = Math.round(nextWidth); + } + } + + let nextHeight = Math.max(0, origElement.height + changeInHeight); + if (property === "height") { + if (shouldChangeByStepSize) { + nextHeight = getStepSizedValue(nextHeight, STEP_SIZE); + } else { + nextHeight = Math.round(nextHeight); + } + } + + nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); + nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); + + resizeElement(nextWidth, nextHeight, false, origElement, elementsMap); + } + } + } + + scene.triggerUpdate(); +}; + +const MultiDimension = ({ + property, + elements, + elementsMap, + atomicUnits, + scene, + appState, +}: MultiDimensionProps) => { + const sizes = useMemo( + () => + atomicUnits.map((atomicUnit) => { + const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap); + + if (elementsInUnit.length > 1) { + const [x1, y1, x2, y2] = getCommonBounds( + elementsInUnit.map((el) => el.latest), + ); + return ( + Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100 + ); + } + const [el] = elementsInUnit; + + return ( + Math.round( + (property === "width" ? el.latest.width : el.latest.height) * 100, + ) / 100 + ); + }), + [elementsMap, atomicUnits, property], + ); + + const value = + new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed"; + + const editable = sizes.length > 0; return ( ); }; diff --git a/packages/excalidraw/components/Stats/MultiFontSize.tsx b/packages/excalidraw/components/Stats/MultiFontSize.tsx index 5afbe4a31..2d4ecfc6c 100644 --- a/packages/excalidraw/components/Stats/MultiFontSize.tsx +++ b/packages/excalidraw/components/Stats/MultiFontSize.tsx @@ -2,7 +2,6 @@ import { isTextElement, refreshTextDimensions } from "../../element"; import { mutateElement } from "../../element/mutateElement"; import { isBoundToContainer } from "../../element/typeChecks"; import type { - ElementsMap, ExcalidrawElement, ExcalidrawTextElement, } from "../../element/types"; @@ -12,83 +11,56 @@ import { fontSizeIcon } from "../icons"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue } from "./utils"; +import type { AppState } from "../../types"; interface MultiFontSizeProps { elements: readonly ExcalidrawElement[]; - elementsMap: ElementsMap; scene: Scene; + appState: AppState; + property: "fontSize"; } const MIN_FONT_SIZE = 4; const STEP_SIZE = 4; -const MultiFontSize = ({ - elements, - elementsMap, - scene, -}: MultiFontSizeProps) => { - const latestTextElements = elements.filter( - (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), +const getApplicableTextElements = ( + elements: readonly (ExcalidrawElement | undefined)[], +) => + elements.filter( + (el) => + el && !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), ) as ExcalidrawTextElement[]; - const fontSizes = latestTextElements.map( - (textEl) => Math.round(textEl.fontSize * 10) / 10, - ); - const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed"; - const editable = fontSizes.length > 0; - const handleFontSizeChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - shouldChangeByStepSize, - nextValue, - }) => { - if (nextValue) { - const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); - - for (const textElement of latestTextElements) { - const newElement = { - ...textElement, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); - mutateElement( - textElement, - { - ...updates, - fontSize: nextFontSize, - }, - false, - ); - } - - scene.triggerUpdate(); - return; - } - - const originalTextElements = originalElements.filter( - (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), - ) as ExcalidrawTextElement[]; +const handleFontSizeChange: DragInputCallbackType< + MultiFontSizeProps["property"] +> = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const latestTextElements = getApplicableTextElements( + originalElements.map((el) => elementsMap.get(el.id)), + ); - for (let i = 0; i < latestTextElements.length; i++) { - const latestElement = latestTextElements[i]; - const originalElement = originalTextElements[i]; + if (nextValue) { + const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); - const originalFontSize = Math.round(originalElement.fontSize); - const changeInFontSize = Math.round(accumulatedChange); - let nextFontSize = Math.max( - originalFontSize + changeInFontSize, - MIN_FONT_SIZE, - ); - if (shouldChangeByStepSize) { - nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); + for (const textElement of latestTextElements.map((el) => + elementsMap.get(el.id), + )) { + if (!textElement || !isTextElement(textElement)) { + continue; } const newElement = { - ...latestElement, + ...textElement, fontSize: nextFontSize, }; const updates = refreshTextDimensions(newElement, null, elementsMap); mutateElement( - latestElement, + textElement, { ...updates, fontSize: nextFontSize, @@ -98,7 +70,56 @@ const MultiFontSize = ({ } scene.triggerUpdate(); - }; + return; + } + + const originalTextElements = originalElements.filter( + (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), + ) as ExcalidrawTextElement[]; + + for (let i = 0; i < latestTextElements.length; i++) { + const latestElement = latestTextElements[i]; + const originalElement = originalTextElements[i]; + + const originalFontSize = Math.round(originalElement.fontSize); + const changeInFontSize = Math.round(accumulatedChange); + let nextFontSize = Math.max( + originalFontSize + changeInFontSize, + MIN_FONT_SIZE, + ); + if (shouldChangeByStepSize) { + nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); + } + const newElement = { + ...latestElement, + fontSize: nextFontSize, + }; + const updates = refreshTextDimensions(newElement, null, elementsMap); + mutateElement( + latestElement, + { + ...updates, + fontSize: nextFontSize, + }, + false, + ); + } + + scene.triggerUpdate(); +}; + +const MultiFontSize = ({ + elements, + scene, + appState, + property, +}: MultiFontSizeProps) => { + const latestTextElements = getApplicableTextElements(elements); + const fontSizes = latestTextElements.map( + (textEl) => Math.round(textEl.fontSize * 10) / 10, + ); + const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed"; + const editable = fontSizes.length > 0; return ( ); }; diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 8cad72acd..c7f3491b4 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -3,11 +3,12 @@ import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; -import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getCommonBounds, isTextElement } from "../../element"; import { useMemo } from "react"; import { getElementsInAtomicUnit, moveElement } from "./utils"; import type { AtomicUnit } from "./utils"; +import type { AppState } from "../../types"; interface MultiPositionProps { property: "x" | "y"; @@ -15,6 +16,7 @@ interface MultiPositionProps { elementsMap: ElementsMap; atomicUnits: AtomicUnit[]; scene: Scene; + appState: AppState; } const STEP_SIZE = 10; @@ -30,7 +32,6 @@ const moveElements = ( ) => { for (let i = 0; i < elements.length; i++) { const origElement = originalElements[i]; - const latestElement = elements[i]; const [cx, cy] = [ origElement.x + origElement.width / 2, @@ -53,7 +54,6 @@ const moveElements = ( moveElement( newTopLeftX, newTopLeftY, - latestElement, origElement, elementsMap, originalElementsMap, @@ -65,7 +65,6 @@ const moveElements = ( const moveGroupTo = ( nextX: number, nextY: number, - latestElements: ExcalidrawElement[], originalElements: ExcalidrawElement[], elementsMap: ElementsMap, originalElementsMap: ElementsMap, @@ -74,9 +73,13 @@ const moveGroupTo = ( const offsetX = nextX - x1; const offsetY = nextY - y1; - for (let i = 0; i < latestElements.length; i++) { + for (let i = 0; i < originalElements.length; i++) { const origElement = originalElements[i]; - const latestElement = latestElements[i]; + + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + continue; + } // bound texts are moved with their containers if (!isTextElement(latestElement) || !latestElement.containerId) { @@ -96,7 +99,6 @@ const moveGroupTo = ( moveElement( topLeftX + offsetX, topLeftY + offsetY, - latestElement, origElement, elementsMap, originalElementsMap, @@ -106,12 +108,110 @@ const moveGroupTo = ( } }; +const handlePositionChange: DragInputCallbackType< + MultiPositionProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldChangeByStepSize, + nextValue, + property, + scene, + originalAppState, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + + if (nextValue !== undefined) { + for (const atomicUnit of getAtomicUnits( + originalElements, + originalAppState, + )) { + const elementsInUnit = getElementsInAtomicUnit( + atomicUnit, + elementsMap, + originalElementsMap, + ); + + if (elementsInUnit.length > 1) { + const [x1, y1, ,] = getCommonBounds( + elementsInUnit.map((el) => el.latest!), + ); + const newTopLeftX = property === "x" ? nextValue : x1; + const newTopLeftY = property === "y" ? nextValue : y1; + + moveGroupTo( + newTopLeftX, + newTopLeftY, + elementsInUnit.map((el) => el.original), + elementsMap, + originalElementsMap, + ); + } else { + const origElement = elementsInUnit[0]?.original; + const latestElement = elementsInUnit[0]?.latest; + if ( + origElement && + latestElement && + isPropertyEditable(latestElement, property) + ) { + const [cx, cy] = [ + origElement.x + origElement.width / 2, + origElement.y + origElement.height / 2, + ]; + const [topLeftX, topLeftY] = rotate( + origElement.x, + origElement.y, + cx, + cy, + origElement.angle, + ); + + const newTopLeftX = property === "x" ? nextValue : topLeftX; + const newTopLeftY = property === "y" ? nextValue : topLeftY; + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + elementsMap, + originalElementsMap, + false, + ); + } + } + } + + scene.triggerUpdate(); + return; + } + + const change = shouldChangeByStepSize + ? getStepSizedValue(accumulatedChange, STEP_SIZE) + : accumulatedChange; + + const changeInTopX = property === "x" ? change : 0; + const changeInTopY = property === "y" ? change : 0; + + moveElements( + property, + changeInTopX, + changeInTopY, + originalElements, + originalElements, + elementsMap, + originalElementsMap, + ); + + scene.triggerUpdate(); +}; + const MultiPosition = ({ property, elements, elementsMap, atomicUnits, scene, + appState, }: MultiPositionProps) => { const positions = useMemo( () => @@ -137,101 +237,15 @@ const MultiPosition = ({ const value = new Set(positions).size === 1 ? positions[0] : "Mixed"; - const handlePositionChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - originalElementsMap, - shouldChangeByStepSize, - nextValue, - }) => { - if (nextValue !== undefined) { - for (const atomicUnit of atomicUnits) { - const elementsInUnit = getElementsInAtomicUnit( - atomicUnit, - elementsMap, - originalElementsMap, - ); - - if (elementsInUnit.length > 1) { - const [x1, y1, ,] = getCommonBounds( - elementsInUnit.map((el) => el.latest!), - ); - const newTopLeftX = property === "x" ? nextValue : x1; - const newTopLeftY = property === "y" ? nextValue : y1; - - moveGroupTo( - newTopLeftX, - newTopLeftY, - elementsInUnit.map((el) => el.latest), - elementsInUnit.map((el) => el.original), - elementsMap, - originalElementsMap, - ); - } else { - const origElement = elementsInUnit[0]?.original; - const latestElement = elementsInUnit[0]?.latest; - if ( - origElement && - latestElement && - isPropertyEditable(latestElement, property) - ) { - const [cx, cy] = [ - origElement.x + origElement.width / 2, - origElement.y + origElement.height / 2, - ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, - origElement.angle, - ); - - const newTopLeftX = property === "x" ? nextValue : topLeftX; - const newTopLeftY = property === "y" ? nextValue : topLeftY; - moveElement( - newTopLeftX, - newTopLeftY, - latestElement, - origElement, - elementsMap, - originalElementsMap, - false, - ); - } - } - } - - scene.triggerUpdate(); - return; - } - - const change = shouldChangeByStepSize - ? getStepSizedValue(accumulatedChange, STEP_SIZE) - : accumulatedChange; - - const changeInTopX = property === "x" ? change : 0; - const changeInTopY = property === "y" ? change : 0; - - moveElements( - property, - changeInTopX, - changeInTopY, - elements, - originalElements, - elementsMap, - originalElementsMap, - ); - - scene.triggerUpdate(); - }; - return ( ); }; diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index b04642b16..b3fcc8530 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -3,90 +3,101 @@ import { rotate } from "../../math"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, moveElement } from "./utils"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; interface PositionProps { property: "x" | "y"; element: ExcalidrawElement; elementsMap: ElementsMap; + scene: Scene; + appState: AppState; } const STEP_SIZE = 10; -const Position = ({ property, element, elementsMap }: PositionProps) => { +const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldChangeByStepSize, + nextValue, + property, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const origElement = originalElements[0]; + const [cx, cy] = [ + origElement.x + origElement.width / 2, + origElement.y + origElement.height / 2, + ]; const [topLeftX, topLeftY] = rotate( - element.x, - element.y, - element.x + element.width / 2, - element.y + element.height / 2, - element.angle, + origElement.x, + origElement.y, + cx, + cy, + origElement.angle, ); - const value = - Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; - - const handlePositionChange: DragInputCallbackType = ({ - accumulatedChange, - originalElements, - originalElementsMap, - shouldChangeByStepSize, - nextValue, - }) => { - const origElement = originalElements[0]; - const [cx, cy] = [ - origElement.x + origElement.width / 2, - origElement.y + origElement.height / 2, - ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, - origElement.angle, - ); - - if (nextValue !== undefined) { - const newTopLeftX = property === "x" ? nextValue : topLeftX; - const newTopLeftY = property === "y" ? nextValue : topLeftY; - moveElement( - newTopLeftX, - newTopLeftY, - element, - origElement, - elementsMap, - originalElementsMap, - ); - return; - } - - const changeInTopX = property === "x" ? accumulatedChange : 0; - const changeInTopY = property === "y" ? accumulatedChange : 0; - - const newTopLeftX = - property === "x" - ? Math.round( - shouldChangeByStepSize - ? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE) - : topLeftX + changeInTopX, - ) - : topLeftX; - - const newTopLeftY = - property === "y" - ? Math.round( - shouldChangeByStepSize - ? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE) - : topLeftY + changeInTopY, - ) - : topLeftY; + if (nextValue !== undefined) { + const newTopLeftX = property === "x" ? nextValue : topLeftX; + const newTopLeftY = property === "y" ? nextValue : topLeftY; moveElement( newTopLeftX, newTopLeftY, - element, origElement, elementsMap, originalElementsMap, ); - }; + return; + } + + const changeInTopX = property === "x" ? accumulatedChange : 0; + const changeInTopY = property === "y" ? accumulatedChange : 0; + + const newTopLeftX = + property === "x" + ? Math.round( + shouldChangeByStepSize + ? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE) + : topLeftX + changeInTopX, + ) + : topLeftX; + + const newTopLeftY = + property === "y" + ? Math.round( + shouldChangeByStepSize + ? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE) + : topLeftY + changeInTopY, + ) + : topLeftY; + + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + elementsMap, + originalElementsMap, + ); +}; + +const Position = ({ + property, + element, + elementsMap, + scene, + appState, +}: PositionProps) => { + const [topLeftX, topLeftY] = rotate( + element.x, + element.y, + element.x + element.width / 2, + element.y + element.height / 2, + element.angle, + ); + const value = + Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; return ( { elements={[element]} dragInputCallback={handlePositionChange} value={value} + property={property} + scene={scene} + appState={appState} /> ); }; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index 58f4cd055..f9c5673b0 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -11,12 +11,7 @@ import Angle from "./Angle"; import FontSize from "./FontSize"; import MultiDimension from "./MultiDimension"; -import { - elementsAreInSameGroup, - getElementsInGroup, - getSelectedGroupIds, - isInGroup, -} from "../../groups"; +import { elementsAreInSameGroup } from "../../groups"; import MultiAngle from "./MultiAngle"; import MultiFontSize from "./MultiFontSize"; import Position from "./Position"; @@ -24,8 +19,9 @@ import MultiPosition from "./MultiPosition"; import Collapsible from "./Collapsible"; import type Scene from "../../scene/Scene"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; -import type { AtomicUnit } from "./utils"; +import { getAtomicUnits } from "./utils"; import { STATS_PANELS } from "../../constants"; +import { isTextElement } from "../../element"; interface StatsProps { scene: Scene; @@ -106,21 +102,7 @@ export const StatsInner = memo( ); const atomicUnits = useMemo(() => { - const selectedGroupIds = getSelectedGroupIds(appState); - const _atomicUnits = selectedGroupIds.map((gid) => { - return getElementsInGroup(selectedElements, gid).reduce((acc, el) => { - acc[el.id] = true; - return acc; - }, {} as AtomicUnit); - }); - selectedElements - .filter((el) => !isInGroup(el)) - .forEach((el) => { - _atomicUnits.push({ - [el.id]: true, - }); - }); - return _atomicUnits; + return getAtomicUnits(selectedElements, appState); }, [selectedElements, appState]); return ( @@ -206,30 +188,40 @@ export const StatsInner = memo( element={singleElement} property="x" elementsMap={elementsMap} + scene={scene} + appState={appState} /> {singleElement.type === "text" && ( )} @@ -254,6 +246,7 @@ export const StatsInner = memo( elementsMap={elementsMap} atomicUnits={atomicUnits} scene={scene} + appState={appState} /> - + {multipleElements.some((el) => isTextElement(el)) && ( + + )} )} diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 5dc2a1981..7fb014c5e 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -30,6 +30,12 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); let stats: HTMLElement | null = null; let elementStats: HTMLElement | null | undefined = null; +const editInput = (input: HTMLInputElement, value: string) => { + input.focus(); + fireEvent.change(input, { target: { value } }); + input.blur(); +}; + const getStatsProperty = (label: string) => { if (elementStats) { const properties = elementStats?.querySelector(".statsItem"); @@ -53,9 +59,7 @@ const testInputProperty = ( ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(initialValue.toString()); - input?.focus(); - input.value = nextValue.toString(); - input?.blur(); + editInput(input, String(nextValue)); if (property === "angle") { expect(element[property]).toBe(degreeToRadian(Number(nextValue))); } else if (property === "fontSize" && isTextElement(element)) { @@ -172,17 +176,13 @@ describe("stats for a generic element", () => { ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(rectangle.width.toString()); - input?.focus(); - input.value = "123.123"; - input?.blur(); + editInput(input, "123.123"); expect(h.elements.length).toBe(1); expect(rectangle.id).toBe(rectangleId); expect(input.value).toBe("123.12"); expect(rectangle.width).toBe(123.12); - input?.focus(); - input.value = "88.98766"; - input?.blur(); + editInput(input, "88.98766"); expect(input.value).toBe("88.99"); expect(rectangle.width).toBe(88.99); }); @@ -335,9 +335,7 @@ describe("stats for a non-generic element", () => { ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(text.fontSize.toString()); - input?.focus(); - input.value = "36"; - input?.blur(); + editInput(input, "36"); expect(text.fontSize).toBe(36); // cannot change width or height @@ -347,9 +345,7 @@ describe("stats for a non-generic element", () => { expect(height).toBeUndefined(); // min font size is 4 - input.focus(); - input.value = "0"; - input.blur(); + editInput(input, "0"); expect(text.fontSize).not.toBe(0); expect(text.fontSize).toBe(4); }); @@ -471,16 +467,12 @@ describe("stats for multiple elements", () => { ) as HTMLInputElement; expect(angle.value).toBe("0"); - width.focus(); - width.value = "250"; - width.blur(); + editInput(width, "250"); h.elements.forEach((el) => { expect(el.width).toBe(250); }); - height.focus(); - height.value = "450"; - height.blur(); + editInput(height, "450"); h.elements.forEach((el) => { expect(el.height).toBe(450); }); @@ -501,7 +493,6 @@ describe("stats for multiple elements", () => { mouse.up(200, 100); const frame = API.createElement({ - id: "id0", type: "frame", x: 150, width: 150, @@ -545,17 +536,13 @@ describe("stats for multiple elements", () => { expect(fontSize).not.toBeNull(); // changing width does not affect text - width.focus(); - width.value = "200"; - width.blur(); + editInput(width, "200"); expect(rectangle?.width).toBe(200); expect(frame.width).toBe(200); expect(text?.width).not.toBe(200); - angle.focus(); - angle.value = "40"; - angle.blur(); + editInput(angle, "40"); const angleInRadian = degreeToRadian(40); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); @@ -595,9 +582,7 @@ describe("stats for multiple elements", () => { expect(x).not.toBeNull(); expect(Number(x.value)).toBe(x1); - x.focus(); - x.value = "300"; - x.blur(); + editInput(x, "300"); expect(h.elements[0].x).toBe(300); expect(h.elements[1].x).toBe(400); @@ -610,9 +595,7 @@ describe("stats for multiple elements", () => { expect(y).not.toBeNull(); expect(Number(y.value)).toBe(y1); - y.focus(); - y.value = "200"; - y.blur(); + editInput(y, "200"); expect(h.elements[0].y).toBe(200); expect(h.elements[1].y).toBe(300); @@ -630,26 +613,20 @@ describe("stats for multiple elements", () => { expect(height).not.toBeNull(); expect(Number(height.value)).toBe(200); - width.focus(); - width.value = "400"; - width.blur(); + editInput(width, "400"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); let newGroupWidth = x2 - x1; expect(newGroupWidth).toBeCloseTo(400, 4); - width.focus(); - width.value = "300"; - width.blur(); + editInput(width, "300"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); newGroupWidth = x2 - x1; expect(newGroupWidth).toBeCloseTo(300, 4); - height.focus(); - height.value = "500"; - height.blur(); + editInput(height, "500"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); const newGroupHeight = y2 - y1; diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 53ad767ef..1408b8a68 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -17,9 +17,23 @@ import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "../../element/types"; +import { + getSelectedGroupIds, + getElementsInGroup, + isInGroup, +} from "../../groups"; import { rotate } from "../../math"; +import type { AppState } from "../../types"; import { getFontString } from "../../utils"; +export type StatsInputProperty = + | "x" + | "y" + | "width" + | "height" + | "angle" + | "fontSize"; + export const SMALLEST_DELTA = 0.01; export const isPropertyEditable = ( @@ -100,12 +114,14 @@ export const resizeElement = ( nextWidth: number, nextHeight: number, keepAspectRatio: boolean, - latestElement: ExcalidrawElement, origElement: ExcalidrawElement, elementsMap: ElementsMap, - originalElementsMap: Map, shouldInformMutation = true, ) => { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + return; + } let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(latestElement, elementsMap); @@ -181,12 +197,15 @@ export const resizeElement = ( export const moveElement = ( newTopLeftX: number, newTopLeftY: number, - latestElement: ExcalidrawElement, originalElement: ExcalidrawElement, elementsMap: ElementsMap, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + const latestElement = elementsMap.get(originalElement.id); + if (!latestElement) { + return; + } const [cx, cy] = [ originalElement.x + originalElement.width / 2, originalElement.y + originalElement.height / 2, @@ -236,3 +255,24 @@ export const moveElement = ( ); } }; + +export const getAtomicUnits = ( + targetElements: readonly ExcalidrawElement[], + appState: AppState, +) => { + const selectedGroupIds = getSelectedGroupIds(appState); + const _atomicUnits = selectedGroupIds.map((gid) => { + return getElementsInGroup(targetElements, gid).reduce((acc, el) => { + acc[el.id] = true; + return acc; + }, {} as AtomicUnit); + }); + targetElements + .filter((el) => !isInGroup(el)) + .forEach((el) => { + _atomicUnits.push({ + [el.id]: true, + }); + }); + return _atomicUnits; +}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a00249142..a1f447b28 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -90,7 +90,7 @@ const shouldResetImageFilter = ( }; const getCanvasPadding = (element: ExcalidrawElement) => - element.type === "freedraw" ? element.strokeWidth * 12 : 20; + element.type === "freedraw" ? element.strokeWidth * 12 : 200; export const getRenderOpacity = ( element: ExcalidrawElement, diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index fb3cc20fc..6f478b310 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Drawable } from "roughjs/bin/core"; import type { ExcalidrawElement, - ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, @@ -96,10 +95,6 @@ export type SceneScroll = { scrollY: number; }; -export interface Scene { - elements: ExcalidrawTextElement[]; -} - export type ExportType = | "png" | "clipboard"