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,47 +26,113 @@ 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<{
label: "X", width: number;
value: Math.round(selectedBoundingBox[0]), height: number;
element: selectedElements[0], }>({
property: "x", width: 0,
}, height: 0,
{ });
label: "Y",
value: Math.round(selectedBoundingBox[1]), const throttledSetSceneDimension = useMemo(
element: selectedElements[0], () =>
property: "y", throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
}, const boundingBox = getCommonBounds(elements);
{ setSceneDimension({
label: "W", width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
value: Math.round(selectedBoundingBox[2] - selectedBoundingBox[0]), height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
element: selectedElements[0], });
property: "width", }, STATS_TIMEOUT),
}, [],
{ );
label: "H",
value: Math.round(selectedBoundingBox[3] - selectedBoundingBox[1]), useEffect(() => {
element: selectedElements[0], throttledSetSceneDimension(elements);
property: "height", }, [props.scene.version, elements, throttledSetSceneDimension]);
},
{ useEffect(
label: "A", () => () => throttledSetSceneDimension.cancel(),
value: selectedElements[0].angle, [throttledSetSceneDimension],
element: selectedElements[0], );
property: "angle",
}, const [elementStats, setElementStats] = useState<ElementStatItem[]>([]);
]
: []; const throttledSetElementStats = useMemo(
() =>
throttle((element: NonDeletedExcalidrawElement | null) => {
const stats: ElementStatItem[] = element
? [
{
label: "X",
value: Math.round(element.x),
element,
property: "x",
version: nanoid(),
},
{
label: "Y",
value: Math.round(element.y),
element,
property: "y",
version: nanoid(),
},
{
label: "W",
value: Math.round(element.width),
element,
property: "width",
version: nanoid(),
},
{
label: "H",
value: Math.round(element.height),
element,
property: "height",
version: nanoid(),
},
{
label: "A",
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 ( return (
<div className="Stats"> <div className="Stats">
@ -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.${singleElement.type}`)}
{t(`element.${selectedElements[0].type}`)} </div>
</div>
)}
<div <div
style={{ style={{
@ -113,33 +185,64 @@ export const Stats = (props: StatsProps) => {
gap: "4px 8px", gap: "4px 8px",
}} }}
> >
{stats.map((statsItem) => ( {elementStats.map((statsItem) => {
<label return (
className="color-input-container" <label
key={statsItem.property} className="color-input-container"
> key={statsItem.property}
<div
className="color-picker-hash"
style={{
width: "30px",
}}
> >
{statsItem.label} <div
</div> className="color-picker-hash"
<input style={{
id={statsItem.label} width: "30px",
key={statsItem.value} }}
defaultValue={statsItem.value} >
className="color-picker-input" {statsItem.label}
style={{ </div>
width: "55px", <input
}} id={statsItem.label}
autoComplete="off" key={statsItem.version}
spellCheck="false" defaultValue={statsItem.value}
onKeyDown={(event) => { className="color-picker-input"
const value = Number(event.target.value); style={{
width: "55px",
}}
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
let value = Number(event.target.value);
if (isNaN(value)) {
return;
}
value =
statsItem.property === "angle"
? degreeToRadian(value)
: value;
if (event.key === KEYS.ENTER) {
mutateElement(statsItem.element, {
[statsItem.property]: value,
});
event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement
] as string;
}
}}
onBlur={(event) => {
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)) { if (!isNaN(value)) {
mutateElement(statsItem.element, { mutateElement(statsItem.element, {
[statsItem.property]: value, [statsItem.property]: value,
@ -149,24 +252,11 @@ export const Stats = (props: StatsProps) => {
event.target.value = statsItem.element[ event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement statsItem.property as keyof ExcalidrawElement
] as string; ] as string;
} }}
}} ></input>
onBlur={(event) => { </label>
const value = Number(event.target.value); );
})}
if (!isNaN(value)) {
mutateElement(statsItem.element, {
[statsItem.property]: value,
});
}
event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement
] as string;
}}
></input>
</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();
} }