diff --git a/excalidraw-app/CustomStats.tsx b/excalidraw-app/CustomStats.tsx index f609096b9..b5799e3e9 100644 --- a/excalidraw-app/CustomStats.tsx +++ b/excalidraw-app/CustomStats.tsx @@ -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,39 +52,33 @@ const CustomStats = (props: Props) => { } return ( - <> - - {t("stats.storage")} - - - {t("stats.scene")} - {nFormatter(storageSizes.scene, 1)} - - - {t("stats.total")} - {nFormatter(storageSizes.total, 1)} - - - {t("stats.version")} - - - { - try { - await copyTextToSystemClipboard(getVersion()); - props.setToast(t("toast.copyToClipboard")); - } catch {} - }} - title={t("stats.versionCopy")} - > - {timestamp} -
- {hash} - - - + + {t("stats.version")} + { + try { + await copyTextToSystemClipboard(getVersion()); + props.setToast(t("toast.copyToClipboard")); + } catch {} + }} + title={t("stats.versionCopy")} + > + {timestamp} +
+ {hash} +
+ + {t("stats.storage")} + +
{t("stats.scene")}
+
{nFormatter(storageSizes.scene, 1)}
+
+ +
{t("stats.total")}
+
{nFormatter(storageSizes.total, 1)}
+
+
); }; diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index c5e633ad6..4d7dbe8a5 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -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 | diff --git a/packages/excalidraw/components/LayerUI.scss b/packages/excalidraw/components/LayerUI.scss index feaf2d9ce..36153d72b 100644 --- a/packages/excalidraw/components/LayerUI.scss +++ b/packages/excalidraw/components/LayerUI.scss @@ -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 { diff --git a/packages/excalidraw/components/Stats/Collapsible.tsx b/packages/excalidraw/components/Stats/Collapsible.tsx index eefa6d232..f655860f7 100644 --- a/packages/excalidraw/components/Stats/Collapsible.tsx +++ b/packages/excalidraw/components/Stats/Collapsible.tsx @@ -31,7 +31,11 @@ const Collapsible = ({ {label} - {open && <>{children}} + {open && ( +
+ {children} +
+ )} ); }; diff --git a/packages/excalidraw/components/Stats/DragInput.scss b/packages/excalidraw/components/Stats/DragInput.scss index 7d72b6c70..28ef9b0df 100644 --- a/packages/excalidraw/components/Stats/DragInput.scss +++ b/packages/excalidraw/components/Stats/DragInput.scss @@ -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; } diff --git a/packages/excalidraw/components/Stats/Stats.scss b/packages/excalidraw/components/Stats/Stats.scss new file mode 100644 index 000000000..106ecf303 --- /dev/null +++ b/packages/excalidraw/components/Stats/Stats.scss @@ -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%; + } + } +} diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index 5dc1f257e..c808717a5 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -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) => ( +
+ {children} +
+); +StatsRow.displayName = "StatsRow"; + +const StatsRows = ({ + children, + order, + style, + ...rest +}: { + children: React.ReactNode; + order?: number; + style?: React.CSSProperties; +} & React.HTMLAttributes) => ( +
+ {children} +
+); +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 ( -
+

{t("stats.title")}

@@ -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( }) } > - - - - - - - - - - - - - - - - - - {renderCustomStats?.(elements, appState)} - -
{t("stats.scene")}
{t("stats.elements")}{elements.length}
{t("stats.width")}{sceneDimension.width}
{t("stats.height")}{sceneDimension.height}
+ + {t("stats.scene")} + +
{t("stats.shapes")}
+
{elements.length}
+
+ +
{t("stats.width")}
+
{sceneDimension.width}
+
+ +
{t("stats.height")}
+
{sceneDimension.height}
+
+
+ + {renderCustomStats?.(elements, appState)} {selectedElements.length > 0 && ( @@ -167,7 +209,6 @@ export const StatsInner = memo( openTrigger={() => setAppState((state) => { return { - ...state, stats: { open: true, panels: @@ -177,117 +218,139 @@ export const StatsInner = memo( }) } > - {singleElement && ( -
-
- {t(`element.${singleElement.type}`)} -
+ + {singleElement && ( + <> + + {t(`element.${singleElement.type}`)} + -
- - - - - {!isElbowArrow(singleElement) && ( - + + + + + + + + + + + + {!isElbowArrow(singleElement) && ( + + + )} - -
-
- )} + + + + + )} - {multipleElements && ( -
- {elementsAreInSameGroup(multipleElements) && ( -
{t("element.group")}
- )} + {multipleElements && ( + <> + {elementsAreInSameGroup(multipleElements) && ( + {t("element.group")} + )} -
-
{t("stats.elements")}
-
{selectedElements.length}
-
+ +
{t("stats.shapes")}
+
{selectedElements.length}
+
-
- - - - - - -
-
- )} + + + + + + + + + + + + + + + + + + + + )} +
)} diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 34f66a4d1..e2abb2fd8 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -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(); diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx index 68a8aa727..f00a52a57 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/packages/excalidraw/element/routing.test.tsx @@ -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")); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 645e5ea8d..ce8bdbe87 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -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"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 9444baf80..b67624efe 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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", diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 98426eb4c..693e78333 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -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; + }; }