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

@ -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,