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

View File

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

View File

@ -472,3 +472,11 @@ export const isRightAngle = (angle: number) => {
// angle, which we can check with modulo after rounding. // angle, which we can check with modulo after rounding.
return Math.round((angle / Math.PI) * 10000) % 5000 === 0; 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"; } from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element"; import { getNonDeletedElements, isNonDeletedElement } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { nanoid } from "nanoid";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@ -56,6 +57,7 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
version: string = nanoid();
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this.elements; return this.elements;
@ -120,6 +122,10 @@ class Scene {
} }
informMutation() { 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)) { for (const callback of Array.from(this.callbacks)) {
callback(); callback();
} }