From d2f67e619fbaedfc666b845dcaec635cb7a0b8a1 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Thu, 13 Jun 2024 01:49:46 +0800 Subject: [PATCH] feat: editable element stats (#6382) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/index.scss | 1 + packages/excalidraw/actions/actionCanvas.tsx | 2 +- .../excalidraw/actions/actionToggleStats.tsx | 7 +- packages/excalidraw/actions/types.ts | 3 +- packages/excalidraw/appState.ts | 8 +- packages/excalidraw/components/App.tsx | 153 ++-- packages/excalidraw/components/HelpDialog.tsx | 2 +- packages/excalidraw/components/LayerUI.scss | 93 +++ packages/excalidraw/components/LayerUI.tsx | 29 +- packages/excalidraw/components/MobileMenu.tsx | 13 - packages/excalidraw/components/Stats.scss | 54 -- packages/excalidraw/components/Stats.tsx | 108 --- .../excalidraw/components/Stats/Angle.tsx | 77 ++ .../components/Stats/Collapsible.tsx | 39 ++ .../excalidraw/components/Stats/Dimension.tsx | 126 ++++ .../components/Stats/DragInput.scss | 75 ++ .../excalidraw/components/Stats/DragInput.tsx | 247 +++++++ .../excalidraw/components/Stats/FontSize.tsx | 75 ++ .../components/Stats/MultiAngle.tsx | 114 +++ .../components/Stats/MultiDimension.tsx | 377 ++++++++++ .../components/Stats/MultiFontSize.tsx | 115 +++ .../components/Stats/MultiPosition.tsx | 239 +++++++ .../excalidraw/components/Stats/Position.tsx | 101 +++ .../excalidraw/components/Stats/index.tsx | 306 ++++++++ .../components/Stats/stats.test.tsx | 658 ++++++++++++++++++ packages/excalidraw/components/Stats/utils.ts | 238 +++++++ packages/excalidraw/components/icons.tsx | 28 + packages/excalidraw/constants.ts | 4 + packages/excalidraw/css/styles.scss | 6 + packages/excalidraw/element/resizeElements.ts | 4 +- packages/excalidraw/groups.ts | 4 +- packages/excalidraw/locales/en.json | 21 +- packages/excalidraw/math.ts | 8 + packages/excalidraw/scene/Scene.ts | 3 + .../__snapshots__/contextmenu.test.tsx.snap | 92 ++- .../tests/__snapshots__/history.test.tsx.snap | 285 ++++++-- .../regressionTests.test.tsx.snap | 260 +++++-- packages/excalidraw/tests/helpers/ui.ts | 6 + packages/excalidraw/types.ts | 7 +- .../utils/__snapshots__/export.test.ts.snap | 5 +- 40 files changed, 3588 insertions(+), 405 deletions(-) delete mode 100644 packages/excalidraw/components/Stats.scss delete mode 100644 packages/excalidraw/components/Stats.tsx create mode 100644 packages/excalidraw/components/Stats/Angle.tsx create mode 100644 packages/excalidraw/components/Stats/Collapsible.tsx create mode 100644 packages/excalidraw/components/Stats/Dimension.tsx create mode 100644 packages/excalidraw/components/Stats/DragInput.scss create mode 100644 packages/excalidraw/components/Stats/DragInput.tsx create mode 100644 packages/excalidraw/components/Stats/FontSize.tsx create mode 100644 packages/excalidraw/components/Stats/MultiAngle.tsx create mode 100644 packages/excalidraw/components/Stats/MultiDimension.tsx create mode 100644 packages/excalidraw/components/Stats/MultiFontSize.tsx create mode 100644 packages/excalidraw/components/Stats/MultiPosition.tsx create mode 100644 packages/excalidraw/components/Stats/Position.tsx create mode 100644 packages/excalidraw/components/Stats/index.tsx create mode 100644 packages/excalidraw/components/Stats/stats.test.tsx create mode 100644 packages/excalidraw/components/Stats/utils.ts diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index 3ca538870..cfaaf9cea 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -25,6 +25,7 @@ margin-bottom: auto; margin-inline-start: auto; margin-inline-end: 0.6em; + z-index: var(--zIndex-layerUI); svg { width: 1.2rem; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 9a3026703..8da5acd6c 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -104,7 +104,7 @@ export const actionClearCanvas = register({ exportBackground: appState.exportBackground, exportEmbedScene: appState.exportEmbedScene, gridSize: appState.gridSize, - showStats: appState.showStats, + stats: appState.stats, pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index fc1e70a47..45402e8ad 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -5,21 +5,22 @@ import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", - label: "stats.title", + label: "stats.fullTitle", icon: abacusIcon, paletteName: "Toggle stats", viewMode: true, trackEvent: { category: "menu" }, + keywords: ["edit", "attributes", "customize"], perform(elements, appState) { return { appState: { ...appState, - showStats: !this.checked!(appState), + stats: { ...appState.stats, open: !this.checked!(appState) }, }, storeAction: StoreAction.NONE, }; }, - checked: (appState) => appState.showStats, + checked: (appState) => appState.stats.open, keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, }); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 28034bdb6..6597ec0f0 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -135,7 +135,8 @@ export type ActionName = | "createContainerFromText" | "wrapTextInContainer" | "commandPalette" - | "autoResize"; + | "autoResize" + | "elementStats"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index b6fdb45e6..677c0a077 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -5,6 +5,7 @@ import { DEFAULT_FONT_SIZE, DEFAULT_TEXT_ALIGN, EXPORT_SCALES, + STATS_PANELS, THEME, } from "./constants"; import type { AppState, NormalizedZoomValue } from "./types"; @@ -80,7 +81,10 @@ export const getDefaultAppState = (): Omit< selectedElementsAreBeingDragged: false, selectionElement: null, shouldCacheIgnoreZoom: false, - showStats: false, + stats: { + open: false, + panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, + }, startBoundElement: null, suggestedBindings: [], frameRendering: { enabled: true, clip: true, name: true, outline: true }, @@ -196,7 +200,7 @@ const APP_STATE_STORAGE_CONF = (< }, selectionElement: { browser: false, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, - showStats: { browser: true, export: false, server: false }, + stats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9fae303f0..d1031caf8 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2135,95 +2135,96 @@ class App extends React.Component { }); }; - private syncActionResult = withBatchedUpdates( - (actionResult: ActionResult) => { - if (this.unmounted || actionResult === false) { - return; - } + public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => { + if (this.unmounted || actionResult === false) { + return; + } - let editingElement: AppState["editingElement"] | null = null; - if (actionResult.elements) { - actionResult.elements.forEach((element) => { - if ( - this.state.editingElement?.id === element.id && - this.state.editingElement !== element && - isNonDeletedElement(element) - ) { - editingElement = element; - } - }); + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); + } - if (actionResult.storeAction === StoreAction.UPDATE) { - this.store.shouldUpdateSnapshot(); - } else if (actionResult.storeAction === StoreAction.CAPTURE) { - this.store.shouldCaptureIncrement(); + let didUpdate = false; + + let editingElement: AppState["editingElement"] | null = null; + if (actionResult.elements) { + actionResult.elements.forEach((element) => { + if ( + this.state.editingElement?.id === element.id && + this.state.editingElement !== element && + isNonDeletedElement(element) + ) { + editingElement = element; } + }); - this.scene.replaceAllElements(actionResult.elements); - } + this.scene.replaceAllElements(actionResult.elements); + didUpdate = true; + } - if (actionResult.files) { - this.files = actionResult.replaceFiles - ? actionResult.files - : { ...this.files, ...actionResult.files }; - this.addNewImagesToImageCache(); + if (actionResult.files) { + this.files = actionResult.replaceFiles + ? actionResult.files + : { ...this.files, ...actionResult.files }; + this.addNewImagesToImageCache(); + } + + if (actionResult.appState || editingElement || this.state.contextMenu) { + let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; + let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false; + let gridSize = actionResult?.appState?.gridSize || null; + const theme = + actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; + const name = actionResult?.appState?.name ?? this.state.name; + const errorMessage = + actionResult?.appState?.errorMessage ?? this.state.errorMessage; + if (typeof this.props.viewModeEnabled !== "undefined") { + viewModeEnabled = this.props.viewModeEnabled; } - if (actionResult.appState || editingElement || this.state.contextMenu) { - if (actionResult.storeAction === StoreAction.UPDATE) { - this.store.shouldUpdateSnapshot(); - } else if (actionResult.storeAction === StoreAction.CAPTURE) { - this.store.shouldCaptureIncrement(); - } + if (typeof this.props.zenModeEnabled !== "undefined") { + zenModeEnabled = this.props.zenModeEnabled; + } - let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; - let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false; - let gridSize = actionResult?.appState?.gridSize || null; - const theme = - actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; - const name = actionResult?.appState?.name ?? this.state.name; - const errorMessage = - actionResult?.appState?.errorMessage ?? this.state.errorMessage; - if (typeof this.props.viewModeEnabled !== "undefined") { - viewModeEnabled = this.props.viewModeEnabled; - } + if (typeof this.props.gridModeEnabled !== "undefined") { + gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; + } - if (typeof this.props.zenModeEnabled !== "undefined") { - zenModeEnabled = this.props.zenModeEnabled; - } + editingElement = + editingElement || actionResult.appState?.editingElement || null; - if (typeof this.props.gridModeEnabled !== "undefined") { - gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; - } + if (editingElement?.isDeleted) { + editingElement = null; + } - editingElement = - editingElement || actionResult.appState?.editingElement || null; + this.setState((state) => { + // using Object.assign instead of spread to fool TS 4.2.2+ into + // regarding the resulting type as not containing undefined + // (which the following expression will never contain) + return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, + editingElement, + viewModeEnabled, + zenModeEnabled, + gridSize, + theme, + name, + errorMessage, + }); + }); - if (editingElement?.isDeleted) { - editingElement = null; - } + didUpdate = true; + } - this.setState((state) => { - // using Object.assign instead of spread to fool TS 4.2.2+ into - // regarding the resulting type as not containing undefined - // (which the following expression will never contain) - return Object.assign(actionResult.appState || {}, { - // NOTE this will prevent opening context menu using an action - // or programmatically from the host, so it will need to be - // rewritten later - contextMenu: null, - editingElement, - viewModeEnabled, - zenModeEnabled, - gridSize, - theme, - name, - errorMessage, - }); - }); - } - }, - ); + if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) { + this.scene.triggerUpdate(); + } + }); // Lifecycle diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index c362889b3..28a856ac7 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={[getShortcutKey("Alt+Shift+D")]} /> * { 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/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 1ca991c8c..dd3270670 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog"; import { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; import { useDevice } from "./App"; -import { Stats } from "./Stats"; -import { actionToggleStats } from "../actions/actionToggleStats"; import Footer from "./footer/Footer"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; @@ -64,6 +62,8 @@ import Scene from "../scene/Scene"; import { LaserPointerButton } from "./LaserPointerButton"; import { MagicSettings } from "./MagicSettings"; import { TTDDialog } from "./TTDDialog/TTDDialog"; +import { Stats } from "./Stats"; +import { actionToggleStats } from "../actions"; interface LayerUIProps { actionManager: ActionManager; @@ -241,6 +241,11 @@ const LayerUI = ({ elements, ); + const shouldShowStats = + appState.stats.open && + !appState.zenModeEnabled && + !appState.viewModeEnabled; + return (
@@ -353,6 +358,15 @@ const LayerUI = ({ appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( )} + {shouldShowStats && ( + { + actionManager.executeAction(actionToggleStats); + }} + renderCustomStats={renderCustomStats} + /> + )}
@@ -542,17 +556,6 @@ const LayerUI = ({ showExitZenModeBtn={showExitZenModeBtn} renderWelcomeScreen={renderWelcomeScreen} /> - {appState.showStats && ( - { - actionManager.executeAction(actionToggleStats); - }} - renderCustomStats={renderCustomStats} - /> - )} {appState.scrolledOutside && (