3ea07076ad
* feat: support creating text containers programatically * fix * fix * fix * fix * update api to use label * fix api and support individual shapes and text element * update test case in package example * support creating arrows and line * support labelled arrows * add in package example * fix alignment * better types * fix * keep element as is unless we support prog api * fix tests * fix lint * ignore * support arrow bindings via start and end in api * fix lint * fix coords * support id as well for elements * preserve bindings if present and fix testcases * preserve bindings for labelled arrows * support ids, clean up code and move the api related stuff to transform.ts * allow multiple arrows to bind to single element * fix singular elements * fix single text element, unique id and tests * fix lint * fix * support binding arrow to text element * fix creation of regular text * use same stroke color as parent for text containers and height 0 for linear element by default * fix types * fix * remove more ts ignore * remove ts ignore * remove * Add coverage script * Add tests * fix tests * make type optional when id present * remove type when id provided in tests * Add more tests * tweak * let host call convertToExcalidrawElements when using programmatic API * remove convertToExcalidrawElements call from restore * lint * update snaps * Add new type excalidraw-api/clipboard for programmatic api * cleanup * rename tweak * tweak * make image attributes optional and better ts check * support image via programmatic API * fix lint * more types * make fileId mandatory for image and export convertToExcalidrawElements * fix * small tweaks * update snaps * fix * use Object.assign instead of mutateElement * lint * preserve z-index by pushing all elements first and then add bindings * instantiate instead of closure for storing elements * use element API to create regular text, diamond, ellipse and rectangle * fix snaps * udpdate api * ts fixes * make `convertToExcalidrawElements` more typesafe * update snaps * refactor the approach so that order of elements doesn't matter * Revert "update snaps" This reverts commit 621dfadccfea975a1f77223f506dce9d260f91fd. * review fixes * rename ExcalidrawProgrammaticElement -> ExcalidrawELementSkeleton * Add tests * give preference to first element when duplicate ids found * use console.error --------- Co-authored-by: dwelle <luzar.david@gmail.com>
984 lines
29 KiB
TypeScript
984 lines
29 KiB
TypeScript
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
|
import {
|
|
ExcalidrawElement,
|
|
ExcalidrawTextContainer,
|
|
ExcalidrawTextElement,
|
|
ExcalidrawTextElementWithContainer,
|
|
FontFamilyValues,
|
|
FontString,
|
|
NonDeletedExcalidrawElement,
|
|
} from "./types";
|
|
import { mutateElement } from "./mutateElement";
|
|
import {
|
|
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
|
ARROW_LABEL_WIDTH_FRACTION,
|
|
BOUND_TEXT_PADDING,
|
|
DEFAULT_FONT_FAMILY,
|
|
DEFAULT_FONT_SIZE,
|
|
FONT_FAMILY,
|
|
isSafari,
|
|
TEXT_ALIGN,
|
|
VERTICAL_ALIGN,
|
|
} from "../constants";
|
|
import { MaybeTransformHandleType } from "./transformHandles";
|
|
import Scene from "../scene/Scene";
|
|
import { isTextElement } from ".";
|
|
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
|
import { LinearElementEditor } from "./linearElementEditor";
|
|
import { AppState } from "../types";
|
|
import { isTextBindableContainer } from "./typeChecks";
|
|
import { getElementAbsoluteCoords } from "../element";
|
|
import { getSelectedElements } from "../scene";
|
|
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
|
import {
|
|
resetOriginalContainerCache,
|
|
updateOriginalContainerCache,
|
|
} from "./textWysiwyg";
|
|
import { ExtractSetType } from "../utility-types";
|
|
|
|
export const normalizeText = (text: string) => {
|
|
return (
|
|
text
|
|
// replace tabs with spaces so they render and measure correctly
|
|
.replace(/\t/g, " ")
|
|
// normalize newlines
|
|
.replace(/\r?\n|\r/g, "\n")
|
|
);
|
|
};
|
|
|
|
export const splitIntoLines = (text: string) => {
|
|
return normalizeText(text).split("\n");
|
|
};
|
|
|
|
export const redrawTextBoundingBox = (
|
|
textElement: ExcalidrawTextElement,
|
|
container: ExcalidrawElement | null,
|
|
) => {
|
|
let maxWidth = undefined;
|
|
const boundTextUpdates = {
|
|
x: textElement.x,
|
|
y: textElement.y,
|
|
text: textElement.text,
|
|
width: textElement.width,
|
|
height: textElement.height,
|
|
baseline: textElement.baseline,
|
|
};
|
|
|
|
boundTextUpdates.text = textElement.text;
|
|
|
|
if (container) {
|
|
maxWidth = getBoundTextMaxWidth(container, textElement);
|
|
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;
|
|
boundTextUpdates.baseline = metrics.baseline;
|
|
|
|
if (container) {
|
|
const maxContainerHeight = getBoundTextMaxHeight(
|
|
container,
|
|
textElement as ExcalidrawTextElementWithContainer,
|
|
);
|
|
const maxContainerWidth = getBoundTextMaxWidth(container);
|
|
|
|
if (metrics.height > maxContainerHeight) {
|
|
const nextHeight = computeContainerDimensionForBoundText(
|
|
metrics.height,
|
|
container.type,
|
|
);
|
|
mutateElement(container, { height: nextHeight });
|
|
updateOriginalContainerCache(container.id, nextHeight);
|
|
}
|
|
if (metrics.width > maxContainerWidth) {
|
|
const nextWidth = computeContainerDimensionForBoundText(
|
|
metrics.width,
|
|
container.type,
|
|
);
|
|
mutateElement(container, { width: nextWidth });
|
|
}
|
|
const updatedTextElement = {
|
|
...textElement,
|
|
...boundTextUpdates,
|
|
} as ExcalidrawTextElementWithContainer;
|
|
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
|
boundTextUpdates.x = x;
|
|
boundTextUpdates.y = y;
|
|
}
|
|
|
|
mutateElement(textElement, boundTextUpdates);
|
|
};
|
|
|
|
export const bindTextToShapeAfterDuplication = (
|
|
sceneElements: ExcalidrawElement[],
|
|
oldElements: ExcalidrawElement[],
|
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
|
): void => {
|
|
const sceneElementMap = arrayToMap(sceneElements) as Map<
|
|
ExcalidrawElement["id"],
|
|
ExcalidrawElement
|
|
>;
|
|
oldElements.forEach((element) => {
|
|
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
|
|
const boundTextElementId = getBoundTextElementId(element);
|
|
|
|
if (boundTextElementId) {
|
|
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
|
|
if (newTextElementId) {
|
|
const newContainer = sceneElementMap.get(newElementId);
|
|
if (newContainer) {
|
|
mutateElement(newContainer, {
|
|
boundElements: (element.boundElements || [])
|
|
.filter(
|
|
(boundElement) =>
|
|
boundElement.id !== newTextElementId &&
|
|
boundElement.id !== boundTextElementId,
|
|
)
|
|
.concat({
|
|
type: "text",
|
|
id: newTextElementId,
|
|
}),
|
|
});
|
|
}
|
|
const newTextElement = sceneElementMap.get(newTextElementId);
|
|
if (newTextElement && isTextElement(newTextElement)) {
|
|
mutateElement(newTextElement, {
|
|
containerId: newContainer ? newElementId : null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
export const handleBindTextResize = (
|
|
container: NonDeletedExcalidrawElement,
|
|
transformHandleType: MaybeTransformHandleType,
|
|
shouldMaintainAspectRatio = false,
|
|
) => {
|
|
const boundTextElementId = getBoundTextElementId(container);
|
|
if (!boundTextElementId) {
|
|
return;
|
|
}
|
|
resetOriginalContainerCache(container.id);
|
|
let textElement = Scene.getScene(container)!.getElement(
|
|
boundTextElementId,
|
|
) as ExcalidrawTextElement;
|
|
if (textElement && textElement.text) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
textElement = Scene.getScene(container)!.getElement(
|
|
boundTextElementId,
|
|
) as ExcalidrawTextElement;
|
|
let text = textElement.text;
|
|
let nextHeight = textElement.height;
|
|
let nextWidth = textElement.width;
|
|
const maxWidth = getBoundTextMaxWidth(container);
|
|
const maxHeight = getBoundTextMaxHeight(
|
|
container,
|
|
textElement as ExcalidrawTextElementWithContainer,
|
|
);
|
|
let containerHeight = container.height;
|
|
let nextBaseLine = textElement.baseline;
|
|
if (
|
|
shouldMaintainAspectRatio ||
|
|
(transformHandleType !== "n" && transformHandleType !== "s")
|
|
) {
|
|
if (text) {
|
|
text = wrapText(
|
|
textElement.originalText,
|
|
getFontString(textElement),
|
|
maxWidth,
|
|
);
|
|
}
|
|
const metrics = measureText(
|
|
text,
|
|
getFontString(textElement),
|
|
textElement.lineHeight,
|
|
);
|
|
nextHeight = metrics.height;
|
|
nextWidth = metrics.width;
|
|
nextBaseLine = metrics.baseline;
|
|
}
|
|
// increase height in case text element height exceeds
|
|
if (nextHeight > maxHeight) {
|
|
containerHeight = computeContainerDimensionForBoundText(
|
|
nextHeight,
|
|
container.type,
|
|
);
|
|
|
|
const diff = containerHeight - container.height;
|
|
// fix the y coord when resizing from ne/nw/n
|
|
const updatedY =
|
|
!isArrowElement(container) &&
|
|
(transformHandleType === "ne" ||
|
|
transformHandleType === "nw" ||
|
|
transformHandleType === "n")
|
|
? container.y - diff
|
|
: container.y;
|
|
mutateElement(container, {
|
|
height: containerHeight,
|
|
y: updatedY,
|
|
});
|
|
}
|
|
|
|
mutateElement(textElement, {
|
|
text,
|
|
width: nextWidth,
|
|
height: nextHeight,
|
|
baseline: nextBaseLine,
|
|
});
|
|
|
|
if (!isArrowElement(container)) {
|
|
mutateElement(
|
|
textElement,
|
|
computeBoundTextPosition(
|
|
container,
|
|
textElement as ExcalidrawTextElementWithContainer,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const computeBoundTextPosition = (
|
|
container: ExcalidrawElement,
|
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
|
) => {
|
|
if (isArrowElement(container)) {
|
|
return LinearElementEditor.getBoundTextElementPosition(
|
|
container,
|
|
boundTextElement,
|
|
);
|
|
}
|
|
const containerCoords = getContainerCoords(container);
|
|
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
|
const maxContainerWidth = getBoundTextMaxWidth(container);
|
|
|
|
let x;
|
|
let y;
|
|
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
|
y = containerCoords.y;
|
|
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
|
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
|
} else {
|
|
y =
|
|
containerCoords.y +
|
|
(maxContainerHeight / 2 - boundTextElement.height / 2);
|
|
}
|
|
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
|
|
x = containerCoords.x;
|
|
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
|
|
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
|
|
} else {
|
|
x =
|
|
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
|
}
|
|
return { x, y };
|
|
};
|
|
|
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
|
|
|
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);
|
|
const baseline = measureBaseline(text, font, lineHeight);
|
|
return { width, height, baseline };
|
|
};
|
|
|
|
export const measureBaseline = (
|
|
text: string,
|
|
font: FontString,
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
wrapInContainer?: boolean,
|
|
) => {
|
|
const container = document.createElement("div");
|
|
container.style.position = "absolute";
|
|
container.style.whiteSpace = "pre";
|
|
container.style.font = font;
|
|
container.style.minHeight = "1em";
|
|
if (wrapInContainer) {
|
|
container.style.overflow = "hidden";
|
|
container.style.wordBreak = "break-word";
|
|
container.style.whiteSpace = "pre-wrap";
|
|
}
|
|
|
|
container.style.lineHeight = String(lineHeight);
|
|
|
|
container.innerText = text;
|
|
|
|
// Baseline is important for positioning text on canvas
|
|
document.body.appendChild(container);
|
|
|
|
const span = document.createElement("span");
|
|
span.style.display = "inline-block";
|
|
span.style.overflow = "hidden";
|
|
span.style.width = "1px";
|
|
span.style.height = "1px";
|
|
container.appendChild(span);
|
|
let baseline = span.offsetTop + span.offsetHeight;
|
|
const height = container.offsetHeight;
|
|
|
|
if (isSafari) {
|
|
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
|
|
const fontSize = parseFloat(font);
|
|
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
|
|
// from the actual canvas height
|
|
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
|
|
if (canvasHeight > height) {
|
|
baseline += canvasHeight - domHeight;
|
|
}
|
|
|
|
if (height > canvasHeight) {
|
|
baseline -= domHeight - canvasHeight;
|
|
}
|
|
}
|
|
document.body.removeChild(container);
|
|
return baseline;
|
|
};
|
|
|
|
/**
|
|
* 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"];
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
};
|
|
|
|
// FIXME rename to getApproxMinContainerHeight
|
|
export const getApproxMinLineHeight = (
|
|
fontSize: ExcalidrawTextElement["fontSize"],
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
) => {
|
|
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
|
};
|
|
|
|
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 = 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 parseTokens = (text: string) => {
|
|
// Splitting words containing "-" as those are treated as separate words
|
|
// by css wrapping algorithm eg non-profit => non-, profit
|
|
const words = text.split("-");
|
|
if (words.length > 1) {
|
|
// non-proft org => ['non-', 'profit org']
|
|
words.forEach((word, index) => {
|
|
if (index !== words.length - 1) {
|
|
words[index] = word += "-";
|
|
}
|
|
});
|
|
}
|
|
// Joining the words with space and splitting them again with space to get the
|
|
// final list of tokens
|
|
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
|
|
return words.join(" ").split(" ");
|
|
};
|
|
|
|
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 = parseTokens(originalLine);
|
|
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();
|
|
// space needs to be appended before next word
|
|
// as currentLine contains chars which couldn't be appended
|
|
// to previous line unless the line ends with hyphen to sync
|
|
// with css word-wrap
|
|
} else if (!currentLine.endsWith("-")) {
|
|
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++;
|
|
|
|
// if word ends with "-" then we don't need to add space
|
|
// to sync with css word-wrap
|
|
const shouldAppendSpace = !word.endsWith("-");
|
|
currentLine += word;
|
|
|
|
if (shouldAppendSpace) {
|
|
currentLine += " ";
|
|
}
|
|
|
|
// Push the word if appending space exceeds max width
|
|
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
|
if (shouldAppendSpace) {
|
|
lines.push(currentLine.slice(0, -1));
|
|
} else {
|
|
lines.push(currentLine);
|
|
}
|
|
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,
|
|
};
|
|
})();
|
|
|
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
|
|
|
// FIXME rename to getApproxMinContainerWidth
|
|
export const getApproxMinLineWidth = (
|
|
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 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 ||
|
|
null
|
|
: null;
|
|
};
|
|
|
|
export const getBoundTextElement = (element: ExcalidrawElement | null) => {
|
|
if (!element) {
|
|
return null;
|
|
}
|
|
const boundTextElementId = getBoundTextElementId(element);
|
|
if (boundTextElementId) {
|
|
return (
|
|
(Scene.getScene(element)?.getElement(
|
|
boundTextElementId,
|
|
) as ExcalidrawTextElementWithContainer) || null
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const getContainerElement = (
|
|
element:
|
|
| (ExcalidrawElement & {
|
|
containerId: ExcalidrawElement["id"] | null;
|
|
})
|
|
| null,
|
|
) => {
|
|
if (!element) {
|
|
return null;
|
|
}
|
|
if (element.containerId) {
|
|
return Scene.getScene(element)?.getElement(element.containerId) || null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const getContainerCenter = (
|
|
container: ExcalidrawElement,
|
|
appState: AppState,
|
|
) => {
|
|
if (!isArrowElement(container)) {
|
|
return {
|
|
x: container.x + container.width / 2,
|
|
y: container.y + container.height / 2,
|
|
};
|
|
}
|
|
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
|
|
if (points.length % 2 === 1) {
|
|
const index = Math.floor(container.points.length / 2);
|
|
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
|
container,
|
|
container.points[index],
|
|
);
|
|
return { x: midPoint[0], y: midPoint[1] };
|
|
}
|
|
const index = container.points.length / 2 - 1;
|
|
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
|
container,
|
|
appState,
|
|
)[index];
|
|
if (!midSegmentMidpoint) {
|
|
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
|
container,
|
|
points[index],
|
|
points[index + 1],
|
|
index + 1,
|
|
);
|
|
}
|
|
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
|
};
|
|
|
|
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
|
let offsetX = BOUND_TEXT_PADDING;
|
|
let offsetY = BOUND_TEXT_PADDING;
|
|
|
|
if (container.type === "ellipse") {
|
|
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
|
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
|
|
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
|
|
}
|
|
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
|
|
if (container.type === "diamond") {
|
|
offsetX += container.width / 4;
|
|
offsetY += container.height / 4;
|
|
}
|
|
return {
|
|
x: container.x + offsetX,
|
|
y: container.y + offsetY,
|
|
};
|
|
};
|
|
|
|
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
|
|
const container = getContainerElement(textElement);
|
|
if (!container || isArrowElement(container)) {
|
|
return textElement.angle;
|
|
}
|
|
return container.angle;
|
|
};
|
|
|
|
export const getBoundTextElementOffset = (
|
|
boundTextElement: ExcalidrawTextElement | null,
|
|
) => {
|
|
const container = getContainerElement(boundTextElement);
|
|
if (!container || !boundTextElement) {
|
|
return 0;
|
|
}
|
|
if (isArrowElement(container)) {
|
|
return BOUND_TEXT_PADDING * 8;
|
|
}
|
|
|
|
return BOUND_TEXT_PADDING;
|
|
};
|
|
|
|
export const getBoundTextElementPosition = (
|
|
container: ExcalidrawElement,
|
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
|
) => {
|
|
if (isArrowElement(container)) {
|
|
return LinearElementEditor.getBoundTextElementPosition(
|
|
container,
|
|
boundTextElement,
|
|
);
|
|
}
|
|
};
|
|
|
|
export const shouldAllowVerticalAlign = (
|
|
selectedElements: NonDeletedExcalidrawElement[],
|
|
) => {
|
|
return selectedElements.some((element) => {
|
|
const hasBoundContainer = isBoundToContainer(element);
|
|
if (hasBoundContainer) {
|
|
const container = getContainerElement(element);
|
|
if (isTextElement(element) && isArrowElement(container)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
};
|
|
|
|
export const suppportsHorizontalAlign = (
|
|
selectedElements: NonDeletedExcalidrawElement[],
|
|
) => {
|
|
return selectedElements.some((element) => {
|
|
const hasBoundContainer = isBoundToContainer(element);
|
|
if (hasBoundContainer) {
|
|
const container = getContainerElement(element);
|
|
if (isTextElement(element) && isArrowElement(container)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return isTextElement(element);
|
|
});
|
|
};
|
|
|
|
export const getTextBindableContainerAtPosition = (
|
|
elements: readonly ExcalidrawElement[],
|
|
appState: AppState,
|
|
x: number,
|
|
y: number,
|
|
): ExcalidrawTextContainer | null => {
|
|
const selectedElements = getSelectedElements(elements, appState);
|
|
if (selectedElements.length === 1) {
|
|
return isTextBindableContainer(selectedElements[0], false)
|
|
? selectedElements[0]
|
|
: null;
|
|
}
|
|
let hitElement = null;
|
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
for (let index = elements.length - 1; index >= 0; --index) {
|
|
if (elements[index].isDeleted) {
|
|
continue;
|
|
}
|
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
|
if (
|
|
isArrowElement(elements[index]) &&
|
|
isHittingElementNotConsideringBoundingBox(
|
|
elements[index],
|
|
appState,
|
|
null,
|
|
[x, y],
|
|
)
|
|
) {
|
|
hitElement = elements[index];
|
|
break;
|
|
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
|
hitElement = elements[index];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
|
};
|
|
|
|
const VALID_CONTAINER_TYPES = new Set([
|
|
"rectangle",
|
|
"ellipse",
|
|
"diamond",
|
|
"arrow",
|
|
]);
|
|
|
|
export const isValidTextContainer = (element: {
|
|
type: ExcalidrawElement["type"];
|
|
}) => VALID_CONTAINER_TYPES.has(element.type);
|
|
|
|
export const computeContainerDimensionForBoundText = (
|
|
dimension: number,
|
|
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
|
) => {
|
|
dimension = Math.ceil(dimension);
|
|
const padding = BOUND_TEXT_PADDING * 2;
|
|
|
|
if (containerType === "ellipse") {
|
|
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
|
}
|
|
if (containerType === "arrow") {
|
|
return dimension + padding * 8;
|
|
}
|
|
if (containerType === "diamond") {
|
|
return 2 * (dimension + padding);
|
|
}
|
|
return dimension + padding;
|
|
};
|
|
|
|
export const getBoundTextMaxWidth = (
|
|
container: ExcalidrawElement,
|
|
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
|
|
container,
|
|
),
|
|
) => {
|
|
const { width } = container;
|
|
if (isArrowElement(container)) {
|
|
const minWidth =
|
|
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
|
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
|
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
|
}
|
|
if (container.type === "ellipse") {
|
|
// The width of the largest rectangle inscribed inside an ellipse is
|
|
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
|
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
|
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
|
}
|
|
if (container.type === "diamond") {
|
|
// The width of the largest rectangle inscribed inside a rhombus is
|
|
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
|
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
|
}
|
|
return width - BOUND_TEXT_PADDING * 2;
|
|
};
|
|
|
|
export const getBoundTextMaxHeight = (
|
|
container: ExcalidrawElement,
|
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
|
) => {
|
|
const { height } = container;
|
|
if (isArrowElement(container)) {
|
|
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
|
if (containerHeight <= 0) {
|
|
return boundTextElement.height;
|
|
}
|
|
return height;
|
|
}
|
|
if (container.type === "ellipse") {
|
|
// The height of the largest rectangle inscribed inside an ellipse is
|
|
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
|
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
|
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
|
}
|
|
if (container.type === "diamond") {
|
|
// The height of the largest rectangle inscribed inside a rhombus is
|
|
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
|
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
|
}
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* 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 Virgil in WebKit and Blink.
|
|
// Gecko if all over the place.
|
|
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
|
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
|
|
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
|
};
|
|
|
|
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
|
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
|
return DEFAULT_LINE_HEIGHT[fontFamily];
|
|
}
|
|
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
|
};
|