Ver código fonte

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

Co-authored-by: dwelle <[email protected]>
Arnost Pleskot 1 ano atrás
pai
commit
cd50aa719f

+ 15 - 22
excalidraw-app/App.tsx

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

+ 8 - 1
excalidraw-app/components/AppMainMenu.tsx

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

+ 23 - 5
excalidraw-app/index.html

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

+ 70 - 0
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 { 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 };
+};

+ 1 - 0
packages/excalidraw/CHANGELOG.md

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

+ 3 - 1
packages/excalidraw/actions/actionCanvas.tsx

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

+ 3 - 3
packages/excalidraw/components/App.tsx

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

+ 3 - 1
packages/excalidraw/components/DarkModeToggle.tsx

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

+ 5 - 2
packages/excalidraw/components/RadioGroup.tsx

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

+ 22 - 0
packages/excalidraw/components/dropdownMenu/DropdownMenu.scss

@@ -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;
     }

+ 51 - 0
packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx

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

+ 10 - 7
packages/excalidraw/components/icons.tsx

@@ -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 },
+);

+ 58 - 6
packages/excalidraw/components/main-menu/DefaultItems.tsx

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

+ 2 - 1
packages/excalidraw/data/magic.ts

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

+ 2 - 1
packages/excalidraw/hooks/useCreatePortalContainer.ts

@@ -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]);
 

+ 2 - 0
packages/excalidraw/locales/en.json

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

+ 2 - 2
packages/excalidraw/renderer/helpers.ts

@@ -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;
   }
 

+ 3 - 2
packages/excalidraw/renderer/renderElement.ts

@@ -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) {

+ 2 - 1
packages/excalidraw/renderer/renderSnaps.ts

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

+ 2 - 1
packages/excalidraw/scene/export.ts

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

+ 14 - 0
setupTests.ts

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