UNPKG

@tldraw/editor

Version:

A tiny little drawing app (editor).

186 lines (185 loc) • 7.21 kB
const fixNewLines = /\r?\n|\r/g; function normalizeTextForDom(text) { return text.replace(fixNewLines, "\n").split("\n").map((x) => x || " ").join("\n"); } const textAlignmentsForLtr = { start: "left", "start-legacy": "left", middle: "center", "middle-legacy": "center", end: "right", "end-legacy": "right" }; const spaceCharacterRegex = /\s/; class TextManager { constructor(editor) { this.editor = editor; this.baseElem = document.createElement("div"); this.baseElem.classList.add("tl-text"); this.baseElem.classList.add("tl-text-measure"); this.baseElem.tabIndex = -1; } baseElem; measureText(textToMeasure, opts) { const div = document.createElement("div"); div.textContent = normalizeTextForDom(textToMeasure); return this.measureHtml(div.innerHTML, opts); } measureHtml(html, opts) { const wrapperElm = this.baseElem.cloneNode(); this.editor.getContainer().appendChild(wrapperElm); wrapperElm.innerHTML = html; this.baseElem.insertAdjacentElement("afterend", wrapperElm); wrapperElm.setAttribute("dir", "auto"); wrapperElm.style.setProperty("unicode-bidi", "plaintext"); wrapperElm.style.setProperty("font-family", opts.fontFamily); wrapperElm.style.setProperty("font-style", opts.fontStyle); wrapperElm.style.setProperty("font-weight", opts.fontWeight); wrapperElm.style.setProperty("font-size", opts.fontSize + "px"); wrapperElm.style.setProperty("line-height", opts.lineHeight * opts.fontSize + "px"); wrapperElm.style.setProperty("max-width", opts.maxWidth === null ? null : opts.maxWidth + "px"); wrapperElm.style.setProperty("min-width", opts.minWidth === null ? null : opts.minWidth + "px"); wrapperElm.style.setProperty("padding", opts.padding); wrapperElm.style.setProperty( "overflow-wrap", opts.disableOverflowWrapBreaking ? "normal" : "break-word" ); const scrollWidth = wrapperElm.scrollWidth; const rect = wrapperElm.getBoundingClientRect(); wrapperElm.remove(); return { x: 0, y: 0, w: rect.width, h: rect.height, scrollWidth }; } /** * Given an html element, measure the position of each span of unbroken * word/white-space characters within any text nodes it contains. */ measureElementTextNodeSpans(element, { shouldTruncateToFirstLine = false } = {}) { const spans = []; const elmBounds = element.getBoundingClientRect(); const offsetX = -elmBounds.left; const offsetY = -elmBounds.top; const range = new Range(); const textNode = element.childNodes[0]; let idx = 0; let currentSpan = null; let prevCharWasSpaceCharacter = null; let prevCharTop = 0; let prevCharLeftForRTLTest = 0; let didTruncate = false; for (const childNode of element.childNodes) { if (childNode.nodeType !== Node.TEXT_NODE) continue; for (const char of childNode.textContent ?? "") { range.setStart(textNode, idx); range.setEnd(textNode, idx + char.length); const rects = range.getClientRects(); const rect = rects[rects.length - 1]; const top = rect.top + offsetY; const left = rect.left + offsetX; const right = rect.right + offsetX; const isRTL = left < prevCharLeftForRTLTest; const isSpaceCharacter = spaceCharacterRegex.test(char); if ( // If we're at a word boundary... isSpaceCharacter !== prevCharWasSpaceCharacter || // ...or we're on a different line... top !== prevCharTop || // ...or we're at the start of the text and haven't created a span yet... !currentSpan ) { if (currentSpan) { if (shouldTruncateToFirstLine && top !== prevCharTop) { didTruncate = true; break; } spans.push(currentSpan); } currentSpan = { box: { x: left, y: top, w: rect.width, h: rect.height }, text: char }; prevCharLeftForRTLTest = left; } else { if (isRTL) { currentSpan.box.x = left; } currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x; currentSpan.text += char; } if (char === "\n") { prevCharLeftForRTLTest = 0; } prevCharWasSpaceCharacter = isSpaceCharacter; prevCharTop = top; idx += char.length; } } if (currentSpan) { spans.push(currentSpan); } return { spans, didTruncate }; } /** * Measure text into individual spans. Spans are created by rendering the * text, then dividing it up according to line breaks and word boundaries. * * It works by having the browser render the text, then measuring the * position of each character. You can use this to replicate the text-layout * algorithm of the current browser in e.g. an SVG export. */ measureTextSpans(textToMeasure, opts) { if (textToMeasure === "") return []; const elm = this.baseElem.cloneNode(); this.editor.getContainer().appendChild(elm); const elementWidth = Math.ceil(opts.width - opts.padding * 2); elm.setAttribute("dir", "auto"); elm.style.setProperty("unicode-bidi", "plaintext"); elm.style.setProperty("width", `${elementWidth}px`); elm.style.setProperty("height", "min-content"); elm.style.setProperty("font-size", `${opts.fontSize}px`); elm.style.setProperty("font-family", opts.fontFamily); elm.style.setProperty("font-weight", opts.fontWeight); elm.style.setProperty("line-height", `${opts.lineHeight * opts.fontSize}px`); elm.style.setProperty("text-align", textAlignmentsForLtr[opts.textAlign]); elm.style.setProperty("font-style", opts.fontStyle); const shouldTruncateToFirstLine = opts.overflow === "truncate-ellipsis" || opts.overflow === "truncate-clip"; if (shouldTruncateToFirstLine) { elm.style.setProperty("overflow-wrap", "anywhere"); elm.style.setProperty("word-break", "break-all"); } const normalizedText = normalizeTextForDom(textToMeasure); elm.textContent = normalizedText; const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, { shouldTruncateToFirstLine }); if (opts.overflow === "truncate-ellipsis" && didTruncate) { elm.textContent = "\u2026"; const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w); elm.style.setProperty("width", `${elementWidth - ellipsisWidth}px`); elm.textContent = normalizedText; const truncatedSpans = this.measureElementTextNodeSpans(elm, { shouldTruncateToFirstLine: true }).spans; const lastSpan = truncatedSpans[truncatedSpans.length - 1]; truncatedSpans.push({ text: "\u2026", box: { x: Math.min(lastSpan.box.x + lastSpan.box.w, opts.width - opts.padding - ellipsisWidth), y: lastSpan.box.y, w: ellipsisWidth, h: lastSpan.box.h } }); return truncatedSpans; } elm.remove(); return spans; } } export { TextManager }; //# sourceMappingURL=TextManager.mjs.map