diff --git a/packages/excalidraw/components/DragInput.tsx b/packages/excalidraw/components/DragInput.tsx deleted file mode 100644 index 8efd70512..000000000 --- a/packages/excalidraw/components/DragInput.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import throttle from "lodash.throttle"; -import { useEffect, useMemo, useRef } from "react"; -import { EVENT } from "../constants"; -import { getTransformHandles } from "../element"; -import { mutateElement } from "../element/mutateElement"; -import { resizeSingleElement } from "../element/resizeElements"; -import type { ElementsMap, ExcalidrawElement } from "../element/types"; -import { KEYS } from "../keys"; -import { degreeToRadian, radianToDegree } from "../math"; -import type { AppState, Point } from "../types"; -import { deepCopyElement } from "../element/newElement"; - -const shouldKeepAspectRatio = (element: ExcalidrawElement) => { - return element.type === "image"; -}; - -type AdjustableProperty = "width" | "height" | "angle" | "x" | "y"; - -interface DragInputProps { - label: string | React.ReactNode; - property: AdjustableProperty; - element: ExcalidrawElement; - elementsMap: ElementsMap; - zoom: AppState["zoom"]; -} - -const DragInput = ({ - label, - property, - element, - elementsMap, - zoom, -}: DragInputProps) => { - const inputRef = useRef(null); - const labelRef = useRef(null); - - const originalElement = useRef(); - const accumulatedDimensionChange = useRef(0); - - const handleChange = useMemo( - () => - ( - initialValue: number, - delta: number, - source: "pointerMove" | "keyDown", - pointerOffset?: number, - ) => { - if (inputRef.current && originalElement.current) { - const keepAspectRatio = shouldKeepAspectRatio(element); - - if ( - (property === "width" || property === "height") && - source === "pointerMove" && - pointerOffset - ) { - const handles = getTransformHandles( - originalElement.current, - zoom, - elementsMap, - "mouse", - {}, - ); - - let referencePoint: Point | undefined; - let handleDirection: "e" | "s" | "se" | undefined; - - if (keepAspectRatio && handles.se) { - referencePoint = [handles.se[0], handles.se[1]]; - handleDirection = "se"; - } else if (property === "width" && handles.e) { - referencePoint = [handles.e[0], handles.e[1]]; - handleDirection = "e"; - } else if (property === "height" && handles.s) { - referencePoint = [handles.s[0], handles.s[1]]; - handleDirection = "s"; - } - - if (referencePoint !== undefined && handleDirection !== undefined) { - accumulatedDimensionChange.current += pointerOffset; - - const pointer: Point = [ - referencePoint[0] + - (property === "width" - ? accumulatedDimensionChange.current - : 0), - referencePoint[1] + - (property === "height" - ? accumulatedDimensionChange.current - : 0), - ]; - - resizeSingleElement( - elementsMap, - keepAspectRatio, - element, - elementsMap, - handleDirection, - false, - pointer[0], - pointer[1], - ); - } - } else if ( - source === "keyDown" || - (source === "pointerMove" && - property !== "width" && - property !== "height") - ) { - const incVal = Math.round( - Math.sign(delta) * Math.pow(Math.abs(delta) / 10, 1.6), - ); - let newVal = initialValue + incVal; - - newVal = - property === "angle" - ? // so the degree converted from radian is an integer - degreeToRadian( - Math.round( - radianToDegree( - degreeToRadian( - Math.sign(newVal % 360) === -1 - ? (newVal % 360) + 360 - : newVal % 360, - ), - ), - ), - ) - : Math.round(newVal); - - mutateElement(element, { - [property]: newVal, - }); - originalElement.current = deepCopyElement(element); - } - } - }, - [element, property, zoom, elementsMap], - ); - - const hangleChangeThrottled = useMemo(() => { - return throttle(handleChange, 16); - }, [handleChange]); - - useEffect(() => { - const value = - Math.round( - property === "angle" - ? radianToDegree(element[property]) * 100 - : element[property] * 100, - ) / 100; - - if (inputRef.current) { - inputRef.current.value = String(value); - } - }, [element, element.version, element.versionNonce, property]); - - useEffect(() => { - hangleChangeThrottled.cancel(); - }); - - useEffect(() => { - accumulatedDimensionChange.current = 0; - originalElement.current = undefined; - }, [element.id]); - - return ( - - ); -}; - -export default DragInput; diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx new file mode 100644 index 000000000..225368e54 --- /dev/null +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -0,0 +1,61 @@ +import { mutateElement } from "../../element/mutateElement"; +import type { ExcalidrawElement } from "../../element/types"; +import { degreeToRadian, radianToDegree } from "../../math"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue, isPropertyEditable } from "./utils"; + +interface AngleProps { + element: ExcalidrawElement; +} + +const STEP_SIZE = 15; + +const Angle = ({ element }: AngleProps) => { + const handleDegreeChange: DragInputCallbackType = ( + accumulatedChange, + instantChange, + stateAtStart, + shouldKeepAspectRatio, + shouldChangeByStepSize, + nextValue, + ) => { + if (nextValue !== undefined) { + const nextAngle = degreeToRadian(nextValue); + mutateElement(element, { + angle: nextAngle, + }); + return; + } + + if (stateAtStart) { + const originalAngleInDegrees = + Math.round(radianToDegree(stateAtStart.angle) * 100) / 100; + const changeInDegrees = Math.round(accumulatedChange); + let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; + if (shouldChangeByStepSize) { + nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE); + } + + mutateElement(element, { + angle: degreeToRadian( + nextAngleInDegrees < 0 + ? nextAngleInDegrees + 360 + : nextAngleInDegrees, + ), + }); + } + }; + + return ( + + ); +}; + +export default Angle; diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx new file mode 100644 index 000000000..448c4178c --- /dev/null +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -0,0 +1,160 @@ +import type { ExcalidrawElement } from "../../element/types"; +import DragInput from "./DragInput"; +import type { DragInputCallbackType } from "./DragInput"; +import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { mutateElement } from "../../element/mutateElement"; + +interface DimensionDragInputProps { + property: "width" | "height"; + element: ExcalidrawElement; +} + +const STEP_SIZE = 10; +const _shouldKeepAspectRatio = (element: ExcalidrawElement) => { + return element.type === "image"; +}; + +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), + }; +}; + +const DimensionDragInput = ({ property, element }: DimensionDragInputProps) => { + const handleDimensionChange: DragInputCallbackType = ( + accumulatedChange, + instantChange, + stateAtStart, + shouldKeepAspectRatio, + shouldChangeByStepSize, + nextValue, + ) => { + if (stateAtStart) { + const keepAspectRatio = + shouldKeepAspectRatio || _shouldKeepAspectRatio(element); + const aspectRatio = stateAtStart.width / stateAtStart.height; + + if (nextValue !== undefined) { + const nextWidth = Math.max( + property === "width" + ? nextValue + : keepAspectRatio + ? nextValue * aspectRatio + : stateAtStart.width, + 0, + ); + const nextHeight = Math.max( + property === "height" + ? nextValue + : keepAspectRatio + ? nextValue / aspectRatio + : stateAtStart.height, + 0, + ); + + mutateElement(element, { + ...newOrigin( + element.x, + element.y, + element.width, + element.height, + nextWidth, + nextHeight, + element.angle, + ), + width: nextWidth, + height: nextHeight, + }); + return; + } + const changeInWidth = property === "width" ? accumulatedChange : 0; + const changeInHeight = property === "height" ? accumulatedChange : 0; + + let nextWidth = Math.max(0, stateAtStart.width + changeInWidth); + if (property === "width") { + if (shouldChangeByStepSize) { + nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); + } else { + nextWidth = Math.round(nextWidth); + } + } + + let nextHeight = Math.max(0, stateAtStart.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; + } + } + + mutateElement(element, { + width: nextWidth, + height: nextHeight, + ...newOrigin( + stateAtStart.x, + stateAtStart.y, + stateAtStart.width, + stateAtStart.height, + nextWidth, + nextHeight, + stateAtStart.angle, + ), + }); + } + }; + + 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 000000000..24859d2e4 --- /dev/null +++ b/packages/excalidraw/components/Stats/DragInput.scss @@ -0,0 +1,75 @@ +.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-lg); + } + } + + .disabled { + opacity: 0.5; + pointer-events: none; + } + + .drag-input-label { + height: var(--default-button-size); + flex-shrink: 0; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + border: 1px solid var(--default-border-color); + border-right: 0; + box-sizing: border-box; + + :root[dir="ltr"] & { + border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); + } + + :root[dir="rtl"] & { + border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; + border-right: 1px solid var(--default-border-color); + border-left: 0; + } + + color: var(--input-label-color); + 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: var(--default-button-size); + border: 1px solid var(--default-border-color); + border-left: 0; + letter-spacing: 0.4px; + + :root[dir="ltr"] & { + border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; + } + + :root[dir="rtl"] & { + border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); + 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 000000000..c00df1f5d --- /dev/null +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import throttle from "lodash.throttle"; +import { EVENT } from "../../constants"; +import { KEYS } from "../../keys"; +import type { ExcalidrawElement } from "../../element/types"; +import { deepCopyElement } from "../../element/newElement"; + +import "./DragInput.scss"; +import clsx from "clsx"; + +export type DragInputCallbackType = ( + accumulatedChange: number, + instantChange: number, + stateAtStart: ExcalidrawElement, + shouldKeepAspectRatio: boolean, + shouldChangeByStepSize: boolean, + nextValue?: number, +) => void; + +interface StatsDragInputProps { + label: string | React.ReactNode; + value: number; + element: ExcalidrawElement; + editable?: boolean; + shouldKeepAspectRatio?: boolean; + dragInputCallback: DragInputCallbackType; +} + +const StatsDragInput = ({ + label, + dragInputCallback, + value, + element, + editable = true, + shouldKeepAspectRatio, +}: StatsDragInputProps) => { + const inputRef = useRef(null); + const labelRef = useRef(null); + + const cbThrottled = useMemo(() => { + return throttle(dragInputCallback, 16); + }, [dragInputCallback]); + + const [inputValue, setInputValue] = useState(value.toString()); + + useEffect(() => { + setInputValue(value.toString()); + }, [value]); + + return ( +
+
{ + if (inputRef.current && editable) { + let startValue = Number(inputRef.current.value); + if (isNaN(startValue)) { + startValue = 0; + } + + let lastPointer: { + x: number; + y: number; + } | null = null; + + let stateAtStart: ExcalidrawElement | null = null; + + let accumulatedChange: number | null = null; + + document.body.classList.add("dragResize"); + + const onPointerMove = (event: PointerEvent) => { + if (!stateAtStart) { + stateAtStart = deepCopyElement(element); + } + + if (!accumulatedChange) { + accumulatedChange = 0; + } + + if (lastPointer && stateAtStart && accumulatedChange !== null) { + const instantChange = event.clientX - lastPointer.x; + accumulatedChange += instantChange; + + cbThrottled( + accumulatedChange, + instantChange, + stateAtStart, + shouldKeepAspectRatio!!, + event.shiftKey, + ); + } + + lastPointer = { + x: event.clientX, + y: event.clientY, + }; + }; + + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false); + window.addEventListener( + EVENT.POINTER_UP, + () => { + window.removeEventListener( + EVENT.POINTER_MOVE, + onPointerMove, + false, + ); + + lastPointer = null; + accumulatedChange = null; + stateAtStart = null; + + document.body.classList.remove("dragResize"); + }, + false, + ); + } + }} + onPointerEnter={() => { + if (labelRef.current) { + labelRef.current.style.cursor = "ew-resize"; + } + }} + > + {label} +
+ { + if (editable) { + const eventTarget = event.target; + + if ( + eventTarget instanceof HTMLInputElement && + event.key === KEYS.ENTER + ) { + const v = Number(eventTarget.value); + if (isNaN(v)) { + setInputValue(value.toString()); + return; + } + dragInputCallback( + 0, + 0, + element, + shouldKeepAspectRatio!!, + false, + v, + ); + } + } + }} + ref={inputRef} + value={inputValue} + onChange={(event) => { + const eventTarget = event.target; + if (eventTarget instanceof HTMLInputElement) { + setInputValue(event.target.value); + } + }} + onBlur={() => { + if (!inputValue) { + setInputValue(value.toString()); + } + }} + disabled={!editable} + > +
+ ); +}; + +export default StatsDragInput; diff --git a/packages/excalidraw/components/Stats.scss b/packages/excalidraw/components/Stats/index.scss similarity index 87% rename from packages/excalidraw/components/Stats.scss rename to packages/excalidraw/components/Stats/index.scss index 73c69224d..ed5278896 100644 --- a/packages/excalidraw/components/Stats.scss +++ b/packages/excalidraw/components/Stats/index.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@import "../../css/variables.module.scss"; .excalidraw { .Stats { @@ -24,18 +24,13 @@ } .statsItem { + width: 100%; margin-bottom: 4px; - display: flex; - align-items: center; - // margin-right: 8px; + display: grid; + gap: 4px; .label { margin-right: 4px; - width: 10px; - } - - .input { - width: 55px; } } diff --git a/packages/excalidraw/components/Stats.tsx b/packages/excalidraw/components/Stats/index.tsx similarity index 63% rename from packages/excalidraw/components/Stats.tsx rename to packages/excalidraw/components/Stats/index.tsx index ce4c9b8c2..27e54055e 100644 --- a/packages/excalidraw/components/Stats.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -1,17 +1,19 @@ import React, { useEffect, useMemo, useState } from "react"; -import { getCommonBounds } from "../element/bounds"; -import type { NonDeletedExcalidrawElement } from "../element/types"; -import { t } from "../i18n"; -import { getTargetElements } from "../scene"; -import type Scene from "../scene/Scene"; -import type { AppState, ExcalidrawProps } from "../types"; -import { CloseIcon } from "./icons"; -import { Island } from "./Island"; -import "./Stats.scss"; +import { getCommonBounds } from "../../element/bounds"; +import type { NonDeletedExcalidrawElement } from "../../element/types"; +import { t } from "../../i18n"; +import { getTargetElements } from "../../scene"; +import type Scene from "../../scene/Scene"; +import type { AppState, ExcalidrawProps } from "../../types"; +import { CloseIcon } from "../icons"; +import { Island } from "../Island"; import { throttle } from "lodash"; -import DragInput from "./DragInput"; +import Dimension from "./Dimension"; +import Angle from "./Angle"; + +import "./index.scss"; +import FontSize from "./FontSize"; -const STATS_TIMEOUT = 50; interface StatsProps { appState: AppState; scene: Scene; @@ -19,14 +21,11 @@ interface StatsProps { onClose: () => void; renderCustomStats: ExcalidrawProps["renderCustomStats"]; } - -type ElementStatItem = { - label: string; - property: "x" | "y" | "width" | "height" | "angle"; -}; +const STATS_TIMEOUT = 50; export const Stats = (props: StatsProps) => { const elements = props.scene.getNonDeletedElements(); + const elementsMap = props.scene.getNonDeletedElementsMap(); const sceneNonce = props.scene.getSceneNonce(); const selectedElements = getTargetElements(elements, props.appState); @@ -106,39 +105,16 @@ export const Stats = (props: StatsProps) => { {t(`element.${singleElement.type}`)} -
- {( - [ - { - label: "W", - property: "width", - }, - { - label: "H", - property: "height", - }, - { - label: "A", - property: "angle", - }, - ] as ElementStatItem[] - ).map((statsItem) => ( - - ))} +
+ + + + {singleElement.type === "text" && ( + + )}
+ + {singleElement.type === "text" &&
}
)} diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts new file mode 100644 index 000000000..8ad73765b --- /dev/null +++ b/packages/excalidraw/components/Stats/utils.ts @@ -0,0 +1,23 @@ +import { isFrameLikeElement, isTextElement } from "../../element/typeChecks"; +import type { ExcalidrawElement } from "../../element/types"; + +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); +};