From 647a264a485a33bf1bf2cec9adcc8cc2151b253c Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:18:06 +0200 Subject: [PATCH] feat(packages/excalidraw): consolidate theme state handling (#11453) --- excalidraw-app/App.tsx | 11 +------ excalidraw-app/components/AppMainMenu.tsx | 7 +---- excalidraw-app/useHandleAppTheme.ts | 21 +------------ packages/excalidraw/CHANGELOG.md | 7 +++++ packages/excalidraw/actions/actionCanvas.tsx | 19 +++++++++--- .../CommandPalette/CommandPalette.tsx | 2 ++ .../defaultCommandPaletteItems.ts | 13 +------- packages/excalidraw/components/HelpDialog.tsx | 13 +++++--- packages/excalidraw/components/Island.scss | 1 - packages/excalidraw/components/LayerUI.tsx | 2 +- .../excalidraw/components/RadioGroup.scss | 5 ++-- .../components/main-menu/DefaultItems.tsx | 29 +++++++++++------- packages/excalidraw/index.tsx | 4 ++- packages/excalidraw/tests/excalidraw.test.tsx | 30 +++++++++++++++++-- packages/excalidraw/types.ts | 6 ++++ 15 files changed, 95 insertions(+), 75 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 011379a4c1..a87ce1ba27 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans"; import { APP_NAME, EVENT, - THEME, VERSION_TIMEOUT, debounce, getVersion, @@ -952,6 +951,7 @@ const ExcalidrawWrapper = () => { handleKeyboardGlobally={true} autoFocus={true} theme={editorTheme} + onThemeChange={setAppTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; @@ -988,7 +988,6 @@ const ExcalidrawWrapper = () => { isCollaborating={isCollaborating} isCollabEnabled={!isCollabDisabled} theme={appTheme} - setTheme={(theme) => setAppTheme(theme)} refresh={() => forceRefresh((prev) => !prev)} /> { } }, }, - { - ...CommandPalette.defaultItems.toggleTheme, - perform: () => { - setAppTheme( - editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, - ); - }, - }, { label: t("labels.installPWA"), category: DEFAULT_CATEGORIES.app, diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index a3f847385f..ad34d7da91 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -20,7 +20,6 @@ export const AppMainMenu: React.FC<{ isCollaborating: boolean; isCollabEnabled: boolean; theme: Theme | "system"; - setTheme: (theme: Theme | "system") => void; refresh: () => void; }> = React.memo((props) => { return ( @@ -78,11 +77,7 @@ export const AppMainMenu: React.FC<{ )} - + diff --git a/excalidraw-app/useHandleAppTheme.ts b/excalidraw-app/useHandleAppTheme.ts index 94f5a3e58f..01756428a7 100644 --- a/excalidraw-app/useHandleAppTheme.ts +++ b/excalidraw-app/useHandleAppTheme.ts @@ -1,5 +1,4 @@ import { THEME } from "@excalidraw/excalidraw"; -import { EVENT, CODES, KEYS } from "@excalidraw/common"; import { useEffect, useLayoutEffect, useState } from "react"; import type { Theme } from "@excalidraw/element/types"; @@ -31,28 +30,10 @@ export const useHandleAppTheme = () => { mediaQuery?.addEventListener("change", handleChange); } - const handleKeydown = (event: KeyboardEvent) => { - if ( - !event[KEYS.CTRL_OR_CMD] && - event.altKey && - event.shiftKey && - event.code === CODES.D - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK); - } - }; - - document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true }); - return () => { mediaQuery?.removeEventListener("change", handleChange); - document.removeEventListener(EVENT.KEYDOWN, handleKeydown, { - capture: true, - }); }; - }, [appTheme, editorTheme, setAppTheme]); + }, [appTheme]); useLayoutEffect(() => { localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 16a98243ee..04dc260da1 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,13 @@ Please add the latest change on the top under the correct section. ### Breaking changes +- Theme changes initiated by the default UI are now delegated to ` ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state. + + - `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `` instead. + - `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`. + - `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below). + - `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled. + - Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`. - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already). diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index d6986a17af..9c8fea322f 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -477,17 +477,28 @@ export const actionToggleTheme = register({ appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, viewMode: true, trackEvent: { category: "canvas" }, - perform: (_, appState, value) => { + perform: (_, appState, value, app) => { + const nextTheme = + value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT); + + if (app.props.onThemeChange) { + app.props.onThemeChange(nextTheme); + return false; + } + return { appState: { ...appState, - theme: - value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), + theme: nextTheme, }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, - keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, + keyTest: (event) => + !event[KEYS.CTRL_OR_CMD] && + event.altKey && + event.shiftKey && + event.code === CODES.D, predicate: (elements, appState, props, app) => { return !!app.props.UIOptions.canvasActions.toggleTheme; }, diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 7a2b7344cf..d87d1d1b40 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -19,6 +19,7 @@ import { actionClearCanvas, actionLink, actionToggleSearchMenu, + actionToggleTheme, } from "../../actions"; import { actionCopyElementLink, @@ -424,6 +425,7 @@ function CommandPaletteInner({ ]; const additionalCommands: CommandPaletteItem[] = [ + actionToCommand(actionToggleTheme, DEFAULT_CATEGORIES.app), { label: t("toolBar.library"), category: DEFAULT_CATEGORIES.app, diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts index 485e1767c6..cb0ff5c3b5 100644 --- a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts +++ b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts @@ -1,12 +1 @@ -import { actionToggleTheme } from "../../actions"; - -import type { CommandPaletteItem } from "./types"; - -export const toggleTheme: CommandPaletteItem = { - ...actionToggleTheme, - category: "App", - label: "Toggle theme", - perform: ({ actionManager }) => { - actionManager.executeAction(actionToggleTheme, "commandPalette"); - }, -}; +export {}; diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 8fea159bec..5f315c70a8 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -4,11 +4,13 @@ import { isDarwin, isFirefox, isWindows } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common"; +import { actionToggleTheme } from "../actions"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { probablySupportsClipboardBlob } from "../clipboard"; import { t } from "../i18n"; import { getShortcutKey } from "../shortcut"; +import { useExcalidrawActionManager } from "./App"; import { Dialog } from "./Dialog"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; @@ -124,6 +126,7 @@ const ShortcutKey = (props: { children: React.ReactNode }) => ( ); export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { + const actionManager = useExcalidrawActionManager(); const handleClose = React.useCallback(() => { if (onClose) { onClose(); @@ -302,10 +305,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("labels.viewMode")} shortcuts={[getShortcutKey("Alt+R")]} /> - + {actionManager.isActionEnabled(actionToggleTheme) && ( + + )} - + ); diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index d550d95b3b..088d498dc4 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -29,6 +29,7 @@ gap: 2px; &__choice { + box-sizing: content-box; position: relative; display: flex; align-items: center; @@ -50,13 +51,11 @@ user-select: none; letter-spacing: 0.4px; - transition: all 75ms ease-out; - &:hover { color: var(--RadioGroup-choice-color-off-hover); } - &:active { + &:not(.active):active { background: var(--RadioGroup-choice-background-off-active); } diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index fbe83f0e7a..fef00c8da4 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -232,18 +232,22 @@ export const ToggleTheme = ( props: | { allowSystemTheme: true; + /** + * Controls the theme of this UI component only. + * You should subscribe to `props.onThemeChange` and control the theme + * upstream. + */ theme: Theme | "system"; - onSelect: (theme: Theme | "system") => void; } | { - allowSystemTheme?: false; - onSelect?: (theme: Theme) => void; + allowSystemTheme: false; }, ) => { const { t } = useI18n(); const appState = useUIAppState(); const actionManager = useExcalidrawActionManager(); const shortcut = getShortcutFromShortcutName("toggleTheme"); + const appProps = useAppProps(); if (!actionManager.isActionEnabled(actionToggleTheme)) { return null; @@ -254,7 +258,16 @@ export const ToggleTheme = ( props.onSelect(value)} + onChange={(value: Theme | "system") => { + if (appProps.onThemeChange) { + appProps.onThemeChange(value); + return; + } + + console.warn( + "MainMenu.DefaultItems.ToggleTheme: ` props.onThemeChange` must be defined to use system theme selection.", + ); + }} choices={[ { value: THEME.LIGHT, @@ -284,13 +297,7 @@ export const ToggleTheme = ( // do not close the menu when changing theme event.preventDefault(); - if (props?.onSelect) { - props.onSelect( - appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK, - ); - } else { - return actionManager.executeAction(actionToggleTheme); - } + actionManager.executeAction(actionToggleTheme); }} icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon} data-testid="toggle-dark-mode" diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index c038dad22f..02ed37779d 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -67,6 +67,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { const { onExport, onChange, + onThemeChange, onIncrement, initialData, onExcalidrawAPI, @@ -129,7 +130,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { if ( UIOptions.canvasActions.toggleTheme === null && - typeof theme === "undefined" + (theme == null || onThemeChange) ) { UIOptions.canvasActions.toggleTheme = true; } @@ -185,6 +186,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { ", () => { const customMenu = useMemo(() => { return ( - + ); }, []); @@ -457,5 +456,32 @@ describe("", () => { queryByTestId(container, "toggle-dark-mode")?.textContent, ).toContain(t("buttons.lightMode")); }); + + it("should show theme toggle when the theme prop and onThemeChange are defined", async () => { + const onThemeChange = vi.fn(); + const { container } = await render( + , + ); + + expect(h.state.theme).toBe(THEME.DARK); + //open menu + toggleMenu(container); + const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); + expect(darkModeToggle).toBeTruthy(); + }); + + it("should call onThemeChange instead of mutating theme when defined", async () => { + const onThemeChange = vi.fn(); + const { container } = await render( + , + ); + + //open menu + toggleMenu(container); + fireEvent.click(queryByTestId(container, "toggle-dark-mode")!); + + expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK); + expect(h.state.theme).toBe(THEME.LIGHT); + }); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index b40ec1b4e1..605b5f83ca 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -573,6 +573,7 @@ export interface ExcalidrawProps { appState: AppState, files: BinaryFiles, ) => void; + onThemeChange?: (theme: Theme | "system") => void; /** * note: only subscribes if the props.onIncrement is defined on initial render */ @@ -750,6 +751,11 @@ export type CanvasActions = Partial<{ export: false | ExportOpts; loadScene: boolean; saveToActiveFile: boolean; + /** + * defaults to true if `props.theme` is omitted or `props.onThemeChange` + * is supplied (at which point the theme is considered as host-app controlled), + * else default to false + * */ toggleTheme: boolean | null; saveAsImage: boolean; }>;