aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/Stats/index.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/Stats/index.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/Stats/index.tsx')
-rw-r--r--packages/excalidraw/components/Stats/index.tsx434
1 files changed, 434 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx
new file mode 100644
index 0000000..2ccd93c
--- /dev/null
+++ b/packages/excalidraw/components/Stats/index.tsx
@@ -0,0 +1,434 @@
+import { useEffect, useMemo, useState, memo } from "react";
+import { getCommonBounds } from "../../element/bounds";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import { t } from "../../i18n";
+import type {
+ AppClassProperties,
+ AppState,
+ ExcalidrawProps,
+} from "../../types";
+import { CloseIcon } from "../icons";
+import { Island } from "../Island";
+import throttle from "lodash.throttle";
+import Dimension from "./Dimension";
+import Angle from "./Angle";
+import FontSize from "./FontSize";
+import MultiDimension from "./MultiDimension";
+import { elementsAreInSameGroup } from "../../groups";
+import MultiAngle from "./MultiAngle";
+import MultiFontSize from "./MultiFontSize";
+import Position from "./Position";
+import MultiPosition from "./MultiPosition";
+import Collapsible from "./Collapsible";
+import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
+import { getAtomicUnits } from "./utils";
+import { STATS_PANELS } from "../../constants";
+import { isElbowArrow, isImageElement } from "../../element/typeChecks";
+import CanvasGrid from "./CanvasGrid";
+import clsx from "clsx";
+
+import "./Stats.scss";
+import { isGridModeEnabled } from "../../snapping";
+import { getUncroppedWidthAndHeight } from "../../element/cropElement";
+import { round } from "@excalidraw/math";
+import { frameAndChildrenSelectedTogether } from "../../frame";
+
+interface StatsProps {
+ app: AppClassProperties;
+ onClose: () => void;
+ renderCustomStats: ExcalidrawProps["renderCustomStats"];
+}
+
+const STATS_TIMEOUT = 50;
+
+export const Stats = (props: StatsProps) => {
+ const appState = useExcalidrawAppState();
+ const sceneNonce = props.app.scene.getSceneNonce() || 1;
+ const selectedElements = props.app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: false,
+ });
+ const gridModeEnabled = isGridModeEnabled(props.app);
+
+ return (
+ <StatsInner
+ {...props}
+ appState={appState}
+ sceneNonce={sceneNonce}
+ selectedElements={selectedElements}
+ gridModeEnabled={gridModeEnabled}
+ />
+ );
+};
+
+const StatsRow = ({
+ children,
+ columns = 1,
+ heading,
+ style,
+ ...rest
+}: {
+ children: React.ReactNode;
+ columns?: number;
+ heading?: boolean;
+ style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
+ style={{
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
+ ...style,
+ }}
+ {...rest}
+ >
+ {children}
+ </div>
+);
+StatsRow.displayName = "StatsRow";
+
+const StatsRows = ({
+ children,
+ order,
+ style,
+ ...rest
+}: {
+ children: React.ReactNode;
+ order?: number;
+ style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+ <div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
+ {children}
+ </div>
+);
+StatsRows.displayName = "StatsRows";
+
+Stats.StatsRow = StatsRow;
+Stats.StatsRows = StatsRows;
+
+export const StatsInner = memo(
+ ({
+ app,
+ onClose,
+ renderCustomStats,
+ selectedElements,
+ appState,
+ sceneNonce,
+ gridModeEnabled,
+ }: StatsProps & {
+ sceneNonce: number;
+ selectedElements: readonly NonDeletedExcalidrawElement[];
+ appState: AppState;
+ gridModeEnabled: boolean;
+ }) => {
+ const scene = app.scene;
+ const elements = scene.getNonDeletedElements();
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const setAppState = useExcalidrawSetAppState();
+
+ const singleElement =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+
+ const multipleElements =
+ selectedElements.length > 1 ? selectedElements : null;
+
+ const cropMode =
+ appState.croppingElementId && isImageElement(singleElement);
+
+ const unCroppedDimension = cropMode
+ ? getUncroppedWidthAndHeight(singleElement)
+ : 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],
+ );
+
+ const atomicUnits = useMemo(() => {
+ return getAtomicUnits(selectedElements, appState);
+ }, [selectedElements, appState]);
+
+ const _frameAndChildrenSelectedTogether = useMemo(() => {
+ return frameAndChildrenSelectedTogether(selectedElements);
+ }, [selectedElements]);
+
+ return (
+ <div className="exc-stats">
+ <Island padding={3}>
+ <div className="title">
+ <h2>{t("stats.title")}</h2>
+ <div className="close" onClick={onClose}>
+ {CloseIcon}
+ </div>
+ </div>
+
+ <Collapsible
+ label={<h3>{t("stats.generalStats")}</h3>}
+ open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
+ openTrigger={() =>
+ setAppState((state) => {
+ return {
+ stats: {
+ open: true,
+ panels: state.stats.panels ^ STATS_PANELS.generalStats,
+ },
+ };
+ })
+ }
+ >
+ <StatsRows>
+ <StatsRow heading>{t("stats.scene")}</StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.shapes")}</div>
+ <div>{elements.length}</div>
+ </StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.width")}</div>
+ <div>{sceneDimension.width}</div>
+ </StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.height")}</div>
+ <div>{sceneDimension.height}</div>
+ </StatsRow>
+ {gridModeEnabled && (
+ <>
+ <StatsRow heading>Canvas</StatsRow>
+ <StatsRow>
+ <CanvasGrid
+ property="gridStep"
+ scene={scene}
+ appState={appState}
+ setAppState={setAppState}
+ />
+ </StatsRow>
+ </>
+ )}
+ </StatsRows>
+
+ {renderCustomStats?.(elements, appState)}
+ </Collapsible>
+
+ {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
+ <div
+ id="elementStats"
+ style={{
+ marginTop: 12,
+ }}
+ >
+ <Collapsible
+ label={<h3>{t("stats.elementProperties")}</h3>}
+ open={
+ !!(appState.stats.panels & STATS_PANELS.elementProperties)
+ }
+ openTrigger={() =>
+ setAppState((state) => {
+ return {
+ stats: {
+ open: true,
+ panels:
+ state.stats.panels ^ STATS_PANELS.elementProperties,
+ },
+ };
+ })
+ }
+ >
+ <StatsRows>
+ {singleElement && (
+ <>
+ {cropMode && (
+ <StatsRow heading>
+ {t("labels.unCroppedDimension")}
+ </StatsRow>
+ )}
+
+ {appState.croppingElementId &&
+ isImageElement(singleElement) &&
+ unCroppedDimension && (
+ <StatsRow columns={2}>
+ <div>{t("stats.width")}</div>
+ <div>{round(unCroppedDimension.width, 2)}</div>
+ </StatsRow>
+ )}
+
+ {appState.croppingElementId &&
+ isImageElement(singleElement) &&
+ unCroppedDimension && (
+ <StatsRow columns={2}>
+ <div>{t("stats.height")}</div>
+ <div>{round(unCroppedDimension.height, 2)}</div>
+ </StatsRow>
+ )}
+
+ <StatsRow heading data-testid="stats-element-type">
+ {appState.croppingElementId
+ ? t("labels.imageCropping")
+ : t(`element.${singleElement.type}`)}
+ </StatsRow>
+
+ <StatsRow>
+ <Position
+ element={singleElement}
+ property="x"
+ elementsMap={elementsMap}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Position
+ element={singleElement}
+ property="y"
+ elementsMap={elementsMap}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Dimension
+ property="width"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Dimension
+ property="height"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ {!isElbowArrow(singleElement) && (
+ <StatsRow>
+ <Angle
+ property="angle"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ )}
+ <StatsRow>
+ <FontSize
+ property="fontSize"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ </>
+ )}
+
+ {multipleElements && (
+ <>
+ {elementsAreInSameGroup(multipleElements) && (
+ <StatsRow heading>{t("element.group")}</StatsRow>
+ )}
+
+ <StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
+ <div>{t("stats.shapes")}</div>
+ <div>{selectedElements.length}</div>
+ </StatsRow>
+
+ <StatsRow>
+ <MultiPosition
+ property="x"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiPosition
+ property="y"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiDimension
+ property="width"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiDimension
+ property="height"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiAngle
+ property="angle"
+ elements={multipleElements}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiFontSize
+ property="fontSize"
+ elements={multipleElements}
+ scene={scene}
+ appState={appState}
+ elementsMap={elementsMap}
+ />
+ </StatsRow>
+ </>
+ )}
+ </StatsRows>
+ </Collapsible>
+ </div>
+ )}
+ </Island>
+ </div>
+ );
+ },
+ (prev, next) => {
+ return (
+ prev.sceneNonce === next.sceneNonce &&
+ prev.selectedElements === next.selectedElements &&
+ prev.appState.stats.panels === next.appState.stats.panels &&
+ prev.gridModeEnabled === next.gridModeEnabled &&
+ prev.appState.gridStep === next.appState.gridStep &&
+ prev.appState.croppingElementId === next.appState.croppingElementId
+ );
+ },
+);