snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
443
packages/excalidraw/components/Stats/index.tsx
Normal file
443
packages/excalidraw/components/Stats/index.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { round } from "@excalidraw/math";
|
||||
import clsx from "clsx";
|
||||
import throttle from "lodash.throttle";
|
||||
import { useEffect, useMemo, useState, memo } from "react";
|
||||
|
||||
import { STATS_PANELS } from "@excalidraw/common";
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
import { getUncroppedWidthAndHeight } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
|
||||
|
||||
import { elementsAreInSameGroup } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { Island } from "../Island";
|
||||
import { CloseIcon } from "../icons";
|
||||
|
||||
import Angle from "./Angle";
|
||||
import CanvasGrid from "./CanvasGrid";
|
||||
import Collapsible from "./Collapsible";
|
||||
import Dimension from "./Dimension";
|
||||
import FontSize from "./FontSize";
|
||||
import MultiAngle from "./MultiAngle";
|
||||
import MultiDimension from "./MultiDimension";
|
||||
import MultiFontSize from "./MultiFontSize";
|
||||
import MultiPosition from "./MultiPosition";
|
||||
import Position from "./Position";
|
||||
import { getAtomicUnits } from "./utils";
|
||||
|
||||
import "./Stats.scss";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
} from "../../types";
|
||||
|
||||
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"
|
||||
style={{ margin: "0.3125rem 0" }}
|
||||
>
|
||||
{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>
|
||||
<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
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user