snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
103
packages/excalidraw/components/Stats/Angle.tsx
Normal file
103
packages/excalidraw/components/Stats/Angle.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
|
||||
|
||||
import { getBoundTextElement } from "@excalidraw/element";
|
||||
import { isArrowElement, isElbowArrow } from "@excalidraw/element";
|
||||
|
||||
import { updateBindings } from "@excalidraw/element";
|
||||
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { angleIcon } from "../icons";
|
||||
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface AngleProps {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
property: "angle";
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
app,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
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);
|
||||
scene.mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, scene, app.state);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
scene.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);
|
||||
|
||||
scene.mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, scene, app.state);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
scene.mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Angle;
|
||||
70
packages/excalidraw/components/Stats/CanvasGrid.tsx
Normal file
70
packages/excalidraw/components/Stats/CanvasGrid.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { getNormalizedGridStep } from "../../scene";
|
||||
|
||||
import StatsDragInput from "./DragInput";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface PositionProps {
|
||||
property: "gridStep";
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}
|
||||
|
||||
const STEP_SIZE = 5;
|
||||
|
||||
const CanvasGrid = ({
|
||||
property,
|
||||
scene,
|
||||
appState,
|
||||
setAppState,
|
||||
}: PositionProps) => {
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="Grid step"
|
||||
sensitivity={8}
|
||||
elements={[]}
|
||||
dragInputCallback={({
|
||||
nextValue,
|
||||
instantChange,
|
||||
shouldChangeByStepSize,
|
||||
setInputValue,
|
||||
}) => {
|
||||
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;
|
||||
50
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
50
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
showCollapsedIcon?: boolean;
|
||||
}
|
||||
|
||||
const Collapsible = ({
|
||||
label,
|
||||
open,
|
||||
openTrigger,
|
||||
children,
|
||||
className,
|
||||
showCollapsedIcon = true,
|
||||
}: CollapsibleProps) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
className={className}
|
||||
onClick={openTrigger}
|
||||
>
|
||||
{label}
|
||||
{showCollapsedIcon && (
|
||||
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collapsible;
|
||||
361
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
361
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { clamp, round } from "@excalidraw/math";
|
||||
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||
import {
|
||||
MINIMAL_CROP_SIZE,
|
||||
getNonDeletedElements,
|
||||
getUncroppedWidthAndHeight,
|
||||
} from "@excalidraw/element";
|
||||
import { resizeSingleElement } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInResizingFrame } from "@excalidraw/element";
|
||||
import { replaceAllElementsInFrame } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
property: "width" | "height";
|
||||
element: NonDeletedExcalidrawElement;
|
||||
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,
|
||||
app,
|
||||
setAppState,
|
||||
}) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
scene.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,
|
||||
};
|
||||
|
||||
scene.mutateElement(element, {
|
||||
crop: nextCrop,
|
||||
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
|
||||
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// User types in a value to stats then presses Enter
|
||||
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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Stats slider is dragged
|
||||
{
|
||||
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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldMaintainAspectRatio: keepAspectRatio,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getNonDeletedElements(
|
||||
getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: nextElementsInFrame,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={isPropertyEditable(element, property)}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionDragInput;
|
||||
56
packages/excalidraw/components/Stats/DragInput.scss
Normal file
56
packages/excalidraw/components/Stats/DragInput.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
.excalidraw {
|
||||
.drag-input-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-input-label {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
padding: 0 0.5rem 0 0.25rem;
|
||||
min-width: 1rem;
|
||||
width: 1.5rem;
|
||||
height: 2rem;
|
||||
box-sizing: content-box;
|
||||
color: var(--popup-text-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: 2rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
padding: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
appearance: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
411
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
411
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
|
||||
import "./DragInput.scss";
|
||||
|
||||
import type { StatsInputProperty } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
export type DragInputCallbackType<
|
||||
P extends StatsInputProperty,
|
||||
E = NonDeletedExcalidrawElement,
|
||||
> = (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;
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
}) => void;
|
||||
|
||||
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
|
||||
app: ReturnType<typeof useApp>;
|
||||
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||
originalElements: readonly E[] | null;
|
||||
originalAppState: AppState;
|
||||
}) => 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<T, E>;
|
||||
property: T;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
/** how many px you need to drag to get 1 unit change */
|
||||
sensitivity?: number;
|
||||
dragFinishedCallback?: DragFinishedCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
T extends StatsInputProperty,
|
||||
E extends ExcalidrawElement = ExcalidrawElement,
|
||||
>({
|
||||
label,
|
||||
icon,
|
||||
dragInputCallback,
|
||||
value,
|
||||
elements,
|
||||
editable = true,
|
||||
shouldKeepAspectRatio,
|
||||
property,
|
||||
scene,
|
||||
appState,
|
||||
sensitivity = 1,
|
||||
dragFinishedCallback,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
const app = useApp();
|
||||
const ownerDocument = app.ownerDocument;
|
||||
const ownerWindow = app.ownerWindow;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(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);
|
||||
// Number.isFinite also rejects "Infinity"/"1e999", which `isNaN` lets
|
||||
// through — non-finite values corrupt element geometry (NaN/Infinity
|
||||
// don't survive the JSON round-trip on save)
|
||||
if (!Number.isFinite(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;
|
||||
// a real snapshot (copies), like the pointer-drag path builds: the
|
||||
// callbacks derive gesture-start values from it — e.g. a sticky note's
|
||||
// width and its label's font ceiling — and the live map would already
|
||||
// carry the change they apply. A typed value is applied once, so the
|
||||
// scene at this point still is the original.
|
||||
const originalElementsMap: ElementsMap = new Map();
|
||||
for (const element of app.scene.getNonDeletedElements()) {
|
||||
originalElementsMap.set(element.id, deepCopyElement(element));
|
||||
}
|
||||
dragInputCallback({
|
||||
accumulatedChange: 0,
|
||||
instantChange: 0,
|
||||
originalElements: elements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: false,
|
||||
scene,
|
||||
nextValue: rounded,
|
||||
property,
|
||||
originalAppState: appState,
|
||||
setInputValue: (value) => setInputValue(String(value)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
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
|
||||
ownerWindow.removeEventListener(
|
||||
EVENT.POINTER_MOVE,
|
||||
callbacks.onPointerMove!,
|
||||
false,
|
||||
);
|
||||
ownerWindow.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,
|
||||
ownerWindow,
|
||||
]);
|
||||
|
||||
if (!editable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("drag-input-container", !editable && "disabled")}
|
||||
data-testid={label}
|
||||
>
|
||||
<div
|
||||
className="drag-input-label"
|
||||
ref={labelRef}
|
||||
onPointerDown={(event) => {
|
||||
if (inputRef.current && editable) {
|
||||
ownerDocument.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: ElementsMap | 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)),
|
||||
app,
|
||||
setAppState,
|
||||
});
|
||||
|
||||
stepChange = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastPointer = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
ownerWindow.removeEventListener(
|
||||
EVENT.POINTER_MOVE,
|
||||
onPointerMove,
|
||||
false,
|
||||
);
|
||||
|
||||
app.syncActionResult({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
// Notify implementors
|
||||
dragFinishedCallback?.({
|
||||
app,
|
||||
setAppState,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
});
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = 0;
|
||||
stepChange = 0;
|
||||
originalElements = null;
|
||||
originalElementsMap = null;
|
||||
|
||||
ownerDocument.body.classList.remove("excalidraw-cursor-resize");
|
||||
|
||||
ownerWindow.removeEventListener(
|
||||
EVENT.POINTER_UP,
|
||||
onPointerUp,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
callbacksRef.current.onPointerMove = onPointerMove;
|
||||
callbacksRef.current.onPointerUp = onPointerUp;
|
||||
|
||||
ownerWindow.addEventListener(
|
||||
EVENT.POINTER_MOVE,
|
||||
onPointerMove,
|
||||
false,
|
||||
);
|
||||
ownerWindow.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
|
||||
}
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (labelRef.current) {
|
||||
labelRef.current.style.cursor = "ew-resize";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon ? <InlineIcon icon={icon} /> : label}
|
||||
</div>
|
||||
<input
|
||||
className="drag-input"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
onKeyDown={(event) => {
|
||||
if (editable) {
|
||||
const eventTarget = event.currentTarget;
|
||||
if (
|
||||
eventTarget instanceof ownerWindow.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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDragInput;
|
||||
114
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
114
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
import { hasBoundTextElement, isTextElement } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { fontSizeIcon } from "../icons";
|
||||
|
||||
import StatsDragInput from "./DragInput";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
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(
|
||||
getBaseFontSize(origElement, elementsMap),
|
||||
);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextFontSize) {
|
||||
scene.mutateElement(
|
||||
latestElement,
|
||||
getBaseFontSizeUpdate(latestElement, nextFontSize, elementsMap),
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
latestElement,
|
||||
scene.getContainerElement(latestElement),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
|
||||
const _element = isTextElement(element)
|
||||
? element
|
||||
: hasBoundTextElement(element)
|
||||
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
|
||||
: null;
|
||||
|
||||
if (!_element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
value={
|
||||
Math.round(
|
||||
getBaseFontSize(_element, scene.getNonDeletedElementsMap()) * 10,
|
||||
) / 10
|
||||
}
|
||||
elements={[_element]}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
icon={fontSizeIcon}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSize;
|
||||
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
|
||||
|
||||
import { getBoundTextElement } from "@excalidraw/element";
|
||||
import { isArrowElement } from "@excalidraw/element";
|
||||
|
||||
import { isInGroup } from "@excalidraw/element";
|
||||
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { angleIcon } from "../icons";
|
||||
|
||||
import DragInput from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface MultiAngleProps {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
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;
|
||||
}
|
||||
scene.mutateElement(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
scene.mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
scene.mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
scene.mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={value}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiAngle;
|
||||
514
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
514
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
getNonDeletedElements,
|
||||
isFrameLikeElement,
|
||||
replaceAllElementsInFrame,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
getStickyNoteResizeIntent,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
import {
|
||||
rescalePointsInElement,
|
||||
resizeSingleElement,
|
||||
} from "@excalidraw/element";
|
||||
import { getBoundTextElement, handleBindTextResize } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/utils";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import DragInput from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit } from "./utils";
|
||||
|
||||
import type {
|
||||
DragFinishedCallbackType,
|
||||
DragInputCallbackType,
|
||||
} from "./DragInput";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const getResizedUpdates = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
scale: number,
|
||||
origElement: NonDeletedExcalidrawElement,
|
||||
) => {
|
||||
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: NonDeletedExcalidrawElement,
|
||||
origElement: NonDeletedExcalidrawElement,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (
|
||||
isTextElement(latestElement) &&
|
||||
isStickyNoteBoundText(latestElement, elementsMap)
|
||||
) {
|
||||
// a group unit lists the note's label too; the note's layout owns it
|
||||
// entirely (a direct scale here would overwrite the fitted size)
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||
|
||||
scene.mutateElement(latestElement, updates);
|
||||
|
||||
if (isStickyNoteElement(latestElement)) {
|
||||
// group scaling is uniform: base height and font ceiling scale with the
|
||||
// note (empty notes included); the layout runs the arrow pass itself
|
||||
updateStickyNoteLayout(latestElement, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
latestElement,
|
||||
originalElementsMap,
|
||||
property === "width" ? "e" : "s",
|
||||
{ proportional: true, fromCenter: false },
|
||||
),
|
||||
anchor: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
updateBoundElements(latestElement, scene);
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||
scene.mutateElement(latestBoundTextElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeGroup = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
initialHeight: number,
|
||||
aspectRatio: number,
|
||||
anchor: GlobalPoint,
|
||||
property: MultiDimensionProps["property"],
|
||||
latestElements: NonDeletedExcalidrawElement[],
|
||||
originalElements: NonDeletedExcalidrawElement[],
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// 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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType<
|
||||
MultiDimensionProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
originalAppState,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
property,
|
||||
setAppState,
|
||||
app,
|
||||
}) => {
|
||||
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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
} 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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
const elementsToHighlight: NonDeletedExcalidrawElement[] = [];
|
||||
|
||||
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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
} 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,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
property === "width" ? "e" : "s",
|
||||
{
|
||||
shouldInformMutation: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle highlighting frame element candidates
|
||||
if (isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getNonDeletedElements(
|
||||
getElementsInResizingFrame(
|
||||
scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
);
|
||||
|
||||
elementsToHighlight.push(...nextElementsInFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight,
|
||||
});
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const handleDragFinished: DragFinishedCallbackType = ({
|
||||
setAppState,
|
||||
app,
|
||||
originalElements,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements?.[0];
|
||||
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||
|
||||
// Handle frame membership update for resized frames
|
||||
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||
const nextElementsInFrame = getElementsInResizingFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
latestElement,
|
||||
originalAppState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const updatedElements = replaceAllElementsInFrame(
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
setAppState({
|
||||
elementsToHighlight: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
dragFinishedCallback={handleDragFinished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiDimension;
|
||||
167
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
167
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
import { hasBoundTextElement, isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { isInGroup } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { fontSizeIcon } from "../icons";
|
||||
|
||||
import StatsDragInput from "./DragInput";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
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) {
|
||||
scene.mutateElement(
|
||||
textElement,
|
||||
getBaseFontSizeUpdate(textElement, nextFontSize, elementsMap),
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
scene.getContainerElement(textElement),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
getBaseFontSize(originalElement, elementsMap),
|
||||
);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
let nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
scene.mutateElement(
|
||||
latestElement,
|
||||
getBaseFontSizeUpdate(latestElement, nextFontSize, elementsMap),
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(
|
||||
latestElement,
|
||||
scene.getContainerElement(latestElement),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
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(getBaseFontSize(textEl, elementsMap) * 10) / 10,
|
||||
);
|
||||
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
|
||||
const editable = fontSizes.length > 0;
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
icon={fontSizeIcon}
|
||||
elements={latestTextElements}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
scene={scene}
|
||||
property={property}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiFontSize;
|
||||
273
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
273
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import StatsDragInput from "./DragInput";
|
||||
import {
|
||||
getAtomicUnits,
|
||||
getStepSizedValue,
|
||||
isPropertyEditable,
|
||||
STEP_SIZE,
|
||||
} from "./utils";
|
||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const moveElements = (
|
||||
property: MultiPositionProps["property"],
|
||||
changeInTopX: number,
|
||||
changeInTopY: number,
|
||||
originalElements: readonly NonDeletedExcalidrawElement[],
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
) => {
|
||||
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,
|
||||
appState,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const moveGroupTo = (
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
originalElements: readonly NonDeletedExcalidrawElement[],
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
) => {
|
||||
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,
|
||||
appState,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionChange: DragInputCallbackType<
|
||||
MultiPositionProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
originalAppState,
|
||||
app,
|
||||
}) => {
|
||||
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,
|
||||
app.state,
|
||||
);
|
||||
} 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,
|
||||
app.state,
|
||||
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,
|
||||
app.state,
|
||||
);
|
||||
|
||||
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 (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={elements}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiPosition;
|
||||
211
packages/excalidraw/components/Stats/Position.tsx
Normal file
211
packages/excalidraw/components/Stats/Position.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { clamp, pointFrom, pointRotateRads, round } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
getFlipAdjustedCropPosition,
|
||||
getUncroppedWidthAndHeight,
|
||||
} from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import StatsDragInput from "./DragInput";
|
||||
import { getStepSizedValue, moveElement, STEP_SIZE } from "./utils";
|
||||
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
element: NonDeletedExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
originalAppState,
|
||||
app,
|
||||
}) => {
|
||||
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,
|
||||
app.state,
|
||||
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,
|
||||
app.state,
|
||||
originalElementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
const Position = ({ property, element, 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 (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handlePositionChange}
|
||||
scene={scene}
|
||||
value={value}
|
||||
property={property}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Position;
|
||||
76
packages/excalidraw/components/Stats/Stats.scss
Normal file
76
packages/excalidraw/components/Stats/Stats.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
443
packages/excalidraw/components/Stats/index.tsx
Normal file
443
packages/excalidraw/components/Stats/index.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { round } from "@excalidraw/math";
|
||||
import clsx from "clsx";
|
||||
import throttle from "lodash.throttle";
|
||||
import { useEffect, useMemo, useState, memo } from "react";
|
||||
|
||||
import { STATS_PANELS } from "@excalidraw/common";
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
import { getUncroppedWidthAndHeight } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
|
||||
|
||||
import { elementsAreInSameGroup } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { Island } from "../Island";
|
||||
import { CloseIcon } from "../icons";
|
||||
|
||||
import Angle from "./Angle";
|
||||
import CanvasGrid from "./CanvasGrid";
|
||||
import Collapsible from "./Collapsible";
|
||||
import Dimension from "./Dimension";
|
||||
import FontSize from "./FontSize";
|
||||
import MultiAngle from "./MultiAngle";
|
||||
import MultiDimension from "./MultiDimension";
|
||||
import MultiFontSize from "./MultiFontSize";
|
||||
import MultiPosition from "./MultiPosition";
|
||||
import Position from "./Position";
|
||||
import { getAtomicUnits } from "./utils";
|
||||
|
||||
import "./Stats.scss";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
} from "../../types";
|
||||
|
||||
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 (
|
||||
<StatsInner
|
||||
{...props}
|
||||
appState={appState}
|
||||
sceneNonce={sceneNonce}
|
||||
selectedElements={selectedElements}
|
||||
gridModeEnabled={gridModeEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StatsRow = ({
|
||||
children,
|
||||
columns = 1,
|
||||
heading,
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
columns?: number;
|
||||
heading?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
StatsRow.displayName = "StatsRow";
|
||||
|
||||
const StatsRows = ({
|
||||
children,
|
||||
order,
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
order?: number;
|
||||
style?: React.CSSProperties;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="exc-stats">
|
||||
<Island padding={3}>
|
||||
<div className="title">
|
||||
<h2>{t("stats.title")}</h2>
|
||||
<div className="close" onClick={onClose}>
|
||||
{CloseIcon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
label={<h3>{t("stats.generalStats")}</h3>}
|
||||
open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
|
||||
openTrigger={() =>
|
||||
setAppState((state) => {
|
||||
return {
|
||||
stats: {
|
||||
open: true,
|
||||
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<StatsRows>
|
||||
<StatsRow heading>{t("stats.scene")}</StatsRow>
|
||||
<StatsRow columns={2}>
|
||||
<div>{t("stats.shapes")}</div>
|
||||
<div>{elements.length}</div>
|
||||
</StatsRow>
|
||||
<StatsRow columns={2}>
|
||||
<div>{t("stats.width")}</div>
|
||||
<div>{sceneDimension.width}</div>
|
||||
</StatsRow>
|
||||
<StatsRow columns={2}>
|
||||
<div>{t("stats.height")}</div>
|
||||
<div>{sceneDimension.height}</div>
|
||||
</StatsRow>
|
||||
{gridModeEnabled && (
|
||||
<>
|
||||
<StatsRow heading>Canvas</StatsRow>
|
||||
<StatsRow>
|
||||
<CanvasGrid
|
||||
property="gridStep"
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</StatsRow>
|
||||
</>
|
||||
)}
|
||||
</StatsRows>
|
||||
|
||||
{renderCustomStats?.(elements, appState)}
|
||||
</Collapsible>
|
||||
|
||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
||||
<div
|
||||
id="elementStats"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<Collapsible
|
||||
label={<h3>{t("stats.elementProperties")}</h3>}
|
||||
open={
|
||||
!!(appState.stats.panels & STATS_PANELS.elementProperties)
|
||||
}
|
||||
openTrigger={() =>
|
||||
setAppState((state) => {
|
||||
return {
|
||||
stats: {
|
||||
open: true,
|
||||
panels:
|
||||
state.stats.panels ^ STATS_PANELS.elementProperties,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<StatsRows>
|
||||
{singleElement && (
|
||||
<>
|
||||
{cropMode && (
|
||||
<StatsRow heading>
|
||||
{t("labels.unCroppedDimension")}
|
||||
</StatsRow>
|
||||
)}
|
||||
|
||||
{appState.croppingElementId &&
|
||||
isImageElement(singleElement) &&
|
||||
unCroppedDimension && (
|
||||
<StatsRow columns={2}>
|
||||
<div>{t("stats.width")}</div>
|
||||
<div>{round(unCroppedDimension.width, 2)}</div>
|
||||
</StatsRow>
|
||||
)}
|
||||
|
||||
{appState.croppingElementId &&
|
||||
isImageElement(singleElement) &&
|
||||
unCroppedDimension && (
|
||||
<StatsRow columns={2}>
|
||||
<div>{t("stats.height")}</div>
|
||||
<div>{round(unCroppedDimension.height, 2)}</div>
|
||||
</StatsRow>
|
||||
)}
|
||||
|
||||
<StatsRow
|
||||
heading
|
||||
data-testid="stats-element-type"
|
||||
style={{ margin: "0.3125rem 0" }}
|
||||
>
|
||||
{appState.croppingElementId
|
||||
? t("labels.imageCropping")
|
||||
: t(`element.${singleElement.type}`)}
|
||||
</StatsRow>
|
||||
|
||||
<StatsRow>
|
||||
<Position
|
||||
element={singleElement}
|
||||
property="x"
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<Position
|
||||
element={singleElement}
|
||||
property="y"
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<Dimension
|
||||
property="width"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<Dimension
|
||||
property="height"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<Angle
|
||||
property="angle"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<FontSize
|
||||
property="fontSize"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{multipleElements && (
|
||||
<>
|
||||
{elementsAreInSameGroup(multipleElements) && (
|
||||
<StatsRow heading>{t("element.group")}</StatsRow>
|
||||
)}
|
||||
|
||||
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
|
||||
<div>{t("stats.shapes")}</div>
|
||||
<div>{selectedElements.length}</div>
|
||||
</StatsRow>
|
||||
|
||||
<StatsRow>
|
||||
<MultiPosition
|
||||
property="x"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<MultiPosition
|
||||
property="y"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<MultiDimension
|
||||
property="width"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<MultiDimension
|
||||
property="height"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<MultiAngle
|
||||
property="angle"
|
||||
elements={multipleElements}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</StatsRow>
|
||||
<StatsRow>
|
||||
<MultiFontSize
|
||||
property="fontSize"
|
||||
elements={multipleElements}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
</StatsRow>
|
||||
</>
|
||||
)}
|
||||
</StatsRows>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Island>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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
|
||||
);
|
||||
},
|
||||
);
|
||||
927
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
927
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
@@ -0,0 +1,927 @@
|
||||
import { degreesToRadians, pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import { act, fireEvent, queryByTestId } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { setDateTimeForTests, reseed } from "@excalidraw/common";
|
||||
|
||||
import { isInGroup } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw, getCommonBounds } from "../..";
|
||||
import { actionGroup } from "../../actions";
|
||||
import { t } from "../../i18n";
|
||||
import * as StaticScene from "../../renderer/staticScene";
|
||||
import { API } from "../../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||
import {
|
||||
GlobalTestState,
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../../tests/test-utils";
|
||||
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
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(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
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("184"));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// single element
|
||||
describe("stats for a generic element", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
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 reject non-finite values", () => {
|
||||
const rectangle = h.elements[0];
|
||||
|
||||
const input = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
|
||||
UI.updateInput(input, "100");
|
||||
expect(rectangle.width).toBe(100);
|
||||
|
||||
for (const garbage of ["Infinity", "-Infinity", "1e999"]) {
|
||||
UI.updateInput(input, garbage);
|
||||
expect(rectangle.width).toBe(100);
|
||||
expect(input.value).toBe("100");
|
||||
}
|
||||
});
|
||||
|
||||
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(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
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 editor = await getTextEditor();
|
||||
updateTextEditor(editor, "Hello!");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
API.setSelectedElements([text] as NonDeletedExcalidrawElement[]);
|
||||
|
||||
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);
|
||||
|
||||
// can change width or height
|
||||
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
|
||||
const textHeightBeforeWrapping = text.height;
|
||||
const textBeforeWrapping = text.text;
|
||||
const originalTextBeforeWrapping = textBeforeWrapping;
|
||||
UI.updateInput(width, "30");
|
||||
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
|
||||
expect(text.text).not.toBe(textBeforeWrapping);
|
||||
expect(text.originalText).toBe(originalTextBeforeWrapping);
|
||||
|
||||
// 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,
|
||||
});
|
||||
h.app.scene.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(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
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<string, true>),
|
||||
});
|
||||
|
||||
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 editor = await getTextEditor();
|
||||
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<string, true>),
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
UI.updateInput(width, "200");
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).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.moveTo(10, 0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame resizing behavior", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
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 add shapes to frame when resizing frame to encompass them", () => {
|
||||
// Create a frame
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 103,
|
||||
});
|
||||
|
||||
// Create a rectangle outside the frame
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle]);
|
||||
|
||||
// Initially, rectangle should not be in the frame
|
||||
expect(rectangle.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Find the width input and update it to encompass the rectangle
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(widthInput).toBeDefined();
|
||||
expect(widthInput.value).toBe("100");
|
||||
|
||||
// Resize frame to width 250, which should encompass the rectangle
|
||||
UI.updateInput(widthInput, "250");
|
||||
|
||||
// After resizing, the rectangle should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add multiple shapes when frame encompasses them through height resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 180,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle1, rectangle2]);
|
||||
|
||||
// Initially, rectangles should not be in the frame
|
||||
expect(rectangle1.frameId).toBe(null);
|
||||
expect(rectangle2.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame height to encompass both rectangles
|
||||
const heightInput = UI.queryStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Resize frame to height 250, which should encompass both rectangles
|
||||
UI.updateInput(heightInput, "250");
|
||||
|
||||
// After resizing, both rectangles should now be part of the frame
|
||||
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect shapes that remain outside frame after resize", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const insideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 120,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
const outsideRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 50,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
API.setElements([frame, insideRect, outsideRect]);
|
||||
|
||||
// Initially, both rectangles should not be in the frame
|
||||
expect(insideRect.frameId).toBe(null);
|
||||
expect(outsideRect.frameId).toBe(null);
|
||||
|
||||
// Select the frame
|
||||
API.setAppState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// Resize frame width to 200, which should only encompass insideRect
|
||||
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
UI.updateInput(widthInput, "200");
|
||||
|
||||
// After resizing, only insideRect should be in the frame
|
||||
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
|
||||
frame.id,
|
||||
);
|
||||
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
254
packages/excalidraw/components/Stats/utils.ts
Normal file
254
packages/excalidraw/components/Stats/utils.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getNonDeletedElements,
|
||||
isBindingElement,
|
||||
unbindBindingElement,
|
||||
} from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
getElementsInGroup,
|
||||
isInGroup,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
|
||||
import { updateBindings } from "@excalidraw/element";
|
||||
import { DRAGGING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
export type StatsInputProperty =
|
||||
| "x"
|
||||
| "y"
|
||||
| "width"
|
||||
| "height"
|
||||
| "angle"
|
||||
| "fontSize"
|
||||
| "gridStep";
|
||||
|
||||
export const SMALLEST_DELTA = 0.01;
|
||||
export const STEP_SIZE = 10;
|
||||
|
||||
export const isPropertyEditable = (
|
||||
element: ExcalidrawElement,
|
||||
property: keyof ExcalidrawElement,
|
||||
) => {
|
||||
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<string, true>;
|
||||
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: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
originalElementsMap: ElementsMap,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
if (
|
||||
isBindingElement(originalElement) &&
|
||||
(originalElement.startBinding || originalElement.endBinding)
|
||||
) {
|
||||
if (
|
||||
Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD &&
|
||||
Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
unbindBindingElement(originalElement, "start", scene);
|
||||
unbindBindingElement(originalElement, "end", scene);
|
||||
}
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
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,
|
||||
);
|
||||
|
||||
scene.mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
},
|
||||
{ informMutation: shouldInformMutation, isDragging: false },
|
||||
);
|
||||
updateBindings(latestElement, scene, appState);
|
||||
|
||||
const boundTextElement = getBoundTextElement(
|
||||
originalElement,
|
||||
originalElementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
latestBoundTextElement &&
|
||||
scene.mutateElement(
|
||||
latestBoundTextElement,
|
||||
{
|
||||
x: boundTextElement.x + changeInX,
|
||||
y: boundTextElement.y + changeInY,
|
||||
},
|
||||
{ informMutation: shouldInformMutation, isDragging: false },
|
||||
);
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(originalElement)) {
|
||||
const originalChildren = getFrameChildren(
|
||||
originalElementsMap,
|
||||
originalElement.id,
|
||||
);
|
||||
originalChildren.forEach((child) => {
|
||||
const latestChildElement = elementsMap.get(child.id);
|
||||
|
||||
if (!latestChildElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [childCX, childCY] = [
|
||||
child.x + child.width / 2,
|
||||
child.y + child.height / 2,
|
||||
];
|
||||
const [childTopLeftX, childTopLeftY] = pointRotateRads(
|
||||
pointFrom(child.x, child.y),
|
||||
pointFrom(childCX, childCY),
|
||||
child.angle,
|
||||
);
|
||||
|
||||
const childNewTopLeftX = Math.round(childTopLeftX + changeInX);
|
||||
const childNewTopLeftY = Math.round(childTopLeftY + changeInY);
|
||||
|
||||
const [childX, childY] = pointRotateRads(
|
||||
pointFrom(childNewTopLeftX, childNewTopLeftY),
|
||||
pointFrom(childCX + changeInX, childCY + changeInY),
|
||||
-child.angle as Radians,
|
||||
);
|
||||
|
||||
scene.mutateElement(
|
||||
latestChildElement,
|
||||
{
|
||||
x: childX,
|
||||
y: childY,
|
||||
},
|
||||
{ informMutation: shouldInformMutation, isDragging: false },
|
||||
);
|
||||
updateBindings(latestChildElement, scene, appState, {
|
||||
simultaneouslyUpdated: getNonDeletedElements(originalChildren),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user