mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-02-18 13:29:36 +01:00
feat: add system mode to the theme selector (#7853)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
92bc08207c
commit
cd50aa719f
@ -17,7 +17,6 @@ import {
|
|||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
Theme,
|
|
||||||
} from "../packages/excalidraw/element/types";
|
} from "../packages/excalidraw/element/types";
|
||||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
@ -124,6 +123,7 @@ import {
|
|||||||
exportToPlus,
|
exportToPlus,
|
||||||
share,
|
share,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@ -303,6 +303,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@ -566,23 +569,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
languageDetector.cacheUserLanguage(langCode);
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
}, [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 = (
|
const onChange = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -592,8 +578,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
collabAPI.syncElements(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(appState.theme);
|
|
||||||
|
|
||||||
// this check is redundant, but since this is a hot path, it's best
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@ -798,7 +782,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={theme}
|
theme={editorTheme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
@ -820,6 +804,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
|
theme={appTheme}
|
||||||
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
/>
|
/>
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
@ -1093,7 +1079,14 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CommandPalette.defaultItems.toggleTheme,
|
{
|
||||||
|
...CommandPalette.defaultItems.toggleTheme,
|
||||||
|
perform: () => {
|
||||||
|
setAppTheme(
|
||||||
|
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
||||||
|
import { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { LanguageList } from "./LanguageList";
|
||||||
|
|
||||||
@ -7,6 +8,8 @@ export const AppMainMenu: React.FC<{
|
|||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
|
theme: Theme | "system";
|
||||||
|
setTheme: (theme: Theme | "system") => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@ -35,7 +38,11 @@ export const AppMainMenu: React.FC<{
|
|||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme />
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
|
allowSystemTheme
|
||||||
|
theme={props.theme}
|
||||||
|
onSelect={props.setTheme}
|
||||||
|
/>
|
||||||
<MainMenu.ItemCustom>
|
<MainMenu.ItemCustom>
|
||||||
<LanguageList style={{ width: "100%" }} />
|
<LanguageList style={{ width: "100%" }} />
|
||||||
</MainMenu.ItemCustom>
|
</MainMenu.ItemCustom>
|
||||||
|
@ -64,12 +64,30 @@
|
|||||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
//
|
function setTheme(theme) {
|
||||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
html.dark {
|
html.dark {
|
||||||
|
70
excalidraw-app/useHandleAppTheme.ts
Normal file
70
excalidraw-app/useHandleAppTheme.ts
Normal 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 };
|
||||||
|
};
|
@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### 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.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)
|
- 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)
|
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
@ -432,7 +432,9 @@ export const actionZoomToFit = register({
|
|||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
label: (_, appState) => {
|
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"],
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||||
|
@ -1014,7 +1014,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: ${
|
color: ${
|
||||||
this.state.theme === "dark" ? "white" : "black"
|
this.state.theme === THEME.DARK ? "white" : "black"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@ -1281,7 +1281,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDarkTheme = this.state.theme === "dark";
|
const isDarkTheme = this.state.theme === THEME.DARK;
|
||||||
|
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
let magicFrameIndex = 0;
|
let magicFrameIndex = 0;
|
||||||
@ -2730,7 +2730,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
this.excalidrawContainerRef.current?.classList.toggle(
|
this.excalidrawContainerRef.current?.classList.toggle(
|
||||||
"theme--dark",
|
"theme--dark",
|
||||||
this.state.theme === "dark",
|
this.state.theme === THEME.DARK,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title =
|
||||||
props.title ||
|
props.title ||
|
||||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
(props.value === THEME.DARK
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@ -3,7 +3,8 @@ import "./RadioGroup.scss";
|
|||||||
|
|
||||||
export type RadioGroupChoice<T> = {
|
export type RadioGroupChoice<T> = {
|
||||||
value: T;
|
value: T;
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RadioGroupProps<T> = {
|
export type RadioGroupProps<T> = {
|
||||||
@ -26,13 +27,15 @@ export const RadioGroup = function <T>({
|
|||||||
className={clsx("RadioGroup__choice", {
|
className={clsx("RadioGroup__choice", {
|
||||||
active: choice.value === value,
|
active: choice.value === value,
|
||||||
})}
|
})}
|
||||||
key={choice.label}
|
key={String(choice.value)}
|
||||||
|
title={choice.ariaLabel}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={choice.value === value}
|
checked={choice.value === value}
|
||||||
onChange={() => onChange(choice.value)}
|
onChange={() => onChange(choice.value)}
|
||||||
|
aria-label={choice.ariaLabel}
|
||||||
/>
|
/>
|
||||||
{choice.label}
|
{choice.label}
|
||||||
</div>
|
</div>
|
||||||
|
@ -75,6 +75,12 @@
|
|||||||
&__shortcut {
|
&__shortcut {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
|
&--orphaned {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&: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 {
|
.dropdown-menu-item-custom {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
@ -433,15 +433,10 @@ export const MoonIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const SunIcon = createIcon(
|
export const SunIcon = createIcon(
|
||||||
<g
|
<g stroke="currentColor" strokeLinejoin="round">
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.25"
|
|
||||||
strokeLinecap="round"
|
|
||||||
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" />
|
<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>,
|
</g>,
|
||||||
modifiedTablerIconProps,
|
{ ...modifiedTablerIconProps, strokeWidth: 1.5 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HamburgerMenuIcon = createIcon(
|
export const HamburgerMenuIcon = createIcon(
|
||||||
@ -2092,3 +2087,11 @@ export const coffeeIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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 },
|
||||||
|
);
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from "../App";
|
} from "../App";
|
||||||
import {
|
import {
|
||||||
boltIcon,
|
boltIcon,
|
||||||
|
DeviceDesktopIcon,
|
||||||
ExportIcon,
|
ExportIcon,
|
||||||
ExportImageIcon,
|
ExportImageIcon,
|
||||||
HelpIcon,
|
HelpIcon,
|
||||||
@ -35,6 +36,9 @@ import { jotaiScope } from "../../jotai";
|
|||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
|
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||||
|
import { THEME } from "../../constants";
|
||||||
|
import type { Theme } from "../../element/types";
|
||||||
|
|
||||||
import "./DefaultItems.scss";
|
import "./DefaultItems.scss";
|
||||||
|
|
||||||
@ -181,32 +185,80 @@ export const ClearCanvas = () => {
|
|||||||
};
|
};
|
||||||
ClearCanvas.displayName = "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 { t } = useI18n();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
const shortcut = getShortcutFromShortcutName("toggleTheme");
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
// do not close the menu when changing theme
|
// do not close the menu when changing theme
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (props?.onSelect) {
|
||||||
|
props.onSelect(
|
||||||
|
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return actionManager.executeAction(actionToggleTheme);
|
return actionManager.executeAction(actionToggleTheme);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
|
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
shortcut={getShortcutFromShortcutName("toggleTheme")}
|
shortcut={shortcut}
|
||||||
aria-label={
|
aria-label={
|
||||||
appState.theme === "dark"
|
appState.theme === THEME.DARK
|
||||||
? t("buttons.lightMode")
|
? t("buttons.lightMode")
|
||||||
: t("buttons.darkMode")
|
: t("buttons.darkMode")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{appState.theme === "dark"
|
{appState.theme === THEME.DARK
|
||||||
? t("buttons.lightMode")
|
? t("buttons.lightMode")
|
||||||
: t("buttons.darkMode")}
|
: t("buttons.darkMode")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { THEME } from "../constants";
|
||||||
import { Theme } from "../element/types";
|
import { Theme } from "../element/types";
|
||||||
import { DataURL } from "../types";
|
import { DataURL } from "../types";
|
||||||
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||||
@ -39,7 +40,7 @@ export async function diagramToHTML({
|
|||||||
image,
|
image,
|
||||||
apiKey,
|
apiKey,
|
||||||
text,
|
text,
|
||||||
theme = "light",
|
theme = THEME.LIGHT,
|
||||||
}: {
|
}: {
|
||||||
image: DataURL;
|
image: DataURL;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useState, useLayoutEffect } from "react";
|
import { useState, useLayoutEffect } from "react";
|
||||||
import { useDevice, useExcalidrawContainer } from "../components/App";
|
import { useDevice, useExcalidrawContainer } from "../components/App";
|
||||||
|
import { THEME } from "../constants";
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
|
||||||
export const useCreatePortalContainer = (opts?: {
|
export const useCreatePortalContainer = (opts?: {
|
||||||
@ -18,7 +19,7 @@ export const useCreatePortalContainer = (opts?: {
|
|||||||
div.className = "";
|
div.className = "";
|
||||||
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
||||||
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
|
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]);
|
}, [div, theme, device.editor.isMobile, opts?.className]);
|
||||||
|
|
||||||
|
@ -110,6 +110,7 @@
|
|||||||
"showStroke": "Show stroke color picker",
|
"showStroke": "Show stroke color picker",
|
||||||
"showBackground": "Show background color picker",
|
"showBackground": "Show background color picker",
|
||||||
"toggleTheme": "Toggle light/dark theme",
|
"toggleTheme": "Toggle light/dark theme",
|
||||||
|
"theme": "Theme",
|
||||||
"personalLib": "Personal Library",
|
"personalLib": "Personal Library",
|
||||||
"excalidrawLib": "Excalidraw Library",
|
"excalidrawLib": "Excalidraw Library",
|
||||||
"decreaseFontSize": "Decrease font size",
|
"decreaseFontSize": "Decrease font size",
|
||||||
@ -180,6 +181,7 @@
|
|||||||
"fullScreen": "Full screen",
|
"fullScreen": "Full screen",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"lightMode": "Light mode",
|
"lightMode": "Light mode",
|
||||||
|
"systemMode": "System mode",
|
||||||
"zenMode": "Zen mode",
|
"zenMode": "Zen mode",
|
||||||
"objectsSnapMode": "Snap to objects",
|
"objectsSnapMode": "Snap to objects",
|
||||||
"exitZenMode": "Exit zen mode",
|
"exitZenMode": "Exit zen mode",
|
||||||
|
@ -2,7 +2,7 @@ import { StaticCanvasAppState, AppState } from "../types";
|
|||||||
|
|
||||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
|
|
||||||
import { THEME_FILTER } from "../constants";
|
import { THEME, THEME_FILTER } from "../constants";
|
||||||
|
|
||||||
export const fillCircle = (
|
export const fillCircle = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@ -49,7 +49,7 @@ export const bootstrapCanvas = ({
|
|||||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
context.scale(scale, scale);
|
context.scale(scale, scale);
|
||||||
|
|
||||||
if (isExporting && theme === "dark") {
|
if (isExporting && theme === THEME.DARK) {
|
||||||
context.filter = THEME_FILTER;
|
context.filter = THEME_FILTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,6 +41,7 @@ import {
|
|||||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
|
THEME,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||||
import {
|
import {
|
||||||
@ -79,7 +80,7 @@ const shouldResetImageFilter = (
|
|||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
appState.theme === "dark" &&
|
appState.theme === THEME.DARK &&
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
!isPendingImageElement(element, renderConfig) &&
|
!isPendingImageElement(element, renderConfig) &&
|
||||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||||
@ -668,7 +669,7 @@ export const renderElement = (
|
|||||||
// TODO change later to only affect AI frames
|
// TODO change later to only affect AI frames
|
||||||
if (isMagicFrameElement(element)) {
|
if (isMagicFrameElement(element)) {
|
||||||
context.strokeStyle =
|
context.strokeStyle =
|
||||||
appState.theme === "light" ? "#7affd7" : "#1d8264";
|
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FRAME_STYLE.radius && context.roundRect) {
|
if (FRAME_STYLE.radius && context.roundRect) {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { THEME } from "../constants";
|
||||||
import { PointSnapLine, PointerSnapLine } from "../snapping";
|
import { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||||
import { InteractiveCanvasAppState, Point } from "../types";
|
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
|
// Don't change if zen mode, because we draw only crosses, we want the
|
||||||
// colors to be more visible
|
// colors to be more visible
|
||||||
const snapColor =
|
const snapColor =
|
||||||
appState.theme === "light" || appState.zenModeEnabled
|
appState.theme === THEME.LIGHT || appState.zenModeEnabled
|
||||||
? SNAP_COLOR_LIGHT
|
? SNAP_COLOR_LIGHT
|
||||||
: SNAP_COLOR_DARK;
|
: SNAP_COLOR_DARK;
|
||||||
// in zen mode make the cross more visible since we don't draw the lines
|
// in zen mode make the cross more visible since we don't draw the lines
|
||||||
|
@ -19,6 +19,7 @@ import {
|
|||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
|
THEME,
|
||||||
THEME_FILTER,
|
THEME_FILTER,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
@ -237,7 +238,7 @@ export const exportToCanvas = async (
|
|||||||
scrollY: -minY + exportPadding,
|
scrollY: -minY + exportPadding,
|
||||||
zoom: defaultAppState.zoom,
|
zoom: defaultAppState.zoom,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||||
},
|
},
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
canvasBackgroundColor: viewBackgroundColor,
|
canvasBackgroundColor: viewBackgroundColor,
|
||||||
|
@ -11,6 +11,20 @@ require("fake-indexeddb/auto");
|
|||||||
|
|
||||||
polyfill();
|
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", () => {
|
vi.mock("nanoid", () => {
|
||||||
return {
|
return {
|
||||||
nanoid: vi.fn(() => "test-id"),
|
nanoid: vi.fn(() => "test-id"),
|
||||||
|
Loading…
Reference in New Issue
Block a user