From 73432856947755e5aff00dcac1a02b61826e3591 Mon Sep 17 00:00:00 2001 From: Hazem Krimi Date: Thu, 24 Apr 2025 05:58:10 +0100 Subject: [PATCH] wip: Fix frame children coords when dragging position stats coords --- packages/element/src/typeChecks.ts | 6 + .../excalidraw/components/Stats/Angle.tsx | 3 +- .../excalidraw/components/Stats/Dimension.tsx | 3 +- .../excalidraw/components/Stats/DragInput.tsx | 19 +- .../excalidraw/components/Stats/FontSize.tsx | 3 +- .../components/Stats/MultiAngle.tsx | 3 +- .../components/Stats/MultiDimension.tsx | 3 +- .../components/Stats/MultiFontSize.tsx | 3 +- .../components/Stats/MultiPosition.tsx | 193 +-------------- .../excalidraw/components/Stats/Position.tsx | 157 +------------ packages/excalidraw/components/Stats/utils.ts | 221 +++++++++++++++++- 11 files changed, 246 insertions(+), 368 deletions(-) diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index 54619726d..8ab2e3182 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -89,6 +89,12 @@ export const isFrameLikeElement = ( ); }; +export const isFrameChildElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawElement & { frameId: string } => { + return element !== null && element.frameId !== null; +}; + export const isFreeDrawElement = ( element?: ExcalidrawElement | null, ): element is ExcalidrawFreeDrawElement => { diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index d0cb187da..38ccdc49e 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -12,9 +12,8 @@ import type Scene from "@excalidraw/element/Scene"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; -import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; +import { DragInputCallbackType, getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; interface AngleProps { diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index c838b581f..e0d7c8d8b 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -13,9 +13,8 @@ import type { ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; import DragInput from "./DragInput"; -import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { DragInputCallbackType, getStepSizedValue, isPropertyEditable } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; interface DimensionDragInputProps { diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 6fdf909b2..1ef8ee299 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -13,30 +13,13 @@ import { CaptureUpdateAction } from "../../store"; import { useApp } from "../App"; import { InlineIcon } from "../InlineIcon"; -import { SMALLEST_DELTA } from "./utils"; +import { DragInputCallbackType, SMALLEST_DELTA } from "./utils"; import "./DragInput.scss"; import type { StatsInputProperty } from "./utils"; import type { AppState } from "../../types"; -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, diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx index 635f2cd5a..22324f93f 100644 --- a/packages/excalidraw/components/Stats/FontSize.tsx +++ b/packages/excalidraw/components/Stats/FontSize.tsx @@ -17,9 +17,8 @@ import type Scene from "@excalidraw/element/Scene"; import { fontSizeIcon } from "../icons"; import StatsDragInput from "./DragInput"; -import { getStepSizedValue } from "./utils"; +import { DragInputCallbackType, getStepSizedValue } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; interface FontSizeProps { diff --git a/packages/excalidraw/components/Stats/MultiAngle.tsx b/packages/excalidraw/components/Stats/MultiAngle.tsx index a22a01147..e4ef720a4 100644 --- a/packages/excalidraw/components/Stats/MultiAngle.tsx +++ b/packages/excalidraw/components/Stats/MultiAngle.tsx @@ -14,9 +14,8 @@ import type Scene from "@excalidraw/element/Scene"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; -import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { DragInputCallbackType, getStepSizedValue, isPropertyEditable } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; interface MultiAngleProps { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index ddac0ee3f..e4ced05a2 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -26,9 +26,8 @@ import type Scene from "@excalidraw/element/Scene"; import DragInput from "./DragInput"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; -import { getElementsInAtomicUnit } from "./utils"; +import { DragInputCallbackType, getElementsInAtomicUnit } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AtomicUnit } from "./utils"; import type { AppState } from "../../types"; diff --git a/packages/excalidraw/components/Stats/MultiFontSize.tsx b/packages/excalidraw/components/Stats/MultiFontSize.tsx index 075016ad1..9e9c94fbc 100644 --- a/packages/excalidraw/components/Stats/MultiFontSize.tsx +++ b/packages/excalidraw/components/Stats/MultiFontSize.tsx @@ -20,9 +20,8 @@ import type Scene from "@excalidraw/element/Scene"; import { fontSizeIcon } from "../icons"; import StatsDragInput from "./DragInput"; -import { getStepSizedValue } from "./utils"; +import { DragInputCallbackType, getStepSizedValue } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; interface MultiFontSizeProps { diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index ae6b52296..025d79058 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -1,8 +1,6 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { useMemo } from "react"; -import { isTextElement } from "@excalidraw/element/typeChecks"; - import { getCommonBounds } from "@excalidraw/element/bounds"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; @@ -10,12 +8,12 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; import StatsDragInput from "./DragInput"; -import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; -import { getElementsInAtomicUnit, moveElement } from "./utils"; +import { handlePositionChange } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; import type { AtomicUnit } from "./utils"; import type { AppState } from "../../types"; +import { getFrameChildren } from "@excalidraw/element/frame"; +import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; interface MultiPositionProps { property: "x" | "y"; @@ -26,185 +24,6 @@ interface MultiPositionProps { appState: AppState; } -const STEP_SIZE = 10; - -const moveElements = ( - property: MultiPositionProps["property"], - changeInTopX: number, - changeInTopY: number, - originalElements: readonly ExcalidrawElement[], - originalElementsMap: ElementsMap, - scene: Scene, -) => { - for (let i = 0; i < originalElements.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, - scene, - originalElementsMap, - false, - ); - } -}; - -const moveGroupTo = ( - nextX: number, - nextY: number, - originalElements: ExcalidrawElement[], - originalElementsMap: ElementsMap, - scene: Scene, -) => { - const elementsMap = scene.getNonDeletedElementsMap(); - 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, - scene, - originalElementsMap, - false, - ); - } - } -}; - -const handlePositionChange: DragInputCallbackType< - MultiPositionProps["property"] -> = ({ - accumulatedChange, - originalElements, - originalElementsMap, - shouldChangeByStepSize, - nextValue, - property, - scene, - originalAppState, -}) => { - const elementsMap = scene.getNonDeletedElementsMap(); - - if (nextValue !== undefined) { - for (const atomicUnit of getAtomicUnits( - originalElements, - originalAppState, - )) { - const elementsInUnit = getElementsInAtomicUnit( - atomicUnit, - elementsMap, - originalElementsMap, - ); - - if (elementsInUnit.length > 1) { - const [x1, y1, ,] = getCommonBounds( - elementsInUnit.map((el) => el.latest!), - ); - const newTopLeftX = property === "x" ? nextValue : x1; - const newTopLeftY = property === "y" ? nextValue : y1; - - moveGroupTo( - newTopLeftX, - newTopLeftY, - elementsInUnit.map((el) => el.original), - 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, - 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, - originalElementsMap, - scene, - ); - - scene.triggerUpdate(); -}; - const MultiPosition = ({ property, elements, @@ -239,13 +58,17 @@ const MultiPosition = ({ }), [atomicUnits, elementsMap, property], ); + const elementsWithFramesChildren = elements.reduce((accumulator: ExcalidrawElement[], element: ExcalidrawElement) => { + if (!isFrameLikeElement(element)) return [...accumulator, element]; + return [...accumulator, element, ...getFrameChildren(elementsMap, element.id)]; + }, []); const value = new Set(positions).size === 1 ? positions[0] : "Mixed"; return ( = ({ - accumulatedChange, - instantChange, - originalElements, - originalElementsMap, - shouldChangeByStepSize, - nextValue, - property, - scene, - originalAppState, -}) => { - const elementsMap = scene.getNonDeletedElementsMap(); - 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, - ), - }; - } - - scene.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), - }; - - scene.mutateElement(element, { - crop: nextCrop, - }); - - return; - } - - if (nextValue !== undefined) { - const newTopLeftX = property === "x" ? nextValue : topLeftX; - const newTopLeftY = property === "y" ? nextValue : topLeftY; - moveElement( - newTopLeftX, - newTopLeftY, - origElement, - 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, - scene, - originalElementsMap, - ); -}; - const Position = ({ property, element, @@ -180,6 +35,8 @@ const Position = ({ pointFrom(element.x + element.width / 2, element.y + element.height / 2), element.angle, ); + const children = isFrameLikeElement(element) ? getFrameChildren(elementsMap, element.id) : []; + const elements = children.length > 0 ? [element, ...children] : [element]; let value = round(property === "x" ? topLeftX : topLeftY, 2); if ( @@ -200,7 +57,7 @@ const Position = ({ return ( = (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; + export const SMALLEST_DELTA = 0.01; export const isPropertyEditable = ( @@ -73,9 +92,9 @@ export const getElementsInAtomicUnit = ( latest: elementsMap.get(id), })) .filter((el) => el.original !== undefined && el.latest !== undefined) as { - original: NonDeletedExcalidrawElement; - latest: NonDeletedExcalidrawElement; - }[]; + original: NonDeletedExcalidrawElement; + latest: NonDeletedExcalidrawElement; + }[]; }; export const newOrigin = ( @@ -174,6 +193,89 @@ export const moveElement = ( } }; +export const moveElements = ( + property: "x" | "y", + changeInTopX: number, + changeInTopY: number, + originalElements: readonly ExcalidrawElement[], + originalElementsMap: ElementsMap, + scene: Scene, +) => { + for (let i = 0; i < originalElements.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, + scene, + originalElementsMap, + false, + ); + } +}; + +export const moveGroup = ( + nextX: number, + nextY: number, + originalElements: ExcalidrawElement[], + originalElementsMap: ElementsMap, + scene: Scene, +) => { + const elementsMap = scene.getNonDeletedElementsMap(); + 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, + scene, + originalElementsMap, + false, + ); + } + } +}; + export const getAtomicUnits = ( targetElements: readonly ExcalidrawElement[], appState: AppState, @@ -210,3 +312,116 @@ export const updateBindings = ( updateBoundElements(latestElement, scene, options); } }; + +export const handlePositionChange: DragInputCallbackType< + "x" | "y" +> = ({ + accumulatedChange, + originalElements, + originalElementsMap, + shouldChangeByStepSize, + nextValue, + property, + scene, + originalAppState, +}) => { + const STEP_SIZE = 10; + const elementsMap = scene.getNonDeletedElementsMap(); + + if (nextValue !== undefined) { + for (const atomicUnit of getAtomicUnits( + originalElements, + originalAppState, + )) { + const elementsInUnit = getElementsInAtomicUnit( + atomicUnit, + elementsMap, + originalElementsMap, + ); + + if (elementsInUnit.length > 1) { + const [x1, y1, ,] = getCommonBounds( + elementsInUnit.map((el) => el.latest!), + ); + const newTopLeftX = property === "x" ? nextValue : x1; + const newTopLeftY = property === "y" ? nextValue : y1; + + moveGroup( + newTopLeftX, + newTopLeftY, + elementsInUnit.map((el) => el.original), + 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, + ); + + if (isFrameChildElement(origElement)) { + const childNewTopLeftX = property === "x" ? nextValue + Math.abs(topLeftX) : topLeftX; + const childNewTopLeftY = property === "y" ? nextValue + Math.abs(topLeftY) : topLeftY; + + moveElement( + childNewTopLeftX, + childNewTopLeftY, + origElement, + scene, + originalElementsMap, + false, + ); + + scene.triggerUpdate(); + return; + } else { + const newTopLeftX = property === "x" ? nextValue : topLeftX; + const newTopLeftY = property === "y" ? nextValue : topLeftY; + + moveElement( + newTopLeftX, + newTopLeftY, + origElement, + 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, + originalElementsMap, + scene, + ); + + scene.triggerUpdate(); + };