mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: text measurements based on font metrics (#7693)
* Introduced vertical offset based on harcoded font metrics * Unified usage of alphabetic baseline for both canvas & svg export * Removed baseline property * Removed font-size rounding on Safari * Removed artificial width offset
This commit is contained in:
parent
160440b860
commit
7e471b55eb
13 changed files with 83 additions and 154 deletions
|
@ -249,7 +249,6 @@ export const newTextElement = (
|
|||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
|
@ -268,13 +267,12 @@ const getAdjustedDimensions = (
|
|||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element), element.lineHeight);
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
@ -328,7 +326,6 @@ const getAdjustedDimensions = (
|
|||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
};
|
||||
|
|
|
@ -52,8 +52,6 @@ import {
|
|||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
measureText,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
|
@ -213,8 +211,7 @@ const measureFontSizeFromWidth = (
|
|||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
elementsMap: ElementsMap,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
): { size: number; baseline: number } | null => {
|
||||
): { size: number } | null => {
|
||||
// We only use width to scale font on resize
|
||||
let width = element.width;
|
||||
|
||||
|
@ -229,14 +226,9 @@ const measureFontSizeFromWidth = (
|
|||
if (nextFontSize < MIN_FONT_SIZE) {
|
||||
return null;
|
||||
}
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
size: nextFontSize,
|
||||
baseline: metrics.baseline + (nextHeight - metrics.height),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -309,12 +301,7 @@ const resizeSingleTextElement = (
|
|||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
element,
|
||||
elementsMap,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
@ -342,7 +329,6 @@ const resizeSingleTextElement = (
|
|||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: metrics.baseline,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
});
|
||||
|
@ -396,7 +382,7 @@ export const resizeSingleElement = (
|
|||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
|
@ -448,7 +434,6 @@ export const resizeSingleElement = (
|
|||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
|
@ -462,14 +447,12 @@ export const resizeSingleElement = (
|
|||
boundTextElement,
|
||||
elementsMap,
|
||||
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
||||
getBoundTextMaxHeight(updatedElement, boundTextElement),
|
||||
);
|
||||
if (nextFont === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
|
@ -638,7 +621,6 @@ export const resizeSingleElement = (
|
|||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
|
@ -769,7 +751,6 @@ export const resizeMultipleElements = (
|
|||
> & {
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
|
@ -844,17 +825,11 @@ export const resizeMultipleElements = (
|
|||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
orig,
|
||||
elementsMap,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
const boundTextElement = originalElements.get(
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
isSafari,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
|
@ -62,7 +61,6 @@ export const redrawTextBoundingBox = (
|
|||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
baseline: textElement.baseline,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
@ -83,7 +81,6 @@ export const redrawTextBoundingBox = (
|
|||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
|
@ -188,7 +185,6 @@ export const handleBindTextResize = (
|
|||
const maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
const maxHeight = getBoundTextMaxHeight(container, textElement);
|
||||
let containerHeight = container.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
|
@ -207,7 +203,6 @@ export const handleBindTextResize = (
|
|||
);
|
||||
nextHeight = metrics.height;
|
||||
nextWidth = metrics.width;
|
||||
nextBaseLine = metrics.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
|
@ -235,7 +230,6 @@ export const handleBindTextResize = (
|
|||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
|
@ -285,8 +279,6 @@ export const computeBoundTextPosition = (
|
|||
return { x, y };
|
||||
};
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
|
@ -301,59 +293,7 @@ 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 };
|
||||
};
|
||||
|
||||
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;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -378,6 +318,23 @@ export const getLineHeightInPx = (
|
|||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates vertical offset for a text with alphabetic baseline.
|
||||
*/
|
||||
export const getVerticalOffset = (
|
||||
fontFamily: ExcalidrawTextElement["fontFamily"],
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeightPx: number,
|
||||
) => {
|
||||
const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily];
|
||||
|
||||
const fontSizeEm = fontSize / unitsPerEm;
|
||||
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
|
||||
|
||||
const verticalOffset = fontSizeEm * ascender + lineGap;
|
||||
return verticalOffset;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
|
@ -964,13 +921,40 @@ const DEFAULT_LINE_HEIGHT = {
|
|||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
// ~1.15 is the average for Helvetica in WebKit and Blink.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
type FontMetrics = {
|
||||
unitsPerEm: number; // head.unitsPerEm
|
||||
ascender: number; // sTypoAscender
|
||||
descender: number; // sTypoDescender
|
||||
};
|
||||
|
||||
/**
|
||||
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
|
||||
* For custom fonts, read these metrics on load and extend this object.
|
||||
*/
|
||||
const FONT_METRICS = {
|
||||
[FONT_FAMILY.Virgil]: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 886,
|
||||
descender: -374,
|
||||
},
|
||||
[FONT_FAMILY.Helvetica]: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1577,
|
||||
descender: -471,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1977,
|
||||
descender: -480,
|
||||
},
|
||||
} as Record<number, FontMetrics>;
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -31,7 +31,6 @@ import {
|
|||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
getBoundTextElement,
|
||||
} from "./textElement";
|
||||
|
@ -227,18 +226,6 @@ export const textWysiwyg = ({
|
|||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
} else {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -247,7 +234,7 @@ export const textWysiwyg = ({
|
|||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight,
|
||||
lineHeight: updatedTextElement.lineHeight,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
|
|
|
@ -176,7 +176,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue