Compare commits

...

15 Commits

Author SHA1 Message Date
Ryan Di
c68c2be44c handle bound texts 2024-06-04 23:06:27 +08:00
Ryan Di
be65ac7f22 resize linear & freedraw 2024-06-04 19:34:17 +08:00
Ryan Di
09e249ae57 capture history 2024-06-04 16:27:53 +08:00
Ryan Di
f0c1e9707a change dimension for multiple elements 2024-06-04 15:28:06 +08:00
Ryan Di
7f4659339b custom font size 2024-05-31 17:21:53 +08:00
Ryan Di
0987c5b770 refactor to include dimension and step size 2024-05-31 17:21:41 +08:00
Ryan Di
0a529bd2ed change a rotated element's width and height 2024-05-28 19:57:34 +08:00
Ryan Di
794b2b21a7 merge with master 2024-05-24 16:21:09 +08:00
Ryan Di
6e577d1308 wip: drag input 2023-04-18 16:26:01 +08:00
Ryan Di
80b9fd18b9 throttled stats 2023-04-10 18:10:46 +08:00
Ryan Di
dbc48cfee2 move stats from layerui to app component 2023-04-06 16:05:36 +08:00
Ryan Di
3fc89b716a editing single element 2023-03-27 17:51:31 +08:00
Ryan Di
30743ec726 split stats into general and element stats 2023-03-22 18:32:21 +08:00
Ryan Di
86d49a273b rename 'stats for nerds' to 'general stats' 2023-03-21 14:49:32 +08:00
Ryan Di
92fe9b95d5 remove element stats from 'stats for nerds' 2023-03-21 14:47:46 +08:00
19 changed files with 1179 additions and 140 deletions

View File

@ -135,7 +135,8 @@ export type ActionName =
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette"
| "autoResize";
| "autoResize"
| "elementStats";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View File

@ -434,6 +434,7 @@ import {
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { Stats } from "./Stats";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -545,7 +546,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: Store;
store: Store;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@ -1669,6 +1670,19 @@ class App extends React.Component<AppProps, AppState> {
}}
/>
)}
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setState}
scene={this.scene}
onClose={() => {
this.actionManager.executeAction(
actionToggleStats,
);
}}
renderCustomStats={renderCustomStats}
/>
)}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}

View File

@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
@ -542,17 +540,6 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
{appState.scrolledOutside && (
<button
type="button"

View File

@ -21,8 +21,6 @@ import { Section } from "./Section";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels";
@ -157,17 +155,6 @@ export const MobileMenu = ({
<>
{renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.openMenu && appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
<div
className="App-bottom-bar"
style={{

View File

@ -1,108 +0,0 @@
import React from "react";
import { getCommonBounds } from "../element/bounds";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getTargetElements } from "../scene";
import type { ExcalidrawProps, UIAppState } from "../types";
import { CloseIcon } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
export const Stats = (props: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
return (
<div className="Stats">
<Island padding={2}>
<div className="close" onClick={props.onClose}>
{CloseIcon}
</div>
<h3>{t("stats.title")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{props.elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</tr>
{selectedElements.length === 1 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
</tr>
)}
{selectedElements.length > 1 && (
<>
<tr>
<th colSpan={2}>{t("stats.selected")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{selectedElements.length}</td>
</tr>
</>
)}
{selectedElements.length > 0 && (
<>
<tr>
<td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td>
</tr>
<tr>
<td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0],
)}
</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>
{Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1],
)}
</td>
</tr>
</>
)}
{selectedElements.length === 1 && (
<tr>
<td>{t("stats.angle")}</td>
<td>
{`${Math.round(
(selectedElements[0].angle * 180) / Math.PI,
)}°`}
</td>
</tr>
)}
{props.renderCustomStats?.(props.elements, props.appState)}
</tbody>
</table>
</Island>
</div>
);
};

View File

@ -0,0 +1,75 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
interface AngleProps {
element: ExcalidrawElement;
elementsMap: ElementsMap;
}
const STEP_SIZE = 15;
const Angle = ({ element, elementsMap }: AngleProps) => {
const handleDegreeChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
return;
}
const originalAngleInDegrees =
Math.round(radianToDegree(_stateAtStart.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
return (
<DragInput
label="A"
value={Math.round(radianToDegree(element.angle) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
/>
);
};
export default Angle;

View File

@ -0,0 +1,247 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getFontString } from "../../utils";
import { updateBoundElements } from "../../element/binding";
interface DimensionDragInputProps {
property: "width" | "height";
element: ExcalidrawElement;
elementsMap: ElementsMap;
}
const STEP_SIZE = 10;
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
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),
};
};
const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
latestState: ExcalidrawElement,
stateAtStart: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: Map<string, ExcalidrawElement>,
) => {
mutateElement(latestState, {
...newOrigin(
latestState.x,
latestState.y,
latestState.width,
latestState.height,
nextWidth,
nextHeight,
latestState.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, true),
});
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestState, elementsMap);
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestState,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
}
updateBoundElements(latestState, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestState, elementsMap, "e", keepAspectRatio);
};
const DimensionDragInput = ({
property,
element,
elementsMap,
}: DimensionDragInputProps) => {
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = _stateAtStart.width / _stateAtStart.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: _stateAtStart.width,
0,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: _stateAtStart.height,
0,
);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, _stateAtStart.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, _stateAtStart.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
_stateAtStart,
elementsMap,
originalElementsMap,
);
}
};
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={[element]}
dragInputCallback={handleDimensionChange}
value={
Math.round(
(property === "width" ? element.width : element.height) * 100,
) / 100
}
editable={isPropertyEditable(element, property)}
/>
);
};
export default DimensionDragInput;

