wip: Fix frame children coords when dragging position stats coords

This commit is contained in:
Hazem Krimi 2025-04-24 05:58:10 +01:00
parent a18b139a60
commit 7343285694
11 changed files with 246 additions and 368 deletions

View file

@ -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 = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {

View file

@ -12,9 +12,8 @@ import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; 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"; import type { AppState } from "../../types";
interface AngleProps { interface AngleProps {

View file

@ -13,9 +13,8 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene"; import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput"; 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"; import type { AppState } from "../../types";
interface DimensionDragInputProps { interface DimensionDragInputProps {

View file

@ -13,30 +13,13 @@ import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App"; import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils"; import { DragInputCallbackType, SMALLEST_DELTA } from "./utils";
import "./DragInput.scss"; import "./DragInput.scss";
import type { StatsInputProperty } from "./utils"; import type { StatsInputProperty } from "./utils";
import type { AppState } from "../../types"; 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< interface StatsDragInputProps<
T extends StatsInputProperty, T extends StatsInputProperty,
E = ExcalidrawElement, E = ExcalidrawElement,

View file

@ -17,9 +17,8 @@ import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons"; import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils"; import { DragInputCallbackType, getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface FontSizeProps { interface FontSizeProps {

View file

@ -14,9 +14,8 @@ import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; 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"; import type { AppState } from "../../types";
interface MultiAngleProps { interface MultiAngleProps {

View file

@ -26,9 +26,8 @@ import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; 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 { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";

View file

@ -20,9 +20,8 @@ import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons"; import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils"; import { DragInputCallbackType, getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface MultiFontSizeProps { interface MultiFontSizeProps {

View file

@ -1,8 +1,6 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import { useMemo } from "react"; import { useMemo } from "react";
import { isTextElement } from "@excalidraw/element/typeChecks";
import { getCommonBounds } from "@excalidraw/element/bounds"; import { getCommonBounds } from "@excalidraw/element/bounds";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; 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 type Scene from "@excalidraw/element/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { handlePositionChange } from "./utils";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFrameChildren } from "@excalidraw/element/frame";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
interface MultiPositionProps { interface MultiPositionProps {
property: "x" | "y"; property: "x" | "y";
@ -26,185 +24,6 @@ interface MultiPositionProps {
appState: AppState; 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 = ({ const MultiPosition = ({
property, property,
elements, elements,
@ -239,13 +58,17 @@ const MultiPosition = ({
}), }),
[atomicUnits, elementsMap, property], [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"; const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
return ( return (
<StatsDragInput <StatsDragInput
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={elements} elements={elementsWithFramesChildren}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
value={value} value={value}
property={property} property={property}

View file

@ -1,20 +1,19 @@
import { clamp, pointFrom, pointRotateRads, round } from "@excalidraw/math"; import { pointFrom, pointRotateRads, round } from "@excalidraw/math";
import { import {
getFlipAdjustedCropPosition, getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "@excalidraw/element/cropElement"; } from "@excalidraw/element/cropElement";
import { isImageElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement, isImageElement } from "@excalidraw/element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene"; import type Scene from "@excalidraw/element/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { handlePositionChange } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFrameChildren } from "@excalidraw/element/frame";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@ -24,150 +23,6 @@ interface PositionProps {
appState: AppState; 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 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 = ({ const Position = ({
property, property,
element, element,
@ -180,6 +35,8 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2), pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle, 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); let value = round(property === "x" ? topLeftX : topLeftY, 2);
if ( if (
@ -200,7 +57,7 @@ const Position = ({
return ( return (
<StatsDragInput <StatsDragInput
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={[element]} elements={elements}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
scene={scene} scene={scene}
value={value} value={value}

View file

@ -5,7 +5,9 @@ import {
updateBoundElements, updateBoundElements,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { getBoundTextElement } from "@excalidraw/element/textElement"; import { getBoundTextElement } from "@excalidraw/element/textElement";
import { getCommonBounds } from "@excalidraw/element/bounds";
import { import {
isFrameChildElement,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
@ -38,6 +40,23 @@ export type StatsInputProperty =
| "fontSize" | "fontSize"
| "gridStep"; | "gridStep";
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;
export const SMALLEST_DELTA = 0.01; export const SMALLEST_DELTA = 0.01;
export const isPropertyEditable = ( export const isPropertyEditable = (
@ -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 = ( export const getAtomicUnits = (
targetElements: readonly ExcalidrawElement[], targetElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
@ -210,3 +312,116 @@ export const updateBindings = (
updateBoundElements(latestElement, scene, options); 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();
};