Compare commits
6 Commits
master
...
dwelle/ren
Author | SHA1 | Date | |
---|---|---|---|
|
a5454d0942 | ||
|
00a139af1b | ||
|
68e8636782 | ||
|
725c3bafe1 | ||
|
5d632a020d | ||
|
776e5a1cc8 |
@ -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",
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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";
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -54,7 +54,7 @@ export const CustomColorList = ({
|
||||
key={i}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
|
||||
<HotkeyLabel color={c} keyLabel={i + 1} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
@ -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 (
|
||||
<div
|
||||
className="color-picker__button__hotkey-label"
|
||||
style={{
|
||||
color: getContrastYIQ(color, isCustomColor),
|
||||
color: getContrastingBWColor(color, 0.7),
|
||||
}}
|
||||
>
|
||||
{isShade && "⇧"}
|
||||
|
@ -93,43 +93,6 @@ export type ActiveColorPickerSectionAtomType =
|
||||
export const activeColorPickerSectionAtom =
|
||||
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 =
|
||||
| "canvasBackground"
|
||||
| "elementBackground"
|
||||
|
@ -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";
|
||||
@ -263,6 +270,12 @@ export const ROUNDNESS = {
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
export const ROUGHNESS = {
|
||||
architect: 0,
|
||||
artist: 1,
|
||||
cartoonist: 2,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_ELEMENT_PROPS: {
|
||||
strokeColor: ExcalidrawElement["strokeColor"];
|
||||
backgroundColor: ExcalidrawElement["backgroundColor"];
|
||||
@ -275,10 +288,10 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
} = {
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
roughness: ROUGHNESS.artist,
|
||||
opacity: 100,
|
||||
locked: false,
|
||||
};
|
||||
@ -289,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;
|
||||
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
@ -30,13 +30,16 @@ 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,
|
||||
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 {
|
||||
@ -48,6 +51,37 @@ 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,
|
||||
) => {
|
||||
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
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@ -337,7 +371,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,
|
||||
@ -388,6 +441,15 @@ export const setShapeForElement = <T extends ExcalidrawElement>(
|
||||
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
@ -414,9 +476,13 @@ export const generateRoughOptions = (
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: element.roughness,
|
||||
roughness: adjustRoughness(
|
||||
Math.min(element.width, element.height),
|
||||
element.roughness,
|
||||
),
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices: continuousPath,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
@ -1114,6 +1180,7 @@ export const renderElementToSvg = (
|
||||
files: BinaryFiles,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
viewBackgroundColor: AppState["viewBackgroundColor"],
|
||||
exportWithDarkMode?: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
@ -1382,6 +1449,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}`);
|
||||
|
@ -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) {
|
||||
|
@ -172,6 +172,7 @@ export const exportToSvg = async (
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
});
|
||||
|
||||
return svgRoot;
|
||||
|
Loading…
x
Reference in New Issue
Block a user