View File

@ -0,0 +1,75 @@
.excalidraw {
.drag-input-container {
display: flex;
width: 100%;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.drag-input-label {
height: var(--default-button-size);
flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.drag-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: var(--default-button-size);
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
}

View File

@ -0,0 +1,208 @@
import { useEffect, useMemo, useRef, useState } from "react";
import throttle from "lodash.throttle";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement";
import "./DragInput.scss";
import clsx from "clsx";
import { useApp } from "../App";
export type DragInputCallbackType = ({
accumulatedChange,
instantChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}: {
accumulatedChange: number;
instantChange: number;
stateAtStart: ExcalidrawElement[];
originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean;
nextValue?: number;
}) => void;
interface StatsDragInputProps {
label: string | React.ReactNode;
value: number;
elements: ExcalidrawElement[];
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType;
}
const StatsDragInput = ({
label,
dragInputCallback,
value,
elements,
editable = true,
shouldKeepAspectRatio,
}: StatsDragInputProps) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const cbThrottled = useMemo(() => {
return throttle(dragInputCallback, 16);
}, [dragInputCallback]);
const [inputValue, setInputValue] = useState(value.toString());
useEffect(() => {
setInputValue(value.toString());
}, [value]);
return (
<div className={clsx("drag-input-container", !editable && "disabled")}>
<div
className="drag-input-label"
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
}
let lastPointer: {
x: number;
y: number;
} | null = null;
let stateAtStart: ExcalidrawElement[] | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null =
null;
let accumulatedChange: number | null = null;
document.body.classList.add("dragResize");
const onPointerMove = (event: PointerEvent) => {
if (!stateAtStart) {
stateAtStart = elements.map((element) =>
deepCopyElement(element),
);
}
if (!originalElementsMap) {
originalElementsMap = app.scene
.getNonDeletedElements()
.reduce((acc, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map() as ElementsMap);
}
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (lastPointer && stateAtStart && accumulatedChange !== null) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
cbThrottled({
accumulatedChange,
instantChange,
stateAtStart,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
});
}
lastPointer = {
x: event.clientX,
y: event.clientY,
};
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.store.shouldCaptureIncrement();
lastPointer = null;
accumulatedChange = null;
stateAtStart = null;
originalElementsMap = null;
document.body.classList.remove("dragResize");
},
false,
);
}
}}
onPointerEnter={() => {
if (labelRef.current) {
labelRef.current.style.cursor = "ew-resize";
}
}}
>
{label}
</div>
<input
className="drag-input"
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
if (editable) {
const eventTarget = event.target;
if (
eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER
) {
const v = Number(eventTarget.value);
if (isNaN(v)) {
setInputValue(value.toString());
return;
}
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
stateAtStart: elements,
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
nextValue: v,
});
app.store.shouldCaptureIncrement();
eventTarget.blur();
}
}
}}
ref={inputRef}
value={inputValue}
onChange={(event) => {
const eventTarget = event.target;
if (eventTarget instanceof HTMLInputElement) {
setInputValue(event.target.value);
}
}}
onBlur={() => {
if (!inputValue) {
setInputValue(value.toString());
}
}}
disabled={!editable}
></input>
</div>
);
};
export default StatsDragInput;

