From 80b9fd18b9cf9c53940ce0e1f11be62debe18cf5 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 10 Apr 2023 18:10:46 +0800 Subject: [PATCH] throttled stats --- src/components/Stats.scss | 1 + src/components/Stats.tsx | 280 +++++++++++++++++++++++++------------- src/locales/en.json | 2 +- src/math.ts | 8 ++ src/scene/Scene.ts | 6 + 5 files changed, 201 insertions(+), 96 deletions(-) diff --git a/src/components/Stats.scss b/src/components/Stats.scss index da58d9a50..5cadcd14a 100644 --- a/src/components/Stats.scss +++ b/src/components/Stats.scss @@ -2,6 +2,7 @@ .excalidraw { .Stats { + width: 202px; position: absolute; top: 64px; right: 12px; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index e54bb5406..f29d3b302 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -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,47 +26,113 @@ 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 - ? [ - { - label: "X", - value: Math.round(selectedBoundingBox[0]), - element: selectedElements[0], - property: "x", - }, - { - label: "Y", - value: Math.round(selectedBoundingBox[1]), - element: selectedElements[0], - property: "y", - }, - { - label: "W", - value: Math.round(selectedBoundingBox[2] - selectedBoundingBox[0]), - element: selectedElements[0], - property: "width", - }, - { - label: "H", - value: Math.round(selectedBoundingBox[3] - selectedBoundingBox[1]), - element: selectedElements[0], - property: "height", - }, - { - label: "A", - value: selectedElements[0].angle, - element: selectedElements[0], - property: "angle", - }, - ] - : []; + 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([]); + + 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 (
@@ -80,31 +153,30 @@ export const Stats = (props: StatsProps) => { {t("stats.width")} - - {Math.round(boundingBox[2]) - Math.round(boundingBox[0])} - + {sceneDimension.width} {t("stats.height")} - - {Math.round(boundingBox[3]) - Math.round(boundingBox[1])} - + {sceneDimension.height} {props.renderCustomStats?.(elements, props.appState)}
- {selectedElements.length > 0 && ( -
+ {singleElement && ( +

{t("stats.elementStats")}

- {selectedElements.length === 1 && ( -
- {t(`element.${selectedElements[0].type}`)} -
- )} +
+ {t(`element.${singleElement.type}`)} +
{ gap: "4px 8px", }} > - {stats.map((statsItem) => ( - - ))} + }} + > + + ); + })}
diff --git a/src/locales/en.json b/src/locales/en.json index 41050b47d..b46efac06 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -245,7 +245,7 @@ "ellipse": "Ellipse", "arrow": "Arrow", "line": "Line", - "freeDraw": "Freedraw", + "freedraw": "Freedraw", "text": "Text", "image": "Image", "group": "Group" diff --git a/src/math.ts b/src/math.ts index 602fe976c..79f51175a 100644 --- a/src/math.ts +++ b/src/math.ts @@ -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; +}; diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index e9fc98f89..b3faf9ad5 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -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["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; @@ -56,6 +57,7 @@ class Scene { private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = []; private elementsMap = new Map(); + 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(); }