@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
222 lines (221 loc) • 7.69 kB
JavaScript
import { objectMapKeys } from "@tldraw/utils";
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/;
const initialDefaultStyles = Object.freeze({
"overflow-wrap": "break-word",
"word-break": "auto",
width: null,
height: null,
"max-width": null,
"min-width": null
});
class TextManager {
constructor(editor) {
this.editor = editor;
const elm = document.createElement("div");
elm.classList.add("tl-text");
elm.classList.add("tl-text-measure");
elm.setAttribute("dir", "auto");
elm.tabIndex = -1;
this.editor.getContainer().appendChild(elm);
this.elm = elm;
for (const key of objectMapKeys(initialDefaultStyles)) {
elm.style.setProperty(key, initialDefaultStyles[key]);
}
}
elm;
setElementStyles(styles) {
const stylesToReinstate = {};
for (const key of objectMapKeys(styles)) {
if (typeof styles[key] === "string") {
const oldValue = this.elm.style.getPropertyValue(key);
if (oldValue === styles[key]) continue;
stylesToReinstate[key] = oldValue;
this.elm.style.setProperty(key, styles[key]);
}
}
return () => {
for (const key of objectMapKeys(stylesToReinstate)) {
this.elm.style.setProperty(key, stylesToReinstate[key]);
}
};
}
dispose() {
return this.elm.remove();
}
measureText(textToMeasure, opts) {
const div = document.createElement("div");
div.textContent = normalizeTextForDom(textToMeasure);
return this.measureHtml(div.innerHTML, opts);
}
measureHtml(html, opts) {
const { elm } = this;
const newStyles = {
"font-family": opts.fontFamily,
"font-style": opts.fontStyle,
"font-weight": opts.fontWeight,
"font-size": opts.fontSize + "px",
"line-height": opts.lineHeight.toString(),
padding: opts.padding,
"max-width": opts.maxWidth ? opts.maxWidth + "px" : void 0,
"min-width": opts.minWidth ? opts.minWidth + "px" : void 0,
"overflow-wrap": opts.disableOverflowWrapBreaking ? "normal" : void 0,
...opts.otherStyles
};
const restoreStyles = this.setElementStyles(newStyles);
try {
elm.innerHTML = html;
const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0;
const rect = elm.getBoundingClientRect();
return {
x: 0,
y: 0,
w: rect.width,
h: rect.height,
scrollWidth
};
} finally {
restoreStyles();
}
}
/**
* 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;
const shouldTruncateToFirstLine = opts.overflow === "truncate-ellipsis" || opts.overflow === "truncate-clip";
const elementWidth = Math.ceil(opts.width - opts.padding * 2);
const newStyles = {
"font-family": opts.fontFamily,
"font-style": opts.fontStyle,
"font-weight": opts.fontWeight,
"font-size": opts.fontSize + "px",
"line-height": opts.lineHeight.toString(),
width: `${elementWidth}px`,
height: "min-content",
"text-align": textAlignmentsForLtr[opts.textAlign],
"overflow-wrap": shouldTruncateToFirstLine ? "anywhere" : void 0,
"word-break": shouldTruncateToFirstLine ? "break-all" : void 0,
...opts.otherStyles
};
const restoreStyles = this.setElementStyles(newStyles);
try {
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;
}
return spans;
} finally {
restoreStyles();
}
}
}
export {
TextManager
};
//# sourceMappingURL=TextManager.mjs.map