1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

feat: Stats popup style tweaks (#8361)

This commit is contained in:
David Luzar 2024-08-11 19:33:44 +02:00 committed by GitHub
parent f7b3befd0a
commit 97981804d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 351 additions and 326 deletions

@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number };
@ -51,24 +52,9 @@ const CustomStats = (props: Props) => {
}
return (
<>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
<Stats.StatsRows order={-1}>
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<Stats.StatsRow
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
@ -81,9 +67,18 @@ const CustomStats = (props: Props) => {
{timestamp}
<br />
{hash}
</td>
</tr>
</>
</Stats.StatsRow>
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.scene")}</div>
<div>{nFormatter(storageSizes.scene, 1)}</div>
</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.total")}</div>
<div>{nFormatter(storageSizes.total, 1)}</div>
</Stats.StatsRow>
</Stats.StatsRows>
);
};

@ -39,6 +39,8 @@ Please add the latest change on the top under the correct section.
### Breaking Changes
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes |

@ -27,99 +27,6 @@
& > * {
pointer-events: var(--ui-pointerEvents);
}
& > .Stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-top: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.statsItem {
margin-top: 8px;
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
white-space: nowrap;
margin: 0;
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
}
}
&__footer {

@ -31,7 +31,11 @@ const Collapsible = ({
{label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div>
{open && <>{children}</>}
{open && (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</>
);
};

@ -5,7 +5,7 @@
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
border-radius: var(--border-radius-md);
}
}
@ -24,11 +24,11 @@
color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
@ -55,11 +55,11 @@
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}

@ -0,0 +1,72 @@
.exc-stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
h2 {
font-size: 1.5em;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
font-weight: bold;
}
h3 {
white-space: nowrap;
font-size: 1.17em;
margin: 0;
font-weight: bold;
}
&__rows {
display: flex;
flex-direction: column;
gap: 0.3125rem;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
gap: 4px;
div + div {
text-align: right;
}
}
&__row--heading {
text-align: center;
font-weight: bold;
margin: 0.25rem 0;
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
}

@ -8,7 +8,6 @@ import { Island } from "../Island";
import { throttle } from "lodash";
import Dimension from "./Dimension";
import Angle from "./Angle";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
@ -22,6 +21,9 @@ import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import clsx from "clsx";
import "./Stats.scss";
interface StatsProps {
scene: Scene;
@ -49,6 +51,50 @@ export const Stats = (props: StatsProps) => {
);
};
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(
({
scene,
@ -106,7 +152,7 @@ export const StatsInner = memo(
}, [selectedElements, appState]);
return (
<div className="Stats">
<div className="exc-stats">
<Island padding={3}>
<div className="title">
<h2>{t("stats.title")}</h2>
@ -121,7 +167,6 @@ export const StatsInner = memo(
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats,
@ -130,26 +175,23 @@ export const StatsInner = memo(
})
}
>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
<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>
</StatsRows>
{renderCustomStats?.(elements, appState)}
</tbody>
</table>
</Collapsible>
{selectedElements.length > 0 && (
@ -167,7 +209,6 @@ export const StatsInner = memo(
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels:
@ -177,13 +218,14 @@ export const StatsInner = memo(
})
}
>
<StatsRows>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
<>
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
</div>
</StatsRow>
<div className="statsItem">
<StatsRow>
<Position
element={singleElement}
property="x"
@ -191,6 +233,8 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<Position
element={singleElement}
property="y"
@ -198,48 +242,56 @@ export const StatsInner = memo(
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}
/>
</div>
</div>
</StatsRow>
</>
)}
{multipleElements && (
<div className="sectionContent">
<>
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
<StatsRow heading>{t("element.group")}</StatsRow>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
<div>{t("stats.shapes")}</div>
<div>{selectedElements.length}</div>
</div>
</StatsRow>
<div className="statsItem">
<StatsRow>
<MultiPosition
property="x"
elements={multipleElements}
@ -248,6 +300,8 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiPosition
property="y"
elements={multipleElements}
@ -256,6 +310,8 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiDimension
property="width"
elements={multipleElements}
@ -264,6 +320,8 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiDimension
property="height"
elements={multipleElements}
@ -272,12 +330,16 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiAngle
property="angle"
elements={multipleElements}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiFontSize
property="fontSize"
elements={multipleElements}
@ -285,9 +347,10 @@ export const StatsInner = memo(
appState={appState}
elementsMap={elementsMap}
/>
</div>
</div>
</StatsRow>
</>
)}
</StatsRows>
</Collapsible>
</div>
)}

@ -32,21 +32,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null;
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
const testInputProperty = (
element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
@ -54,7 +39,7 @@ const testInputProperty = (
initialValue: number,
nextValue: number,
) => {
const input = getStatsProperty(label)?.querySelector(
const input = UI.queryStatsProperty(label)?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@ -136,7 +121,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -148,7 +133,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -159,7 +144,7 @@ describe("binding with linear elements", () => {
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -171,7 +156,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -225,18 +210,14 @@ describe("stats for a generic element", () => {
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type
const elementType = elementStats?.querySelector(".elementType");
const elementType = queryByTestId(elementStats!, "stats-element-type");
expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect(
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
).toBeDefined();
});
});
@ -257,7 +238,7 @@ describe("stats for a generic element", () => {
const rectangle = h.elements[0];
const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector(
const input = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@ -287,11 +268,11 @@ describe("stats for a generic element", () => {
rectangle.angle,
);
const xInput = getStatsProperty("X")?.querySelector(
const xInput = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector(
const yInput = UI.queryStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -417,7 +398,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats");
// can change font size
const input = getStatsProperty("F")?.querySelector(
const input = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@ -426,9 +407,9 @@ describe("stats for a non-generic element", () => {
expect(text.fontSize).toBe(36);
// cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input");
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input");
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined();
// min font size is 4
@ -456,7 +437,7 @@ describe("stats for a non-generic element", () => {
expect(elementStats).toBeDefined();
// cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input");
const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined();
// can change width or height
@ -506,7 +487,7 @@ describe("stats for a non-generic element", () => {
API.setElements([container, text]);
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
@ -570,15 +551,15 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle.value).toBe("0");
@ -629,25 +610,25 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle).toBeDefined();
expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector(
const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
@ -692,7 +673,7 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector(
const x = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -705,7 +686,7 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector(
const y = UI.queryStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
@ -718,13 +699,13 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();

@ -22,21 +22,6 @@ const { h } = window;
const mouse = new Pointer("mouse");
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
@ -193,7 +178,7 @@ describe("elbow arrow ui", () => {
mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
UI.updateInput(inputAngle, String("40"));

@ -271,6 +271,7 @@ export { MainMenu };
export { useDevice } from "./components/App";
export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog";

@ -462,16 +462,15 @@
},
"stats": {
"angle": "Angle",
"element": "Element",
"elements": "Elements",
"shapes": "Shapes",
"height": "Height",
"scene": "Scene",
"selected": "Selected",
"storage": "Storage",
"fullTitle": "Stats & Element properties",
"title": "Stats",
"generalStats": "General stats",
"elementProperties": "Element properties",
"fullTitle": "Canvas & Shape properties",
"title": "Properties",
"generalStats": "General",
"elementProperties": "Shape properties",
"total": "Total",
"version": "Version",
"versionCopy": "Click to copy",

@ -579,7 +579,23 @@ export class UI {
static queryStats = () => {
return GlobalTestState.renderResult.container.querySelector(
".Stats",
".exc-stats",
) as HTMLElement | null;
};
static queryStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
expect(elementStats).not.toBeNull();
if (elementStats) {
return (
elementStats?.querySelector(
`.exc-stats__row .drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
}