feat: add system mode to the theme selector (#7853)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Arnost Pleskot 2024-04-08 16:46:24 +02:00 committed by GitHub
parent 92bc08207c
commit cd50aa719f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 301 additions and 56 deletions

View File

@ -17,7 +17,6 @@ import {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
@ -124,6 +123,7 @@ import {
exportToPlus,
share,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
polyfill();
@ -303,6 +303,9 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
// initial state
// ---------------------------------------------------------------------------
@ -566,23 +569,6 @@ const ExcalidrawWrapper = () => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const [theme, setTheme] = useState<Theme>(
() =>
(localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_THEME,
) as Theme | null) ||
// FIXME migration from old LS scheme. Can be removed later. #5660
importFromLocalStorage().appState?.theme ||
THEME.LIGHT,
);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
// currently only used for body styling during init (see public/index.html),
// but may change in the future
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
}, [theme]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@ -592,8 +578,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
setTheme(appState.theme);
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
@ -798,7 +782,7 @@ const ExcalidrawWrapper = () => {
detectScroll={false}
handleKeyboardGlobally={true}
autoFocus={true}
theme={theme}
theme={editorTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
@ -820,6 +804,8 @@ const ExcalidrawWrapper = () => {
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@ -1093,7 +1079,14 @@ const ExcalidrawWrapper = () => {
}
},
},
CommandPalette.defaultItems.toggleTheme,
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
]}
/>
</Excalidraw>

View File

@ -1,5 +1,6 @@
import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList";
@ -7,6 +8,8 @@ export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => {
return (
<MainMenu>
@ -35,7 +38,11 @@ export const AppMainMenu: React.FC<{
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>

View File

@ -64,12 +64,30 @@
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
try {
//
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme === "dark") {
document.documentElement.classList.add("dark");
function setTheme(theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
} catch {}
function getTheme() {
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme && theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} else {
return theme || "light";
}
}
setTheme(getTheme());
} catch (e) {
console.error("Error setting dark mode", e);
}
</script>
<style>
html.dark {

View File

@ -0,0 +1,70 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import { Theme } from "../packages/excalidraw/element/types";
import { KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
const mediaQuery = getDarkThemeMediaQuery();
const handleChange = (e: MediaQueryListEvent) => {
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
};
if (appTheme === "system") {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === KEYS.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]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
if (appTheme === "system") {
setEditorTheme(
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
);
} else {
setEditorTheme(appTheme);
}
}, [appTheme]);
return { editorTheme };
};

View File

@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
### Features
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)

View File

@ -432,7 +432,9 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === "dark" ? "buttons.lightMode" : "buttons.darkMode";
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),

View File

@ -1014,7 +1014,7 @@ class App extends React.Component<AppProps, AppState> {
width: 100%;
height: 100%;
color: ${
this.state.theme === "dark" ? "white" : "black"
this.state.theme === THEME.DARK ? "white" : "black"
};
}
body {
@ -1281,7 +1281,7 @@ class App extends React.Component<AppProps, AppState> {
return null;
}
const isDarkTheme = this.state.theme === "dark";
const isDarkTheme = this.state.theme === THEME.DARK;
let frameIndex = 0;
let magicFrameIndex = 0;
@ -2730,7 +2730,7 @@ class App extends React.Component<AppProps, AppState> {
this.excalidrawContainerRef.current?.classList.toggle(
"theme--dark",
this.state.theme === "dark",
this.state.theme === THEME.DARK,
);
if (

View File

@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
}) => {
const title =
props.title ||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
(props.value === THEME.DARK
? t("buttons.lightMode")
: t("buttons.darkMode"));
return (
<ToolButton

View File

@ -3,7 +3,8 @@ import "./RadioGroup.scss";
export type RadioGroupChoice<T> = {
value: T;
label: string;
label: React.ReactNode;
ariaLabel?: string;
};
export type RadioGroupProps<T> = {
@ -26,13 +27,15 @@ export const RadioGroup = function <T>({
className={clsx("RadioGroup__choice", {
active: choice.value === value,
})}
key={choice.label}
key={String(choice.value)}
title={choice.ariaLabel}
>
<input
name={name}
type="radio"
checked={choice.value === value}
onChange={() => onChange(choice.value)}
aria-label={choice.ariaLabel}
/>
{choice.label}
</div>

View File

@ -75,6 +75,12 @@
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
&--orphaned {
text-align: right;
font-size: 0.875rem;
padding: 0 0.625rem;
}
}
&:hover {
@ -94,6 +100,22 @@
}
}
.dropdown-menu-item-bare {
align-items: center;
height: 2rem;
justify-content: space-between;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}

View File

@ -0,0 +1,51 @@
import { useDevice } from "../App";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
value: T;
shortcut?: string;
choices: {
value: T;
label: React.ReactNode;
ariaLabel?: string;
}[];
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
};
const DropdownMenuItemContentRadio = <T,>({
value,
shortcut,
onChange,
choices,
children,
name,
}: Props<T>) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text" htmlFor={name}>
{children}
</label>
<RadioGroup
name={name}
value={value}
onChange={onChange}
choices={choices}
/>
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut dropdown-menu-item__shortcut--orphaned">
{shortcut}
</div>
)}
</>
);
};
DropdownMenuItemContentRadio.displayName = "DropdownMenuItemContentRadio";
export default DropdownMenuItemContentRadio;

