Skip to content

Commit

Permalink
[cherry] system theme support
Browse files Browse the repository at this point in the history
  • Loading branch information
dwelle committed Apr 8, 2024
1 parent f8062d8 commit 48c5e47
Show file tree
Hide file tree
Showing 20 changed files with 297 additions and 61 deletions.
37 changes: 15 additions & 22 deletions excalidraw-app/App.tsx
Expand Up @@ -17,7 +17,6 @@ import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
Expand Down Expand Up @@ -123,6 +122,7 @@ import {
exportToPlus,
share,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";

polyfill();

Expand Down Expand Up @@ -302,6 +302,9 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();

const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();

// initial state
// ---------------------------------------------------------------------------

Expand Down Expand Up @@ -565,23 +568,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 ExcalidrawElement[],
appState: AppState,
Expand All @@ -591,8 +577,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()) {
Expand Down Expand Up @@ -866,7 +850,7 @@ const ExcalidrawWrapper = () => {
detectScroll={false}
handleKeyboardGlobally={true}
autoFocus={true}
theme={theme}
theme={editorTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
Expand All @@ -889,6 +873,8 @@ const ExcalidrawWrapper = () => {
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
Expand Down Expand Up @@ -1180,7 +1166,14 @@ const ExcalidrawWrapper = () => {
}
},
},
CommandPalette.defaultItems.toggleTheme,
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
]}
/>
</Excalidraw>
Expand Down
9 changes: 8 additions & 1 deletion excalidraw-app/components/AppMainMenu.tsx
@@ -1,11 +1,14 @@
import React from "react";
import { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList";

export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => {
return (
<MainMenu>
Expand Down Expand Up @@ -33,7 +36,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>
Expand Down
28 changes: 23 additions & 5 deletions excalidraw-app/index.html
Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions excalidraw-app/useHandleAppTheme.ts
@@ -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 { CODES, 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 === 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]);

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 };
};
1 change: 1 addition & 0 deletions packages/excalidraw/CHANGELOG.md
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion packages/excalidraw/actions/actionCanvas.tsx
Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions packages/excalidraw/components/App.tsx
Expand Up @@ -1001,7 +1001,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 {
Expand Down Expand Up @@ -1268,7 +1268,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;
Expand Down Expand Up @@ -2777,7 +2777,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 (
Expand Down
4 changes: 3 additions & 1 deletion packages/excalidraw/components/DarkModeToggle.tsx
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/excalidraw/components/RadioGroup.tsx
Expand Up @@ -3,7 +3,8 @@ import "./RadioGroup.scss";

export type RadioGroupChoice<T> = {
value: T;
label: string;
label: React.ReactNode;
ariaLabel?: string;
};

export type RadioGroupProps<T> = {
Expand All @@ -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>
Expand Down
22 changes: 22 additions & 0 deletions packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
@@ -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;

0 comments on commit 48c5e47

Please sign in to comment.