diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx
index d6b5c2c91..dbbe9dbca 100644
--- a/src/actions/actionBoundText.tsx
+++ b/src/actions/actionBoundText.tsx
@@ -42,9 +42,12 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
- const { width, height } = measureTextElement(boundTextElement, {
- text: boundTextElement.originalText,
- });
+ const { width, height, baseline } = measureTextElement(
+ boundTextElement,
+ {
+ text: boundTextElement.originalText,
+ },
+ );
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
@@ -54,6 +57,7 @@ export const actionUnbindText = register({
containerId: null,
width,
height,
+ baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx
index 1f39e9ea7..da5b978e1 100644
--- a/src/actions/actionProperties.tsx
+++ b/src/actions/actionProperties.tsx
@@ -1,4 +1,5 @@
import { AppState } from "../../src/types";
+import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker";
@@ -37,6 +38,7 @@ import {
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
+ FillZigZagIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
@@ -294,7 +296,12 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
trackEvent: false,
- perform: (elements, appState, value) => {
+ perform: (elements, appState, value, app) => {
+ trackEvent(
+ "element",
+ "changeFillStyle",
+ `${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
+ );
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -305,40 +312,55 @@ export const actionChangeFillStyle = register({
commitToHistory: true,
};
},
- PanelComponent: ({ elements, appState, updateData }) => (
-
- ),
+ PanelComponent: ({ elements, appState, updateData }) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ const allElementsZigZag = selectedElements.every(
+ (el) => el.fillStyle === "zigzag",
+ );
+
+ return (
+
+ );
+ },
});
export const actionChangeStrokeWidth = register({
diff --git a/src/components/ButtonIconSelect.tsx b/src/components/ButtonIconSelect.tsx
index 899ec150d..eec8870a9 100644
--- a/src/components/ButtonIconSelect.tsx
+++ b/src/components/ButtonIconSelect.tsx
@@ -1,33 +1,59 @@
import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component
-export const ButtonIconSelect = ({
- options,
- value,
- onChange,
- group,
-}: {
- options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
- value: T | null;
- onChange: (value: T) => void;
- group: string;
-}) => (
+export const ButtonIconSelect = (
+ props: {
+ options: {
+ value: T;
+ text: string;
+ icon: JSX.Element;
+ testId?: string;
+ /** if not supplied, defaults to value identity check */
+ active?: boolean;
+ }[];
+ value: T | null;
+ type?: "radio" | "button";
+ } & (
+ | { type?: "radio"; group: string; onChange: (value: T) => void }
+ | {
+ type: "button";
+ onClick: (
+ value: T,
+ event: React.MouseEvent,
+ ) => void;
+ }
+ ),
+) => (
- {options.map((option) => (
-
- ))}
+ title={option.text}
+ >
+ {option.icon}
+
+ ) : (
+
+ ),
+ )}
);
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index c7d4e04f3..85ded8f20 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
+export const FillZigZagIcon = createIcon(
+
+
+ ,
+ modifiedTablerIconProps,
+);
+
export const FillHachureIcon = createIcon(
<>
("text", opts),
@@ -196,6 +197,7 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
+ baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
lineHeight,
@@ -213,10 +215,15 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
+ baseline: number;
} => {
const container = getContainerElement(element);
- const { width: nextWidth, height: nextHeight } = measureTextElement(element, {
+ const {
+ width: nextWidth,
+ height: nextHeight,
+ baseline: nextBaseline,
+ } = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element;
@@ -289,6 +296,7 @@ const getAdjustedDimensions = (
return {
width: nextWidth,
height: nextHeight,
+ baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
};
@@ -298,6 +306,9 @@ export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
text = textElement.text,
) => {
+ if (textElement.isDeleted) {
+ return;
+ }
const container = getContainerElement(textElement);
if (container) {
text = wrapTextElement(textElement, getMaxContainerWidth(container), {
diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts
index a49559a7e..fde5f4d9c 100644
--- a/src/element/resizeElements.ts
+++ b/src/element/resizeElements.ts
@@ -46,6 +46,8 @@ import {
handleBindTextResize,
getMaxContainerWidth,
getApproxMinLineHeight,
+ measureTextElement,
+ getMaxContainerHeight,
} from "./textElement";
export const normalizeAngle = (angle: number): number => {
@@ -193,7 +195,8 @@ const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = (
element: NonDeleted,
nextWidth: number,
-): number | null => {
+ nextHeight: number,
+): { size: number; baseline: number } | null => {
// We only use width to scale font on resize
let width = element.width;
@@ -208,8 +211,11 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
-
- return nextFontSize;
+ const metrics = measureTextElement(element, { fontSize: nextFontSize });
+ return {
+ size: nextFontSize,
+ baseline: metrics.baseline + (nextHeight - metrics.height),
+ };
};
const getSidesForTransformHandle = (
@@ -280,8 +286,8 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
- const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
- if (nextFontSize === null) {
+ const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
+ if (metrics === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -305,9 +311,10 @@ const resizeSingleTextElement = (
deltaY2,
);
mutateElement(element, {
- fontSize: nextFontSize,
+ fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
+ baseline: metrics.baseline,
x: nextElementX,
y: nextElementY,
});
@@ -360,7 +367,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
- let boundTextFontSize: number | null = null;
+ let boundTextFont: { fontSize?: number; baseline?: number } = {};
const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) {
@@ -410,7 +417,10 @@ export const resizeSingleElement = (
boundTextElement.id,
) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) {
- boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
+ boundTextFont = {
+ fontSize: stateOfBoundTextElementAtResize.fontSize,
+ baseline: stateOfBoundTextElementAtResize.baseline,
+ };
}
if (shouldMaintainAspectRatio) {
const updatedElement = {
@@ -419,14 +429,18 @@ export const resizeSingleElement = (
height: eleNewHeight,
};
- const nextFontSize = measureFontSizeFromWidth(
+ const nextFont = measureFontSizeFromWidth(
boundTextElement,
getMaxContainerWidth(updatedElement),
+ getMaxContainerHeight(updatedElement),
);
- if (nextFontSize === null) {
+ if (nextFont === null) {
return;
}
- boundTextFontSize = nextFontSize;
+ boundTextFont = {
+ fontSize: nextFont.size,
+ baseline: nextFont.baseline,
+ };
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
@@ -568,9 +582,10 @@ export const resizeSingleElement = (
});
mutateElement(element, resizedElement);
- if (boundTextElement && boundTextFontSize != null) {
+ if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
- fontSize: boundTextFontSize,
+ fontSize: boundTextFont.fontSize,
+ baseline: boundTextFont.baseline,
});
}
handleBindTextResize(element, transformHandleDirection);
@@ -677,6 +692,7 @@ const resizeMultipleElements = (
y: number;
points?: Point[];
fontSize?: number;
+ baseline?: number;
} = {
width,
height,
@@ -685,7 +701,7 @@ const resizeMultipleElements = (
...rescaledPoints,
};
- let boundTextUpdates: { fontSize: number } | null = null;
+ let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
@@ -695,24 +711,29 @@ const resizeMultipleElements = (
width,
height,
};
- const fontSize = measureFontSizeFromWidth(
+ const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
boundTextElement
? getMaxContainerWidth(updatedElement)
: updatedElement.width,
+ boundTextElement
+ ? getMaxContainerHeight(updatedElement)
+ : updatedElement.height,
);
- if (!fontSize) {
+ if (!metrics) {
return;
}
if (isTextElement(element.orig)) {
- update.fontSize = fontSize;
+ update.fontSize = metrics.size;
+ update.baseline = metrics.baseline;
}
if (boundTextElement) {
boundTextUpdates = {
- fontSize,
+ fontSize: metrics.size,
+ baseline: metrics.baseline,
};
}
}
diff --git a/src/element/textElement.ts b/src/element/textElement.ts
index 594ca8f58..b86f936ca 100644
--- a/src/element/textElement.ts
+++ b/src/element/textElement.ts
@@ -15,6 +15,7 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
+ isSafari,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@@ -83,6 +84,7 @@ export const redrawTextBoundingBox = (
text: textElement.text,
width: textElement.width,
height: textElement.height,
+ baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
@@ -97,6 +99,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
+ boundTextUpdates.baseline = metrics.baseline;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
@@ -210,13 +213,15 @@ export const handleBindTextResize = (
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height;
+ let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapTextElement(textElement, maxWidth);
}
- const dimensions = measureTextElement(textElement, { text });
- nextHeight = dimensions.height;
- nextWidth = dimensions.width;
+ const metrics = measureTextElement(textElement, { text });
+ nextHeight = metrics.height;
+ nextWidth = metrics.width;
+ nextBaseLine = metrics.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
@@ -244,6 +249,7 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
+ baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
@@ -304,8 +310,59 @@ export const measureText = (
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
+ const baseline = measureBaseline(text, font, lineHeight);
+ return { width, height, baseline };
+};
- return { width, height };
+export const measureBaseline = (
+ text: string,
+ font: FontString,
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+ wrapInContainer?: boolean,
+) => {
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.whiteSpace = "pre";
+ container.style.font = font;
+ container.style.minHeight = "1em";
+ if (wrapInContainer) {
+ container.style.overflow = "hidden";
+ container.style.wordBreak = "break-word";
+ container.style.whiteSpace = "pre-wrap";
+ }
+
+ container.style.lineHeight = String(lineHeight);
+
+ container.innerText = text;
+
+ // Baseline is important for positioning text on canvas
+ document.body.appendChild(container);
+
+ const span = document.createElement("span");
+ span.style.display = "inline-block";
+ span.style.overflow = "hidden";
+ span.style.width = "1px";
+ span.style.height = "1px";
+ container.appendChild(span);
+ let baseline = span.offsetTop + span.offsetHeight;
+ const height = container.offsetHeight;
+
+ if (isSafari) {
+ const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
+ const fontSize = parseFloat(font);
+ // In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
+ // from the actual canvas height
+ const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
+ if (canvasHeight > height) {
+ baseline += canvasHeight - domHeight;
+ }
+
+ if (height > canvasHeight) {
+ baseline -= domHeight - canvasHeight;
+ }
+ }
+ document.body.removeChild(container);
+ return baseline;
};
/**
diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx
index 8f31e0d13..bd6e37fe9 100644
--- a/src/element/textWysiwyg.tsx
+++ b/src/element/textWysiwyg.tsx
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
-import { CLASSES, VERTICAL_ALIGN } from "../constants";
+import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -35,6 +35,7 @@ import {
getMaxContainerHeight,
getMaxContainerWidth,
computeContainerDimensionForBoundText,
+ detectLineHeight,
} from "./textElement";
import {
actionDecreaseFontSize,
@@ -328,13 +329,24 @@ export const textWysiwyg = ({
? offWidth / 2
: 0;
const { width: w, height: h } = updatedTextElement;
+
+ let lineHeight = updatedTextElement.lineHeight;
+
+ // In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
+ if (isSafari) {
+ lineHeight = detectLineHeight({
+ ...updatedTextElement,
+ fontSize: Math.round(updatedTextElement.fontSize),
+ });
+ }
+
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
- lineHeight: element.lineHeight,
+ lineHeight,
width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
diff --git a/src/element/types.ts b/src/element/types.ts
index 6a30af70f..c11cd838c 100644
--- a/src/element/types.ts
+++ b/src/element/types.ts
@@ -10,7 +10,7 @@ import {
import { MarkNonNullable, ValueOf } from "../utility-types";
export type ChartType = "bar" | "line";
-export type FillStyle = "hachure" | "cross-hatch" | "solid";
+export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
export type Theme = typeof THEME[keyof typeof THEME];
@@ -133,6 +133,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
+ baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;
diff --git a/src/packages/extensions/ts/mathjax/implementation.tsx b/src/packages/extensions/ts/mathjax/implementation.tsx
index de3109e8c..64cceb13e 100644
--- a/src/packages/extensions/ts/mathjax/implementation.tsx
+++ b/src/packages/extensions/ts/mathjax/implementation.tsx
@@ -5,6 +5,7 @@ import { getFontString, getFontFamilyString, isRTL } from "../../../../utils";
import {
getBoundTextElement,
getContainerElement,
+ getDefaultLineHeight,
getMaxContainerWidth,
getTextWidth,
measureText,
@@ -859,7 +860,7 @@ const ensureMathElement = (element: Partial) => {
const cleanMathElementUpdate = function (updates) {
const oldUpdates = {};
for (const key in updates) {
- if (key !== "fontFamily") {
+ if (key !== "fontFamily" && key !== "lineHeight") {
(oldUpdates as any)[key] = (updates as any)[key];
}
if (key === "customData") {
@@ -874,6 +875,7 @@ const cleanMathElementUpdate = function (updates) {
}
}
(updates as any).fontFamily = FONT_FAMILY_MATH;
+ (updates as any).lineHeight = getDefaultLineHeight(FONT_FAMILY_MATH);
return oldUpdates;
} as SubtypeMethods["clean"];
@@ -888,8 +890,8 @@ const measureMathElement = function (element, next) {
ensureMathElement(element);
const isMathJaxLoaded = mathJaxLoaded;
if (!isMathJaxLoaded && isMathElement(element as ExcalidrawElement)) {
- const { width, height } = element as ExcalidrawMathElement;
- return { width, height };
+ const { width, height, baseline } = element as ExcalidrawMathElement;
+ return { width, height, baseline };
}
const fontSize = next?.fontSize ?? element.fontSize;
const lineHeight = element.lineHeight;
@@ -903,8 +905,7 @@ const measureMathElement = function (element, next) {
mathProps,
isMathJaxLoaded,
);
- const { width, height } = metrics;
- return { width, height };
+ return metrics;
} as SubtypeMethods["measureText"];
const renderMathElement = function (element, context, renderCb) {
diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts
index 58a37330d..8a74750f9 100644
--- a/src/renderer/renderElement.ts
+++ b/src/renderer/renderElement.ts
@@ -246,7 +246,6 @@ const drawImagePlaceholder = (
size,
);
};
-
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
@@ -338,18 +337,16 @@ const drawElementOnCanvas = (
: element.textAlign === "right"
? element.width
: 0;
- context.textBaseline = "bottom";
-
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
-
+ const verticalOffset = element.height - element.baseline;
for (let index = 0; index < lines.length; index++) {
context.fillText(
lines[index],
horizontalOffset,
- (index + 1) * lineHeightPx,
+ (index + 1) * lineHeightPx - verticalOffset,
);
}
context.restore();
diff --git a/src/subtypes.ts b/src/subtypes.ts
index 0e21940fb..a26f8bf94 100644
--- a/src/subtypes.ts
+++ b/src/subtypes.ts
@@ -237,7 +237,7 @@ export type SubtypeMethods = {
text?: string;
customData?: ExcalidrawElement["customData"];
},
- ) => { width: number; height: number };
+ ) => { width: number; height: number; baseline: number };
render: (
element: NonDeleted,
context: CanvasRenderingContext2D,
diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap
index e9a0da005..7e30b9d86 100644
--- a/src/tests/data/__snapshots__/restore.test.ts.snap
+++ b/src/tests/data/__snapshots__/restore.test.ts.snap
@@ -282,6 +282,7 @@ exports[`restoreElements should restore text element correctly passing value for
Object {
"angle": 0,
"backgroundColor": "transparent",
+ "baseline": 0,
"boundElements": Array [],
"containerId": null,
"fillStyle": "hachure",
@@ -321,6 +322,7 @@ exports[`restoreElements should restore text element correctly with unknown font
Object {
"angle": 0,
"backgroundColor": "transparent",
+ "baseline": 0,
"boundElements": Array [],
"containerId": null,
"fillStyle": "hachure",