View File

@ -433,15 +433,10 @@ export const MoonIcon = createIcon(
);
export const SunIcon = createIcon(
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<g stroke="currentColor" strokeLinejoin="round">
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM10 4.167V2.5M14.167 5.833l1.166-1.166M15.833 10H17.5M14.167 14.167l1.166 1.166M10 15.833V17.5M5.833 14.167l-1.166 1.166M5 10H3.333M5.833 5.833 4.667 4.667" />
</g>,
modifiedTablerIconProps,
{ ...modifiedTablerIconProps, strokeWidth: 1.5 },
);
export const HamburgerMenuIcon = createIcon(
@ -2092,3 +2087,11 @@ export const coffeeIcon = createIcon(
</g>,
tablerIconProps,
);
export const DeviceDesktopIcon = createIcon(
<g stroke="currentColor">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 5a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-10zM7 20h10M9 16v4M15 16v4" />
</g>,
{ ...tablerIconProps, strokeWidth: 1.5 },
);

View File

@ -8,6 +8,7 @@ import {
} from "../App";
import {
boltIcon,
DeviceDesktopIcon,
ExportIcon,
ExportImageIcon,
HelpIcon,
@ -35,6 +36,9 @@ import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import { THEME } from "../../constants";
import type { Theme } from "../../element/types";
import "./DefaultItems.scss";
@ -181,32 +185,80 @@ export const ClearCanvas = () => {
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
export const ToggleTheme = (
props:
| {
allowSystemTheme: true;
theme: Theme | "system";
onSelect: (theme: Theme | "system") => void;
}
| {
allowSystemTheme?: false;
onSelect?: (theme: Theme) => void;
},
) => {
const { t } = useI18n();
const appState = useUIAppState();
const actionManager = useExcalidrawActionManager();
const shortcut = getShortcutFromShortcutName("toggleTheme");
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
}
if (props?.allowSystemTheme) {
return (
<DropdownMenuItemContentRadio
name="theme"
value={props.theme}
onChange={(value: Theme | "system") => props.onSelect(value)}
choices={[
{
value: THEME.LIGHT,
label: SunIcon,
ariaLabel: `${t("buttons.lightMode")} - ${shortcut}`,
},
{
value: THEME.DARK,
label: MoonIcon,
ariaLabel: `${t("buttons.darkMode")} - ${shortcut}`,
},
{
value: "system",
label: DeviceDesktopIcon,
ariaLabel: t("buttons.systemMode"),
},
]}
>
{t("labels.theme")}
</DropdownMenuItemContentRadio>
);
}
return (
<DropdownMenuItem
onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
return actionManager.executeAction(actionToggleTheme);
if (props?.onSelect) {
props.onSelect(
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
} else {
return actionManager.executeAction(actionToggleTheme);
}
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
shortcut={shortcut}
aria-label={
appState.theme === "dark"
appState.theme === THEME.DARK
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
{appState.theme === THEME.DARK
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>

View File

@ -1,3 +1,4 @@
import { THEME } from "../constants";
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
@ -39,7 +40,7 @@ export async function diagramToHTML({
image,
apiKey,
text,
theme = "light",
theme = THEME.LIGHT,
}: {
image: DataURL;
apiKey: string;

View File

@ -1,5 +1,6 @@
import { useState, useLayoutEffect } from "react";
import { useDevice, useExcalidrawContainer } from "../components/App";
import { THEME } from "../constants";
import { useUIAppState } from "../context/ui-appState";
export const useCreatePortalContainer = (opts?: {
@ -18,7 +19,7 @@ export const useCreatePortalContainer = (opts?: {
div.className = "";
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
div.classList.toggle("theme--dark", theme === "dark");
div.classList.toggle("theme--dark", theme === THEME.DARK);
}
}, [div, theme, device.editor.isMobile, opts?.className]);

View File

@ -110,6 +110,7 @@
"showStroke": "Show stroke color picker",
"showBackground": "Show background color picker",
"toggleTheme": "Toggle light/dark theme",
"theme": "Theme",
"personalLib": "Personal Library",
"excalidrawLib": "Excalidraw Library",
"decreaseFontSize": "Decrease font size",
@ -180,6 +181,7 @@
"fullScreen": "Full screen",
"darkMode": "Dark mode",
"lightMode": "Light mode",
"systemMode": "System mode",
"zenMode": "Zen mode",
"objectsSnapMode": "Snap to objects",
"exitZenMode": "Exit zen mode",

View File

@ -2,7 +2,7 @@ import { StaticCanvasAppState, AppState } from "../types";
import { StaticCanvasRenderConfig } from "../scene/types";
import { THEME_FILTER } from "../constants";
import { THEME, THEME_FILTER } from "../constants";
export const fillCircle = (
context: CanvasRenderingContext2D,
@ -49,7 +49,7 @@ export const bootstrapCanvas = ({
context.setTransform(1, 0, 0, 1, 0, 0);
context.scale(scale, scale);
if (isExporting && theme === "dark") {
if (isExporting && theme === THEME.DARK) {
context.filter = THEME_FILTER;
}

View File

@ -41,6 +41,7 @@ import {
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MIME_TYPES,
THEME,
} from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import {
@ -79,7 +80,7 @@ const shouldResetImageFilter = (
appState: StaticCanvasAppState,
) => {
return (
appState.theme === "dark" &&
appState.theme === THEME.DARK &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
@ -668,7 +669,7 @@ export const renderElement = (
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
appState.theme === "light" ? "#7affd7" : "#1d8264";
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
}
if (FRAME_STYLE.radius && context.roundRect) {

View File

@ -1,3 +1,4 @@
import { THEME } from "../constants";
import { PointSnapLine, PointerSnapLine } from "../snapping";
import { InteractiveCanvasAppState, Point } from "../types";
@ -18,7 +19,7 @@ export const renderSnaps = (
// Don't change if zen mode, because we draw only crosses, we want the
// colors to be more visible
const snapColor =
appState.theme === "light" || appState.zenModeEnabled
appState.theme === THEME.LIGHT || appState.zenModeEnabled
? SNAP_COLOR_LIGHT
: SNAP_COLOR_DARK;
// in zen mode make the cross more visible since we don't draw the lines

View File

@ -19,6 +19,7 @@ import {
FONT_FAMILY,
FRAME_STYLE,
SVG_NS,
THEME,
THEME_FILTER,
} from "../constants";
import { getDefaultAppState } from "../appState";
@ -237,7 +238,7 @@ export const exportToCanvas = async (
scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? "dark" : "light",
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
renderConfig: {
canvasBackgroundColor: viewBackgroundColor,

View File

@ -11,6 +11,20 @@ require("fake-indexeddb/auto");
polyfill();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
vi.mock("nanoid", () => {
return {
nanoid: vi.fn(() => "test-id"),