From 00a139af1b31c36b4c8991d98247e140bf50587b Mon Sep 17 00:00:00 2001 From: dwelle Date: Thu, 22 Jun 2023 00:42:45 +0200 Subject: [PATCH] feat: auto white text outline for dark colors --- src/actions/actionProperties.tsx | 9 +- src/charts.ts | 3 +- src/colors.ts | 51 ++++++++++++ .../ColorPicker/CustomColorList.tsx | 2 +- src/components/ColorPicker/HotkeyLabel.tsx | 6 +- .../ColorPicker/colorPickerUtils.ts | 37 --------- src/constants.ts | 12 ++- src/element/textWysiwyg.tsx | 29 +++++++ src/renderer/renderElement.ts | 83 ++++++++++++++++++- src/renderer/renderScene.ts | 5 +- src/scene/export.ts | 1 + 11 files changed, 188 insertions(+), 50 deletions(-) diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index d319337c3..6a86d55b3 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -50,6 +50,7 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, + FONT_SIZE, ROUNDNESS, VERTICAL_ALIGN, } from "../constants"; @@ -567,25 +568,25 @@ export const actionChangeFontSize = register({ group="font-size" options={[ { - value: 16, + value: FONT_SIZE.small, text: t("labels.small"), icon: FontSizeSmallIcon, testId: "fontSize-small", }, { - value: 20, + value: FONT_SIZE.medium, text: t("labels.medium"), icon: FontSizeMediumIcon, testId: "fontSize-medium", }, { - value: 28, + value: FONT_SIZE.large, text: t("labels.large"), icon: FontSizeLargeIcon, testId: "fontSize-large", }, { - value: 36, + value: FONT_SIZE.veryLarge, text: t("labels.veryLarge"), icon: FontSizeExtraLargeIcon, testId: "fontSize-veryLarge", diff --git a/src/charts.ts b/src/charts.ts index b5714686c..a7d93edc6 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -6,6 +6,7 @@ import { import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, + FONT_SIZE, ENV, VERTICAL_ALIGN, } from "./constants"; @@ -205,7 +206,7 @@ const chartXLabels = ( y: y + BAR_GAP / 2, width: BAR_WIDTH, angle: 5.87, - fontSize: 16, + fontSize: FONT_SIZE.small, textAlign: "center", verticalAlign: "top", }); diff --git a/src/colors.ts b/src/colors.ts index 7da128399..0126fe943 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -167,4 +167,55 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => export const rgbToHex = (r: number, g: number, b: number) => `#${((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"; +}; + // ----------------------------------------------------------------------------- diff --git a/src/components/ColorPicker/CustomColorList.tsx b/src/components/ColorPicker/CustomColorList.tsx index b028dcc76..0424e3cc0 100644 --- a/src/components/ColorPicker/CustomColorList.tsx +++ b/src/components/ColorPicker/CustomColorList.tsx @@ -54,7 +54,7 @@ export const CustomColorList = ({ key={i} >
- + ); })} diff --git a/src/components/ColorPicker/HotkeyLabel.tsx b/src/components/ColorPicker/HotkeyLabel.tsx index 145060d19..b5cbecd72 100644 --- a/src/components/ColorPicker/HotkeyLabel.tsx +++ b/src/components/ColorPicker/HotkeyLabel.tsx @@ -1,23 +1,21 @@ import React from "react"; -import { getContrastYIQ } from "./colorPickerUtils"; +import { getContrastingBWColor } from "../../colors"; interface HotkeyLabelProps { color: string; keyLabel: string | number; - isCustomColor?: boolean; isShade?: boolean; } const HotkeyLabel = ({ color, keyLabel, - isCustomColor = false, isShade = false, }: HotkeyLabelProps) => { return (
{isShade && "⇧"} diff --git a/src/components/ColorPicker/colorPickerUtils.ts b/src/components/ColorPicker/colorPickerUtils.ts index 37e5c88a6..987ac77b8 100644 --- a/src/components/ColorPicker/colorPickerUtils.ts +++ b/src/components/ColorPicker/colorPickerUtils.ts @@ -93,43 +93,6 @@ export type ActiveColorPickerSectionAtomType = export const activeColorPickerSectionAtom = atom(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 = | "canvasBackground" | "elementBackground" diff --git a/src/constants.ts b/src/constants.ts index 0fa884069..614e92fb9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -96,7 +96,14 @@ export const THEME = { 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_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; @@ -295,3 +302,6 @@ export const DEFAULT_SIDEBAR = { name: "default", defaultTab: LIBRARY_SIDEBAR_TAB, } as const; + +export const TEXT_OUTLINE_DEFAULT_WIDTH = 2; +export const TEXT_OUTLINE_CONTRAST_THRESHOLD = 0.62; diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 9105ba700..c5b0b69ec 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -44,6 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; +import { + getTextOutlineColor, + getTextOutlineWidth, +} from "../renderer/renderElement"; const getTransform = ( width: number, @@ -297,6 +301,31 @@ export const textWysiwyg = ({ if (isTestEnv()) { 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 }); } }; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 09abdf0a5..2b8157fe8 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -30,14 +30,17 @@ import { RenderConfig } from "../scene/types"; import { distance, getFontString, getFontFamilyString, isRTL } from "../utils"; import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; -import { AppState, BinaryFiles, Zoom } from "../types"; +import { AppState, BinaryFiles, NormalizedZoomValue, Zoom } from "../types"; import { getDefaultAppState } from "../appState"; import { BOUND_TEXT_PADDING, + FONT_SIZE, MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, ROUGHNESS, SVG_NS, + TEXT_OUTLINE_CONTRAST_THRESHOLD, + TEXT_OUTLINE_DEFAULT_WIDTH, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { @@ -49,6 +52,31 @@ import { getBoundTextMaxWidth, } from "../element/textElement"; 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 // as a temp hack to make images in dark theme look closer to original @@ -338,7 +366,26 @@ const drawElementOnCanvas = ( element.lineHeight, ); const verticalOffset = element.height - element.baseline; + + const textOutlineColor = getTextOutlineColor( + element.strokeColor, + renderConfig.viewBackgroundColor, + ); + 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( lines[index], horizontalOffset, @@ -1128,6 +1175,7 @@ export const renderElementToSvg = ( files: BinaryFiles, offsetX: number, offsetY: number, + viewBackgroundColor: AppState["viewBackgroundColor"], exportWithDarkMode?: boolean, ) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); @@ -1396,6 +1444,39 @@ export const renderElementToSvg = ( ? "end" : "start"; 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"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index c8b64b47b..7cff09c1b 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -1135,11 +1135,13 @@ export const renderSceneToSvg = ( offsetX = 0, offsetY = 0, exportWithDarkMode = false, + viewBackgroundColor, }: { offsetX?: number; offsetY?: number; exportWithDarkMode?: boolean; - } = {}, + viewBackgroundColor: AppState["viewBackgroundColor"]; + }, ) => { if (!svgRoot) { return; @@ -1155,6 +1157,7 @@ export const renderSceneToSvg = ( files, element.x + offsetX, element.y + offsetY, + viewBackgroundColor, exportWithDarkMode, ); } catch (error: any) { diff --git a/src/scene/export.ts b/src/scene/export.ts index 6c5712be6..e6fe407dc 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -172,6 +172,7 @@ export const exportToSvg = async ( offsetX: -minX + exportPadding, offsetY: -minY + exportPadding, exportWithDarkMode: appState.exportWithDarkMode, + viewBackgroundColor: appState.viewBackgroundColor, }); return svgRoot;