From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: packages/ --- packages/excalidraw/components/Stats/Angle.tsx | 95 +++ .../excalidraw/components/Stats/CanvasGrid.tsx | 67 ++ .../excalidraw/components/Stats/Collapsible.tsx | 46 ++ packages/excalidraw/components/Stats/Dimension.tsx | 272 ++++++++ .../excalidraw/components/Stats/DragInput.scss | 76 +++ packages/excalidraw/components/Stats/DragInput.tsx | 355 ++++++++++ packages/excalidraw/components/Stats/FontSize.tsx | 99 +++ .../excalidraw/components/Stats/MultiAngle.tsx | 136 ++++ .../excalidraw/components/Stats/MultiDimension.tsx | 401 ++++++++++++ .../excalidraw/components/Stats/MultiFontSize.tsx | 164 +++++ .../excalidraw/components/Stats/MultiPosition.tsx | 270 ++++++++ packages/excalidraw/components/Stats/Position.tsx | 214 ++++++ packages/excalidraw/components/Stats/Stats.scss | 72 ++ packages/excalidraw/components/Stats/index.tsx | 434 ++++++++++++ .../excalidraw/components/Stats/stats.test.tsx | 724 +++++++++++++++++++++ packages/excalidraw/components/Stats/utils.ts | 219 +++++++ 16 files changed, 3644 insertions(+) create mode 100644 packages/excalidraw/components/Stats/Angle.tsx create mode 100644 packages/excalidraw/components/Stats/CanvasGrid.tsx create mode 100644 packages/excalidraw/components/Stats/Collapsible.tsx create mode 100644 packages/excalidraw/components/Stats/Dimension.tsx create mode 100644 packages/excalidraw/components/Stats/DragInput.scss create mode 100644 packages/excalidraw/components/Stats/DragInput.tsx create mode 100644 packages/excalidraw/components/Stats/FontSize.tsx create mode 100644 packages/excalidraw/components/Stats/MultiAngle.tsx create mode 100644 packages/excalidraw/components/Stats/MultiDimension.tsx create mode 100644 packages/excalidraw/components/Stats/MultiFontSize.tsx create mode 100644 packages/excalidraw/components/Stats/MultiPosition.tsx create mode 100644 packages/excalidraw/components/Stats/Position.tsx create mode 100644 packages/excalidraw/components/Stats/Stats.scss create mode 100644 packages/excalidraw/components/Stats/index.tsx create mode 100644 packages/excalidraw/components/Stats/stats.test.tsx create mode 100644 packages/excalidraw/components/Stats/utils.ts (limited to 'packages/excalidraw/components/Stats') diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx new file mode 100644 index 0000000..409476a --- /dev/null +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -0,0 +1,95 @@ +import { mutateElement } from "../../element/mutateElement"; +import { getBoundTextElement } from "../../element/textElement"; +import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; +import type { ExcalidrawElement } from "../../element/types"; +import { angleIcon } from "../icons"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; +import type { Degrees } from "@excalidraw/math"; +import { degreesToRadians, radiansToDegrees } from "@excalidraw/math"; + +interface AngleProps { + element: ExcalidrawElement; + scene: Scene; + appState: AppState; + property: "angle"; +} + +const STEP_SIZE = 15; + +const handleDegreeChange: DragInputCallbackType = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + const origElement = originalElements[0]; + if (origElement && !isElbowArrow(origElement)) { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + return; + } + + if (nextValue !== undefined) { + const nextAngle = degreesToRadians(nextValue as Degrees); + mutateElement(latestElement, { + angle: nextAngle, + }); + updateBindings(latestElement, elementsMap, elements, scene); + + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + if (boundTextElement && !isArrowElement(latestElement)) { + mutateElement(boundTextElement, { angle: nextAngle }); + } + + return; + } + + const originalAngleInDegrees = + Math.round(radiansToDegrees(origElement.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 = degreesToRadians(nextAngleInDegrees as Degrees); + + mutateElement(latestElement, { + angle: nextAngle, + }); + updateBindings(latestElement, elementsMap, elements, scene); + + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + if (boundTextElement && !isArrowElement(latestElement)) { + mutateElement(boundTextElement, { angle: nextAngle }); + } + } +}; + +const Angle = ({ element, scene, appState, property }: AngleProps) => { + return ( + + ); +}; + +export default Angle; diff --git a/packages/excalidraw/components/Stats/CanvasGrid.tsx b/packages/excalidraw/components/Stats/CanvasGrid.tsx new file mode 100644 index 0000000..a08f709 --- /dev/null +++ b/packages/excalidraw/components/Stats/CanvasGrid.tsx @@ -0,0 +1,67 @@ +import StatsDragInput from "./DragInput"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; +import { getStepSizedValue } from "./utils"; +import { getNormalizedGridStep } from "../../scene"; + +interface PositionProps { + property: "gridStep"; + scene: Scene; + appState: AppState; + setAppState: React.Component["setState"]; +} + +const STEP_SIZE = 5; + +const CanvasGrid = ({ + property, + scene, + appState, + setAppState, +}: PositionProps) => { + return ( + { + setAppState((state) => { + let nextGridStep; + + if (nextValue) { + nextGridStep = nextValue; + } else if (instantChange) { + nextGridStep = shouldChangeByStepSize + ? getStepSizedValue( + state.gridStep + STEP_SIZE * Math.sign(instantChange), + STEP_SIZE, + ) + : state.gridStep + instantChange; + } + + if (!nextGridStep) { + setInputValue(state.gridStep); + return null; + } + + nextGridStep = getNormalizedGridStep(nextGridStep); + setInputValue(nextGridStep); + return { + gridStep: nextGridStep, + }; + }); + }} + scene={scene} + value={appState.gridStep} + property={property} + appState={appState} + /> + ); +}; + +export default CanvasGrid; diff --git a/packages/excalidraw/components/Stats/Collapsible.tsx b/packages/excalidraw/components/Stats/Collapsible.tsx new file mode 100644 index 0000000..13d476d --- /dev/null +++ b/packages/excalidraw/components/Stats/Collapsible.tsx @@ -0,0 +1,46 @@ +import { InlineIcon } from "../InlineIcon"; +import { collapseDownIcon, collapseUpIcon } from "../icons"; + +interface CollapsibleProps { + label: React.ReactNode; + // having it controlled so that the state is managed outside + // this is to keep the user's previous choice even when the + // Collapsible is unmounted + open: boolean; + openTrigger: () => void; + children: React.ReactNode; + className?: string; +} + +const Collapsible = ({ + label, + open, + openTrigger, + children, + className, +}: CollapsibleProps) => { + return ( + <> +
+ {label} + +
+ {open && ( +
+ {children} +
+ )} + + ); +}; + +export default Collapsible; diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx new file mode 100644 index 0000000..ce096d2 --- /dev/null +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -0,0 +1,272 @@ +import type { ExcalidrawElement } from "../../element/types"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import { resizeSingleElement } from "../../element/resizeElements"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; +import { isImageElement } from "../../element/typeChecks"; +import { + MINIMAL_CROP_SIZE, + getUncroppedWidthAndHeight, +} from "../../element/cropElement"; +import { mutateElement } from "../../element/mutateElement"; +import { clamp, round } from "@excalidraw/math"; + +interface DimensionDragInputProps { + property: "width" | "height"; + element: ExcalidrawElement; + scene: Scene; + appState: AppState; +} + +const STEP_SIZE = 10; +const _shouldKeepAspectRatio = (element: ExcalidrawElement) => { + return element.type === "image"; +}; + +const handleDimensionChange: DragInputCallbackType< + DimensionDragInputProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldKeepAspectRatio, + shouldChangeByStepSize, + nextValue, + property, + originalAppState, + instantChange, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const origElement = originalElements[0]; + const latestElement = elementsMap.get(origElement.id); + if (origElement && latestElement) { + const keepAspectRatio = + shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); + const aspectRatio = origElement.width / origElement.height; + + if (originalAppState.croppingElementId === origElement.id) { + const element = elementsMap.get(origElement.id); + + if (!element || !isImageElement(element) || !element.crop) { + return; + } + + const crop = element.crop; + let nextCrop = { ...crop }; + + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth; + const naturalToUncroppedHeightRatio = + crop.naturalHeight / uncroppedHeight; + + const MAX_POSSIBLE_WIDTH = isFlippedByX + ? crop.width + crop.x + : crop.naturalWidth - crop.x; + + const MAX_POSSIBLE_HEIGHT = isFlippedByY + ? crop.height + crop.y + : crop.naturalHeight - crop.y; + + const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio; + const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio; + + if (nextValue !== undefined) { + if (property === "width") { + const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio; + + const nextCropWidth = clamp( + nextValueInNatural, + MIN_WIDTH, + MAX_POSSIBLE_WIDTH, + ); + + nextCrop = { + ...nextCrop, + width: nextCropWidth, + x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x, + }; + } else if (property === "height") { + const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio; + const nextCropHeight = clamp( + nextValueInNatural, + MIN_HEIGHT, + MAX_POSSIBLE_HEIGHT, + ); + + nextCrop = { + ...nextCrop, + height: nextCropHeight, + y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y, + }; + } + + mutateElement(element, { + crop: nextCrop, + width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), + height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), + }); + return; + } + + const changeInWidth = property === "width" ? instantChange : 0; + const changeInHeight = property === "height" ? instantChange : 0; + + const nextCropWidth = clamp( + crop.width + changeInWidth, + MIN_WIDTH, + MAX_POSSIBLE_WIDTH, + ); + + const nextCropHeight = clamp( + crop.height + changeInHeight, + MIN_WIDTH, + MAX_POSSIBLE_HEIGHT, + ); + + nextCrop = { + ...crop, + x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x, + y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y, + width: nextCropWidth, + height: nextCropHeight, + }; + + mutateElement(element, { + crop: nextCrop, + width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), + height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), + }); + + return; + } + + 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, + ); + + resizeSingleElement( + nextWidth, + nextHeight, + latestElement, + origElement, + elementsMap, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldMaintainAspectRatio: keepAspectRatio, + }, + ); + + 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 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; + } + } + + nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); + nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); + + resizeSingleElement( + nextWidth, + nextHeight, + latestElement, + origElement, + elementsMap, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldMaintainAspectRatio: keepAspectRatio, + }, + ); + } +}; + +const DimensionDragInput = ({ + property, + element, + scene, + appState, +}: DimensionDragInputProps) => { + let value = round(property === "width" ? element.width : element.height, 2); + + if ( + appState.croppingElementId && + appState.croppingElementId === element.id && + isImageElement(element) && + element.crop + ) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + if (property === "width") { + const ratio = uncroppedWidth / element.crop.naturalWidth; + value = round(element.crop.width * ratio, 2); + } + if (property === "height") { + const ratio = uncroppedHeight / element.crop.naturalHeight; + value = round(element.crop.height * ratio, 2); + } + } + + return ( + + ); +}; + +export default DimensionDragInput; diff --git a/packages/excalidraw/components/Stats/DragInput.scss b/packages/excalidraw/components/Stats/DragInput.scss new file mode 100644 index 0000000..76b9d14 --- /dev/null +++ b/packages/excalidraw/components/Stats/DragInput.scss @@ -0,0 +1,76 @@ +.excalidraw { + .drag-input-container { + display: flex; + width: 100%; + + &:focus-within { + box-shadow: 0 0 0 1px var(--color-primary-darkest); + border-radius: var(--border-radius-md); + } + } + + .disabled { + opacity: 0.5; + pointer-events: none; + } + + .drag-input-label { + flex-shrink: 0; + border: 1px solid var(--default-border-color); + border-right: 0; + padding: 0 0.5rem 0 0.75rem; + min-width: 1rem; + height: 2rem; + box-sizing: border-box; + color: var(--popup-text-color); + + :root[dir="ltr"] & { + border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); + } + + :root[dir="rtl"] & { + border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; + border-right: 1px solid var(--default-border-color); + border-left: 0; + } + + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + + .drag-input { + box-sizing: border-box; + width: 100%; + margin: 0; + font-size: 0.875rem; + font-family: inherit; + background-color: transparent; + color: var(--text-primary-color); + border: 0; + outline: none; + height: 2rem; + border: 1px solid var(--default-border-color); + border-left: 0; + letter-spacing: 0.4px; + + :root[dir="ltr"] & { + border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; + } + + :root[dir="rtl"] & { + border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); + border-left: 1px solid var(--default-border-color); + border-right: 0; + } + + padding: 0.5rem; + padding-left: 0.25rem; + appearance: none; + + &:focus-visible { + box-shadow: none; + } + } +} diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx new file mode 100644 index 0000000..82d6419 --- /dev/null +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -0,0 +1,355 @@ +import { useEffect, useRef, useState } from "react"; +import { EVENT } from "../../constants"; +import { KEYS } from "../../keys"; +import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import { deepCopyElement } from "../../element/newElement"; +import clsx from "clsx"; +import { useApp } from "../App"; +import { InlineIcon } from "../InlineIcon"; +import type { StatsInputProperty } from "./utils"; +import { SMALLEST_DELTA } from "./utils"; +import { CaptureUpdateAction } from "../../store"; +import type Scene from "../../scene/Scene"; + +import "./DragInput.scss"; +import type { AppState } from "../../types"; +import { cloneJSON } from "../../utils"; + +export type DragInputCallbackType< + P extends StatsInputProperty, + E = ExcalidrawElement, +> = (props: { + accumulatedChange: number; + instantChange: number; + originalElements: readonly E[]; + originalElementsMap: ElementsMap; + shouldKeepAspectRatio: boolean; + shouldChangeByStepSize: boolean; + scene: Scene; + nextValue?: number; + property: P; + originalAppState: AppState; + setInputValue: (value: number) => void; +}) => void; + +interface StatsDragInputProps< + T extends StatsInputProperty, + E = ExcalidrawElement, +> { + label: string | React.ReactNode; + icon?: React.ReactNode; + value: number | "Mixed"; + elements: readonly E[]; + editable?: boolean; + shouldKeepAspectRatio?: boolean; + dragInputCallback: DragInputCallbackType; + property: T; + scene: Scene; + appState: AppState; + /** how many px you need to drag to get 1 unit change */ + sensitivity?: number; +} + +const StatsDragInput = < + T extends StatsInputProperty, + E extends ExcalidrawElement = ExcalidrawElement, +>({ + label, + icon, + dragInputCallback, + value, + elements, + editable = true, + shouldKeepAspectRatio, + property, + scene, + appState, + sensitivity = 1, +}: 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 E[]; + lastUpdatedValue: string; + updatePending: boolean; + }>(null!); + if (!stateRef.current) { + stateRef.current = { + originalAppState: cloneJSON(appState), + originalElements: elements, + lastUpdatedValue: inputValue, + updatePending: false, + }; + } + + useEffect(() => { + const inputValue = value.toString(); + setInputValue(inputValue); + stateRef.current.lastUpdatedValue = inputValue; + }, [value]); + + const handleInputValue = ( + updatedValue: string, + elements: readonly E[], + appState: AppState, + ) => { + if (!stateRef.current.updatePending) { + return false; + } + stateRef.current.updatePending = false; + + const parsed = Number(updatedValue); + if (isNaN(parsed)) { + setInputValue(value.toString()); + return; + } + + const rounded = Number(parsed.toFixed(2)); + const original = Number(value); + + // only update when + // 1. original was "Mixed" and we have a new value + // 2. original was not "Mixed" and the difference between a new value and previous value is greater + // 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, + originalElements: elements, + originalElementsMap: app.scene.getNonDeletedElementsMap(), + shouldKeepAspectRatio: shouldKeepAspectRatio!!, + shouldChangeByStepSize: false, + scene, + nextValue: rounded, + property, + originalAppState: appState, + setInputValue: (value) => setInputValue(String(value)), + }); + app.syncActionResult({ + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + } + }; + + const callbacksRef = useRef< + Partial<{ + handleInputValue: typeof handleInputValue; + onPointerUp: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + }> + >({}); + callbacksRef.current.handleInputValue = handleInputValue; + + // make sure that clicking on canvas (which umounts the component) + // updates current input value (blur isn't triggered) + useEffect(() => { + const input = inputRef.current; + const callbacks = callbacksRef.current; + return () => { + const nextValue = input?.value; + if (nextValue) { + callbacks.handleInputValue?.( + nextValue, + stateRef.current.originalElements, + stateRef.current.originalAppState, + ); + } + + // generally not needed, but in case `pointerup` doesn't fire and + // we don't remove the listeners that way, we should at least remove + // on unmount + window.removeEventListener( + EVENT.POINTER_MOVE, + callbacks.onPointerMove!, + false, + ); + window.removeEventListener( + EVENT.POINTER_UP, + callbacks.onPointerUp!, + false, + ); + }; + }, [ + // 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, + ]); + + if (!editable) { + return null; + } + + return ( +
+
{ + if (inputRef.current && editable) { + document.body.classList.add("excalidraw-cursor-resize"); + + let startValue = Number(inputRef.current.value); + if (isNaN(startValue)) { + startValue = 0; + } + + let lastPointer: { + x: number; + y: number; + } | null = null; + + let originalElementsMap: Map | null = + app.scene + .getNonDeletedElements() + .reduce((acc: ElementsMap, element) => { + acc.set(element.id, deepCopyElement(element)); + return acc; + }, new Map()); + + let originalElements: readonly E[] | null = elements.map( + (element) => originalElementsMap!.get(element.id) as E, + ); + + const originalAppState: AppState = cloneJSON(appState); + + let accumulatedChange = 0; + let stepChange = 0; + + const onPointerMove = (event: PointerEvent) => { + if ( + lastPointer && + originalElementsMap !== null && + originalElements !== null + ) { + const instantChange = event.clientX - lastPointer.x; + + if (instantChange !== 0) { + stepChange += instantChange; + + if (Math.abs(stepChange) >= sensitivity) { + stepChange = + Math.sign(stepChange) * + Math.floor(Math.abs(stepChange) / sensitivity); + + accumulatedChange += stepChange; + + dragInputCallback({ + accumulatedChange, + instantChange: stepChange, + originalElements, + originalElementsMap, + shouldKeepAspectRatio: shouldKeepAspectRatio!!, + shouldChangeByStepSize: event.shiftKey, + property, + scene, + originalAppState, + setInputValue: (value) => setInputValue(String(value)), + }); + + stepChange = 0; + } + } + } + + lastPointer = { + x: event.clientX, + y: event.clientY, + }; + }; + + const onPointerUp = () => { + window.removeEventListener( + EVENT.POINTER_MOVE, + onPointerMove, + false, + ); + + app.syncActionResult({ + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + lastPointer = null; + accumulatedChange = 0; + stepChange = 0; + originalElements = null; + originalElementsMap = null; + + document.body.classList.remove("excalidraw-cursor-resize"); + + window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false); + }; + + callbacksRef.current.onPointerMove = onPointerMove; + callbacksRef.current.onPointerUp = onPointerUp; + + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false); + window.addEventListener(EVENT.POINTER_UP, onPointerUp, false); + } + }} + onPointerEnter={() => { + if (labelRef.current) { + labelRef.current.style.cursor = "ew-resize"; + } + }} + > + {icon ? : label} +
+ { + if (editable) { + const eventTarget = event.target; + if ( + eventTarget instanceof HTMLInputElement && + event.key === KEYS.ENTER + ) { + handleInputValue(eventTarget.value, elements, appState); + app.focusContainer(); + } + } + }} + 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, + stateRef.current.originalElements, + stateRef.current.originalAppState, + ); + } + }} + disabled={!editable} + /> +
+ ); +}; + +export default StatsDragInput; diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx new file mode 100644 index 0000000..13dc6db --- /dev/null +++ b/packages/excalidraw/components/Stats/FontSize.tsx @@ -0,0 +1,99 @@ +import type { + ExcalidrawElement, + ExcalidrawTextElement, +} from "../../element/types"; +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, redrawTextBoundingBox } from "../../element"; +import { hasBoundTextElement } from "../../element/typeChecks"; +import { getBoundTextElement } from "../../element/textElement"; + +interface FontSizeProps { + element: ExcalidrawElement; + scene: Scene; + appState: AppState; + property: "fontSize"; +} + +const MIN_FONT_SIZE = 4; +const STEP_SIZE = 4; + +const handleFontSizeChange: DragInputCallbackType< + FontSizeProps["property"], + ExcalidrawTextElement +> = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + + const origElement = originalElements[0]; + if (origElement) { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement || !isTextElement(latestElement)) { + return; + } + + let nextFontSize; + + if (nextValue !== undefined) { + nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); + } else if (origElement.type === "text") { + const originalFontSize = Math.round(origElement.fontSize); + const changeInFontSize = Math.round(accumulatedChange); + nextFontSize = Math.max( + originalFontSize + changeInFontSize, + MIN_FONT_SIZE, + ); + if (shouldChangeByStepSize) { + nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); + } + } + + if (nextFontSize) { + mutateElement(latestElement, { + fontSize: nextFontSize, + }); + redrawTextBoundingBox( + latestElement, + scene.getContainerElement(latestElement), + scene.getNonDeletedElementsMap(), + ); + } + } +}; + +const FontSize = ({ element, scene, appState, property }: FontSizeProps) => { + const _element = isTextElement(element) + ? element + : hasBoundTextElement(element) + ? getBoundTextElement(element, scene.getNonDeletedElementsMap()) + : null; + + if (!_element) { + return null; + } + + return ( + + ); +}; + +export default FontSize; diff --git a/packages/excalidraw/components/Stats/MultiAngle.tsx b/packages/excalidraw/components/Stats/MultiAngle.tsx new file mode 100644 index 0000000..d9a7882 --- /dev/null +++ b/packages/excalidraw/components/Stats/MultiAngle.tsx @@ -0,0 +1,136 @@ +import { mutateElement } from "../../element/mutateElement"; +import { getBoundTextElement } from "../../element/textElement"; +import { isArrowElement } from "../../element/typeChecks"; +import type { ExcalidrawElement } from "../../element/types"; +import { isInGroup } from "../../groups"; +import type Scene from "../../scene/Scene"; +import { angleIcon } from "../icons"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue, isPropertyEditable } from "./utils"; +import type { AppState } from "../../types"; +import type { Degrees } from "@excalidraw/math"; +import { degreesToRadians, radiansToDegrees } from "@excalidraw/math"; + +interface MultiAngleProps { + elements: readonly ExcalidrawElement[]; + scene: Scene; + appState: AppState; + property: "angle"; +} + +const STEP_SIZE = 15; + +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), + ); + + if (nextValue !== undefined) { + const nextAngle = degreesToRadians(nextValue as Degrees); + + for (const element of editableLatestIndividualElements) { + if (!element) { + continue; + } + mutateElement( + element, + { + angle: nextAngle, + }, + false, + ); + + 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(radiansToDegrees(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 = degreesToRadians(nextAngleInDegrees as Degrees); + + 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"), + ); + const angles = editableLatestIndividualElements.map( + (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100, + ); + const value = new Set(angles).size === 1 ? angles[0] : "Mixed"; + + const editable = editableLatestIndividualElements.some((el) => + isPropertyEditable(el, "angle"), + ); + + return ( + + ); +}; + +export default MultiAngle; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx new file mode 100644 index 0000000..04f1937 --- /dev/null +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -0,0 +1,401 @@ +import { useMemo } from "react"; +import { getCommonBounds, isTextElement } from "../../element"; +import { updateBoundElements } from "../../element/binding"; +import { mutateElement } from "../../element/mutateElement"; +import { + rescalePointsInElement, + resizeSingleElement, +} from "../../element/resizeElements"; +import { + getBoundTextElement, + handleBindTextResize, +} from "../../element/textElement"; +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; +import { getElementsInAtomicUnit } from "./utils"; +import type { AtomicUnit } from "./utils"; +import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import { pointFrom, type GlobalPoint } from "@excalidraw/math"; + +interface MultiDimensionProps { + property: "width" | "height"; + elements: readonly ExcalidrawElement[]; + elementsMap: NonDeletedSceneElementsMap; + atomicUnits: AtomicUnit[]; + scene: Scene; + appState: AppState; +} + +const STEP_SIZE = 10; + +const getResizedUpdates = ( + anchorX: number, + anchorY: number, + scale: number, + origElement: ExcalidrawElement, +) => { + const offsetX = origElement.x - anchorX; + const offsetY = origElement.y - anchorY; + const nextWidth = origElement.width * scale; + const nextHeight = origElement.height * scale; + const x = anchorX + offsetX * scale; + const y = anchorY + offsetY * scale; + + return { + width: nextWidth, + height: nextHeight, + x, + y, + ...rescalePointsInElement(origElement, nextWidth, nextHeight, false), + ...(isTextElement(origElement) + ? { fontSize: origElement.fontSize * scale } + : {}), + }; +}; + +const resizeElementInGroup = ( + anchorX: number, + anchorY: number, + property: MultiDimensionProps["property"], + scale: number, + latestElement: ExcalidrawElement, + origElement: ExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + originalElementsMap: ElementsMap, +) => { + const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); + + mutateElement(latestElement, updates, false); + const boundTextElement = getBoundTextElement( + origElement, + originalElementsMap, + ); + if (boundTextElement) { + const newFontSize = boundTextElement.fontSize * scale; + updateBoundElements(latestElement, elementsMap, { + newSize: { width: updates.width, height: updates.height }, + }); + const latestBoundTextElement = elementsMap.get(boundTextElement.id); + if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { + mutateElement( + latestBoundTextElement, + { + fontSize: newFontSize, + }, + false, + ); + handleBindTextResize( + latestElement, + elementsMap, + property === "width" ? "e" : "s", + true, + ); + } + } +}; + +const resizeGroup = ( + nextWidth: number, + nextHeight: number, + initialHeight: number, + aspectRatio: number, + anchor: GlobalPoint, + property: MultiDimensionProps["property"], + latestElements: ExcalidrawElement[], + originalElements: ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, + originalElementsMap: ElementsMap, +) => { + // keep aspect ratio for groups + if (property === "width") { + nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; + } else { + nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; + } + + const scale = nextHeight / initialHeight; + + for (let i = 0; i < originalElements.length; i++) { + const origElement = originalElements[i]; + const latestElement = latestElements[i]; + + resizeElementInGroup( + anchor[0], + anchor[1], + property, + scale, + latestElement, + origElement, + elementsMap, + originalElementsMap, + ); + } +}; + +const handleDimensionChange: DragInputCallbackType< + MultiDimensionProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + originalAppState, + shouldChangeByStepSize, + nextValue, + scene, + property, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const atomicUnits = getAtomicUnits(originalElements, originalAppState); + 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, + pointFrom(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); + + resizeSingleElement( + nextWidth, + nextHeight, + latestElement, + origElement, + elementsMap, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldInformMutation: 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, + pointFrom(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); + + resizeSingleElement( + nextWidth, + nextHeight, + latestElement, + origElement, + elementsMap, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldInformMutation: false, + }, + ); + } + } + } + + 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 ( + + ); +}; + +export default MultiDimension; diff --git a/packages/excalidraw/components/Stats/MultiFontSize.tsx b/packages/excalidraw/components/Stats/MultiFontSize.tsx new file mode 100644 index 0000000..419bbbc --- /dev/null +++ b/packages/excalidraw/components/Stats/MultiFontSize.tsx @@ -0,0 +1,164 @@ +import { isTextElement, redrawTextBoundingBox } from "../../element"; +import { mutateElement } from "../../element/mutateElement"; +import { hasBoundTextElement } from "../../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; +import { isInGroup } from "../../groups"; +import type Scene from "../../scene/Scene"; +import { fontSizeIcon } from "../icons"; +import StatsDragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue } from "./utils"; +import type { AppState } from "../../types"; +import { getBoundTextElement } from "../../element/textElement"; + +interface MultiFontSizeProps { + elements: readonly ExcalidrawElement[]; + scene: Scene; + elementsMap: NonDeletedSceneElementsMap; + appState: AppState; + property: "fontSize"; +} + +const MIN_FONT_SIZE = 4; +const STEP_SIZE = 4; + +const getApplicableTextElements = ( + elements: readonly (ExcalidrawElement | undefined)[], + elementsMap: NonDeletedSceneElementsMap, +) => + elements.reduce( + (acc: ExcalidrawTextElement[], el) => { + if (!el || isInGroup(el)) { + return acc; + } + if (isTextElement(el)) { + acc.push(el); + return acc; + } + if (hasBoundTextElement(el)) { + const boundTextElement = getBoundTextElement(el, elementsMap); + if (boundTextElement) { + acc.push(boundTextElement); + return acc; + } + } + + return acc; + }, + + [], + ); + +const handleFontSizeChange: DragInputCallbackType< + MultiFontSizeProps["property"], + ExcalidrawTextElement +> = ({ + accumulatedChange, + originalElements, + shouldChangeByStepSize, + nextValue, + scene, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const latestTextElements = originalElements.map((el) => + elementsMap.get(el.id), + ) as ExcalidrawTextElement[]; + + let nextFontSize; + + if (nextValue) { + nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); + + for (const textElement of latestTextElements) { + mutateElement( + textElement, + { + fontSize: nextFontSize, + }, + false, + ); + + redrawTextBoundingBox( + textElement, + scene.getContainerElement(textElement), + elementsMap, + false, + ); + } + + scene.triggerUpdate(); + } else { + const originalTextElements = originalElements 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); + } + mutateElement( + latestElement, + { + fontSize: nextFontSize, + }, + false, + ); + + redrawTextBoundingBox( + latestElement, + scene.getContainerElement(latestElement), + elementsMap, + false, + ); + } + + scene.triggerUpdate(); + } +}; + +const MultiFontSize = ({ + elements, + scene, + appState, + property, + elementsMap, +}: MultiFontSizeProps) => { + const latestTextElements = getApplicableTextElements(elements, elementsMap); + + if (!latestTextElements.length) { + return null; + } + + 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 ( + + ); +}; + +export default MultiFontSize; diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx new file mode 100644 index 0000000..8a29aa6 --- /dev/null +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -0,0 +1,270 @@ +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; +import type Scene from "../../scene/Scene"; +import StatsDragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +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"; +import { pointFrom, pointRotateRads } from "@excalidraw/math"; + +interface MultiPositionProps { + property: "x" | "y"; + elements: readonly ExcalidrawElement[]; + elementsMap: ElementsMap; + atomicUnits: AtomicUnit[]; + scene: Scene; + appState: AppState; +} + +const STEP_SIZE = 10; + +const moveElements = ( + property: MultiPositionProps["property"], + changeInTopX: number, + changeInTopY: number, + elements: readonly ExcalidrawElement[], + originalElements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, + originalElementsMap: ElementsMap, + scene: Scene, +) => { + for (let i = 0; i < elements.length; i++) { + const origElement = originalElements[i]; + + const [cx, cy] = [ + origElement.x + origElement.width / 2, + origElement.y + origElement.height / 2, + ]; + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(origElement.x, origElement.y), + pointFrom(cx, cy), + origElement.angle, + ); + + const newTopLeftX = + property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX; + + const newTopLeftY = + property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY; + + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + elementsMap, + elements, + scene, + originalElementsMap, + false, + ); + } +}; + +const moveGroupTo = ( + nextX: number, + nextY: number, + originalElements: ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + originalElementsMap: ElementsMap, + scene: Scene, +) => { + const [x1, y1, ,] = getCommonBounds(originalElements); + const offsetX = nextX - x1; + const offsetY = nextY - y1; + + for (let i = 0; i < originalElements.length; i++) { + const origElement = originalElements[i]; + + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + continue; + } + + // bound texts are moved with their containers + if (!isTextElement(latestElement) || !latestElement.containerId) { + const [cx, cy] = [ + latestElement.x + latestElement.width / 2, + latestElement.y + latestElement.height / 2, + ]; + + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(latestElement.x, latestElement.y), + pointFrom(cx, cy), + latestElement.angle, + ); + + moveElement( + topLeftX + offsetX, + topLeftY + offsetY, + origElement, + elementsMap, + elements, + scene, + originalElementsMap, + false, + ); + } + } +}; + +const handlePositionChange: DragInputCallbackType< + MultiPositionProps["property"] +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldChangeByStepSize, + nextValue, + property, + scene, + originalAppState, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + + 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, + elements, + originalElementsMap, + scene, + ); + } 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] = pointRotateRads( + pointFrom(origElement.x, origElement.y), + pointFrom(cx, cy), + origElement.angle, + ); + + const newTopLeftX = property === "x" ? nextValue : topLeftX; + const newTopLeftY = property === "y" ? nextValue : topLeftY; + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + elementsMap, + elements, + scene, + 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, + ); + + scene.triggerUpdate(); +}; + +const MultiPosition = ({ + property, + elements, + elementsMap, + atomicUnits, + scene, + appState, +}: MultiPositionProps) => { + const positions = useMemo( + () => + atomicUnits.map((atomicUnit) => { + const elementsInUnit = Object.keys(atomicUnit) + .map((id) => elementsMap.get(id)) + .filter((el) => el !== undefined) as ExcalidrawElement[]; + + // we're dealing with a group + if (elementsInUnit.length > 1) { + const [x1, y1] = getCommonBounds(elementsInUnit); + return Math.round((property === "x" ? x1 : y1) * 100) / 100; + } + + const [el] = elementsInUnit; + const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; + + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(el.x, el.y), + pointFrom(cx, cy), + el.angle, + ); + + return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; + }), + [atomicUnits, elementsMap, property], + ); + + const value = new Set(positions).size === 1 ? positions[0] : "Mixed"; + + return ( + + ); +}; + +export default MultiPosition; diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx new file mode 100644 index 0000000..038e58e --- /dev/null +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -0,0 +1,214 @@ +import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +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"; +import { clamp, pointFrom, pointRotateRads, round } from "@excalidraw/math"; +import { isImageElement } from "../../element/typeChecks"; +import { + getFlipAdjustedCropPosition, + getUncroppedWidthAndHeight, +} from "../../element/cropElement"; +import { mutateElement } from "../../element/mutateElement"; + +interface PositionProps { + property: "x" | "y"; + element: ExcalidrawElement; + elementsMap: ElementsMap; + scene: Scene; + appState: AppState; +} + +const STEP_SIZE = 10; + +const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ + accumulatedChange, + instantChange, + originalElements, + originalElementsMap, + shouldChangeByStepSize, + nextValue, + property, + scene, + originalAppState, +}) => { + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + const origElement = originalElements[0]; + const [cx, cy] = [ + origElement.x + origElement.width / 2, + origElement.y + origElement.height / 2, + ]; + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(origElement.x, origElement.y), + pointFrom(cx, cy), + origElement.angle, + ); + + if (originalAppState.croppingElementId === origElement.id) { + const element = elementsMap.get(origElement.id); + + if (!element || !isImageElement(element) || !element.crop) { + return; + } + + const crop = element.crop; + let nextCrop = crop; + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + if (nextValue !== undefined) { + if (property === "x") { + const nextValueInNatural = + nextValue * (crop.naturalWidth / uncroppedWidth); + + if (isFlippedByX) { + nextCrop = { + ...crop, + x: clamp( + crop.naturalWidth - nextValueInNatural - crop.width, + 0, + crop.naturalWidth - crop.width, + ), + }; + } else { + nextCrop = { + ...crop, + x: clamp( + nextValue * (crop.naturalWidth / uncroppedWidth), + 0, + crop.naturalWidth - crop.width, + ), + }; + } + } + + if (property === "y") { + nextCrop = { + ...crop, + y: clamp( + nextValue * (crop.naturalHeight / uncroppedHeight), + 0, + crop.naturalHeight - crop.height, + ), + }; + } + + mutateElement(element, { + crop: nextCrop, + }); + + return; + } + + const changeInX = + (property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1); + const changeInY = + (property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1); + + nextCrop = { + ...crop, + x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width), + y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height), + }; + + mutateElement(element, { + crop: nextCrop, + }); + + return; + } + + if (nextValue !== undefined) { + const newTopLeftX = property === "x" ? nextValue : topLeftX; + const newTopLeftY = property === "y" ? nextValue : topLeftY; + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + elementsMap, + elements, + scene, + 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, + elements, + scene, + originalElementsMap, + ); +}; + +const Position = ({ + property, + element, + elementsMap, + scene, + appState, +}: PositionProps) => { + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(element.x, element.y), + pointFrom(element.x + element.width / 2, element.y + element.height / 2), + element.angle, + ); + let value = round(property === "x" ? topLeftX : topLeftY, 2); + + if ( + appState.croppingElementId === element.id && + isImageElement(element) && + element.crop + ) { + const flipAdjustedPosition = getFlipAdjustedCropPosition(element); + + if (flipAdjustedPosition) { + value = round( + property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y, + 2, + ); + } + } + + return ( + + ); +}; + +export default Position; diff --git a/packages/excalidraw/components/Stats/Stats.scss b/packages/excalidraw/components/Stats/Stats.scss new file mode 100644 index 0000000..106ecf3 --- /dev/null +++ b/packages/excalidraw/components/Stats/Stats.scss @@ -0,0 +1,72 @@ +.exc-stats { + width: 204px; + position: absolute; + top: 60px; + font-size: 12px; + z-index: var(--zIndex-layerUI); + pointer-events: var(--ui-pointerEvents); + + :root[dir="rtl"] & { + left: 12px; + right: initial; + } + + h2 { + font-size: 1.5em; + margin-block-start: 0.83em; + margin-block-end: 0.83em; + font-weight: bold; + } + h3 { + white-space: nowrap; + font-size: 1.17em; + margin: 0; + font-weight: bold; + } + + &__rows { + display: flex; + flex-direction: column; + gap: 0.3125rem; + } + + &__row { + display: flex; + justify-content: space-between; + align-items: center; + + display: grid; + gap: 4px; + + div + div { + text-align: right; + } + } + + &__row--heading { + text-align: center; + font-weight: bold; + margin: 0.25rem 0; + } + + .title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + h2 { + margin: 0; + } + } + + .close { + height: 16px; + width: 16px; + cursor: pointer; + svg { + width: 100%; + height: 100%; + } + } +} diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx new file mode 100644 index 0000000..2ccd93c --- /dev/null +++ b/packages/excalidraw/components/Stats/index.tsx @@ -0,0 +1,434 @@ +import { useEffect, useMemo, useState, memo } from "react"; +import { getCommonBounds } from "../../element/bounds"; +import type { NonDeletedExcalidrawElement } from "../../element/types"; +import { t } from "../../i18n"; +import type { + AppClassProperties, + AppState, + ExcalidrawProps, +} from "../../types"; +import { CloseIcon } from "../icons"; +import { Island } from "../Island"; +import throttle from "lodash.throttle"; +import Dimension from "./Dimension"; +import Angle from "./Angle"; +import FontSize from "./FontSize"; +import MultiDimension from "./MultiDimension"; +import { elementsAreInSameGroup } from "../../groups"; +import MultiAngle from "./MultiAngle"; +import MultiFontSize from "./MultiFontSize"; +import Position from "./Position"; +import MultiPosition from "./MultiPosition"; +import Collapsible from "./Collapsible"; +import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; +import { getAtomicUnits } from "./utils"; +import { STATS_PANELS } from "../../constants"; +import { isElbowArrow, isImageElement } from "../../element/typeChecks"; +import CanvasGrid from "./CanvasGrid"; +import clsx from "clsx"; + +import "./Stats.scss"; +import { isGridModeEnabled } from "../../snapping"; +import { getUncroppedWidthAndHeight } from "../../element/cropElement"; +import { round } from "@excalidraw/math"; +import { frameAndChildrenSelectedTogether } from "../../frame"; + +interface StatsProps { + app: AppClassProperties; + onClose: () => void; + renderCustomStats: ExcalidrawProps["renderCustomStats"]; +} + +const STATS_TIMEOUT = 50; + +export const Stats = (props: StatsProps) => { + const appState = useExcalidrawAppState(); + const sceneNonce = props.app.scene.getSceneNonce() || 1; + const selectedElements = props.app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: false, + }); + const gridModeEnabled = isGridModeEnabled(props.app); + + return ( + + ); +}; + +const StatsRow = ({ + children, + columns = 1, + heading, + style, + ...rest +}: { + children: React.ReactNode; + columns?: number; + heading?: boolean; + style?: React.CSSProperties; +} & React.HTMLAttributes) => ( +
+ {children} +
+); +StatsRow.displayName = "StatsRow"; + +const StatsRows = ({ + children, + order, + style, + ...rest +}: { + children: React.ReactNode; + order?: number; + style?: React.CSSProperties; +} & React.HTMLAttributes) => ( +
+ {children} +
+); +StatsRows.displayName = "StatsRows"; + +Stats.StatsRow = StatsRow; +Stats.StatsRows = StatsRows; + +export const StatsInner = memo( + ({ + app, + onClose, + renderCustomStats, + selectedElements, + appState, + sceneNonce, + gridModeEnabled, + }: StatsProps & { + sceneNonce: number; + selectedElements: readonly NonDeletedExcalidrawElement[]; + appState: AppState; + gridModeEnabled: boolean; + }) => { + const scene = app.scene; + const elements = scene.getNonDeletedElements(); + const elementsMap = scene.getNonDeletedElementsMap(); + const setAppState = useExcalidrawSetAppState(); + + const singleElement = + selectedElements.length === 1 ? selectedElements[0] : null; + + const multipleElements = + selectedElements.length > 1 ? selectedElements : null; + + const cropMode = + appState.croppingElementId && isImageElement(singleElement); + + const unCroppedDimension = cropMode + ? getUncroppedWidthAndHeight(singleElement) + : null; + + const [sceneDimension, setSceneDimension] = useState<{ + width: number; + height: number; + }>({ + width: 0, + height: 0, + }); + + const throttledSetSceneDimension = useMemo( + () => + throttle((elements: readonly NonDeletedExcalidrawElement[]) => { + const boundingBox = getCommonBounds(elements); + setSceneDimension({ + width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]), + height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]), + }); + }, STATS_TIMEOUT), + [], + ); + + useEffect(() => { + throttledSetSceneDimension(elements); + }, [sceneNonce, elements, throttledSetSceneDimension]); + + useEffect( + () => () => throttledSetSceneDimension.cancel(), + [throttledSetSceneDimension], + ); + + const atomicUnits = useMemo(() => { + return getAtomicUnits(selectedElements, appState); + }, [selectedElements, appState]); + + const _frameAndChildrenSelectedTogether = useMemo(() => { + return frameAndChildrenSelectedTogether(selectedElements); + }, [selectedElements]); + + return ( +
+ +
+

{t("stats.title")}

+
+ {CloseIcon} +
+
+ + {t("stats.generalStats")}} + open={!!(appState.stats.panels & STATS_PANELS.generalStats)} + openTrigger={() => + setAppState((state) => { + return { + stats: { + open: true, + panels: state.stats.panels ^ STATS_PANELS.generalStats, + }, + }; + }) + } + > + + {t("stats.scene")} + +
{t("stats.shapes")}
+
{elements.length}
+
+ +
{t("stats.width")}
+
{sceneDimension.width}
+
+ +
{t("stats.height")}
+
{sceneDimension.height}
+
+ {gridModeEnabled && ( + <> + Canvas + + + + + )} +
+ + {renderCustomStats?.(elements, appState)} +
+ + {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && ( +
+ {t("stats.elementProperties")}} + open={ + !!(appState.stats.panels & STATS_PANELS.elementProperties) + } + openTrigger={() => + setAppState((state) => { + return { + stats: { + open: true, + panels: + state.stats.panels ^ STATS_PANELS.elementProperties, + }, + }; + }) + } + > + + {singleElement && ( + <> + {cropMode && ( + + {t("labels.unCroppedDimension")} + + )} + + {appState.croppingElementId && + isImageElement(singleElement) && + unCroppedDimension && ( + +
{t("stats.width")}
+
{round(unCroppedDimension.width, 2)}
+
+ )} + + {appState.croppingElementId && + isImageElement(singleElement) && + unCroppedDimension && ( + +
{t("stats.height")}
+
{round(unCroppedDimension.height, 2)}
+
+ )} + + + {appState.croppingElementId + ? t("labels.imageCropping") + : t(`element.${singleElement.type}`)} + + + + + + + + + + + + + + + {!isElbowArrow(singleElement) && ( + + + + )} + + + + + )} + + {multipleElements && ( + <> + {elementsAreInSameGroup(multipleElements) && ( + {t("element.group")} + )} + + +
{t("stats.shapes")}
+
{selectedElements.length}
+
+ + + + + + + + + + + + + + + + + + + + + )} +
+
+
+ )} +
+
+ ); + }, + (prev, next) => { + return ( + prev.sceneNonce === next.sceneNonce && + prev.selectedElements === next.selectedElements && + prev.appState.stats.panels === next.appState.stats.panels && + prev.gridModeEnabled === next.gridModeEnabled && + prev.appState.gridStep === next.appState.gridStep && + prev.appState.croppingElementId === next.appState.croppingElementId + ); + }, +); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx new file mode 100644 index 0000000..49d6a62 --- /dev/null +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -0,0 +1,724 @@ +import React from "react"; +import { act, fireEvent, queryByTestId } from "@testing-library/react"; +import { Keyboard, Pointer, UI } from "../../tests/helpers/ui"; +import { getStepSizedValue } from "./utils"; +import { + GlobalTestState, + mockBoundingClientRect, + render, + restoreOriginalGetBoundingClientRect, +} from "../../tests/test-utils"; +import * as StaticScene from "../../renderer/staticScene"; +import { vi } from "vitest"; +import { reseed } from "../../random"; +import { setDateTimeForTests } from "../../utils"; +import { Excalidraw, mutateElement } from "../.."; +import { t } from "../../i18n"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, +} from "../../element/types"; +import { getTextEditor, updateTextEditor } from "../../tests/queries/dom"; +import { getCommonBounds, isTextElement } from "../../element"; +import { API } from "../../tests/helpers/api"; +import { actionGroup } from "../../actions"; +import { isInGroup } from "../../groups"; +import type { Degrees } from "@excalidraw/math"; +import { degreesToRadians, pointFrom, pointRotateRads } from "@excalidraw/math"; + +const { h } = window; +const mouse = new Pointer("mouse"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); +let stats: HTMLElement | null = null; +let elementStats: HTMLElement | null | undefined = null; + +const testInputProperty = ( + element: ExcalidrawElement, + property: "x" | "y" | "width" | "height" | "angle" | "fontSize", + label: string, + initialValue: number, + nextValue: number, +) => { + const input = UI.queryStatsProperty(label)?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(input).toBeDefined(); + expect(input.value).toBe(initialValue.toString()); + UI.updateInput(input, String(nextValue)); + if (property === "angle") { + expect(element[property]).toBe( + degreesToRadians(Number(nextValue) as Degrees), + ); + } else if (property === "fontSize" && isTextElement(element)) { + expect(element[property]).toBe(Number(nextValue)); + } else if (property !== "fontSize") { + expect(element[property]).toBe(Number(nextValue)); + } +}; + +describe("step sized value", () => { + it("should return edge values correctly", () => { + const steps = [10, 15, 20, 25, 30]; + const values = [10, 15, 20, 25, 30]; + + steps.forEach((step, idx) => { + expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]); + }); + }); + + it("step sized value lies in the middle", () => { + let stepSize = 15; + let values = [7.5, 9, 12, 14.99, 15, 22.49]; + + values.forEach((value) => { + expect(getStepSizedValue(value, stepSize)).toEqual(15); + }); + + stepSize = 10; + values = [-5, 4.99, 0, 1.23]; + values.forEach((value) => { + expect(getStepSizedValue(value, stepSize)).toEqual(0); + }); + }); +}); + +describe("binding with linear elements", () => { + beforeEach(async () => { + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(19); + setDateTimeForTests("201933152653"); + + await render(); + + API.setElements([]); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + stats = UI.queryStats(); + + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(200, 100); + + UI.clickTool("arrow"); + mouse.down(5, 0); + mouse.up(300, 50); + + elementStats = stats?.querySelector("#elementStats"); + }); + + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should remain bound to linear element on small position change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputX = UI.queryStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + expect(inputX).not.toBeNull(); + UI.updateInput(inputX, String("204")); + expect(linear.startBinding).not.toBe(null); + }); + + it("should remain bound to linear element on small angle change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputAngle = UI.queryStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + UI.updateInput(inputAngle, String("1")); + expect(linear.startBinding).not.toBe(null); + }); + + it("should unbind linear element on large position change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputX = UI.queryStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + expect(inputX).not.toBeNull(); + UI.updateInput(inputX, String("254")); + expect(linear.startBinding).toBe(null); + }); + + it("should remain bound to linear element on small angle change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputAngle = UI.queryStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + UI.updateInput(inputAngle, String("45")); + expect(linear.startBinding).toBe(null); + }); +}); + +// single element +describe("stats for a generic element", () => { + beforeEach(async () => { + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(7); + setDateTimeForTests("201933152653"); + + await render(); + + API.setElements([]); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + stats = UI.queryStats(); + + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(200, 100); + elementStats = stats?.querySelector("#elementStats"); + }); + + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should open stats", () => { + expect(stats).toBeDefined(); + expect(elementStats).toBeDefined(); + + // title + const title = elementStats?.querySelector("h3"); + expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties")); + + // element type + const elementType = queryByTestId(elementStats!, "stats-element-type"); + expect(elementType).toBeDefined(); + expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle")); + + // properties + ["X", "Y", "W", "H", "A"].forEach((label) => () => { + expect( + stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`), + ).toBeDefined(); + }); + }); + + it("should be able to edit all properties for a general element", () => { + const rectangle = h.elements[0]; + const initialX = rectangle.x; + const initialY = rectangle.y; + + testInputProperty(rectangle, "width", "W", 200, 100); + testInputProperty(rectangle, "height", "H", 100, 200); + testInputProperty(rectangle, "x", "X", initialX, 230); + testInputProperty(rectangle, "y", "Y", initialY, 220); + testInputProperty(rectangle, "angle", "A", 0, 45); + }); + + it("should keep only two decimal places", () => { + const rectangle = h.elements[0]; + const rectangleId = rectangle.id; + + const input = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(input).toBeDefined(); + expect(input.value).toBe(rectangle.width.toString()); + UI.updateInput(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); + + UI.updateInput(input, "88.98766"); + expect(input.value).toBe("88.99"); + expect(rectangle.width).toBe(88.99); + }); + + it("should update input x and y when angle is changed", () => { + const rectangle = h.elements[0]; + const [cx, cy] = [ + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ]; + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + + const xInput = UI.queryStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + const yInput = UI.queryStatsProperty("Y")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(xInput.value).toBe(topLeftX.toString()); + expect(yInput.value).toBe(topLeftY.toString()); + + testInputProperty(rectangle, "angle", "A", 0, 45); + + let [newTopLeftX, newTopLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + + expect(newTopLeftX.toString()).not.toEqual(xInput.value); + expect(newTopLeftY.toString()).not.toEqual(yInput.value); + + testInputProperty(rectangle, "angle", "A", 45, 66); + + [newTopLeftX, newTopLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + expect(newTopLeftX.toString()).not.toEqual(xInput.value); + expect(newTopLeftY.toString()).not.toEqual(yInput.value); + }); + + it("should fix top left corner when width or height is changed", () => { + const rectangle = h.elements[0]; + + testInputProperty(rectangle, "angle", "A", 0, 45); + let [cx, cy] = [ + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ]; + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + testInputProperty(rectangle, "width", "W", rectangle.width, 400); + [cx, cy] = [ + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ]; + let [currentTopLeftX, currentTopLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); + expect(currentTopLeftY).toBeCloseTo(topLeftY, 4); + + testInputProperty(rectangle, "height", "H", rectangle.height, 400); + [cx, cy] = [ + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ]; + [currentTopLeftX, currentTopLeftY] = pointRotateRads( + pointFrom(rectangle.x, rectangle.y), + pointFrom(cx, cy), + rectangle.angle, + ); + + expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); + expect(currentTopLeftY).toBeCloseTo(topLeftY, 4); + }); +}); + +describe("stats for a non-generic element", () => { + beforeEach(async () => { + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(7); + setDateTimeForTests("201933152653"); + + await render(); + + API.setElements([]); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + stats = UI.queryStats(); + }); + + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("text element", async () => { + UI.clickTool("text"); + mouse.clickAt(20, 30); + const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello!"); + act(() => { + editor.blur(); + }); + + const text = h.elements[0] as ExcalidrawTextElement; + mouse.clickOn(text); + + elementStats = stats?.querySelector("#elementStats"); + + // can change font size + const input = UI.queryStatsProperty("F")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(input).toBeDefined(); + expect(input.value).toBe(text.fontSize.toString()); + UI.updateInput(input, "36"); + expect(text.fontSize).toBe(36); + + // cannot change width or height + const width = UI.queryStatsProperty("W")?.querySelector(".drag-input"); + expect(width).toBeUndefined(); + const height = UI.queryStatsProperty("H")?.querySelector(".drag-input"); + expect(height).toBeUndefined(); + + // min font size is 4 + UI.updateInput(input, "0"); + expect(text.fontSize).not.toBe(0); + expect(text.fontSize).toBe(4); + }); + + it("frame element", () => { + const frame = API.createElement({ + id: "id0", + type: "frame", + x: 150, + width: 150, + }); + API.setElements([frame]); + API.setAppState({ + selectedElementIds: { + [frame.id]: true, + }, + }); + + elementStats = stats?.querySelector("#elementStats"); + + expect(elementStats).toBeDefined(); + + // cannot change angle + const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input"); + expect(angle).toBeUndefined(); + + // can change width or height + testInputProperty(frame, "width", "W", frame.width, 250); + testInputProperty(frame, "height", "H", frame.height, 500); + }); + + it("image element", () => { + const image = API.createElement({ type: "image", width: 200, height: 100 }); + API.setElements([image]); + mouse.clickOn(image); + API.setAppState({ + selectedElementIds: { + [image.id]: true, + }, + }); + elementStats = stats?.querySelector("#elementStats"); + expect(elementStats).toBeDefined(); + const widthToHeight = image.width / image.height; + + // when width or height is changed, the aspect ratio is preserved + testInputProperty(image, "width", "W", image.width, 400); + expect(image.width).toBe(400); + expect(image.width / image.height).toBe(widthToHeight); + + testInputProperty(image, "height", "H", image.height, 80); + expect(image.height).toBe(80); + expect(image.width / image.height).toBe(widthToHeight); + }); + + it("should display fontSize for bound text", () => { + const container = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + const text = API.createElement({ + type: "text", + width: 200, + height: 100, + containerId: container.id, + fontSize: 20, + }); + mutateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + API.setElements([container, text]); + + API.setSelectedElements([container]); + const fontSize = UI.queryStatsProperty("F")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(fontSize).toBeDefined(); + + UI.updateInput(fontSize, "40"); + + expect(text.fontSize).toBe(40); + }); +}); + +// multiple elements +describe("stats for multiple elements", () => { + beforeEach(async () => { + mouse.reset(); + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(7); + setDateTimeForTests("201933152653"); + + await render(); + + API.setElements([]); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + stats = UI.queryStats(); + }); + + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should display MIXED for elements with different values", () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(200, 100); + + UI.clickTool("ellipse"); + mouse.down(50, 50); + mouse.up(100, 100); + + UI.clickTool("diamond"); + mouse.down(-100, -100); + mouse.up(125, 145); + + API.setAppState({ + selectedElementIds: h.elements.reduce((acc, el) => { + acc[el.id] = true; + return acc; + }, {} as Record), + }); + + elementStats = stats?.querySelector("#elementStats"); + + const width = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(width?.value).toBe("Mixed"); + const height = UI.queryStatsProperty("H")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(height?.value).toBe("Mixed"); + const angle = UI.queryStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(angle.value).toBe("0"); + + UI.updateInput(width, "250"); + h.elements.forEach((el) => { + expect(el.width).toBe(250); + }); + + UI.updateInput(height, "450"); + h.elements.forEach((el) => { + expect(el.height).toBe(450); + }); + }); + + it("should display a property when one of the elements is editable for that property", async () => { + // text, rectangle, frame + UI.clickTool("text"); + mouse.clickAt(20, 30); + const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello!"); + act(() => { + editor.blur(); + }); + + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(200, 100); + + const frame = API.createElement({ + type: "frame", + x: 150, + width: 150, + }); + + API.setElements([...h.elements, frame]); + + const text = h.elements.find((el) => el.type === "text"); + const rectangle = h.elements.find((el) => el.type === "rectangle"); + + API.setAppState({ + selectedElementIds: h.elements.reduce((acc, el) => { + acc[el.id] = true; + return acc; + }, {} as Record), + }); + + elementStats = stats?.querySelector("#elementStats"); + + const width = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(width).toBeDefined(); + expect(width.value).toBe("Mixed"); + + const height = UI.queryStatsProperty("H")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(height).toBeDefined(); + expect(height.value).toBe("Mixed"); + + const angle = UI.queryStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(angle).toBeDefined(); + expect(angle.value).toBe("0"); + + const fontSize = UI.queryStatsProperty("F")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(fontSize).toBeDefined(); + + // changing width does not affect text + UI.updateInput(width, "200"); + + expect(rectangle?.width).toBe(200); + expect(frame.width).toBe(200); + expect(text?.width).not.toBe(200); + + UI.updateInput(angle, "40"); + + const angleInRadian = degreesToRadians(40 as Degrees); + expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); + expect(text?.angle).toBeCloseTo(angleInRadian, 4); + expect(frame.angle).toBe(0); + }); + + it("should treat groups as single units", () => { + const createAndSelectGroup = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(); + }); + + API.executeAction(actionGroup); + }; + + createAndSelectGroup(); + + const elementsInGroup = h.elements.filter((el) => isInGroup(el)); + let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); + + elementStats = stats?.querySelector("#elementStats"); + + const x = UI.queryStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(x).toBeDefined(); + expect(Number(x.value)).toBe(x1); + + UI.updateInput(x, "300"); + + expect(h.elements[0].x).toBe(300); + expect(h.elements[1].x).toBe(400); + expect(x.value).toBe("300"); + + const y = UI.queryStatsProperty("Y")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(y).toBeDefined(); + expect(Number(y.value)).toBe(y1); + + UI.updateInput(y, "200"); + + expect(h.elements[0].y).toBe(200); + expect(h.elements[1].y).toBe(300); + expect(y.value).toBe("200"); + + const width = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(width).toBeDefined(); + expect(Number(width.value)).toBe(200); + + const height = UI.queryStatsProperty("H")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(height).toBeDefined(); + expect(Number(height.value)).toBe(200); + + UI.updateInput(width, "400"); + + [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); + let newGroupWidth = x2 - x1; + + expect(newGroupWidth).toBeCloseTo(400, 4); + + UI.updateInput(width, "300"); + + [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); + newGroupWidth = x2 - x1; + expect(newGroupWidth).toBeCloseTo(300, 4); + + UI.updateInput(height, "500"); + + [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); + const newGroupHeight = y2 - y1; + expect(newGroupHeight).toBeCloseTo(500, 4); + }); +}); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts new file mode 100644 index 0000000..61555ba --- /dev/null +++ b/packages/excalidraw/components/Stats/utils.ts @@ -0,0 +1,219 @@ +import type { Radians } from "@excalidraw/math"; +import { pointFrom, pointRotateRads } from "@excalidraw/math"; +import { + bindOrUnbindLinearElements, + updateBoundElements, +} from "../../element/binding"; +import { mutateElement } from "../../element/mutateElement"; +import { getBoundTextElement } from "../../element/textElement"; +import { + isFrameLikeElement, + isLinearElement, + isTextElement, +} from "../../element/typeChecks"; +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; +import { + getSelectedGroupIds, + getElementsInGroup, + isInGroup, +} from "../../groups"; +import type Scene from "../../scene/Scene"; +import type { AppState } from "../../types"; + +export type StatsInputProperty = + | "x" + | "y" + | "width" + | "height" + | "angle" + | "fontSize" + | "gridStep"; + +export const SMALLEST_DELTA = 0.01; + +export const isPropertyEditable = ( + element: ExcalidrawElement, + property: keyof ExcalidrawElement, +) => { + if (property === "height" && isTextElement(element)) { + return false; + } + if (property === "width" && isTextElement(element)) { + return false; + } + if (property === "angle" && isFrameLikeElement(element)) { + return false; + } + return true; +}; + +export const getStepSizedValue = (value: number, stepSize: number) => { + const v = value + stepSize / 2; + return v - (v % stepSize); +}; + +export type AtomicUnit = Record; +export const getElementsInAtomicUnit = ( + atomicUnit: AtomicUnit, + elementsMap: ElementsMap, + originalElementsMap?: ElementsMap, +) => { + return Object.keys(atomicUnit) + .map((id) => ({ + original: (originalElementsMap ?? elementsMap).get(id), + latest: elementsMap.get(id), + })) + .filter((el) => el.original !== undefined && el.latest !== undefined) as { + original: NonDeletedExcalidrawElement; + latest: NonDeletedExcalidrawElement; + }[]; +}; + +export const newOrigin = ( + x1: number, + y1: number, + w1: number, + h1: number, + w2: number, + h2: number, + angle: number, +) => { + /** + * The formula below is the result of solving + * rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle) + * where rotate is the function defined in math.ts + * + * This is so that the new origin (x2, y2), + * when rotated against the new center (cx2, cy2), + * coincides with (x1, y1) rotated against (cx1, cy1) + * + * The reason for doing this computation is so the element's top left corner + * on the canvas remains fixed after any changes in its dimension. + */ + + return { + x: + x1 + + (w1 - w2) / 2 + + ((w2 - w1) / 2) * Math.cos(angle) + + ((h1 - h2) / 2) * Math.sin(angle), + y: + y1 + + (h1 - h2) / 2 + + ((w2 - w1) / 2) * Math.sin(angle) + + ((h2 - h1) / 2) * Math.cos(angle), + }; +}; + +export const moveElement = ( + newTopLeftX: number, + newTopLeftY: number, + originalElement: ExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, + 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, + ]; + const [topLeftX, topLeftY] = pointRotateRads( + pointFrom(originalElement.x, originalElement.y), + pointFrom(cx, cy), + originalElement.angle, + ); + + const changeInX = newTopLeftX - topLeftX; + const changeInY = newTopLeftY - topLeftY; + + const [x, y] = pointRotateRads( + pointFrom(newTopLeftX, newTopLeftY), + pointFrom(cx + changeInX, cy + changeInY), + -originalElement.angle as Radians, + ); + + mutateElement( + latestElement, + { + x, + y, + }, + shouldInformMutation, + ); + updateBindings(latestElement, elementsMap, elements, scene); + + const boundTextElement = getBoundTextElement( + originalElement, + originalElementsMap, + ); + if (boundTextElement) { + const latestBoundTextElement = elementsMap.get(boundTextElement.id); + latestBoundTextElement && + mutateElement( + latestBoundTextElement, + { + x: boundTextElement.x + changeInX, + y: boundTextElement.y + changeInY, + }, + shouldInformMutation, + ); + } +}; + +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; +}; + +export const updateBindings = ( + latestElement: ExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, + options?: { + simultaneouslyUpdated?: readonly ExcalidrawElement[]; + newSize?: { width: number; height: number }; + zoom?: AppState["zoom"]; + }, +) => { + if (isLinearElement(latestElement)) { + bindOrUnbindLinearElements( + [latestElement], + elementsMap, + elements, + scene, + true, + [], + options?.zoom, + ); + } else { + updateBoundElements(latestElement, elementsMap, options); + } +}; -- cgit v1.2.3