UNPKG

ducjs

Version:

The duc 2D CAD file format is a cornerstone of our advanced design system, conceived to cater to professionals seeking precision and efficiency in their design work.

566 lines (565 loc) 24.8 kB
import { TEXT_ALIGN, VERTICAL_ALIGN } from "../../flatbuffers/duc"; import { isArrowElement, isBoundToContainer, isTextElement } from "../../types/elements/typeChecks"; import { getContainerElement, getElementAbsoluteCoords, getResizedElementAbsoluteCoords } from "../bounds"; import { ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, ARROW_LABEL_WIDTH_FRACTION, BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, WINDOWS_EMOJI_FALLBACK_FONT } from "../constants"; import { getBoundTextElementPosition, getPointGlobalCoordinates, getPointsGlobalCoordinates, getSegmentMidPoint } from "./linearElement"; import { adjustXYWithRotation } from "../math"; import { normalizeText } from "../normalize"; import { getPrecisionValueFromRaw, getScopedBezierPointFromDucPoint } from "../../technical/scopes"; export const computeBoundTextPosition = (container, boundTextElement, elementsMap, currentScope) => { if (isArrowElement(container)) { const coords = getBoundTextElementPosition(container, boundTextElement, elementsMap, currentScope); if (coords === null) { return { x: getPrecisionValueFromRaw(0, boundTextElement.scope, currentScope).scoped, y: getPrecisionValueFromRaw(0, boundTextElement.scope, currentScope).scoped, }; } return { x: coords.x, y: coords.y }; } const containerCoords = getContainerCoords(container); const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement); 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.value); } else { y = containerCoords.y + (maxContainerHeight / 2 - boundTextElement.height.value / 2); } if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) { x = containerCoords.x; } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) { x = containerCoords.x + (maxContainerWidth - boundTextElement.width.value); } else { x = containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width.value / 2); } const xValue = getPrecisionValueFromRaw(x, boundTextElement.scope, currentScope); const yValue = getPrecisionValueFromRaw(y, boundTextElement.scope, currentScope); return { x: xValue.scoped, y: yValue.scoped }; }; export const measureText = (text, font, lineHeight, currentScope) => { 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 = getPrecisionValueFromRaw(parseFloat(font), currentScope, currentScope); const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); return { width, height }; }; /** * We calculate the line height from the font size and the unitless line height, * aligning with the W3C spec. */ export const getLineHeightInPx = (fontSize, lineHeight) => { return fontSize.value * lineHeight; }; // FIXME rename to getApproxMinContainerHeight export const getApproxMinLineHeight = (fontSize, lineHeight) => { return getLineHeightInPx(fontSize, lineHeight) + (BOUND_TEXT_PADDING * 2); }; let canvas; /** * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width. * * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position. * * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for: * - text wrapping * - wysiwyg editor (+padding) * * Everything else should be based on the actual bounding box width. * * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies. */ const getLineWidth = (text, font, forceAdvanceWidth, isTestEnv) => { if (!canvas) { canvas = document.createElement("canvas"); } const canvas2dContext = canvas.getContext("2d"); canvas2dContext.font = font; const metrics = canvas2dContext.measureText(text); const advanceWidth = metrics.width; // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage) if (!forceAdvanceWidth && typeof window !== "undefined" && window.TextMetrics && "actualBoundingBoxLeft" in window.TextMetrics.prototype && "actualBoundingBoxRight" in window.TextMetrics.prototype) { // could be negative, therefore getting the absolute value const actualWidth = Math.abs(metrics.actualBoundingBoxLeft) + Math.abs(metrics.actualBoundingBoxRight); // fallback to advance width if the actual width is zero, i.e. on text editing start // or when actual width does not respect whitespace chars, i.e. spaces // otherwise actual width should always be bigger return Math.max(actualWidth, advanceWidth); } // 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 advanceWidth * 10; } return advanceWidth; }; export const getTextWidth = (text, font, forceAdvanceWidth) => { const lines = splitIntoLines(text); let width = 0; lines.forEach((line) => { width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth)); }); return width; }; export const getTextHeight = (text, fontSize, lineHeight) => { const lineCount = splitIntoLines(text).length; return getLineHeightInPx(fontSize, lineHeight) * lineCount; }; export const parseTokens = (text) => { // 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, font, maxWidth) => { // 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 = []; const originalLines = text.split("\n"); const spaceAdvanceWidth = getLineWidth(" ", font, true); let currentLine = ""; let currentLineWidthTillNow = 0; const push = (str) => { if (str.trim()) { lines.push(str); } }; const resetParams = () => { currentLine = ""; currentLineWidthTillNow = 0; }; for (const originalLine of originalLines) { const currentLineWidth = getLineWidth(originalLine, font, true); // Push the line if its <= maxWidth if (currentLineWidth <= maxWidth) { lines.push(originalLine); continue; } const words = parseTokens(originalLine); resetParams(); let index = 0; while (index < words.length) { const currentWordWidth = getLineWidth(words[index], font, true); // 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 line = currentLine + currentChar; // use advance width instead of the actual width as it's closest to the browser wapping algo // use width of the whole line instead of calculating individual chars to accomodate for kerning const lineAdvanceWidth = getLineWidth(line, font, true); const charAdvanceWidth = charWidth.calculate(currentChar, font); currentLineWidthTillNow = lineAdvanceWidth; words[index] = words[index].slice(currentChar.length); if (currentLineWidthTillNow >= maxWidth) { push(currentLine); currentLine = currentChar; currentLineWidthTillNow = charAdvanceWidth; } else { currentLine = line; } } // push current line if appending space exceeds max width if (currentLineWidthTillNow + spaceAdvanceWidth >= 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 += spaceAdvanceWidth; } 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, true); 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 + spaceAdvanceWidth >= 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 = {}; const calculate = (char, font) => { const ascii = char.charCodeAt(0); if (!cachedCharWidth[font]) { cachedCharWidth[font] = []; } if (!cachedCharWidth[font][ascii]) { const width = getLineWidth(char, font, true); cachedCharWidth[font][ascii] = width; } return cachedCharWidth[font][ascii]; }; const getCache = (font) => { return cachedCharWidth[font]; }; return { calculate, getCache, }; })(); const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); // FIXME rename to getApproxMinContainerWidth export const getApproxMinLineWidth = (font, lineHeight, currentScope) => { const maxCharWidth = getMaxCharWidth(font); if (maxCharWidth === 0) { return (measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight, currentScope).width + BOUND_TEXT_PADDING * 2); } return (maxCharWidth + BOUND_TEXT_PADDING * 2); }; export const getMinCharWidth = (font) => { const cache = charWidth.getCache(font); if (!cache) { return 0; } const cacheWithOutEmpty = cache.filter((val) => val !== undefined); return Math.min(...cacheWithOutEmpty); }; export const getMaxCharWidth = (font) => { const cache = charWidth.getCache(font); if (!cache) { return 0; } const cacheWithOutEmpty = cache.filter((val) => val !== undefined); return Math.max(...cacheWithOutEmpty); }; export const getContainerCenter = (container, DucState, elementsMap) => { if (!isArrowElement(container)) { return { x: container.x.scoped + container.width.scoped / 2, y: container.y.scoped + container.height.scoped / 2, }; } const points = getPointsGlobalCoordinates(container, elementsMap, DucState.scope); if (points.length % 2 === 1) { const index = Math.floor(container.points.length / 2); const midPoint = getPointGlobalCoordinates(container, getScopedBezierPointFromDucPoint(container.points[index]), elementsMap, DucState.scope); return { x: midPoint.x, y: midPoint.y }; } const index = container.points.length / 2 - 1; // const initialMidSegmentMidpoint = getEditorMidPoints( // container, // elementsMap, // DucState, // )[index]; let initialMidSegmentMidpoint; // FIXME: provide a better implementation for mid points handling const initMidPoints = initialMidSegmentMidpoint && getScopedBezierPointFromDucPoint(initialMidSegmentMidpoint); // Remove casting in the future let midSegmentMidpoint = initMidPoints && { x: initMidPoints.x, y: initMidPoints.y }; if (!midSegmentMidpoint) { midSegmentMidpoint = getSegmentMidPoint(container, getScopedBezierPointFromDucPoint(points[index]), getScopedBezierPointFromDucPoint(points[index + 1]), index + 1, elementsMap, DucState.scope); } return { x: midSegmentMidpoint.x, y: midSegmentMidpoint.y }; }; export const getContainerCoords = (container) => { 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.value / 2) * (1 - Math.sqrt(2) / 2); offsetY += (container.height.value / 2) * (1 - Math.sqrt(2) / 2); } // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265 if (container.type === "polygon") { offsetX += container.width.value / 4; offsetY += container.height.value / 4; } return { x: container.x.value + offsetX, y: container.y.value + offsetY, }; }; export const getTextElementAngle = (textElement, container) => { if (!container || isArrowElement(container)) { return textElement.angle; } return container.angle; }; export const shouldAllowVerticalAlign = (selectedElements, elementsMap) => { return selectedElements.some((element) => { if (isBoundToContainer(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { return false; } return true; } return false; }); }; export const suppportsHorizontalAlign = (selectedElements, elementsMap) => { return selectedElements.some((element) => { if (isBoundToContainer(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { return false; } return true; } return isTextElement(element); }); }; const VALID_CONTAINER_TYPES = new Set([ "rectangle", "ellipse", "diamond", "arrow", ]); export const isValidTextContainer = (element) => VALID_CONTAINER_TYPES.has(element.type); export const computeContainerDimensionForBoundText = (dimension, containerType) => { 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, boundTextElement) => { var _a; const { width } = container; if (isArrowElement(container)) { const minWidth = ((_a = boundTextElement === null || boundTextElement === void 0 ? void 0 : boundTextElement.fontSize.value) !== null && _a !== void 0 ? _a : DEFAULT_FONT_SIZE) * ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO; return Math.max(ARROW_LABEL_WIDTH_FRACTION * width.value, 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.value / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2); } if (container.type === "polygon") { // 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.value / 2) - BOUND_TEXT_PADDING * 2); } return (width.value - BOUND_TEXT_PADDING * 2); }; export const getBoundTextMaxHeight = (container, boundTextElement) => { const { height } = container; if (isArrowElement(container)) { const containerHeight = height.value - BOUND_TEXT_PADDING * 8 * 2; if (containerHeight <= 0) { return boundTextElement.height.value; } return height.value; } 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.value / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2); } if (container.type === "polygon") { // 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.value / 2) - BOUND_TEXT_PADDING * 2); } return (height.value - BOUND_TEXT_PADDING * 2); }; export const isMeasureTextSupported = (currentScope) => { const width = getTextWidth(DUMMY_TEXT, getFontString({ fontSize: getPrecisionValueFromRaw(DEFAULT_FONT_SIZE, currentScope, currentScope), fontFamily: DEFAULT_FONT_FAMILY, })); return width > 0; }; export const getMinTextElementWidth = (font, lineHeight, currentScope) => { return measureText("", font, lineHeight, currentScope).width + BOUND_TEXT_PADDING * 2; }; /** retrieves text from text elements and concatenates to a single string */ export const getTextFromElements = (elements, separator = "\n\n") => { const text = elements .reduce((acc, element) => { if (isTextElement(element)) { acc.push(element.text); } return acc; }, []) .join(separator); return text; }; export const getFontFamilyString = ({ fontFamily, }) => { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { if (id === fontFamily) { return `${fontFamilyString}, ${WINDOWS_EMOJI_FALLBACK_FONT}`; } } return WINDOWS_EMOJI_FALLBACK_FONT; }; /** returns fontSize+fontFamily string for assignment to DOM elements */ export const getFontString = ({ fontSize, fontFamily, }) => { return `${fontSize.scoped}px ${getFontFamilyString({ fontFamily })}`; }; /** computes element x/y offset based on textAlign/verticalAlign */ export const getTextElementPositionOffsets = (opts, metrics) => { return { x: opts.textAlign === TEXT_ALIGN.CENTER ? metrics.width / 2 : opts.textAlign === TEXT_ALIGN.RIGHT ? metrics.width : 0, y: opts.verticalAlign === VERTICAL_ALIGN.MIDDLE ? metrics.height / 2 : 0, }; }; export const refreshTextDimensions = (textElement, container, elementsMap, currentScope, text = textElement.text) => { if (textElement.isDeleted) { return; } if (container || !textElement.autoResize) { text = wrapText(text, getFontString({ fontFamily: textElement.fontFamily, fontSize: textElement.fontSize }), container ? getBoundTextMaxWidth(container, textElement) : textElement.width.scoped); } const dimensions = getAdjustedDimensions(textElement, elementsMap, text, currentScope); return Object.assign({ text }, dimensions); }; export const splitIntoLines = (text) => { return normalizeText(text).split("\n"); }; /** * To get unitless line-height (if unknown) we can calculate it by dividing * height-per-line by fontSize. */ export const detectLineHeight = (textElement) => { const lineCount = splitIntoLines(textElement.text).length; return (textElement.height.scoped / lineCount / textElement.fontSize.scoped); }; export const getAdjustedDimensions = (element, elementsMap, nextText, currentScope) => { let { width: nextWidth, height: nextHeight } = measureText(nextText, getFontString({ fontFamily: element.fontFamily, fontSize: element.fontSize }), element.lineHeight, currentScope); // wrapped text if (!element.autoResize) { nextWidth = element.width.value; } const { textAlign, verticalAlign } = element; let x; let y; if (textAlign === TEXT_ALIGN.CENTER && verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId && element.autoResize) { const prevMetrics = measureText(element.text, getFontString({ fontFamily: element.fontFamily, fontSize: element.fontSize }), element.lineHeight, currentScope); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, }); x = element.x.value - offsets.x; y = element.y.value - offsets.y; } else { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap, currentScope); const nextWidthValue = getPrecisionValueFromRaw(nextWidth, element.scope, currentScope); const nextHeightValue = getPrecisionValueFromRaw(nextHeight, element.scope, currentScope); const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(element, nextWidthValue.scoped, nextHeightValue.scoped, false, currentScope); const deltaX1 = (x1 - nextX1) / 2; const deltaY1 = (y1 - nextY1) / 2; const deltaX2 = (x2 - nextX2) / 2; const deltaY2 = (y2 - nextY2) / 2; const rotationPoint = adjustXYWithRotation({ s: true, e: textAlign === TEXT_ALIGN.CENTER || textAlign === TEXT_ALIGN.LEFT, w: textAlign === TEXT_ALIGN.CENTER || textAlign === TEXT_ALIGN.RIGHT, }, element.x.value, element.y.value, element.angle, deltaX1, deltaY1, deltaX2, deltaY2); x = rotationPoint.x; y = rotationPoint.y; } return { width: nextWidth, height: nextHeight, x: Number.isFinite(x) ? x : element.x.value, y: Number.isFinite(y) ? y : element.y.value, }; }; export { getBoundTextElementPosition };