throttled stats

This commit is contained in:
Ryan Di 2023-04-10 18:10:46 +08:00
parent dbc48cfee2
commit 80b9fd18b9
5 changed files with 201 additions and 96 deletions

View File

@ -2,6 +2,7 @@
.excalidraw {
.Stats {
width: 202px;
position: absolute;
top: 64px;
right: 12px;

View File

@ -1,16 +1,23 @@
import React from "react";
import { nanoid } from "nanoid";
import React, { useEffect, useMemo, useState } from "react";
import { getCommonBounds } from "../element/bounds";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { degreeToRadian, radianToDegree } from "../math";
import { getTargetElements } from "../scene";
import Scene from "../scene/Scene";
import { AppState, ExcalidrawProps } from "../types";
import { CloseIcon } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
import { throttle } from "lodash";
const STATS_TIMEOUT = 50;
interface StatsProps {
appState: AppState;
scene: Scene;
@ -19,48 +26,114 @@ interface StatsProps {
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
type ElementStatItem = {
label: string;
value: number;
element: NonDeletedExcalidrawElement;
version: string;
property: "x" | "y" | "width" | "height" | "angle";
};
export const Stats = (props: StatsProps) => {
const elements = props.scene.getNonDeletedElements();
const boundingBox = getCommonBounds(elements);
const selectedElements = getTargetElements(elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
const stats =
selectedElements.length === 1
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : 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);
}, [props.scene.version, elements, throttledSetSceneDimension]);
useEffect(
() => () => throttledSetSceneDimension.cancel(),
[throttledSetSceneDimension],
);
const [elementStats, setElementStats] = useState<ElementStatItem[]>([]);
const throttledSetElementStats = useMemo(
() =>
throttle((element: NonDeletedExcalidrawElement | null) => {
const stats: ElementStatItem[] = element
? [
{
label: "X",
value: Math.round(selectedBoundingBox[0]),
element: selectedElements[0],
value: Math.round(element.x),
element,
property: "x",
version: nanoid(),
},
{
label: "Y",
value: Math.round(selectedBoundingBox[1]),
element: selectedElements[0],
value: Math.round(element.y),
element,
property: "y",
version: nanoid(),
},
{
label: "W",
value: Math.round(selectedBoundingBox[2] - selectedBoundingBox[0]),
element: selectedElements[0],
value: Math.round(element.width),
element,
property: "width",
version: nanoid(),
},
{
label: "H",
value: Math.round(selectedBoundingBox[3] - selectedBoundingBox[1]),
element: selectedElements[0],
value: Math.round(element.height),
element,
property: "height",
version: nanoid(),
},
{
label: "A",
value: selectedElements[0].angle,
element: selectedElements[0],
value: Math.round(radianToDegree(element.angle) * 100) / 100,
element,
property: "angle",
version: nanoid(),
},
]
: [];
setElementStats(stats);
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetElementStats(singleElement);
}, [
singleElement,
singleElement?.version,
singleElement?.versionNonce,
throttledSetElementStats,
]);
useEffect(
() => () => throttledSetElementStats.cancel(),
[throttledSetElementStats],
);
return (
<div className="Stats">
<Island padding={3}>
@ -80,31 +153,30 @@ export const Stats = (props: StatsProps) => {
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}
</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>
{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}
</td>
<td>{sceneDimension.height}</td>
</tr>
{props.renderCustomStats?.(elements, props.appState)}
</tbody>
</table>
</div>
{selectedElements.length > 0 && (
<div className="section">
{singleElement && (
<div
className="section"
style={{
marginTop: 12,
}}
>
<h3>{t("stats.elementStats")}</h3>
<div className="sectionContent">
{selectedElements.length === 1 && (
<div className="elementType">
{t(`element.${selectedElements[0].type}`)}
{t(`element.${singleElement.type}`)}
</div>
)}
<div
style={{
@ -113,7 +185,8 @@ export const Stats = (props: StatsProps) => {
gap: "4px 8px",
}}
>
{stats.map((statsItem) => (
{elementStats.map((statsItem) => {
return (
<label
className="color-input-container"
key={statsItem.property}
@ -128,7 +201,7 @@ export const Stats = (props: StatsProps) => {
</div>
<input
id={statsItem.label}
key={statsItem.value}
key={statsItem.version}
defaultValue={statsItem.value}
className="color-picker-input"
style={{
@ -137,14 +210,21 @@ export const Stats = (props: StatsProps) => {
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
const value = Number(event.target.value);
let value = Number(event.target.value);
if (isNaN(value)) {
return;
}
value =
statsItem.property === "angle"
? degreeToRadian(value)
: value;
if (event.key === KEYS.ENTER) {
if (!isNaN(value)) {
mutateElement(statsItem.element, {
[statsItem.property]: value,
});
}
event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement
@ -152,7 +232,16 @@ export const Stats = (props: StatsProps) => {
}
}}
onBlur={(event) => {
const value = Number(event.target.value);
let value = Number(event.target.value);
if (isNaN(value)) {
return;
}
value =
statsItem.property === "angle"
? degreeToRadian(value)
: value;
if (!isNaN(value)) {
mutateElement(statsItem.element, {
@ -166,7 +255,8 @@ export const Stats = (props: StatsProps) => {
}}
></input>
</label>
))}
);
})}
</div>
</div>
</div>

View File

@ -245,7 +245,7 @@
"ellipse": "Ellipse",
"arrow": "Arrow",
"line": "Line",
"freeDraw": "Freedraw",
"freedraw": "Freedraw",
"text": "Text",
"image": "Image",
"group": "Group"

View File

@ -472,3 +472,11 @@ export const isRightAngle = (angle: number) => {
// angle, which we can check with modulo after rounding.
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;
};

View File

@ -5,6 +5,7 @@ import {
} from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { nanoid } from "nanoid";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -56,6 +57,7 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
version: string = nanoid();
getElementsIncludingDeleted() {
return this.elements;
@ -120,6 +122,10 @@ class Scene {
}
informMutation() {
// we update the version of the scene when we want to inform callbacks of
// changes to the scene
this.version = nanoid();
for (const callback of Array.from(this.callbacks)) {
callback();
}