feat: auto white text outline for dark colors

This commit is contained in:
dwelle 2023-06-22 00:42:45 +02:00
parent 68e8636782
commit 00a139af1b
11 changed files with 188 additions and 50 deletions

View File

@ -50,6 +50,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
FONT_SIZE,
ROUNDNESS, ROUNDNESS,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@ -567,25 +568,25 @@ export const actionChangeFontSize = register({
group="font-size" group="font-size"
options={[ options={[
{ {
value: 16, value: FONT_SIZE.small,
text: t("labels.small"), text: t("labels.small"),
icon: FontSizeSmallIcon, icon: FontSizeSmallIcon,
testId: "fontSize-small", testId: "fontSize-small",
}, },
{ {
value: 20, value: FONT_SIZE.medium,
text: t("labels.medium"), text: t("labels.medium"),
icon: FontSizeMediumIcon, icon: FontSizeMediumIcon,
testId: "fontSize-medium", testId: "fontSize-medium",
}, },
{ {
value: 28, value: FONT_SIZE.large,
text: t("labels.large"), text: t("labels.large"),
icon: FontSizeLargeIcon, icon: FontSizeLargeIcon,
testId: "fontSize-large", testId: "fontSize-large",
}, },
{ {
value: 36, value: FONT_SIZE.veryLarge,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon, icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge", testId: "fontSize-veryLarge",

View File

@ -6,6 +6,7 @@ import {
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_SIZE,
ENV, ENV,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "./constants"; } from "./constants";
@ -205,7 +206,7 @@ const chartXLabels = (
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
angle: 5.87, angle: 5.87,
fontSize: 16, fontSize: FONT_SIZE.small,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
}); });

View File

@ -167,4 +167,55 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
export const rgbToHex = (r: number, g: number, b: number) => export const rgbToHex = (r: number, g: number, b: number) =>
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
const parseRGBFunction = (
/**
* `rgb()` or `rgba()` color string
*/
rgbFunctionValue: string,
) => {
const match = rgbFunctionValue.match(/(rgba?|hsla?)\(([^)]+)\)/i);
if (!match) {
return { r: 0, g: 0, b: 0 };
}
const values = match[2].split(",");
const r = parseInt(values[0].trim());
const g = parseInt(values[1].trim());
const b = parseInt(values[2].trim());
return { r, g, b };
};
export const colorToRGB = (
/**
* HTML color, HEX(A), rgba()...
*/
color: string,
) => {
const style = new Option().style;
style.color = color;
if (style.color) {
return parseRGBFunction(style.color);
}
return { r: 0, g: 0, b: 0 };
};
// inspiration from https://stackoverflow.com/a/11868398
export const getContrastingBWColor = (
color: string,
/**
* <0-1>, the closer to one the more light the input color needs to be
* to get `black` return value
*/
threshold = 0.627,
) => {
if (color === "transparent") {
return "black";
}
const { r, g, b } = colorToRGB(color);
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq / 255 >= threshold ? "black" : "white";
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -54,7 +54,7 @@ export const CustomColorList = ({
key={i} key={i}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor /> <HotkeyLabel color={c} keyLabel={i + 1} />
</button> </button>
); );
})} })}

View File

@ -1,23 +1,21 @@
import React from "react"; import React from "react";
import { getContrastYIQ } from "./colorPickerUtils"; import { getContrastingBWColor } from "../../colors";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
keyLabel: string | number; keyLabel: string | number;
isCustomColor?: boolean;
isShade?: boolean; isShade?: boolean;
} }
const HotkeyLabel = ({ const HotkeyLabel = ({
color, color,
keyLabel, keyLabel,
isCustomColor = false,
isShade = false, isShade = false,
}: HotkeyLabelProps) => { }: HotkeyLabelProps) => {
return ( return (
<div <div
className="color-picker__button__hotkey-label" className="color-picker__button__hotkey-label"
style={{ style={{
color: getContrastYIQ(color, isCustomColor), color: getContrastingBWColor(color, 0.7),
}} }}
> >
{isShade && "⇧"} {isShade && "⇧"}

View File

@ -93,43 +93,6 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number) => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 160 ? "black" : "white";
};
// inspiration from https://stackoverflow.com/a/11868398
export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
if (isCustomColor) {
const style = new Option().style;
style.color = bgHex;
if (style.color) {
const rgb = style.color
.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);
}
}
// TODO: ? is this wanted?
if (bgHex === "transparent") {
return "black";
}
const r = parseInt(bgHex.substring(1, 3), 16);
const g = parseInt(bgHex.substring(3, 5), 16);
const b = parseInt(bgHex.substring(5, 7), 16);
return calculateContrast(r, g, b);
};
export type ColorPickerType = export type ColorPickerType =
| "canvasBackground" | "canvasBackground"
| "elementBackground" | "elementBackground"

