Compare commits

...

6 Commits

Author SHA1 Message Date
dwelle
a5454d0942 tweak scaling factor 2023-06-22 17:28:29 +02:00
dwelle
00a139af1b feat: auto white text outline for dark colors 2023-06-22 00:42:45 +02:00
dwelle
68e8636782 ajust roughness to size 2023-06-08 09:51:22 +02:00
dwelle
725c3bafe1 feat: change default fill style to solid 2023-06-08 09:51:22 +02:00
dwelle
5d632a020d feat: change default stroke width to 2 2023-06-08 09:51:22 +02:00
dwelle
776e5a1cc8 feat: preserve vertices on roughness < 2 2023-06-08 09:51:20 +02:00
11 changed files with 218 additions and 55 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";
@ -263,6 +270,12 @@ export const ROUNDNESS = {
* collaboration */ * collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const ROUGHNESS = {
architect: 0,
artist: 1,
cartoonist: 2,
} as const;
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"]; backgroundColor: ExcalidrawElement["backgroundColor"];
@ -275,10 +288,10 @@ export const DEFAULT_ELEMENT_PROPS: {
} = { } = {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "hachure", fillStyle: "solid",
strokeWidth: 1, strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
roughness: 1, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
locked: false, locked: false,
}; };
@ -289,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,13 +30,16 @@ 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,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
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 {
@ -48,6 +51,37 @@ 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,
) => {
if (strokeColor === "transparent") {
return COLOR_PALETTE.black;
}
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,
);
// arbitrary scaling factor that looks good
const scalingFactor = 80;
return width * Math.max(1, fontSize / scalingFactor);
};
// 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
@ -337,7 +371,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,
@ -388,6 +441,15 @@ export const setShapeForElement = <T extends ExcalidrawElement>(
export const invalidateShapeForElement = (element: ExcalidrawElement) => export const invalidateShapeForElement = (element: ExcalidrawElement) =>
shapeCache.delete(element); shapeCache.delete(element);
function adjustRoughness(size: number, roughness: number): number {
if (size >= 50) {
return roughness;
}
const factor = 2 + (50 - size) / 10;
return roughness / factor;
}
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
@ -414,9 +476,13 @@ export const generateRoughOptions = (
// calculate them (and we don't want the fills to be modified) // calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: element.roughness, roughness: adjustRoughness(
Math.min(element.width, element.height),
element.roughness,
),
stroke: element.strokeColor, stroke: element.strokeColor,
preserveVertices: continuousPath, preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
switch (element.type) { switch (element.type) {
@ -1114,6 +1180,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);
@ -1382,6 +1449,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;