feat: stop using CSS filters for dark mode (static canvas) (#10578)

* feat: stop using CSS filters for dark mode (static canvas)

* fix comment

* remove conditional dark mode export

* make shape cache theme-aware

* refactor

* refactor

* fixes and notes
This commit is contained in:
David Luzar
2026-01-04 15:16:35 +01:00
committed by GitHub
parent b5fc873323
commit 63e1148280
30 changed files with 1070 additions and 389 deletions
+10 -4
View File
@@ -47,7 +47,6 @@ import {
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
THEME,
THEME_FILTER,
TOUCH_CTX_MENU_TIMEOUT,
VERTICAL_ALIGN,
YOUTUBE_STATES,
@@ -89,6 +88,7 @@ import {
getDateTime,
isShallowEqual,
arrayToMap,
applyDarkModeFilter,
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
@@ -1770,8 +1770,9 @@ class App extends React.Component<AppProps, AppState> {
}
}}
style={{
background: this.state.viewBackgroundColor,
filter: isDarkTheme ? THEME_FILTER : "none",
background: isDarkTheme
? applyDarkModeFilter(this.state.viewBackgroundColor)
: this.state.viewBackgroundColor,
zIndex: 2,
border: "none",
display: "block",
@@ -1781,7 +1782,9 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: "Assistant",
fontSize: `${FRAME_STYLE.nameFontSize}px`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)",
color: isDarkTheme
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
overflow: "hidden",
maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
@@ -2116,6 +2119,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
{this.state.newElement && (
@@ -2136,6 +2140,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
theme: this.state.theme,
}}
/>
)}
@@ -3181,6 +3186,7 @@ class App extends React.Component<AppProps, AppState> {
) {
setEraserCursor(this.interactiveCanvas, this.state.theme);
}
// Hide hyperlink popup if shown when element type is not selection
if (
prevState.activeTool.type === "selection" &&
@@ -1,7 +1,9 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS } from "@excalidraw/common";
import { isTransparent, KEYS } from "@excalidraw/common";
import tinycolor from "tinycolor2";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai";
@@ -10,18 +12,32 @@ import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons";
import { getColor } from "./ColorPicker";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (tc.getFormat() === "hex" && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
export const ColorInput = ({
color,
@@ -29,7 +45,13 @@ export const ColorInput = ({
label,
colorPickerType,
placeholder,
}: ColorInputProps) => {
}: {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom(
@@ -43,7 +65,7 @@ export const ColorInput = ({
const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase();
const color = getColor(value);
const color = normalizeInputColor(value);
if (color) {
onChange(color);
@@ -5,7 +5,6 @@ import { useRef, useEffect } from "react";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
isTransparent,
isWritableElement,
} from "@excalidraw/common";
@@ -38,27 +37,6 @@ import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
export const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
interface ColorPickerProps {
type: ColorPickerType;
/**
@@ -1,4 +1,8 @@
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "@excalidraw/common";
import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
tinycolor,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -108,48 +112,17 @@ export const isColorDark = (color: string, threshold = 160): boolean => {
return true;
}
if (color === "transparent") {
if (isTransparent(color)) {
return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
.split(",");
const r = parseInt(rgb[0]);
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b) < threshold;
}
// invalid color -> assume it default to black
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
@@ -40,9 +40,6 @@ import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
export const ErrorCanvasPreview = () => {
return (
<div>
@@ -230,25 +227,23 @@ const ImageExportModal = ({
}}
/>
</ExportSetting>
{supportsContextFilters && (
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
)}
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.embedScene")}
tooltip={t("imageExportDialog.tooltip.embedScene")}
@@ -101,6 +101,12 @@ export const convertMermaidToExcalidraw = async ({
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
appState: {
// TODO hack (will be refactored in TTD v2)
exportWithDarkMode: document
.querySelector(".excalidraw-container")
?.classList.contains("theme--dark"),
},
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)