View File

@ -96,7 +96,14 @@ export const THEME = {
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20; export const FONT_SIZE = {
small: 16,
medium: 20,
large: 28,
veryLarge: 36,
} as const;
export const DEFAULT_FONT_SIZE = FONT_SIZE.medium;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERTICAL_ALIGN = "top";
@ -295,3 +302,6 @@ export const DEFAULT_SIDEBAR = {
name: "default", name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB, defaultTab: LIBRARY_SIDEBAR_TAB,
} as const; } as const;
export const TEXT_OUTLINE_DEFAULT_WIDTH = 2;
export const TEXT_OUTLINE_CONTRAST_THRESHOLD = 0.62;

View File

@ -44,6 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
import {
getTextOutlineColor,
getTextOutlineWidth,
} from "../renderer/renderElement";
const getTransform = ( const getTransform = (
width: number, width: number,
@ -297,6 +301,31 @@ export const textWysiwyg = ({
if (isTestEnv()) { if (isTestEnv()) {
editable.style.fontFamily = getFontFamilyString(updatedTextElement); editable.style.fontFamily = getFontFamilyString(updatedTextElement);
} }
const textOutlineColor = getTextOutlineColor(
updatedTextElement.strokeColor,
appState.viewBackgroundColor,
);
if (textOutlineColor) {
// text-shadow must be around half of the outline width
const textShadowWidth =
getTextOutlineWidth(
updatedTextElement.fontSize,
appState.zoom.value,
) / 2;
const p = `${textShadowWidth}px`;
const n = `-${textShadowWidth}px`;
editable.style.setProperty(
"text-shadow",
`${n} ${n} 0 ${textOutlineColor}, ${p} ${n} 0 ${textOutlineColor},
${n} ${p} 0 ${textOutlineColor}, ${p} ${p} 0 ${textOutlineColor}`,
);
} else {
editable.style.removeProperty("text-shadow");
}
mutateElement(updatedTextElement, { x: coordX, y: coordY }); mutateElement(updatedTextElement, { x: coordX, y: coordY });
} }
}; };

View File

