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 }) => ( -
- {t("labels.fill")} - element.fillStyle, - appState.currentItemFillStyle, - )} - onChange={(value) => { - updateData(value); - }} - /> -
- ), + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + const allElementsZigZag = selectedElements.every( + (el) => el.fillStyle === "zigzag", + ); + + return ( +
+ {t("labels.fill")} + element.fillStyle, + appState.currentItemFillStyle, + )} + onClick={(value, event) => { + const nextValue = + event.altKey && + value === "hachure" && + selectedElements.every((el) => el.fillStyle === "hachure") + ? "zigzag" + : value; + + updateData(nextValue); + }} + /> +
+ ); + }, }); 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) => ( -
); 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",