mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-25 17:04:40 +02:00
Compare commits
23 Commits
aakansha-f
...
aakansha-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993294ac08 | ||
|
|
f584416c9a | ||
|
|
87b0c7a679 | ||
|
|
ee8fff8e8b | ||
|
|
b799490ece | ||
|
|
83383977f5 | ||
|
|
ac4c8b3ca7 | ||
|
|
5c8941467d | ||
|
|
0726911fa6 | ||
|
|
fd18896293 | ||
|
|
e900cb0b64 | ||
|
|
54bf3d9092 | ||
|
|
15f19835fe | ||
|
|
96c4cff805 | ||
|
|
1ac580136d | ||
|
|
8c89fdfa51 | ||
|
|
0e54994187 | ||
|
|
91f6e87317 | ||
|
|
a05db6864e | ||
|
|
eacee9a158 | ||
|
|
7722de4ef2 | ||
|
|
0a295e523b | ||
|
|
60deddb0e2 |
@@ -4,9 +4,9 @@ import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
@@ -45,6 +45,7 @@ export const actionUnbindText = register({
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
@@ -239,15 +240,23 @@ export const actionCreateContainerFromText = register({
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = null;
|
||||
let endBinding = null;
|
||||
if (ele.startBinding) {
|
||||
startBinding = { ...ele.startBinding, elementId: container.id };
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
elementId: container.id,
|
||||
};
|
||||
}
|
||||
if (ele.endBinding) {
|
||||
endBinding = { ...ele.endBinding, elementId: container.id };
|
||||
|
||||
if (endBinding?.elementId === textElement.id) {
|
||||
endBinding = { ...endBinding, elementId: container.id };
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
}
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
@@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getDefaultRoundnessTypeForElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -92,12 +93,18 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
||||
@@ -260,17 +260,20 @@ import throttle from "lodash.throttle";
|
||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxLineHeight,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextBindableContainerAtPosition,
|
||||
isMeasureTextSupported,
|
||||
isValidTextContainer,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
isMeasureTextSupported,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textMeasurements";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
normalizeLink,
|
||||
@@ -1653,7 +1656,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
|
||||
newElements.forEach((newElement) => {
|
||||
if (isTextElement(newElement)) {
|
||||
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
|
||||
const container = getContainerElement(newElement);
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
@@ -1731,12 +1734,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const text = line.trim();
|
||||
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
if (text.length) {
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x,
|
||||
y: currentY,
|
||||
text,
|
||||
lineHeight,
|
||||
});
|
||||
acc.push(element);
|
||||
currentY += element.height + LINE_GAP;
|
||||
@@ -1745,14 +1750,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// add paragraph only if previous line was not empty, IOW don't add
|
||||
// more than one empty line
|
||||
if (prevLine) {
|
||||
const defaultLineHeight = getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
}),
|
||||
);
|
||||
|
||||
currentY += defaultLineHeight + LINE_GAP;
|
||||
currentY +=
|
||||
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
|
||||
LINE_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2607,6 +2607,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||
}
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const fontSize = this.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
@@ -2614,11 +2621,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isArrowElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(getFontString(fontString));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(fontString));
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
@@ -2652,8 +2662,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness: null,
|
||||
text: "",
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: parentCenterPosition
|
||||
? "center"
|
||||
: this.state.currentItemTextAlign,
|
||||
@@ -2663,6 +2673,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
|
||||
@@ -89,7 +89,9 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
||||
// from 111.0.5563.64 (arm64), see #6349
|
||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
|
||||
@@ -35,6 +35,10 @@ import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import oc from "open-color";
|
||||
import { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textMeasurements";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -165,17 +169,32 @@ const restoreElement = (
|
||||
const [fontPx, _fontFamily]: [string, string] = (
|
||||
element as any
|
||||
).font.split(" ");
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontSize = parseFloat(fontPx);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
const text = element.text ?? "";
|
||||
|
||||
element = restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
text,
|
||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: element.containerId ?? null,
|
||||
originalText: element.originalText || element.text,
|
||||
originalText: element.originalText || text,
|
||||
// line-height might not be specified either when creating elements
|
||||
// programmatically, or when importing old diagrams.
|
||||
// For the latter we want to detect the original line height which
|
||||
// will likely differ from our per-font fixed line height we now use,
|
||||
// to maintain backward compatibility.
|
||||
lineHeight:
|
||||
element.lineHeight ||
|
||||
(element.height
|
||||
? // detect line-height from current element height and font-size
|
||||
detectLineHeight(element)
|
||||
: // no element height likely means programmatic use, so default
|
||||
// to a fixed line height
|
||||
getDefaultLineHeight(element.fontFamily)),
|
||||
});
|
||||
|
||||
if (refreshDimensions) {
|
||||
|
||||
@@ -786,7 +786,12 @@ export const findFocusPointForEllipse = (
|
||||
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
|
||||
squares;
|
||||
|
||||
const n = (-m * px - 1) / py;
|
||||
let n = (-m * px - 1) / py;
|
||||
|
||||
if (n === 0) {
|
||||
// if zero {-0, 0}, fall back to a same-sign value in the similar range
|
||||
n = (Object.is(n, -0) ? -1 : 1) * 0.01;
|
||||
}
|
||||
|
||||
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
|
||||
return GA.point(x, (-m * x - 1) / n);
|
||||
|
||||
@@ -25,14 +25,17 @@ import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import {
|
||||
measureText,
|
||||
wrapText,
|
||||
getDefaultLineHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -137,10 +140,12 @@ export const newTextElement = (
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(text, getFontString(opts));
|
||||
const metrics = measureText(text, getFontString(opts), lineHeight);
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
@@ -156,6 +161,7 @@ export const newTextElement = (
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -176,6 +182,7 @@ const getAdjustedDimensions = (
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
@@ -185,7 +192,11 @@ const getAdjustedDimensions = (
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
@@ -261,7 +272,7 @@ export const refreshTextDimensions = (
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
|
||||
@@ -39,15 +39,16 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
} from "./textMeasurements";
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
@@ -201,7 +202,7 @@ const measureFontSizeFromWidth = (
|
||||
if (hasContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
width = getMaxContainerWidth(container);
|
||||
width = getBoundTextMaxWidth(container);
|
||||
}
|
||||
}
|
||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||
@@ -360,7 +361,7 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
let boundTextFontSize: number | null = null;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
@@ -410,9 +411,7 @@ export const resizeSingleElement = (
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
};
|
||||
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const updatedElement = {
|
||||
@@ -423,17 +422,21 @@ export const resizeSingleElement = (
|
||||
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
getMaxContainerWidth(updatedElement),
|
||||
getBoundTextMaxWidth(updatedElement),
|
||||
);
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
boundTextFontSize = nextFontSize;
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
@@ -566,8 +569,10 @@ export const resizeSingleElement = (
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||
if (boundTextElement && boundTextFontSize != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
}
|
||||
@@ -694,7 +699,7 @@ const resizeMultipleElements = (
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getMaxContainerWidth(updatedElement)
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,190 +1,11 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
wrapText,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth
|
||||
isiswhatsupwith
|
||||
youIamtypingggg
|
||||
gandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello
|
||||
Excalidraw`);
|
||||
});
|
||||
});
|
||||
import { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
||||
@@ -261,7 +82,7 @@ describe("Test measureText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerWidth", () => {
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
@@ -269,39 +90,84 @@ describe("Test measureText", () => {
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(168);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(116);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(79);
|
||||
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||
});
|
||||
|
||||
it("should return max width when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxWidth(container)).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerHeight", () => {
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: "container-id",
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(184);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(127);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(87);
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
@@ -30,6 +23,7 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
import { ExtractSetType } from "../utility-types";
|
||||
import { measureText, wrapText } from "./textMeasurements";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -46,7 +40,6 @@ export const redrawTextBoundingBox = (
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
@@ -55,54 +48,48 @@ export const redrawTextBoundingBox = (
|
||||
height: textElement.height,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
if (container) {
|
||||
if (isArrowElement(container)) {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
||||
} else {
|
||||
const containerDims = getContainerDims(container);
|
||||
let maxContainerHeight = getMaxContainerHeight(container);
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let nextHeight = containerDims.height;
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
maxContainerHeight = getMaxContainerHeight(container);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
mutateElement(textElement, boundTextUpdates);
|
||||
@@ -174,8 +161,11 @@ export const handleBindTextResize = (
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
@@ -185,7 +175,11 @@ export const handleBindTextResize = (
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const dimensions = measureText(text, getFontString(textElement));
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
}
|
||||
@@ -229,16 +223,22 @@ export const handleBindTextResize = (
|
||||
}
|
||||
};
|
||||
|
||||
const computeBoundTextPosition = (
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getMaxContainerHeight(container);
|
||||
const maxContainerWidth = getMaxContainerWidth(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
@@ -259,266 +259,6 @@ const computeBoundTextPosition = (
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
|
||||
export const measureText = (text: string, font: FontString) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
|
||||
const height = getTextHeight(text, font);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
const cacheApproxLineHeight: { [key: FontString]: number } = {};
|
||||
|
||||
export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
const fontSize = parseInt(font);
|
||||
|
||||
// Calculate line height relative to font size
|
||||
cacheApproxLineHeight[font] = fontSize * 1.2;
|
||||
return cacheApproxLineHeight[font];
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return width * 10;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
return lineHeight * lines.length;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
const words = originalLine.split(" ");
|
||||
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinLineHeight = (font: FontString) => {
|
||||
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
|
||||
@@ -644,18 +384,6 @@ export const getBoundTextElementOffset = (
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
@@ -756,18 +484,10 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
@@ -784,16 +504,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
@@ -810,14 +529,3 @@ export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
213
src/element/textMeasurements.test.ts
Normal file
213
src/element/textMeasurements.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
wrapText,
|
||||
} from "./textMeasurements";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello \nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello\nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello \nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
});
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
});
|
||||
it("should return correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
339
src/element/textMeasurements.ts
Normal file
339
src/element/textMeasurements.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getFontString, isTestEnv } from "../utils";
|
||||
import { normalizeText } from "./textElement";
|
||||
import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types";
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letter is 10px
|
||||
const DUMMY_CHAR_WIDTH = 10;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (isTestEnv()) {
|
||||
return width * DUMMY_CHAR_WIDTH;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const getApproxMinContainerWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinContainerHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
/** this is not used currently but might be useful
|
||||
* in future hence keeping it
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
const words = originalLine.split(" ");
|
||||
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
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 Helvetica in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
};
|
||||
@@ -783,7 +783,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(25);
|
||||
expect(text.height).toBe(48);
|
||||
expect(text.height).toBe(50);
|
||||
expect(text.width).toBe(60);
|
||||
|
||||
// Edit and text by removing second line and it should
|
||||
@@ -810,7 +810,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.height).toBe(24);
|
||||
expect(text.height).toBe(25);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
@@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
85,
|
||||
5,
|
||||
4.5,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -929,7 +929,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -1067,9 +1067,9 @@ describe("textWysiwyg", () => {
|
||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
||||
expect(rectangle.x).toBe(80);
|
||||
expect(rectangle.y).toBe(-35);
|
||||
expect(rectangle.y).toBe(-40);
|
||||
expect(text.x).toBe(85);
|
||||
expect(text.y).toBe(-30);
|
||||
expect(text.y).toBe(-35);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
@@ -1112,7 +1112,7 @@ describe("textWysiwyg", () => {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(178);
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
@@ -1186,6 +1186,41 @@ describe("textWysiwyg", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.25);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.2);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/normal/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Helvetica);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.15);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
let editor: HTMLTextAreaElement;
|
||||
|
||||
@@ -1245,7 +1280,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1257,7 +1292,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1269,7 +1304,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1281,7 +1316,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1292,7 +1327,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1303,7 +1338,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1333,7 +1368,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(textElement.width).toBe(600);
|
||||
expect(textElement.height).toBe(24);
|
||||
expect(textElement.height).toBe(25);
|
||||
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
|
||||
expect((textElement as ExcalidrawTextElement).text).toBe(
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
@@ -1365,7 +1400,7 @@ describe("textWysiwyg", () => {
|
||||
],
|
||||
fillStyle: "hachure",
|
||||
groupIds: [],
|
||||
height: 34,
|
||||
height: 35,
|
||||
isDeleted: false,
|
||||
link: null,
|
||||
locked: false,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,19 +22,15 @@ import {
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -44,6 +40,12 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import {
|
||||
getTextWidth,
|
||||
measureText,
|
||||
wrapText,
|
||||
getTextHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -150,9 +152,7 @@ export const textWysiwyg = ({
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
@@ -180,15 +180,12 @@ export const textWysiwyg = ({
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
textElementHeight = getTextHeight(
|
||||
updatedTextElement.text,
|
||||
updatedTextElement.fontSize,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
@@ -206,14 +203,17 @@ export const textWysiwyg = ({
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
return;
|
||||
@@ -226,25 +226,15 @@ export const textWysiwyg = ({
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
@@ -266,10 +256,6 @@ export const textWysiwyg = ({
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
@@ -282,7 +268,7 @@ export const textWysiwyg = ({
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
lineHeight: element.lineHeight,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
@@ -369,7 +355,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
@@ -386,9 +372,13 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
const { width, height } = measureText(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
|
||||
@@ -40,14 +40,14 @@ import {
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
|
||||
// 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
|
||||
@@ -279,9 +279,6 @@ const drawElementOnCanvas = (
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.containerId
|
||||
? getApproxLineHeight(getFontString(element))
|
||||
: element.height / lines.length;
|
||||
const horizontalOffset =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
@@ -290,11 +287,16 @@ const drawElementOnCanvas = (
|
||||
: 0;
|
||||
context.textBaseline = "bottom";
|
||||
|
||||
const lineHeightPx = getLineHeightInPx(
|
||||
element.fontSize,
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
context.fillText(
|
||||
lines[index],
|
||||
horizontalOffset,
|
||||
(index + 1) * lineHeight,
|
||||
(index + 1) * lineHeightPx,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@@ -820,14 +822,17 @@ const drawElementFromCanvas = (
|
||||
process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX &&
|
||||
hasBoundTextElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
const coords = getContainerCoords(element);
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
||||
getMaxContainerWidth(element) * window.devicePixelRatio,
|
||||
getMaxContainerHeight(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1313,7 +1318,10 @@ export const renderElementToSvg = (
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const lineHeightPx = getLineHeightInPx(
|
||||
element.fontSize,
|
||||
element.lineHeight,
|
||||
);
|
||||
const horizontalOffset =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
@@ -1331,7 +1339,7 @@ export const renderElementToSvg = (
|
||||
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
|
||||
text.textContent = lines[i];
|
||||
text.setAttribute("x", `${horizontalOffset}`);
|
||||
text.setAttribute("y", `${i * lineHeight}`);
|
||||
text.setAttribute("y", `${i * lineHeightPx}`);
|
||||
text.setAttribute("font-family", getFontFamilyString(element));
|
||||
text.setAttribute("font-size", `${element.fontSize}px`);
|
||||
text.setAttribute("fill", element.strokeColor);
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -209,4 +210,103 @@ describe("element binding", () => {
|
||||
).toBe(null);
|
||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||
});
|
||||
|
||||
it("should update binding when text containerized", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
],
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -87.45777932247563],
|
||||
],
|
||||
startBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
text: "ola",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
],
|
||||
});
|
||||
|
||||
h.elements = [rectangle1, arrow1, arrow2, text1];
|
||||
|
||||
API.setSelectedElements([text1]);
|
||||
|
||||
expect(h.state.selectedElementIds[text1.id]).toBe(true);
|
||||
|
||||
h.app.actionManager.executeAction(actionCreateContainerFromText);
|
||||
|
||||
// new text container will be placed before the text element
|
||||
const container = h.elements.at(-2)!;
|
||||
|
||||
expect(container.type).toBe("rectangle");
|
||||
expect(container.id).not.toBe(rectangle1.id);
|
||||
|
||||
expect(container).toEqual(
|
||||
expect.objectContaining({
|
||||
boundElements: expect.arrayContaining([
|
||||
{
|
||||
type: "text",
|
||||
id: text1.id,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: arrow1.id,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: arrow2.id,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
|
||||
expect(arrow1.endBinding?.elementId).toBe(container.id);
|
||||
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
||||
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,13 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { KEYS } from "../keys";
|
||||
import { getApproxLineHeight } from "../element/textElement";
|
||||
import { getFontString } from "../utils";
|
||||
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "../element/textMeasurements";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -118,12 +121,10 @@ describe("paste text as single lines", () => {
|
||||
|
||||
it("should space items correctly", async () => {
|
||||
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
|
||||
const lineHeight =
|
||||
getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: h.app.state.currentItemFontSize,
|
||||
fontFamily: h.app.state.currentItemFontFamily,
|
||||
}),
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
@@ -135,19 +136,17 @@ describe("paste text as single lines", () => {
|
||||
for (let i = 1; i < h.elements.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [fx, elY] = getElementBounds(h.elements[i]);
|
||||
expect(elY).toEqual(firstElY + lineHeight * i);
|
||||
expect(elY).toEqual(firstElY + lineHeightPx * i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should leave a space for blank new lines", async () => {
|
||||
const text = "hkhkjhki\n\njgkjhffjh";
|
||||
const lineHeight =
|
||||
getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: h.app.state.currentItemFontSize,
|
||||
fontFamily: h.app.state.currentItemFontFamily,
|
||||
}),
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
@@ -158,7 +157,7 @@ describe("paste text as single lines", () => {
|
||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [lx, lastElY] = getElementBounds(h.elements[1]);
|
||||
expect(lastElY).toEqual(firstElY + lineHeight * 2);
|
||||
expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -224,7 +223,7 @@ describe("Paste bound text container", () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(354);
|
||||
expect(container.height).toBe(368);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
@@ -247,7 +246,7 @@ describe("Paste bound text container", () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(740);
|
||||
expect(container.height).toBe(770);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +291,7 @@ Object {
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -312,7 +313,7 @@ Object {
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
"y": -8.4,
|
||||
"y": -8.75,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -329,6 +330,7 @@ Object {
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
|
||||
@@ -111,6 +111,9 @@ export class API {
|
||||
fileId?: T extends "image" ? string : never;
|
||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||
startBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["startBinding"]
|
||||
: never;
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["endBinding"]
|
||||
: never;
|
||||
@@ -178,11 +181,13 @@ export class API {
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
element = newTextElement({
|
||||
...base,
|
||||
text: rest.text || "test",
|
||||
fontSize: rest.fontSize ?? appState.currentItemFontSize,
|
||||
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: rest.textAlign ?? appState.currentItemTextAlign,
|
||||
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: rest.containerId ?? undefined,
|
||||
@@ -221,6 +226,10 @@ export class API {
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
element.startBinding = rest.startBinding ?? null;
|
||||
element.endBinding = rest.endBinding ?? null;
|
||||
}
|
||||
if (id) {
|
||||
element.id = id;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,8 @@ import { KEYS } from "../keys";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { queryByTestId, queryByText } from "@testing-library/react";
|
||||
import { resize, rotate } from "./utils";
|
||||
import {
|
||||
getBoundTextElementPosition,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextMaxWidth } from "../element/textElement";
|
||||
import { wrapText } from "../element/textMeasurements";
|
||||
import * as textElementUtils from "../element/textElement";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
|
||||
@@ -729,7 +726,7 @@ describe("Test Linear Elements", () => {
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: wrapText(text, font, getMaxContainerWidth(container)),
|
||||
text: wrapText(text, font, getBoundTextMaxWidth(container)),
|
||||
containerId: container.id,
|
||||
width: 30,
|
||||
height: 20,
|
||||
@@ -937,8 +934,9 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
expect(container.angle).toBe(0);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(arrow, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(arrow, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
@@ -964,8 +962,9 @@ describe("Test Linear Elements", () => {
|
||||
rotate(container, -35, 55);
|
||||
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 21.73926141863671,
|
||||
"y": 73.31003398390868,
|
||||
@@ -1002,8 +1001,9 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(container.width).toBe(70);
|
||||
expect(container.height).toBe(50);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
@@ -1031,16 +1031,17 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 367,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 272,
|
||||
"y": 46,
|
||||
"y": 45,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
@@ -1052,11 +1053,11 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
36,
|
||||
35,
|
||||
502,
|
||||
94,
|
||||
95,
|
||||
205.9061448421403,
|
||||
53,
|
||||
52.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1070,8 +1071,9 @@ describe("Test Linear Elements", () => {
|
||||
arrow,
|
||||
);
|
||||
expect(container.width).toBe(40);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 25,
|
||||
"y": 10,
|
||||
@@ -1090,16 +1092,17 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 340,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": -4,
|
||||
"y": -5,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
@@ -1149,7 +1152,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
@@ -1172,7 +1175,7 @@ describe("Test Linear Elements", () => {
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
|
||||
Reference in New Issue
Block a user