@ -30,14 +30,17 @@ import { RenderConfig } from "../scene/types";
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils"; import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { AppState, BinaryFiles, Zoom } from "../types"; import { AppState, BinaryFiles, NormalizedZoomValue, Zoom } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
FONT_SIZE,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
ROUGHNESS, ROUGHNESS,
SVG_NS, SVG_NS,
TEXT_OUTLINE_CONTRAST_THRESHOLD,
TEXT_OUTLINE_DEFAULT_WIDTH,
} from "../constants"; } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand"; import { getStroke, StrokeOptions } from "perfect-freehand";
import { import {
@ -49,6 +52,31 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { COLOR_PALETTE, getContrastingBWColor } from "../colors";
/** @returns null if outline should be disabled */
export const getTextOutlineColor = (
strokeColor: ExcalidrawElement["strokeColor"],
viewBackgroundColor: AppState["viewBackgroundColor"] | null,
) => {
return getContrastingBWColor(strokeColor, TEXT_OUTLINE_CONTRAST_THRESHOLD) ===
"white"
? viewBackgroundColor || COLOR_PALETTE.white
: null;
};
export const getTextOutlineWidth = (
fontSize: ExcalidrawTextElement["fontSize"],
zoom: NormalizedZoomValue,
) => {
const normalizedZoom = Math.max(1, zoom);
const width = Math.max(
TEXT_OUTLINE_DEFAULT_WIDTH / 2,
TEXT_OUTLINE_DEFAULT_WIDTH / normalizedZoom,
);
return width * (fontSize / FONT_SIZE.medium);
};
// using a stronger invert (100% vs our regular 93%) and saturate // 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 // as a temp hack to make images in dark theme look closer to original
@ -338,7 +366,26 @@ const drawElementOnCanvas = (
element.lineHeight, element.lineHeight,
); );
const verticalOffset = element.height - element.baseline; const verticalOffset = element.height - element.baseline;
const textOutlineColor = getTextOutlineColor(
element.strokeColor,
renderConfig.viewBackgroundColor,
);
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
if (textOutlineColor) {
context.strokeStyle = textOutlineColor;
context.lineWidth = getTextOutlineWidth(
element.fontSize,
renderConfig.zoom.value,
);
context.strokeText(
lines[index],
horizontalOffset,
(index + 1) * lineHeightPx - verticalOffset,
);
}
context.fillText( context.fillText(
lines[index], lines[index],
horizontalOffset, horizontalOffset,
@ -1128,6 +1175,7 @@ export const renderElementToSvg = (
files: BinaryFiles, files: BinaryFiles,
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
viewBackgroundColor: AppState["viewBackgroundColor"],
exportWithDarkMode?: boolean, exportWithDarkMode?: boolean,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -1396,6 +1444,39 @@ export const renderElementToSvg = (
? "end" ? "end"
: "start"; : "start";
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const textOutlineColor = getTextOutlineColor(
element.strokeColor,
viewBackgroundColor,
);
if (textOutlineColor) {
// create a text stroke
const stroke = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"text",
);
stroke.textContent = lines[i];
stroke.setAttribute("x", `${horizontalOffset}`);
stroke.setAttribute("y", `${i * lineHeightPx}`);
stroke.setAttribute("font-family", getFontFamilyString(element));
stroke.setAttribute("font-size", `${element.fontSize}px`);
stroke.setAttribute("fill", element.strokeColor);
stroke.setAttribute("stroke", textOutlineColor);
stroke.setAttribute(
"stroke-wiidth",
`${getTextOutlineWidth(
element.fontSize,
1 as NormalizedZoomValue,
)}px`,
);
stroke.setAttribute("text-anchor", textAnchor);
stroke.setAttribute("style", "white-space: pre;");
stroke.setAttribute("direction", direction);
stroke.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(stroke);
}
// create a text fill
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i]; text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`); text.setAttribute("x", `${horizontalOffset}`);

View File

@ -1135,11 +1135,13 @@ export const renderSceneToSvg = (
offsetX = 0, offsetX = 0,
offsetY = 0, offsetY = 0,
exportWithDarkMode = false, exportWithDarkMode = false,
viewBackgroundColor,
}: { }: {
offsetX?: number; offsetX?: number;
offsetY?: number; offsetY?: number;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
} = {}, viewBackgroundColor: AppState["viewBackgroundColor"];
},
) => { ) => {
if (!svgRoot) { if (!svgRoot) {
return; return;
@ -1155,6 +1157,7 @@ export const renderSceneToSvg = (
files, files,
element.x + offsetX, element.x + offsetX,
element.y + offsetY, element.y + offsetY,
viewBackgroundColor,
exportWithDarkMode, exportWithDarkMode,
); );
} catch (error: any) { } catch (error: any) {

View File

@ -172,6 +172,7 @@ export const exportToSvg = async (
offsetX: -minX + exportPadding, offsetX: -minX + exportPadding,
offsetY: -minY + exportPadding, offsetY: -minY + exportPadding,
exportWithDarkMode: appState.exportWithDarkMode, exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor: appState.viewBackgroundColor,
}); });
return svgRoot; return svgRoot;