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;