View File

@ -0,0 +1,73 @@
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
import { refreshTextDimensions } from "../../element/newElement";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils";
interface FontSizeProps {
element: ExcalidrawTextElement;
elementsMap: ElementsMap;
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
const handleFontSizeChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
shouldChangeByStepSize,
nextValue,
}) => {
const _stateAtStart = stateAtStart[0];
if (_stateAtStart) {
if (nextValue) {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
return;
}
if (_stateAtStart.type === "text") {
const originalFontSize = Math.round(_stateAtStart.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
let nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
}
}
};
return (
<StatsDragInput
label="F"
value={Math.round(element.fontSize * 10) / 10}
elements={[element]}
dragInputCallback={handleFontSizeChange}
/>
);
};
export default FontSize;

View File

@ -0,0 +1,210 @@
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
} from "../../element/textElement";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils";
interface MultiDimensionProps {
property: "width" | "height";
elements: ExcalidrawElement[];
elementsMap: ElementsMap;
}
const STEP_SIZE = 10;
const getResizedUpdates = (
anchorX: number,
anchorY: number,
scale: number,
stateAtStart: ExcalidrawElement,
) => {
const offsetX = stateAtStart.x - anchorX;
const offsetY = stateAtStart.y - anchorY;
const nextWidth = stateAtStart.width * scale;
const nextHeight = stateAtStart.height * scale;
const x = anchorX + offsetX * scale;
const y = anchorY + offsetY * scale;
return {
width: nextWidth,
height: nextHeight,
x,
y,
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, false),
...(isTextElement(stateAtStart)
? { fontSize: stateAtStart.fontSize * scale }
: {}),
};
};
const resizeElement = (
anchorX: number,
anchorY: number,
property: MultiDimensionProps["property"],
scale: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
shouldInformMutation: boolean,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, shouldInformMutation);
const boundTextElement = getBoundTextElement(
origElement,
originalElementsMap,
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement(
latestBoundTextElement,
{
fontSize: newFontSize,
},
shouldInformMutation,
);
handleBindTextResize(
latestElement,
elementsMap,
property === "width" ? "e" : "s",
true,
);
}
}
};
const MultiDimension = ({
property,
elements,
elementsMap,
}: MultiDimensionProps) => {
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
stateAtStart,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
const [x1, y1, x2, y2] = getCommonBounds(stateAtStart);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const keepAspectRatio = true;
const aspectRatio = initialWidth / initialHeight;
if (nextValue !== undefined) {
const nextHeight =
property === "height" ? nextValue : nextValue / aspectRatio;
const scale = nextHeight / initialHeight;
const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
// it should never happen that element and origElement are different
// but check just in case
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
);
}
i++;
}
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
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);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
const scale = nextHeight / initialHeight;
const anchorX = property === "width" ? x1 : x1 + width / 2;
const anchorY = property === "height" ? y1 : y1 + height / 2;
let i = 0;
while (i < stateAtStart.length) {
const latestElement = elements[i];
const origElement = stateAtStart[i];
if (latestElement.id === origElement.id) {
resizeElement(
anchorX,
anchorY,
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
i === stateAtStart.length - 1,
);
}
i++;
}
};
const [x1, y1, x2, y2] = getCommonBounds(elements);
const width = x2 - x1;
const height = y2 - y1;
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={elements}
dragInputCallback={handleDimensionChange}
value={Math.round((property === "width" ? width : height) * 100) / 100}
/>
);
};
export default MultiDimension;

View File

