Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle
c37b5a2db4 fix comment 2025-12-28 23:28:33 +01:00
dwelle
da58518c4d feat: stop using CSS filters for dark mode (static canvas) 2025-12-28 23:11:00 +01:00
34 changed files with 854 additions and 666 deletions

View File

@@ -55,5 +55,11 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"tinycolor2": "1.6.0"
},
"devDependencies": {
"@types/tinycolor2": "1.4.6"
}
}

View File

@@ -0,0 +1,93 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{
"black": "#d3d3d3",
"blue": [
"#121e26",
"#154162",
"#2273b4",
"#3791e0",
"#56a2e8",
],
"bronze": [
"#221c1a",
"#362b26",
"#5a463d",
"#917569",
"#a98d84",
],
"cyan": [
"#0a1e20",
"#004149",
"#007281",
"#0f8fa1",
"#3da5b6",
],
"grape": [
"#211a25",
"#5b3165",
"#a954be",
"#d471ed",
"#e28af8",
],
"gray": [
"#161718",
"#202325",
"#33383d",
"#6e757c",
"#b7bcc1",
],
"green": [
"#0f1d12",
"#043b0c",
"#056715",
"#16842a",
"#39994b",
],
"orange": [
"#22190d",
"#4c2b01",
"#924800",
"#cd6005",
"#f17634",
],
"pink": [
"#26191e",
"#602e40",
"#b04d70",
"#f56e9d",
"#ff8dbc",
],
"red": [
"#1f1717",
"#5a2c2c",
"#b44d4d",
"#fa6969",
"#ff8383",
],
"teal": [
"#0a1d17",
"#00422b",
"#00744b",
"#039267",
"#32a783",
],
"transparent": "#ededed00",
"violet": [
"#1f1c29",
"#4a3b72",
"#8a6cdf",
"#a885ff",
"#b595ff",
],
"white": "#121212",
"yellow": [
"#1e1900",
"#362600",
"#5f3a00",
"#905000",
"#b86200",
],
}
`;

View File

@@ -0,0 +1,280 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
rgbToHex,
} from "@excalidraw/common";
describe("applyDarkModeFilter", () => {
describe("basic transformations", () => {
it("transforms black to near-white", () => {
const result = applyDarkModeFilter("#000000");
// Black inverted 93% + hue rotate should be near white/light gray
expect(result).toBe("#ededed");
});
it("transforms white to near-black", () => {
const result = applyDarkModeFilter("#ffffff");
// White inverted 93% should be near black/dark gray
expect(result).toBe("#121212");
});
it("transforms pure red", () => {
const result = applyDarkModeFilter("#ff0000");
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
expect(result).toBe("#ff9090");
});
it("transforms pure green", () => {
const result = applyDarkModeFilter("#00ff00");
// Invert 93% + hue rotate 180deg
expect(result).toBe("#008f00");
});
it("transforms pure blue", () => {
const result = applyDarkModeFilter("#0000ff");
// Invert 93% + hue rotate 180deg produces a light purple
expect(result).toBe("#cdcdff");
});
});
describe("color formats", () => {
it("handles hex with hash", () => {
const result = applyDarkModeFilter("#ff0000");
// Fully opaque colors return 6-char hex
expect(result).toMatch(/^#[0-9a-f]{6}$/);
});
it("handles named colors", () => {
const result = applyDarkModeFilter("red");
// "red" = #ff0000, fully opaque
expect(result).toBe("#ff9090");
});
it("handles rgb format", () => {
const result = applyDarkModeFilter("rgb(255, 0, 0)");
expect(result).toBe("#ff9090");
});
it("handles rgba format and preserves alpha", () => {
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
expect(result).toMatch(/^#[0-9a-f]{8}$/);
// Alpha 0.5 = 128 in hex = 80
expect(result).toBe("#ff909080");
});
it("handles transparent", () => {
const result = applyDarkModeFilter("transparent");
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
expect(result).toBe("#ededed00");
});
it("handles shorthand hex", () => {
const result = applyDarkModeFilter("#f00");
expect(result).toBe("#ff9090");
});
});
describe("alpha preservation", () => {
it("omits alpha for full opacity", () => {
const result = applyDarkModeFilter("#ff0000ff");
// Full opacity returns 6-char hex (no alpha suffix)
expect(result).toBe("#ff9090");
});
it("preserves 50% opacity", () => {
const result = applyDarkModeFilter("#ff000080");
expect(result.slice(-2)).toBe("80");
});
it("preserves 0% opacity", () => {
const result = applyDarkModeFilter("#ff000000");
expect(result.slice(-2)).toBe("00");
});
});
describe("COLOR_PALETTE regression tests", () => {
it("transforms black from palette", () => {
// COLOR_PALETTE.black is #1e1e1e (not pure black)
const result = applyDarkModeFilter(COLOR_PALETTE.black);
expect(result).toBe("#d3d3d3");
});
it("transforms white from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.white);
expect(result).toBe("#121212");
});
it("transforms transparent from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
expect(result).toBe("#ededed00");
});
// Test each color family from the palette (all opaque, so 6-char hex)
describe("red shades", () => {
const redShades = COLOR_PALETTE.red;
it.each(redShades.map((color, i) => [color, i]))(
"transforms red shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("blue shades", () => {
const blueShades = COLOR_PALETTE.blue;
it.each(blueShades.map((color, i) => [color, i]))(
"transforms blue shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("green shades", () => {
const greenShades = COLOR_PALETTE.green;
it.each(greenShades.map((color, i) => [color, i]))(
"transforms green shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("gray shades", () => {
const grayShades = COLOR_PALETTE.gray;
it.each(grayShades.map((color, i) => [color, i]))(
"transforms gray shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("bronze shades", () => {
const bronzeShades = COLOR_PALETTE.bronze;
it.each(bronzeShades.map((color, i) => [color, i]))(
"transforms bronze shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
// Snapshot test for full palette to catch any regressions
it("matches snapshot for all palette colors", () => {
const transformedPalette: Record<string, string | string[]> = {};
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
transformedPalette.transparent = applyDarkModeFilter(
COLOR_PALETTE.transparent,
);
// Transform color arrays
for (const colorName of [
"gray",
"red",
"pink",
"grape",
"violet",
"blue",
"cyan",
"teal",
"green",
"yellow",
"orange",
"bronze",
] as const) {
const shades = COLOR_PALETTE[colorName];
transformedPalette[colorName] = shades.map((shade) =>
applyDarkModeFilter(shade),
);
}
expect(transformedPalette).toMatchSnapshot();
});
});
describe("caching", () => {
it("returns same result for same input (cached)", () => {
const result1 = applyDarkModeFilter("#ff0000");
const result2 = applyDarkModeFilter("#ff0000");
expect(result1).toBe(result2);
});
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
expect(rgbToHex(0, 0, 0)).toBe("#000000");
});
it("converts white (255,255,255)", () => {
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
});
it("converts red (255,0,0)", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
});
it("converts green (0,255,0)", () => {
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
});
it("converts blue (0,0,255)", () => {
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
});
it("converts arbitrary color", () => {
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
});
});
describe("leading zeros preservation", () => {
it("preserves leading zeros for low values", () => {
expect(rgbToHex(0, 0, 1)).toBe("#000001");
expect(rgbToHex(0, 1, 0)).toBe("#000100");
expect(rgbToHex(1, 0, 0)).toBe("#010000");
});
it("preserves zeros for single-digit hex values", () => {
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
});
});
describe("alpha handling", () => {
it("omits alpha when undefined", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
});
it("omits alpha when fully opaque (1)", () => {
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
});
it("includes alpha for semi-transparent (0.5)", () => {
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
});
it("includes alpha for fully transparent (0)", () => {
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
});
it("includes alpha for near-opaque (0.99)", () => {
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
});
it("pads alpha with leading zero when needed", () => {
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
});
});
});

View File

@@ -1,7 +1,121 @@
import oc from "open-color";
import tinycolor from "tinycolor2";
import { clamp } from "@excalidraw/math";
import { degreesToRadians } from "@excalidraw/math";
import type { Degrees } from "@excalidraw/math";
import type { Merge } from "./utility-types";
export { tinycolor };
// Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
}
const tc = tinycolor(color);
const alpha = tc.getAlpha();
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
if (DARK_MODE_COLORS_CACHE) {
DARK_MODE_COLORS_CACHE.set(color, result);
}
return result;
};
// ---------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency
@@ -167,7 +281,22 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index],
] as const;
export const rgbToHex = (r: number, g: number, b: number) =>
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)}`;
if (a !== undefined && a < 1) {
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
// e.g. 0.5 -> 128 -> "80"
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, "0");
return `${hex6}${alphaHex}`;
}
return hex6;
};
// -----------------------------------------------------------------------------

