123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- import { getFontString, arrayToMap, isTestEnv } from "../utils";
- import {
- ExcalidrawElement,
- ExcalidrawTextElement,
- ExcalidrawTextElementWithContainer,
- FontString,
- NonDeletedExcalidrawElement,
- } from "./types";
- import { mutateElement } from "./mutateElement";
- import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
- import { MaybeTransformHandleType } from "./transformHandles";
- import Scene from "../scene/Scene";
- import { AppState } from "../types";
- import { isTextElement } from ".";
- export const redrawTextBoundingBox = (
- element: ExcalidrawTextElement,
- container: ExcalidrawElement | null,
- appState: AppState,
- ) => {
- const maxWidth = container
- ? container.width - BOUND_TEXT_PADDING * 2
- : undefined;
- let text = element.text;
- if (container) {
- text = wrapText(
- element.originalText,
- getFontString(element),
- container.width,
- );
- }
- const metrics = measureText(
- element.originalText,
- getFontString(element),
- maxWidth,
- );
- let coordY = element.y;
- // Resize container and vertically center align the text
- if (container) {
- let nextHeight = container.height;
- if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
- coordY = container.y + BOUND_TEXT_PADDING;
- } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
- coordY =
- container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
- } else {
- coordY = container.y + container.height / 2 - metrics.height / 2;
- if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
- nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
- coordY = container.y + nextHeight / 2 - metrics.height / 2;
- }
- }
- mutateElement(container, { height: nextHeight });
- }
- mutateElement(element, {
- width: metrics.width,
- height: metrics.height,
- baseline: metrics.baseline,
- y: coordY,
- text,
- });
- };
- 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?.concat({
- type: "text",
- id: newTextElementId,
- }),
- });
- }
- const newTextElement = sceneElementMap.get(newTextElementId);
- if (newTextElement && isTextElement(newTextElement)) {
- mutateElement(newTextElement, {
- containerId: newContainer ? newElementId : null,
- });
- }
- }
- }
- });
- };
- export const handleBindTextResize = (
- element: NonDeletedExcalidrawElement,
- transformHandleType: MaybeTransformHandleType,
- ) => {
- const boundTextElementId = getBoundTextElementId(element);
- if (boundTextElementId) {
- const textElement = Scene.getScene(element)!.getElement(
- boundTextElementId,
- ) as ExcalidrawTextElement;
- if (textElement && textElement.text) {
- if (!element) {
- return;
- }
- let text = textElement.text;
- let nextHeight = textElement.height;
- let containerHeight = element.height;
- let nextBaseLine = textElement.baseline;
- if (transformHandleType !== "n" && transformHandleType !== "s") {
- if (text) {
- text = wrapText(
- textElement.originalText,
- getFontString(textElement),
- element.width,
- );
- }
- const dimensions = measureText(
- text,
- getFontString(textElement),
- element.width,
- );
- nextHeight = dimensions.height;
- nextBaseLine = dimensions.baseline;
- }
- // increase height in case text element height exceeds
- if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
- containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
- const diff = containerHeight - element.height;
- // fix the y coord when resizing from ne/nw/n
- const updatedY =
- transformHandleType === "ne" ||
- transformHandleType === "nw" ||
- transformHandleType === "n"
- ? element.y - diff
- : element.y;
- mutateElement(element, {
- height: containerHeight,
- y: updatedY,
- });
- }
- let updatedY;
- if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
- updatedY = element.y + BOUND_TEXT_PADDING;
- } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
- updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
- } else {
- updatedY = element.y + element.height / 2 - nextHeight / 2;
- }
- mutateElement(textElement, {
- text,
- // preserve padding and set width correctly
- width: element.width - BOUND_TEXT_PADDING * 2,
- height: nextHeight,
- x: element.x + BOUND_TEXT_PADDING,
- y: updatedY,
- baseline: nextBaseLine,
- });
- }
- }
- };
- // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
- export const measureText = (
- text: string,
- font: FontString,
- maxWidth?: number | null,
- ) => {
- 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 container = document.createElement("div");
- container.style.position = "absolute";
- container.style.whiteSpace = "pre";
- container.style.font = font;
- container.style.minHeight = "1em";
- if (maxWidth) {
- const lineHeight = getApproxLineHeight(font);
- container.style.width = `${String(maxWidth)}px`;
- container.style.maxWidth = `${String(maxWidth)}px`;
- container.style.overflow = "hidden";
- container.style.wordBreak = "break-word";
- container.style.lineHeight = `${String(lineHeight)}px`;
- container.style.whiteSpace = "pre-wrap";
- }
- document.body.appendChild(container);
- container.innerText = text;
- 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);
- // Baseline is important for positioning text on canvas
- const baseline = span.offsetTop + span.offsetHeight;
- const width = container.offsetWidth;
- const height = container.offsetHeight;
- document.body.removeChild(container);
- return { width, height, baseline };
- };
- const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
- const cacheApproxLineHeight: { [key: FontString]: number } = {};
- export const getApproxLineHeight = (font: FontString) => {
- if (cacheApproxLineHeight[font]) {
- return cacheApproxLineHeight[font];
- }
- cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
- return cacheApproxLineHeight[font];
- };
- let canvas: HTMLCanvasElement | undefined;
- const getTextWidth = (text: string, font: FontString) => {
- if (!canvas) {
- canvas = document.createElement("canvas");
- }
- const canvas2dContext = canvas.getContext("2d")!;
- canvas2dContext.font = font;
- const metrics = canvas2dContext.measureText(text);
- // 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 metrics.width * 10;
- }
- return metrics.width;
- };
- export const wrapText = (
- text: string,
- font: FontString,
- containerWidth: number,
- ) => {
- const maxWidth = containerWidth - BOUND_TEXT_PADDING * 2;
- const lines: Array<string> = [];
- const originalLines = text.split("\n");
- const spaceWidth = getTextWidth(" ", font);
- originalLines.forEach((originalLine) => {
- const words = originalLine.split(" ");
- // This means its newline so push it
- if (words.length === 1 && words[0] === "") {
- lines.push(words[0]);
- } else {
- let currentLine = "";
- let currentLineWidthTillNow = 0;
- let index = 0;
- while (index < words.length) {
- const currentWordWidth = getTextWidth(words[index], font);
- // Start breaking longer words exceeding max width
- if (currentWordWidth >= maxWidth) {
- // push current line since the current word exceeds the max width
- // so will be appended in next line
- if (currentLine) {
- lines.push(currentLine);
- }
- currentLine = "";
- currentLineWidthTillNow = 0;
- while (words[index].length > 0) {
- const currentChar = words[index][0];
- const width = charWidth.calculate(currentChar, font);
- currentLineWidthTillNow += width;
- words[index] = words[index].slice(1);
- if (currentLineWidthTillNow >= maxWidth) {
- // only remove last trailing space which we have added when joining words
- if (currentLine.slice(-1) === " ") {
- currentLine = currentLine.slice(0, -1);
- }
- lines.push(currentLine);
- currentLine = currentChar;
- currentLineWidthTillNow = width;
- if (currentLineWidthTillNow === maxWidth) {
- currentLine = "";
- currentLineWidthTillNow = 0;
- }
- } else {
- currentLine += currentChar;
- }
- }
- // push current line if appending space exceeds max width
- if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
- lines.push(currentLine);
- currentLine = "";
- currentLineWidthTillNow = 0;
- } 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 = getTextWidth(currentLine + word, font);
- if (currentLineWidthTillNow >= maxWidth) {
- lines.push(currentLine);
- currentLineWidthTillNow = 0;
- currentLine = "";
- break;
- }
- index++;
- currentLine += `${word} `;
- // Push the word if appending space exceeds max width
- if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
- lines.push(currentLine.slice(0, -1));
- currentLine = "";
- currentLineWidthTillNow = 0;
- break;
- }
- }
- if (currentLineWidthTillNow === maxWidth) {
- currentLine = "";
- currentLineWidthTillNow = 0;
- }
- }
- }
- if (currentLine) {
- // only remove last trailing space which we have added when joining words
- if (currentLine.slice(-1) === " ") {
- currentLine = currentLine.slice(0, -1);
- }
- lines.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 = getTextWidth(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 += getTextWidth(str, font);
- if (index === dummyText.length - 1) {
- index = 0;
- }
- index = index + batchLength;
- }
- while (widthTillNow > width) {
- str = str.substr(0, str.length - 1);
- widthTillNow = getTextWidth(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;
- };
|