@ -1,7 +1,8 @@
@import "../css/variables.module.scss";
@import "../../css/variables.module.scss";
.excalidraw {
.Stats {
width: 204px;
position: absolute;
top: 64px;
right: 12px;
@ -9,6 +10,38 @@
z-index: 10;
pointer-events: var(--ui-pointerEvents);
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-bottom: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.statsItem {
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
@ -39,6 +72,12 @@
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;

View File

@ -0,0 +1,175 @@
import React, { useEffect, useMemo, useState } from "react";
import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { getSelectedElements } from "../../scene";
import type Scene from "../../scene/Scene";
import type { AppState, ExcalidrawProps } from "../../types";
import { CloseIcon } from "../icons";
import { Island } from "../Island";
import { throttle } from "lodash";
import Dimension from "./Dimension";
import Angle from "./Angle";
import "./index.scss";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
interface StatsProps {
appState: AppState;
scene: Scene;
setAppState: React.Component<any, AppState>["setState"];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => {
const elements = props.scene.getNonDeletedElements();
const elementsMap = props.scene.getNonDeletedElementsMap();
const sceneNonce = props.scene.getSceneNonce();
// const selectedElements = getTargetElements(elements, props.appState);
const selectedElements = getSelectedElements(
props.scene.getNonDeletedElementsMap(),
props.appState,
{
includeBoundTextElement: false,
},
);
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : null;
const multipleElements =
selectedElements.length > 1 ? selectedElements : 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],
);
return (
<div className="Stats">
<Island padding={3}>
<div className="section">
<div className="close" onClick={props.onClose}>
{CloseIcon}
</div>
<h3>{t("stats.generalStats")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{props.renderCustomStats?.(elements, props.appState)}
</tbody>
</table>
</div>
{selectedElements.length > 0 && (
<div
className="section"
style={{
marginTop: 12,
}}
>
<h3>{t("stats.elementStats")}</h3>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<div className="statsItem">
<Dimension
property="width"
element={singleElement}
elementsMap={elementsMap}
/>
<Dimension
property="height"
element={singleElement}
elementsMap={elementsMap}
/>
<Angle element={singleElement} elementsMap={elementsMap} />
{singleElement.type === "text" && (
<FontSize
element={singleElement}
elementsMap={elementsMap}
/>
)}
</div>
{singleElement.type === "text" && <div></div>}
</div>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<div className="statsItem">
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
/>
</div>
</div>
)}
</div>
)}
</Island>
</div>
);
};

View File

@ -0,0 +1,23 @@
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
export const isPropertyEditable = (
element: ExcalidrawElement,
property: keyof ExcalidrawElement,
) => {
if (property === "height" && isTextElement(element)) {
return false;
}
if (property === "width" && isTextElement(element)) {
return false;
}
if (property === "angle" && isFrameLikeElement(element)) {
return false;
}
return true;
};
export const getStepSizedValue = (value: number, stepSize: number) => {
const v = value + stepSize / 2;
return v - (v % stepSize);
};

View File

@ -22,6 +22,12 @@
--sat: env(safe-area-inset-top);
}
body.dragResize,
body.dragResize a:hover,
body.dragResize * {
cursor: ew-resize;
}
.excalidraw {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;

View File

@ -182,7 +182,7 @@ const rotateSingleElement = (
}
};
const rescalePointsInElement = (
export const rescalePointsInElement = (
element: NonDeletedExcalidrawElement,
width: number,
height: number,
@ -199,7 +199,7 @@ const rescalePointsInElement = (
}
: {};
const measureFontSizeFromWidth = (
export const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number,

View File

@ -270,6 +270,22 @@
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
},
"element": {
"rectangle": "Rectangle",
"diamond": "Diamond",
"ellipse": "Ellipse",
"arrow": "Arrow",
"line": "Line",
"freedraw": "Freedraw",
"text": "Text",
"image": "Image",
"group": "Group",
"frame": "Frame",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed",
"selection": "Selection",
"iframe": "IFrame"
},
"headings": {
"canvasActions": "Canvas actions",
"selectedShapeActions": "Selected shape actions",
@ -443,7 +459,9 @@
"scene": "Scene",
"selected": "Selected",
"storage": "Storage",
"title": "Stats for nerds",
"title": "Stats",
"generalStats": "General stats",
"elementStats": "Element stats",
"total": "Total",
"version": "Version",
"versionCopy": "Click to copy",

View File

@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => {
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
};
export const radianToDegree = (r: number) => {
return (r * 180) / Math.PI;
};
export const degreeToRadian = (d: number) => {
return (d / 180) * Math.PI;
};
// Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = (

View File

@ -592,6 +592,7 @@ export type AppClassProperties = {
files: BinaryFiles;
device: App["device"];
scene: App["scene"];
store: App["store"];
pasteFromClipboard: App["pasteFromClipboard"];
id: App["id"];
onInsertElements: App["onInsertElements"];