feat(editor): sync export theme with ui theme (#10903)

This commit is contained in:
David Luzar
2026-03-06 18:37:28 +01:00
committed by GitHub
parent 499e9d64a5
commit a0e93b6040
6 changed files with 106 additions and 25 deletions
+2 -1
View File
@@ -300,7 +300,8 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
perform: (_elements, appState, value, app) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return {
appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
+9
View File
@@ -595,6 +595,7 @@ const gesture: Gesture = {
class App extends React.Component<AppProps, AppState> {
canvas: AppClassProperties["canvas"];
interactiveCanvas: AppClassProperties["interactiveCanvas"] = null;
public sessionExportThemeOverride: AppState["theme"] | undefined;
rc: RoughCanvas;
unmounted: boolean = false;
actionManager: ActionManager;
@@ -710,6 +711,7 @@ class App extends React.Component<AppProps, AppState> {
this.state = {
...defaultAppState,
theme,
exportWithDarkMode: theme === THEME.DARK,
isLoading: true,
...this.getCanvasOffsets(),
viewModeEnabled,
@@ -3241,6 +3243,13 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
const shouldExportWithDarkMode =
(this.sessionExportThemeOverride ?? this.state.theme) === THEME.DARK;
if (this.state.exportWithDarkMode !== shouldExportWithDarkMode) {
this.setState({ exportWithDarkMode: shouldExportWithDarkMode });
}
if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true });
}
@@ -59,6 +59,7 @@ type ImageExportModalProps = {
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
name: string;
exportWithDarkMode: boolean;
};
const ImageExportModal = ({
@@ -68,6 +69,7 @@ const ImageExportModal = ({
actionManager,
onExportImage,
name,
exportWithDarkMode,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
@@ -79,15 +81,13 @@ const ImageExportModal = ({
const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const previewRenderRequestIdRef = useRef(0);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
@@ -99,7 +99,7 @@ const ImageExportModal = ({
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
resetCopyStatus,
@@ -122,13 +122,18 @@ const ImageExportModal = ({
return;
}
const requestId = ++previewRenderRequestIdRef.current;
const isStaleRequest = () => {
return requestId !== previewRenderRequestIdRef.current;
};
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportWithDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
@@ -137,25 +142,41 @@ const ImageExportModal = ({
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
.then(async (canvas) => {
if (isStaleRequest()) {
return;
}
// If converting to blob fails, there's some problem that will likely
// prevent preview and export (e.g. canvas too big).
try {
await canvasToBlob(canvas);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw error;
}
if (isStaleRequest()) {
return;
}
setRenderError(null);
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas)
.then(() => {
previewNode.replaceChildren(canvas);
})
.catch((e) => {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
});
previewNode.replaceChildren(canvas);
})
.catch((error) => {
if (isStaleRequest()) {
return;
}
console.error(error);
setRenderError(error);
});
return () => {
previewRenderRequestIdRef.current += 1;
};
}, [
appStateSnapshot,
files,
@@ -163,7 +184,7 @@ const ImageExportModal = ({
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
]);
@@ -233,9 +254,8 @@ const ImageExportModal = ({
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
checked={exportWithDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
@@ -399,6 +419,7 @@ export const ImageExportDialog = ({
actionManager={actionManager}
onExportImage={onExportImage}
name={name}
exportWithDarkMode={appState.exportWithDarkMode}
/>
</Dialog>
);
@@ -2,7 +2,7 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div
aria-labelledby="radix-:r7t:"
aria-labelledby="radix-:r85:"
aria-orientation="vertical"
class="dropdown-menu main-menu"
data-align="start"
@@ -12,7 +12,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:r7u:"
id="radix-:r86:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
+50 -1
View File
@@ -6,8 +6,16 @@ import { THEME } from "@excalidraw/common";
import { t } from "../i18n";
import { Excalidraw, Footer, MainMenu } from "../index";
import { actionExportWithDarkMode } from "../actions/actionExport";
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
import {
act,
fireEvent,
GlobalTestState,
toggleMenu,
render,
waitFor,
} from "./test-utils";
const { h } = window;
@@ -304,6 +312,47 @@ describe("<Excalidraw/>", () => {
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBe(null);
});
it("should sync export theme with the UI theme when there is no session override", async () => {
await render(<Excalidraw theme={THEME.DARK} />);
expect(h.state.exportWithDarkMode).toBe(true);
act(() => {
h.setState({ exportWithDarkMode: false });
});
await waitFor(() => {
expect(h.state.exportWithDarkMode).toBe(true);
});
});
it("should keep the export theme override for the current session", async () => {
await render(<Excalidraw theme={THEME.LIGHT} />);
act(() => {
(h.app as any).actionManager.executeAction(
actionExportWithDarkMode,
"ui",
true,
);
});
expect(h.app.sessionExportThemeOverride).toBe(THEME.DARK);
expect(h.state.exportWithDarkMode).toBe(true);
act(() => {
h.setState({ theme: THEME.DARK });
});
act(() => {
h.setState({ theme: THEME.LIGHT });
});
await waitFor(() => {
expect(h.state.exportWithDarkMode).toBe(true);
});
});
});
describe("Test name prop", () => {
+1
View File
@@ -719,6 +719,7 @@ export type AppProps = Merge<
export type AppClassProperties = {
props: AppProps;
state: AppState;
sessionExportThemeOverride: App["sessionExportThemeOverride"];
interactiveCanvas: HTMLCanvasElement | null;
/** static canvas */
canvas: HTMLCanvasElement;