View File

@@ -108,13 +108,6 @@ export const CLASSES = {
FRAME_NAME: "frame-name",
};
export const FONT_SIZES = {
sm: 16,
md: 20,
lg: 28,
xl: 36,
} as const;
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -305,9 +298,6 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
// duplicates --theme-filter, should be removed soon
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;

View File

@@ -10,7 +10,7 @@ import type {
Zoom,
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import { tinycolor } from "./colors";
import {
DEFAULT_VERSION,
ENV,
@@ -549,13 +549,7 @@ export const mapFind = <T, K>(
};
export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
return (
isRGBTransparent ||
isRRGGBBTransparent ||
color === COLOR_PALETTE.transparent
);
return tinycolor(color).getAlpha() === 0;
};
export type ResolvablePromise<T> = Promise<T> & {

View File

@@ -53,18 +53,13 @@ import {
isBindableElement,
isBoundToContainer,
isElbowArrow,
isRectangularElement,
isRectanguloidElement,
isTextElement,
} from "./typeChecks";
import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
projectFixedPointOntoDiagonal,
} from "./utils";
import { projectFixedPointOntoDiagonal } from "./utils";
import type { Scene } from "./Scene";
@@ -76,7 +71,6 @@ import type {
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElement,
FixedPoint,
FixedPointBinding,
@@ -2340,434 +2334,3 @@ export const normalizeFixedPoint = <T extends FixedPoint | null>(
}
return fixedPoint as any as T extends null ? null : FixedPoint;
};
type Side =
| "top"
| "top-right"
| "right"
| "bottom-right"
| "bottom"
| "bottom-left"
| "left"
| "top-left";
type ShapeType = "rectangle" | "ellipse" | "diamond";
const getShapeType = (element: ExcalidrawBindableElement): ShapeType => {
if (element.type === "ellipse" || element.type === "diamond") {
return element.type;
}
return "rectangle";
};
interface SectorConfig {
// center angle of the sector in degrees
centerAngle: number;
// width of the sector in degrees
sectorWidth: number;
side: Side;
}
// Define sector configurations for different shape types
const SHAPE_CONFIGS: Record<ShapeType, SectorConfig[]> = {
// rectangle: 15° corners, 75° edges
rectangle: [
{ centerAngle: 0, sectorWidth: 75, side: "right" },
{ centerAngle: 45, sectorWidth: 15, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 75, side: "bottom" },
{ centerAngle: 135, sectorWidth: 15, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 75, side: "left" },
{ centerAngle: 225, sectorWidth: 15, side: "top-left" },
{ centerAngle: 270, sectorWidth: 75, side: "top" },
{ centerAngle: 315, sectorWidth: 15, side: "top-right" },
],
// diamond: 15° vertices, 75° edges
diamond: [
{ centerAngle: 0, sectorWidth: 15, side: "right" },
{ centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 15, side: "bottom" },
{ centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 15, side: "left" },
{ centerAngle: 225, sectorWidth: 75, side: "top-left" },
{ centerAngle: 270, sectorWidth: 15, side: "top" },
{ centerAngle: 315, sectorWidth: 75, side: "top-right" },
],
// ellipse: 15° cardinal points, 75° diagonals
ellipse: [
{ centerAngle: 0, sectorWidth: 15, side: "right" },
{ centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 15, side: "bottom" },
{ centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 15, side: "left" },
{ centerAngle: 225, sectorWidth: 75, side: "top-left" },
{ centerAngle: 270, sectorWidth: 15, side: "top" },
{ centerAngle: 315, sectorWidth: 75, side: "top-right" },
],
};
const getSectorBoundaries = (
config: SectorConfig[],
): Array<{ start: number; end: number; side: Side }> => {
return config.map((sector, index) => {
const halfWidth = sector.sectorWidth / 2;
let start = sector.centerAngle - halfWidth;
let end = sector.centerAngle + halfWidth;
// normalize angles to [0, 360) range
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
return { start, end, side: sector.side };
});
};
// determine which side a point falls into using adaptive sectors
const getShapeSideAdaptive = (
fixedPoint: FixedPoint,
shapeType: ShapeType,
): Side => {
const [x, y] = fixedPoint;
// convert to centered coordinates
const centerX = x - 0.5;
const centerY = y - 0.5;
// calculate angle
let angle = Math.atan2(centerY, centerX);
if (angle < 0) {
angle += 2 * Math.PI;
}
const degrees = (angle * 180) / Math.PI;
// get sector configuration for this shape type
const config = SHAPE_CONFIGS[shapeType];
const boundaries = getSectorBoundaries(config);
// find which sector the angle falls into
for (const boundary of boundaries) {
if (boundary.start <= boundary.end) {
// Normal case: sector doesn't cross 0°
if (degrees >= boundary.start && degrees <= boundary.end) {
return boundary.side;
}
} else if (degrees >= boundary.start || degrees <= boundary.end) {
return boundary.side;
}
}
// fallback - find nearest sector center
let minDiff = Infinity;
let nearestSide = config[0].side;
for (const sector of config) {
let diff = Math.abs(degrees - sector.centerAngle);
// handle wraparound
if (diff > 180) {
diff = 360 - diff;
}
if (diff < minDiff) {
minDiff = diff;
nearestSide = sector.side;
}
}
return nearestSide;
};
export const getBindingSideMidPoint = (
binding: FixedPointBinding,
elementsMap: ElementsMap,
) => {
const bindableElement = elementsMap.get(binding.elementId);
if (
!bindableElement ||
bindableElement.isDeleted ||
!isBindableElement(bindableElement)
) {
return null;
}
const center = elementCenterPoint(bindableElement, elementsMap);
const shapeType = getShapeType(bindableElement);
const side = getShapeSideAdaptive(
normalizeFixedPoint(binding.fixedPoint),
shapeType,
);
// small offset to avoid precision issues in elbow
const OFFSET = 0.01;
if (bindableElement.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(bindableElement);
const [bottomRight, bottomLeft, topLeft, topRight] = sides;
let x: number;
let y: number;
switch (side) {
case "left": {
// left vertex - use the center of the left corner curve
if (corners.length >= 3) {
const leftCorner = corners[2];
const midPoint = leftCorner[1];
x = midPoint[0] - OFFSET;
y = midPoint[1];
} else {
// fallback for non-rounded diamond
const midPoint = getMidPoint(bottomLeft[1], topLeft[0]);
x = midPoint[0] - OFFSET;
y = midPoint[1];
}
break;
}
case "right": {
if (corners.length >= 1) {
const rightCorner = corners[0];
const midPoint = rightCorner[1];
x = midPoint[0] + OFFSET;
y = midPoint[1];
} else {
const midPoint = getMidPoint(topRight[1], bottomRight[0]);
x = midPoint[0] + OFFSET;
y = midPoint[1];
}
break;
}
case "top": {
if (corners.length >= 4) {
const topCorner = corners[3];
const midPoint = topCorner[1];
x = midPoint[0];
y = midPoint[1] - OFFSET;
} else {
const midPoint = getMidPoint(topLeft[1], topRight[0]);
x = midPoint[0];
y = midPoint[1] - OFFSET;
}
break;
}
case "bottom": {
if (corners.length >= 2) {
const bottomCorner = corners[1];
const midPoint = bottomCorner[1];
x = midPoint[0];
y = midPoint[1] + OFFSET;
} else {
const midPoint = getMidPoint(bottomRight[1], bottomLeft[0]);
x = midPoint[0];
y = midPoint[1] + OFFSET;
}
break;
}
case "top-right": {
const midPoint = getMidPoint(topRight[0], topRight[1]);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
break;
}
case "bottom-right": {
const midPoint = getMidPoint(bottomRight[0], bottomRight[1]);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
break;
}
case "bottom-left": {
const midPoint = getMidPoint(bottomLeft[0], bottomLeft[1]);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
break;
}
case "top-left": {
const midPoint = getMidPoint(topLeft[0], topLeft[1]);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
if (bindableElement.type === "ellipse") {
const ellipseCenterX = bindableElement.x + bindableElement.width / 2;
const ellipseCenterY = bindableElement.y + bindableElement.height / 2;
const radiusX = bindableElement.width / 2;
const radiusY = bindableElement.height / 2;
let x: number;
let y: number;
switch (side) {
case "top": {
x = ellipseCenterX;
y = ellipseCenterY - radiusY - OFFSET;
break;
}
case "right": {
x = ellipseCenterX + radiusX + OFFSET;
y = ellipseCenterY;
break;
}
case "bottom": {
x = ellipseCenterX;
y = ellipseCenterY + radiusY + OFFSET;
break;
}
case "left": {
x = ellipseCenterX - radiusX - OFFSET;
y = ellipseCenterY;
break;
}
case "top-right": {
const angle = -Math.PI / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX + OFFSET * 0.707;
y = ellipseCenterY + ellipseY - OFFSET * 0.707;
break;
}
case "bottom-right": {
const angle = Math.PI / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX + OFFSET * 0.707;
y = ellipseCenterY + ellipseY + OFFSET * 0.707;
break;
}
case "bottom-left": {
const angle = (3 * Math.PI) / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX - OFFSET * 0.707;
y = ellipseCenterY + ellipseY + OFFSET * 0.707;
break;
}
case "top-left": {
const angle = (-3 * Math.PI) / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX - OFFSET * 0.707;
y = ellipseCenterY + ellipseY - OFFSET * 0.707;
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
if (isRectangularElement(bindableElement)) {
const [sides, corners] = deconstructRectanguloidElement(
bindableElement as ExcalidrawRectanguloidElement,
);
const [top, right, bottom, left] = sides;
let x: number;
let y: number;
switch (side) {
case "top": {
const midPoint = getMidPoint(top[0], top[1]);
x = midPoint[0];
y = midPoint[1] - OFFSET;
break;
}
case "right": {
const midPoint = getMidPoint(right[0], right[1]);
x = midPoint[0] + OFFSET;
y = midPoint[1];
break;
}
case "bottom": {
const midPoint = getMidPoint(bottom[0], bottom[1]);
x = midPoint[0];
y = midPoint[1] + OFFSET;
break;
}
case "left": {
const midPoint = getMidPoint(left[0], left[1]);
x = midPoint[0] - OFFSET;
y = midPoint[1];
break;
}
case "top-left": {
if (corners.length >= 1) {
const corner = corners[0];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
} else {
x = bindableElement.x - OFFSET;
y = bindableElement.y - OFFSET;
}
break;
}
case "top-right": {
if (corners.length >= 2) {
const corner = corners[1];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
} else {
x = bindableElement.x + bindableElement.width + OFFSET;
y = bindableElement.y - OFFSET;
}
break;
}
case "bottom-right": {
if (corners.length >= 3) {
const corner = corners[2];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
} else {
x = bindableElement.x + bindableElement.width + OFFSET;
y = bindableElement.y + bindableElement.height + OFFSET;
}
break;
}
case "bottom-left": {
if (corners.length >= 4) {
const corner = corners[3];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
} else {
x = bindableElement.x - OFFSET;
y = bindableElement.y + bindableElement.height + OFFSET;
}
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
return null;
};
const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
};

View File

@@ -92,7 +92,6 @@ export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transform";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";

View File

@@ -22,6 +22,7 @@ import {
isRTL,
getVerticalOffset,
invariant,
applyDarkModeFilter,
} from "@excalidraw/common";
import type {
@@ -81,13 +82,6 @@ import type {
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const isPendingImageElement = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
@@ -95,19 +89,6 @@ const isPendingImageElement = (
isInitializedImageElement(element) &&
!renderConfig.imageCache.has(element.fileId);
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
);
};
const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) {
case "freedraw":
@@ -272,11 +253,6 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
@@ -437,7 +413,11 @@ const drawElementOnCanvas = (
case "freedraw": {
// Draw directly to canvas
context.save();
context.fillStyle = element.strokeColor;
const isDarkMode = renderConfig.theme === THEME.DARK;
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = strokeColor;
const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = ShapeCache.get(element);
@@ -446,7 +426,7 @@ const drawElementOnCanvas = (
rc.draw(fillShape);
}
context.fillStyle = element.strokeColor;
context.fillStyle = strokeColor;
context.fill(path);
context.restore();
@@ -506,7 +486,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle = element.strokeColor;
const isDarkMode = renderConfig.theme === THEME.DARK;
context.fillStyle = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -759,12 +742,17 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
appState.theme === THEME.LIGHT
? "#7affd7"
: applyDarkModeFilter("#1d8264");
}
if (FRAME_STYLE.radius && context.roundRect) {
@@ -790,7 +778,7 @@ export const renderElement = (
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, null);
ShapeCache.generateElementShape(element, renderConfig);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -861,9 +849,6 @@ export const renderElement = (
context.save();
context.translate(cx, cy);
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = "none";
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {

View File

@@ -17,10 +17,12 @@ import {
} from "@excalidraw/math";
import {
ROUGHNESS,
THEME,
isTransparent,
assertNever,
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -117,6 +119,7 @@ export class ShapeCache {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
theme?: AppState["theme"];
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
@@ -139,12 +142,18 @@ export class ShapeCache {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
theme: THEME.LIGHT,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
// if we're not passing renderConfig, we don't have the correct theme
// and canvas background color info so we could populate the cahce with
// wrong colors in dark mode
if (renderConfig) {
ShapeCache.cache.set(element, shape);
}
return shape;
};
@@ -180,6 +189,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
export const generateRoughOptions = (
element: ExcalidrawElement,
continuousPath = false,
isDarkMode: boolean = false,
): Options => {
const options: Options = {
seed: element.seed,
@@ -204,7 +214,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: element.strokeColor,
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -218,6 +230,8 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
@@ -231,6 +245,8 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
}
return options;
@@ -284,6 +300,7 @@ const getArrowheadShapes = (
generator: RoughGenerator,
options: Options,
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
@@ -309,6 +326,10 @@ const getArrowheadShapes = (
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) {
case "dot":
case "circle":
@@ -324,10 +345,10 @@ const getArrowheadShapes = (
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
stroke: element.strokeColor,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
@@ -352,7 +373,7 @@ const getArrowheadShapes = (
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
@@ -380,7 +401,7 @@ const getArrowheadShapes = (
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
@@ -609,12 +630,15 @@ const generateElementShape = (
isExporting,
canvasBackgroundColor,
embedsValidationStatus,
theme,
}: {
isExporting: boolean;
canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null;
theme?: AppState["theme"];
},
): Drawable | Drawable[] | null => {
const isDarkMode = theme === THEME.DARK;
switch (element.type) {
case "rectangle":
case "iframe":
@@ -640,6 +664,7 @@ const generateElementShape = (
embedsValidationStatus,
),
true,
isDarkMode,
),
);
} else {
@@ -655,6 +680,7 @@ const generateElementShape = (
embedsValidationStatus,
),
false,
isDarkMode,
),
);
}
@@ -692,7 +718,7 @@ const generateElementShape = (
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius
}`,
generateRoughOptions(element, true),
generateRoughOptions(element, true, isDarkMode),
);
} else {
shape = generator.polygon(
@@ -702,7 +728,7 @@ const generateElementShape = (
[bottomX, bottomY],
[leftX, leftY],
],
generateRoughOptions(element),
generateRoughOptions(element, false, isDarkMode),
);
}
return shape;
@@ -713,14 +739,14 @@ const generateElementShape = (
element.height / 2,
element.width,
element.height,
generateRoughOptions(element),
generateRoughOptions(element, false, isDarkMode),
);
return shape;
}
case "line":
case "arrow": {
let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element);
const options = generateRoughOptions(element, false, isDarkMode);
// points array can be empty in the beginning, so it is important to add
// initial position to it
@@ -745,7 +771,7 @@ const generateElementShape = (
shape = [
generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
generateRoughOptions(element, true, isDarkMode),
),
];
}
@@ -778,6 +804,7 @@ const generateElementShape = (
generator,
options,
canvasBackgroundColor,
isDarkMode,
);
shape.push(...shapes);
}
@@ -795,6 +822,7 @@ const generateElementShape = (
generator,
options,
canvasBackgroundColor,
isDarkMode,
);
shape.push(...shapes);
}
@@ -812,7 +840,7 @@ const generateElementShape = (
0.75,
);
shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element),
...generateRoughOptions(element, false, isDarkMode),
stroke: "none",
});
} else {

View File

@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>

View File

@@ -22,7 +22,6 @@ import {
isTransparent,
reduceToCommonValue,
invariant,
FONT_SIZES,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -759,25 +758,25 @@ export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
group="font-size"
options={[
{
value: FONT_SIZES.sm,
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: FONT_SIZES.md,
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: FONT_SIZES.lg,
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: FONT_SIZES.xl,
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",

View File

@@ -9,7 +9,6 @@ import {
VERTICAL_ALIGN,
randomId,
isDevEnv,
FONT_SIZES,
} from "@excalidraw/common";
import {
@@ -214,7 +213,7 @@ const chartXLabels = (
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87 as Radians,
fontSize: FONT_SIZES.sm,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
});

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,
@@ -248,8 +248,6 @@ import {
doBoundsIntersect,
isPointInElement,
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -397,6 +395,7 @@ import {
SnapCache,
isGridModeEnabled,
} from "../snapping";
import { convertToExcalidrawElements } from "../data/transform";
import { Renderer } from "../scene/Renderer";
import {
setEraserCursor,
@@ -458,7 +457,7 @@ import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
import type { ExcalidrawElementSkeleton } from "../data/transform";
import type {
AppClassProperties,
AppProps,
@@ -1770,8 +1769,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 +1781,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 +2118,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
{this.state.newElement && (
@@ -2136,6 +2139,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
theme: this.state.theme,
}}
/>
)}
@@ -3119,6 +3123,11 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
// reset cache on theme change to redraw elements with inverted colors
if (prevState.theme !== this.state.theme) {
ShapeCache.destroy();
}
if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true });
}
@@ -3178,6 +3187,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" &&

View File

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

View File

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

View File

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

View File

@@ -106,6 +106,9 @@ body.excalidraw-cursor-resize * {
&.interactive {
z-index: var(--zIndex-interactiveCanvas);
// Apply theme filter only to interactive canvas for UI elements
// (resize handles, selection boxes, etc.)
filter: var(--theme-filter);
}
// Remove the main canvas from document flow to avoid resizeObserver
@@ -134,16 +137,6 @@ body.excalidraw-cursor-resize * {
pointer-events: none;
}
&.theme--dark {
// The percentage is inspired by
// https://material.io/design/color/dark-theme.html#properties, which
// recommends surface color of #121212, 93% yields #111111 for #FFF
canvas {
filter: var(--theme-filter);
}
}
.FixedSideContainer {
padding-top: var(--sat, 0);
padding-right: var(--sar, 0);

View File

@@ -1,12 +1,11 @@
import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest";
import {
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "../transform";
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
import type { ExcalidrawArrowElement } from "../types";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawElementSkeleton } from "./transform";
const opts = { regenerateIds: false };

View File

@@ -16,9 +16,7 @@ import {
getLineHeight,
} from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types";
import { bindBindingElement } from "./binding";
import { bindBindingElement } from "@excalidraw/element";
import {
newArrowElement,
newElement,
@@ -27,20 +25,21 @@ import {
newLinearElement,
newMagicFrameElement,
newTextElement,
type ElementConstructorOpts,
} from "./newElement";
import { measureText, normalizeText } from "./textMeasurements";
import { isArrowElement } from "./typeChecks";
} from "@excalidraw/element";
import { measureText, normalizeText } from "@excalidraw/element";
import { isArrowElement } from "@excalidraw/element";
import { syncInvalidIndices } from "./fractionalIndex";
import { syncInvalidIndices } from "@excalidraw/element";
import { redrawTextBoundingBox } from "./textElement";
import { redrawTextBoundingBox } from "@excalidraw/element";
import { LinearElementEditor } from "./linearElementEditor";
import { LinearElementEditor } from "@excalidraw/element";
import { getCommonBounds } from "./bounds";
import { getCommonBounds } from "@excalidraw/element";
import { Scene } from "./Scene";
import { Scene } from "@excalidraw/element";
import type { ElementConstructorOpts } from "@excalidraw/element";
import type {
ExcalidrawArrowElement,
@@ -60,7 +59,9 @@ import type {
NonDeletedSceneElementsMap,
TextAlign,
VerticalAlign,
} from "./types";
} from "@excalidraw/element/types";
import type { MarkOptional } from "@excalidraw/common/utility-types";
export type ValidLinearElement = {
type: "arrow" | "line";

View File

@@ -4,7 +4,6 @@ import {
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
getFontFamilyFallbacks,
FONT_SIZES,
} from "@excalidraw/common";
import { getContainerElement } from "@excalidraw/element";
import { charWidth } from "@excalidraw/element";
@@ -241,7 +240,7 @@ export class Fonts {
for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({
fontFamily,
fontSize: FONT_SIZES.sm,
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!

View File

@@ -293,11 +293,8 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
export { zoomToFitBounds } from "./actions/actionCanvas";
export {
getCommonBounds,
getVisibleSceneBounds,
convertToExcalidrawElements,
} from "@excalidraw/element";
export { convertToExcalidrawElements } from "./data/transform";
export { getCommonBounds, getVisibleSceneBounds } from "@excalidraw/element";
export {
elementsOverlappingBBox,

View File

@@ -1,4 +1,4 @@
import { THEME, THEME_FILTER } from "@excalidraw/common";
import { THEME, applyDarkModeFilter } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types";
@@ -51,10 +51,6 @@ export const bootstrapCanvas = ({
context.setTransform(1, 0, 0, 1, 0, 0);
context.scale(scale, scale);
if (isExporting && theme === THEME.DARK) {
context.filter = THEME_FILTER;
}
// Paint background
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
@@ -66,7 +62,10 @@ export const bootstrapCanvas = ({
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = viewBackgroundColor;
context.fillStyle =
theme === THEME.DARK
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {

View File

@@ -1,12 +1,13 @@
import {
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
SVG_NS,
THEME,
getFontFamilyString,
isRTL,
isTestEnv,
getVerticalOffset,
applyDarkModeFilter,
} from "@excalidraw/common";
import { normalizeLink, toValidURL } from "@excalidraw/common";
import { hashString } from "@excalidraw/element";
@@ -31,7 +32,7 @@ import { getCornerRadius, isPathALoop } from "@excalidraw/element";
import { ShapeCache } from "@excalidraw/element";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "@excalidraw/element";
import { getFreeDrawSvgPath } from "@excalidraw/element";
import { getElementAbsoluteCoords } from "@excalidraw/element";
@@ -147,7 +148,7 @@ const renderElementToSvg = (
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null);
const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
@@ -397,7 +398,12 @@ const renderElementToSvg = (
);
node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
@@ -462,14 +468,6 @@ const renderElementToSvg = (
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
let normalizedCropX = 0;
let normalizedCropY = 0;
@@ -598,7 +596,12 @@ const renderElementToSvg = (
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
rect.setAttribute(
"stroke",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor,
);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
addToRoot(rect, element);
@@ -649,7 +652,12 @@ const renderElementToSvg = (
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);

View File

@@ -6,13 +6,13 @@ import {
FONT_FAMILY,
SVG_NS,
THEME,
THEME_FILTER,
MIME_TYPES,
EXPORT_DATA_TYPES,
arrayToMap,
distance,
getFontString,
toBrandedType,
applyDarkModeFilter,
} from "@excalidraw/common";
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
@@ -268,6 +268,7 @@ export const exportToCanvas = async (
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
});
@@ -348,9 +349,6 @@ export const exportToSvg = async (
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`);
if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER);
}
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -455,7 +453,12 @@ export const exportToSvg = async (
rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${height}`);
rect.setAttribute("fill", viewBackgroundColor);
rect.setAttribute(
"fill",
exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
);
svgRoot.appendChild(rect);
}
@@ -489,6 +492,7 @@ export const exportToSvg = async (
)
: new Map(),
reuseImages: opts?.reuseImages ?? true,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
},
);

View File

@@ -36,6 +36,7 @@ export type StaticCanvasRenderConfig = {
embedsValidationStatus: EmbedsValidationStatus;
elementsPendingErasure: ElementsPendingErasure;
pendingFlowchartNodes: PendingExcalidrawElements | null;
theme: AppState["theme"];
};
export type SVGRenderConfig = {
@@ -54,6 +55,7 @@ export type SVGRenderConfig = {
* @default true
*/
reuseImages: boolean;
theme: AppState["theme"];
};
export type InteractiveCanvasRenderConfig = {

View File

@@ -0,0 +1,115 @@
import { normalizeInputColor } from "../components/ColorPicker/ColorInput";
describe("normalizeInputColor", () => {
describe("hex colors", () => {
it("returns hex color with hash as-is", () => {
expect(normalizeInputColor("#ff0000")).toBe("#ff0000");
expect(normalizeInputColor("#FF0000")).toBe("#FF0000");
expect(normalizeInputColor("#abc")).toBe("#abc");
expect(normalizeInputColor("#ABC")).toBe("#ABC");
});
it("adds hash to hex color without hash", () => {
expect(normalizeInputColor("ff0000")).toBe("#ff0000");
expect(normalizeInputColor("FF0000")).toBe("#FF0000");
expect(normalizeInputColor("abc")).toBe("#abc");
expect(normalizeInputColor("ABC")).toBe("#ABC");
});
it("handles 8-digit hex (hexa) with alpha", () => {
expect(normalizeInputColor("#ff000080")).toBe("#ff000080");
expect(normalizeInputColor("#ff0000ff")).toBe("#ff0000ff");
});
it("does NOT add hash to hexa without hash (tinycolor detects as hex8, not hex)", () => {
// Note: tinycolor detects 8-digit hex as "hex8" format, not "hex",
// so the hash prefix logic doesn't apply
expect(normalizeInputColor("ff000080")).toBe("ff000080");
});
});
describe("named colors", () => {
it("returns named colors as-is", () => {
expect(normalizeInputColor("red")).toBe("red");
expect(normalizeInputColor("blue")).toBe("blue");
expect(normalizeInputColor("green")).toBe("green");
expect(normalizeInputColor("white")).toBe("white");
expect(normalizeInputColor("black")).toBe("black");
expect(normalizeInputColor("transparent")).toBe("transparent");
});
it("handles case variations of named colors", () => {
expect(normalizeInputColor("RED")).toBe("RED");
expect(normalizeInputColor("Red")).toBe("Red");
});
});
describe("rgb/rgba colors", () => {
it("returns rgb colors as-is", () => {
expect(normalizeInputColor("rgb(255, 0, 0)")).toBe("rgb(255, 0, 0)");
expect(normalizeInputColor("rgb(0,0,0)")).toBe("rgb(0,0,0)");
});
// NOTE: tinycolor clamps values, so rgb(256, 0, 0) is treated as valid
it("tinycolor considers out-of-range rgb values as valid (clamped)", () => {
expect(normalizeInputColor("rgb(256, 0, 0)")).toBe("rgb(256, 0, 0)");
});
it("returns rgba colors as-is", () => {
expect(normalizeInputColor("rgba(255, 0, 0, 0.5)")).toBe(
"rgba(255, 0, 0, 0.5)",
);
expect(normalizeInputColor("rgba(0,0,0,1)")).toBe("rgba(0,0,0,1)");
});
});
describe("hsl/hsla colors", () => {
it("returns hsl colors as-is", () => {
expect(normalizeInputColor("hsl(0, 100%, 50%)")).toBe(
"hsl(0, 100%, 50%)",
);
});
it("returns hsla colors as-is", () => {
expect(normalizeInputColor("hsla(0, 100%, 50%, 0.5)")).toBe(
"hsla(0, 100%, 50%, 0.5)",
);
});
});
describe("whitespace handling", () => {
it("trims leading whitespace", () => {
expect(normalizeInputColor(" #ff0000")).toBe("#ff0000");
expect(normalizeInputColor(" red")).toBe("red");
});
it("trims trailing whitespace", () => {
expect(normalizeInputColor("#ff0000 ")).toBe("#ff0000");
expect(normalizeInputColor("red ")).toBe("red");
});
it("trims both leading and trailing whitespace", () => {
expect(normalizeInputColor(" #ff0000 ")).toBe("#ff0000");
expect(normalizeInputColor(" red ")).toBe("red");
});
it("adds hash to trimmed hex without hash", () => {
expect(normalizeInputColor(" ff0000 ")).toBe("#ff0000");
});
});
describe("invalid colors", () => {
it("returns null for invalid color strings", () => {
expect(normalizeInputColor("notacolor")).toBe(null);
expect(normalizeInputColor("gggggg")).toBe(null);
expect(normalizeInputColor("#gggggg")).toBe(null);
expect(normalizeInputColor("")).toBe(null);
expect(normalizeInputColor(" ")).toBe(null);
});
it("returns null for partial/malformed colors", () => {
expect(normalizeInputColor("#ff")).toBe(null);
expect(normalizeInputColor("rgb(")).toBe(null);
});
});
});

View File

@@ -59,6 +59,7 @@ export const textFixture: ExcalidrawElement = {
type: "text",
fontSize: 20,
fontFamily: DEFAULT_FONT_FAMILY,
strokeColor: "#1e1e1e",
text: "original text",
originalText: "original text",
textAlign: "left",

View File

@@ -23,8 +23,6 @@ import { isLinearElementType } from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element";
import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { FONT_SIZES } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawGenericElement,
@@ -408,7 +406,7 @@ export class API {
text: opts?.label?.text || "sample-text",
width: 50,
height: 20,
fontSize: FONT_SIZES.sm,
fontSize: 16,
containerId: rectangle.id,
frameId:
opts?.label?.frameId === undefined

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,10 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils";
import { FONT_FAMILY, FRAME_STYLE } from "@excalidraw/common";
import {
applyDarkModeFilter,
FONT_FAMILY,
FRAME_STYLE,
} from "@excalidraw/common";
import type {
ExcalidrawTextElement,
@@ -116,9 +120,15 @@ describe("exportToSvg", () => {
null,
);
expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
`"invert(93%) hue-rotate(180deg)"`,
);
const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0);
textElements.forEach((textEl) => {
// fill color should be inverted in dark mode
expect(textEl.getAttribute("fill")).toBe(
applyDarkModeFilter(textFixture.strokeColor),
);
});
});
it("with exportPadding", async () => {

View File

@@ -3,11 +3,13 @@ import {
KEYS,
CLASSES,
POINTER_BUTTON,
THEME,
isWritableElement,
getFontString,
getFontFamilyString,
isTestEnv,
MIME_TYPES,
applyDarkModeFilter,
} from "@excalidraw/common";
import {
@@ -260,9 +262,11 @@ export const textWysiwyg = ({
),
textAlign,
verticalAlign,
color: updatedTextElement.strokeColor,
color:
appState.theme === THEME.DARK
? applyDarkModeFilter(updatedTextElement.strokeColor)
: updatedTextElement.strokeColor,
opacity: updatedTextElement.opacity / 100,
filter: "var(--theme-filter)",
maxHeight: `${editorMaxHeight}px`,
});
editable.scrollTop = 0;

View File

@@ -3009,6 +3009,11 @@
dependencies:
socket.io-client "*"
"@types/tinycolor2@1.4.6":
version "1.4.6"
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz#670cbc0caf4e58dd61d1e3a6f26386e473087f06"
integrity sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==
"@types/trusted-types@^2.0.2":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
@@ -9098,6 +9103,11 @@ tinybench@^2.9.0:
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
tinycolor2@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
tinyexec@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"