From 5db42c3ddbbdc44d10120ab2f18e0864d083e268 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:42:59 +0200 Subject: [PATCH] feat(editor): zen mode polish, hide toolbar hints preference & delayed canvas tooltips (#12165) * feat(editor): preference to hide toolbar hints Add a "Show hints" checkbox to the Preferences menu, backed by a new browser-persisted `appState.showHints` (default true). When off, HintViewer renders nothing. Also exposed as `Preferences.ToggleShowHints` for hosts composing their own preferences list. Opus 5.5 (1M context), low * fix(editor): keep main menu open when toggling view mode Entering view mode ran terminateActiveInteraction, which reset `openMenu` and closed the main menu the user had just toggled view mode from (Preferences submenu). The main menu stays available in view mode, so only close it when interaction gets disabled. Opus 5.5 (1M context), low * feat(editor): chrome-less canvas controls & in-place fades in zen mode - UI hidden in zen mode fades out in place (opacity + visibility) instead of sliding off the viewport, and is non-interactive from the start of the fade - visible canvas controls (toolbar, main menu trigger, zoom, scroll back to content, exit zen mode) drop their background/border/shadow; hover/active states still apply, popups & dialogs are unaffected - only the zoom level stays visible out of the zoom controls - chrome fades are applied only while toggling (data-zen-mode-transition on the container, set before the DOM update) so hover transitions stay instant - single ZEN_MODE_TRANSITION_DURATION, exposed to CSS as --zen-mode-transition-duration - zoom & undo/redo button groups paint a single background (on the group) instead of one per button Opus 5.5 (1M context), low * feat(editor): shift zoom level into the zoom-out slot in zen mode With the zoom +/- buttons hidden in zen mode, slide the zoom level over to the left edge of the group. The shift is applied to the Tooltip wrapper (new `className` prop) rather than the button, so the tooltip (positioned off the wrapper) moves along. Also give the reset-zoom button its own hover background: unlike the +/- buttons it has no .ToolIcon__icon, and lost its (incidental) hover once the per-button backgrounds were removed. Opus 5.5 (1M context), low * feat(editor): delayed tooltips on canvas buttons - Tooltip gets a `delay` prop: shows after 500ms, or right away if a tooltip was visible within the last 300ms (moving across adjacent buttons); passing over a button quickly doesn't flash one - enable it for the zoom, undo/redo & help buttons - zoom +/- use our Tooltip instead of the native `title`, and the reset zoom button drops its native `title` (it was showing both) - zen mode now hides the zoom +/- tooltip wrappers rather than the buttons, so the invisible wrappers don't show tooltips Opus 5.5 (1M context), low Co-authored-by: GitUserVivek --- packages/common/src/constants.ts | 1 + packages/excalidraw/actions/actionCanvas.tsx | 62 +++++++++++------- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.scss | 21 +++++- packages/excalidraw/components/Actions.tsx | 8 ++- packages/excalidraw/components/App.tsx | 36 +++++++++- packages/excalidraw/components/HelpButton.tsx | 2 +- packages/excalidraw/components/HintViewer.tsx | 4 ++ packages/excalidraw/components/LayerUI.scss | 33 +++++----- packages/excalidraw/components/Tooltip.tsx | 53 +++++++++++---- .../components/main-menu/DefaultItems.tsx | 19 ++++++ packages/excalidraw/css/styles.scss | 65 +++++++++++++++++++ packages/excalidraw/locales/en.json | 1 + .../__snapshots__/contextmenu.test.tsx.snap | 17 +++++ .../tests/__snapshots__/history.test.tsx.snap | 63 ++++++++++++++++++ .../regressionTests.test.tsx.snap | 52 +++++++++++++++ packages/excalidraw/types.ts | 2 + .../tests/__snapshots__/export.test.ts.snap | 1 + 18 files changed, 380 insertions(+), 62 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c3405f2db2..63b5756c91 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -355,6 +355,7 @@ export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TITLE_TIMEOUT = 10000; export const VERSION_TIMEOUT = 30000; export const SCROLL_TIMEOUT = 100; +export const ZEN_MODE_TRANSITION_DURATION = 250; export const ZOOM_STEP = 0.1; export const MIN_ZOOM = 0.1; export const MAX_ZOOM = 30; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 6ec1add522..c4696fce58 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -155,17 +155,22 @@ export const actionZoomIn = register({ PanelComponent: ({ updateData }) => { const zoomValue = useAppStateValue((appState) => appState.zoom.value); return ( - = MAX_ZOOM} - onClick={() => { - updateData(null); - }} - /> + + = MAX_ZOOM} + onClick={() => { + updateData(null); + }} + /> + ); }, keyTest: (event) => @@ -202,17 +207,22 @@ export const actionZoomOut = register({ PanelComponent: ({ updateData }) => { const zoomValue = useAppStateValue((appState) => appState.zoom.value); return ( - { - updateData(null); - }} - /> + + { + updateData(null); + }} + /> + ); }, keyTest: (event) => @@ -254,11 +264,15 @@ export const actionResetZoom = register({ PanelComponent: ({ updateData }) => { const zoomValue = useAppStateValue((appState) => appState.zoom.value); return ( - + { updateData(null); diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 7d9b0f3485..e448d2084b 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -77,6 +77,7 @@ export const getDefaultAppState = (): Omit< isBindingEnabled: true, bindingPreference: "enabled", isMidpointSnappingEnabled: true, + showHints: true, inputDevice: "auto", defaultSidebarDockedPreference: false, isLoading: false, @@ -225,6 +226,7 @@ const APP_STATE_STORAGE_CONF = (< boxSelectionMode: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false }, + showHints: { browser: true, export: false, server: false }, inputDevice: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { browser: true, diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 26930a533f..954d0b5112 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -1,6 +1,6 @@ .zoom-actions, .undo-redo-buttons { - background-color: var(--island-bg-color); + background-color: var(--color-surface-low); border-radius: var(--border-radius-lg); box-shadow: 0 0 0 1px var(--color-surface-lowest); } @@ -8,7 +8,6 @@ .zoom-button, .undo-redo-buttons button { border-radius: 0 !important; - background-color: var(--color-surface-low) !important; font-size: 0.875rem !important; width: var(--lg-button-size); height: var(--lg-button-size); @@ -31,6 +30,24 @@ width: 3.75rem !important; justify-content: center; color: var(--text-primary-color); + + // unlike the other zoom buttons, has no .ToolIcon__icon to show the hover + .zoom-actions &:hover:not(:active) { + background-color: var(--button-hover-bg); + } +} + +// shifted left in zen mode (the wrapper rather than the button, so that the +// tooltip, positioned off the wrapper, moves along) +.reset-zoom-button-wrapper { + transition: transform var(--zen-mode-transition-duration) ease-in-out; +} + +// faded out in zen mode +.zoom-out-button-wrapper, +.zoom-in-button-wrapper { + transition: opacity var(--zen-mode-transition-duration) ease-in-out, + visibility var(--zen-mode-transition-duration); } .zoom-out-button { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 6e09452237..b10e855c89 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -897,10 +897,14 @@ export const UndoRedoActions = ({ }) => (
- {renderAction("undo")} + + {renderAction("undo")} +
- {renderAction("redo")} + + {renderAction("redo")} +
); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1d310179d3..a1cf8242f2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -57,6 +57,7 @@ import { ARROW_TYPE, DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_STICKY_NOTE_SIZE, + ZEN_MODE_TRANSITION_DURATION, isLocalLink, normalizeLink, toValidURL, @@ -647,6 +648,7 @@ class App extends React.Component { ); private excalidrawContainerRef = React.createRef(); + private zenModeTransitionTimer = 0; public get ownerDocument(): Document { return ( @@ -2447,6 +2449,7 @@ class App extends React.Component { ? POINTER_EVENTS.disabled : POINTER_EVENTS.enabled, ["--right-sidebar-width" as any]: `${RIGHT_SIDEBAR_WIDTH}px`, + ["--zen-mode-transition-duration" as any]: `${ZEN_MODE_TRANSITION_DURATION}ms`, }} ref={this.excalidrawContainerRef} onDrop={this.isInteractionEnabled() ? this.handleAppOnDrop : undefined} @@ -3364,7 +3367,9 @@ class App extends React.Component { this.setState({ contextMenu: null, - openMenu: null, + // keep the main menu open when entering view mode, since that's where + // the user usually toggles it from (Preferences submenu) + openMenu: this.isInteractionEnabled() ? this.state.openMenu : null, openPopup: null, cursorButton: "up", bindMode: "orbit", @@ -3923,6 +3928,7 @@ class App extends React.Component { } } + clearTimeout(this.zenModeTransitionTimer); this.editorLifecycleEvents.emit("editor:unmount"); this.props.onUnmount?.(); this.props.onExcalidrawAPI?.(null); @@ -4249,6 +4255,34 @@ class App extends React.Component { } } + getSnapshotBeforeUpdate(prevProps: AppProps, prevState: AppState) { + // before the DOM update, so the transition styles are in place by the time + // the zen-mode class changes (layout reads during commit would otherwise + // resolve the new styles without them) + if (prevState.zenModeEnabled !== this.state.zenModeEnabled) { + this.markZenModeTransition(); + } + return null; + } + + /** + * Flags the container while toggling zen mode so that UI chrome + * (backgrounds etc.) animates only then, and hover transitions stay + * instant. A data attribute so that React doesn't reset it on re-render. + */ + private markZenModeTransition() { + const container = this.excalidrawContainerRef.current; + if (!container) { + return; + } + container.setAttribute("data-zen-mode-transition", ""); + clearTimeout(this.zenModeTransitionTimer); + this.zenModeTransitionTimer = window.setTimeout(() => { + container.removeAttribute("data-zen-mode-transition"); + // slightly past the CSS transition so it isn't cut short + }, ZEN_MODE_TRANSITION_DURATION + 100); + } + componentDidUpdate(prevProps: AppProps, prevState: AppState) { const renderOverridesUpdatePending = this.renderOverridesUpdatePending; this.renderOverridesUpdatePending = false; diff --git a/packages/excalidraw/components/HelpButton.tsx b/packages/excalidraw/components/HelpButton.tsx index 3dfe7da742..5dc6c9e79f 100644 --- a/packages/excalidraw/components/HelpButton.tsx +++ b/packages/excalidraw/components/HelpButton.tsx @@ -10,7 +10,7 @@ type HelpButtonProps = { }; export const HelpButton = (props: HelpButtonProps) => ( - +