[Improvement] Text measurement tweaks (#2670)

This PR duplicates a template node rather than creating a new node each
time or querying for a selector. Functions clean up the node that is
created.

### Change Type

- [x] `patch`
This commit is contained in:
Steve Ruiz 2024-01-28 11:39:11 +00:00 committed by GitHub
parent 2610790873
commit 8b73e77ec2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 36 additions and 38 deletions

View file

@ -787,9 +787,9 @@ input,
.tl-text-measure { .tl-text-measure {
position: absolute; position: absolute;
z-index: 999999; z-index: -999999;
top: -9999px; top: 0px;
right: -9999px; left: 0px;
opacity: 0; opacity: 0;
width: max-content; width: max-content;
box-sizing: border-box; box-sizing: border-box;

View file

@ -1,5 +1,4 @@
import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema' import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
import { uniqueId } from '../../utils/uniqueId'
import { Editor } from '../Editor' import { Editor } from '../Editor'
const fixNewLines = /\r?\n|\r/g const fixNewLines = /\r?\n|\r/g
@ -38,21 +37,16 @@ type TLMeasureTextSpanOpts = {
const spaceCharacterRegex = /\s/ const spaceCharacterRegex = /\s/
export class TextManager { export class TextManager {
constructor(public editor: Editor) {} baseElm: HTMLDivElement
private getTextElement() {
const oldElm = document.querySelector('.tl-text-measure')
oldElm?.remove()
constructor(public editor: Editor) {
const elm = document.createElement('div') const elm = document.createElement('div')
this.editor.getContainer().appendChild(elm) elm.id = `tldraw_text_measure`
elm.id = `__textMeasure_${uniqueId()}`
elm.classList.add('tl-text') elm.classList.add('tl-text')
elm.classList.add('tl-text-measure') elm.classList.add('tl-text-measure')
elm.tabIndex = -1 elm.tabIndex = -1
this.editor.getContainer().appendChild(elm)
return elm this.baseElm = elm
} }
measureText = ( measureText = (
@ -73,7 +67,9 @@ export class TextManager {
padding: string padding: string
} }
): BoxModel => { ): BoxModel => {
const elm = this.getTextElement() // Duplicate our base element; we don't need to clone deep
const elm = this.baseElm?.cloneNode() as HTMLDivElement
this.baseElm.insertAdjacentElement('afterend', elm)
elm.setAttribute('dir', 'ltr') elm.setAttribute('dir', 'ltr')
elm.style.setProperty('font-family', opts.fontFamily) elm.style.setProperty('font-family', opts.fontFamily)
@ -87,6 +83,7 @@ export class TextManager {
elm.textContent = normalizeTextForDom(textToMeasure) elm.textContent = normalizeTextForDom(textToMeasure)
const rect = elm.getBoundingClientRect() const rect = elm.getBoundingClientRect()
elm.remove()
return { return {
x: 0, x: 0,
@ -198,45 +195,46 @@ export class TextManager {
): { text: string; box: BoxModel }[] { ): { text: string; box: BoxModel }[] {
if (textToMeasure === '') return [] if (textToMeasure === '') return []
const elm = this.baseElm?.cloneNode() as HTMLDivElement
this.baseElm.insertAdjacentElement('afterend', elm)
const elementWidth = Math.ceil(opts.width - opts.padding * 2)
elm.style.setProperty('width', `${elementWidth}px`)
elm.style.setProperty('height', 'min-content')
elm.style.setProperty('dir', 'ltr')
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])
const shouldTruncateToFirstLine = const shouldTruncateToFirstLine =
opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip' opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
// Create a measurement element:
const element = this.getTextElement()
const elementWidth = Math.ceil(opts.width - opts.padding * 2)
element.style.setProperty('width', `${elementWidth}px`)
element.style.setProperty('height', 'min-content')
element.style.setProperty('dir', 'ltr')
element.style.setProperty('font-size', `${opts.fontSize}px`)
element.style.setProperty('font-family', opts.fontFamily)
element.style.setProperty('font-weight', opts.fontWeight)
element.style.setProperty('line-height', `${opts.lineHeight * opts.fontSize}px`)
element.style.setProperty('text-align', textAlignmentsForLtr[opts.textAlign])
if (shouldTruncateToFirstLine) { if (shouldTruncateToFirstLine) {
element.style.setProperty('overflow-wrap', 'anywhere') elm.style.setProperty('overflow-wrap', 'anywhere')
element.style.setProperty('word-break', 'break-all') elm.style.setProperty('word-break', 'break-all')
} }
textToMeasure = normalizeTextForDom(textToMeasure) const normalizedText = normalizeTextForDom(textToMeasure)
// Render the text into the measurement element: // Render the text into the measurement element:
element.textContent = textToMeasure elm.textContent = normalizedText
// actually measure the text: // actually measure the text:
const { spans, didTruncate } = this.measureElementTextNodeSpans(element, { const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine, shouldTruncateToFirstLine,
}) })
if (opts.overflow === 'truncate-ellipsis' && didTruncate) { if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
// we need to measure the ellipsis to know how much space it takes up // we need to measure the ellipsis to know how much space it takes up
element.textContent = '…' elm.textContent = '…'
const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(element).spans[0].box.w) const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)
// then, we need to subtract that space from the width we have and measure again: // then, we need to subtract that space from the width we have and measure again:
element.style.setProperty('width', `${elementWidth - ellipsisWidth}px`) elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
element.textContent = textToMeasure elm.textContent = normalizedText
const truncatedSpans = this.measureElementTextNodeSpans(element, { const truncatedSpans = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine: true, shouldTruncateToFirstLine: true,
}).spans }).spans
@ -257,7 +255,7 @@ export class TextManager {
return truncatedSpans return truncatedSpans
} }
element.remove() elm.remove()
return spans return spans
} }