feat(packages/excalidraw): consolidate theme state handling (#11453)

This commit is contained in:
David Luzar
2026-06-06 18:18:06 +02:00
committed by GitHub
parent b6d80e4256
commit 647a264a48
15 changed files with 95 additions and 75 deletions
+1 -10
View File
@@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -952,6 +951,7 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true} handleKeyboardGlobally={true}
autoFocus={true} autoFocus={true}
theme={editorTheme} theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
@@ -988,7 +988,6 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
theme={appTheme} theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<AppWelcomeScreen <AppWelcomeScreen
@@ -1229,14 +1228,6 @@ const ExcalidrawWrapper = () => {
} }
}, },
}, },
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{ {
label: t("labels.installPWA"), label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app, category: DEFAULT_CATEGORIES.app,
+1 -6
View File
@@ -20,7 +20,6 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean; isCollabEnabled: boolean;
theme: Theme | "system"; theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void; refresh: () => void;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
@@ -78,11 +77,7 @@ export const AppMainMenu: React.FC<{
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences /> <MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} /> <LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
+1 -20
View File
@@ -1,5 +1,4 @@
import { THEME } from "@excalidraw/excalidraw"; import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react"; import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange); 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 () => { return () => {
mediaQuery?.removeEventListener("change", handleChange); mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
}; };
}, [appTheme, editorTheme, setAppTheme]); }, [appTheme]);
useLayoutEffect(() => { useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+7
View File
@@ -17,6 +17,13 @@ Please add the latest change on the top under the correct section.
### Breaking changes ### Breaking changes
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` 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 `<Excalidraw />` 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`. - 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). - `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).
+15 -4
View File
@@ -477,17 +477,28 @@ export const actionToggleTheme = register<AppState["theme"]>({
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, 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 { return {
appState: { appState: {
...appState, ...appState,
theme: theme: nextTheme,
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, 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) => { predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme; return !!app.props.UIOptions.canvasActions.toggleTheme;
}, },
@@ -19,6 +19,7 @@ import {
actionClearCanvas, actionClearCanvas,
actionLink, actionLink,
actionToggleSearchMenu, actionToggleSearchMenu,
actionToggleTheme,
} from "../../actions"; } from "../../actions";
import { import {
actionCopyElementLink, actionCopyElementLink,
@@ -424,6 +425,7 @@ function CommandPaletteInner({
]; ];
const additionalCommands: CommandPaletteItem[] = [ const additionalCommands: CommandPaletteItem[] = [
actionToCommand(actionToggleTheme, DEFAULT_CATEGORIES.app),
{ {
label: t("toolBar.library"), label: t("toolBar.library"),
category: DEFAULT_CATEGORIES.app, category: DEFAULT_CATEGORIES.app,
@@ -1,12 +1 @@
import { actionToggleTheme } from "../../actions"; export {};
import type { CommandPaletteItem } from "./types";
export const toggleTheme: CommandPaletteItem = {
...actionToggleTheme,
category: "App",
label: "Toggle theme",
perform: ({ actionManager }) => {
actionManager.executeAction(actionToggleTheme, "commandPalette");
},
};
@@ -4,11 +4,13 @@ import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { actionToggleTheme } from "../actions";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useExcalidrawActionManager } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -124,6 +126,7 @@ const ShortcutKey = (props: { children: React.ReactNode }) => (
); );
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const actionManager = useExcalidrawActionManager();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -302,10 +305,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")} label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]} shortcuts={[getShortcutKey("Alt+R")]}
/> />
<Shortcut {actionManager.isActionEnabled(actionToggleTheme) && (
label={t("labels.toggleTheme")} <Shortcut
shortcuts={[getShortcutKey("Alt+Shift+D")]} label={t("labels.toggleTheme")}
/> shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
)}
<Shortcut <Shortcut
label={t("stats.fullTitle")} label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
@@ -7,7 +7,6 @@
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode { &.zen-mode {
box-shadow: none; box-shadow: none;
+1 -1
View File
@@ -122,7 +122,7 @@ const DefaultMainMenu: React.FC<{
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme /> <MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
<MainMenu.DefaultItems.ChangeCanvasBackground /> <MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu> </MainMenu>
); );
@@ -29,6 +29,7 @@
gap: 2px; gap: 2px;
&__choice { &__choice {
box-sizing: content-box;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -50,13 +51,11 @@
user-select: none; user-select: none;
letter-spacing: 0.4px; letter-spacing: 0.4px;
transition: all 75ms ease-out;
&:hover { &:hover {
color: var(--RadioGroup-choice-color-off-hover); color: var(--RadioGroup-choice-color-off-hover);
} }
&:active { &:not(.active):active {
background: var(--RadioGroup-choice-background-off-active); background: var(--RadioGroup-choice-background-off-active);
} }
@@ -232,18 +232,22 @@ export const ToggleTheme = (
props: props:
| { | {
allowSystemTheme: true; allowSystemTheme: true;
/**
* Controls the theme of this UI component only.
* You should subscribe to `props.onThemeChange` and control the theme
* upstream.
*/
theme: Theme | "system"; theme: Theme | "system";
onSelect: (theme: Theme | "system") => void;
} }
| { | {
allowSystemTheme?: false; allowSystemTheme: false;
onSelect?: (theme: Theme) => void;
}, },
) => { ) => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useUIAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
const shortcut = getShortcutFromShortcutName("toggleTheme"); const shortcut = getShortcutFromShortcutName("toggleTheme");
const appProps = useAppProps();
if (!actionManager.isActionEnabled(actionToggleTheme)) { if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null; return null;
@@ -254,7 +258,16 @@ export const ToggleTheme = (
<DropdownMenuItemContentRadio <DropdownMenuItemContentRadio
name="theme" name="theme"
value={props.theme} value={props.theme}
onChange={(value: Theme | "system") => props.onSelect(value)} onChange={(value: Theme | "system") => {
if (appProps.onThemeChange) {
appProps.onThemeChange(value);
return;
}
console.warn(
"MainMenu.DefaultItems.ToggleTheme: `<Excalidraw/> props.onThemeChange` must be defined to use system theme selection.",
);
}}
choices={[ choices={[
{ {
value: THEME.LIGHT, value: THEME.LIGHT,
@@ -284,13 +297,7 @@ export const ToggleTheme = (
// do not close the menu when changing theme // do not close the menu when changing theme
event.preventDefault(); event.preventDefault();
if (props?.onSelect) { actionManager.executeAction(actionToggleTheme);
props.onSelect(
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
} else {
return actionManager.executeAction(actionToggleTheme);
}
}} }}
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon} icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
+3 -1
View File
@@ -67,6 +67,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
const { const {
onExport, onExport,
onChange, onChange,
onThemeChange,
onIncrement, onIncrement,
initialData, initialData,
onExcalidrawAPI, onExcalidrawAPI,
@@ -129,7 +130,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
if ( if (
UIOptions.canvasActions.toggleTheme === null && UIOptions.canvasActions.toggleTheme === null &&
typeof theme === "undefined" (theme == null || onThemeChange)
) { ) {
UIOptions.canvasActions.toggleTheme = true; UIOptions.canvasActions.toggleTheme = true;
} }
@@ -185,6 +186,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<App <App
onExport={onExport} onExport={onExport}
onChange={onChange} onChange={onChange}
onThemeChange={onThemeChange}
onIncrement={onIncrement} onIncrement={onIncrement}
initialData={initialData} initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
+28 -2
View File
@@ -1,5 +1,4 @@
import { queryByText, queryByTestId } from "@testing-library/react"; import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
@@ -433,7 +432,7 @@ describe("<Excalidraw/>", () => {
const customMenu = useMemo(() => { const customMenu = useMemo(() => {
return ( return (
<MainMenu> <MainMenu>
<MainMenu.DefaultItems.ToggleTheme /> <MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
</MainMenu> </MainMenu>
); );
}, []); }, []);
@@ -457,5 +456,32 @@ describe("<Excalidraw/>", () => {
queryByTestId(container, "toggle-dark-mode")?.textContent, queryByTestId(container, "toggle-dark-mode")?.textContent,
).toContain(t("buttons.lightMode")); ).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(
<Excalidraw theme={THEME.DARK} onThemeChange={onThemeChange} />,
);
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(
<Excalidraw theme={THEME.LIGHT} onThemeChange={onThemeChange} />,
);
//open menu
toggleMenu(container);
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK);
expect(h.state.theme).toBe(THEME.LIGHT);
});
}); });
}); });
+6
View File
@@ -573,6 +573,7 @@ export interface ExcalidrawProps {
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => void; ) => void;
onThemeChange?: (theme: Theme | "system") => void;
/** /**
* note: only subscribes if the props.onIncrement is defined on initial render * note: only subscribes if the props.onIncrement is defined on initial render
*/ */
@@ -750,6 +751,11 @@ export type CanvasActions = Partial<{
export: false | ExportOpts; export: false | ExportOpts;
loadScene: boolean; loadScene: boolean;
saveToActiveFile: 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; toggleTheme: boolean | null;
saveAsImage: boolean; saveAsImage: boolean;
}>; }>;