@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
442 lines (379 loc) • 12.8 kB
text/typescript
import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
import { objectMapKeys } from '@tldraw/utils'
import type { Editor } from '../../Editor'
const fixNewLines = /\r?\n|\r/g
function normalizeTextForDom(text: string) {
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',
}
interface PoolItem {
el: HTMLDivElement
html: string
appliedStyleKeys: string[]
}
/** @public */
export interface BatchMeasurementRequest {
html: string
opts: TLMeasureTextOpts
}
/** @public */
export type TLMeasuredTextSize = BoxModel & {
scrollWidth: number
}
/** @public */
export interface TLMeasureTextOpts {
fontStyle: string
fontWeight: string
fontFamily: string
fontSize: number
/** This must be a number, e.g. 1.35, not a pixel value. */
lineHeight: number
/**
* When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
* is null, the text will be measured without wrapping, but explicit line breaks and
* space are preserved.
*/
maxWidth: null | number
minWidth?: null | number
// todo: make this a number so that it is consistent with other TLMeasureTextSpanOpts
padding: string
otherStyles?: Record<string, string>
disableOverflowWrapBreaking?: boolean
measureScrollWidth?: boolean
}
/** @public */
export interface TLMeasureTextSpanOpts {
overflow: 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
width: number
height: number
padding: number
fontSize: number
fontWeight: string
fontFamily: string
fontStyle: string
lineHeight: number
textAlign: TLDefaultHorizontalAlignStyle
otherStyles?: Record<string, string>
measureScrollWidth?: boolean
}
const spaceCharacterRegex = /\s/
const initialDefaultStyles = Object.freeze({
'overflow-wrap': 'break-word',
'word-break': 'auto',
width: null,
height: null,
'max-width': null,
'min-width': null,
})
/** @public */
export class TextManager {
private elm: HTMLDivElement
private poolElms: PoolItem[] = []
constructor(public editor: Editor) {
this.elm = this.createMeasurementEl()
this.editor.getContainer().appendChild(this.elm)
}
private createMeasurementEl(): HTMLDivElement {
const elm = this.editor.getContainerDocument().createElement('div')
elm.classList.add('tl-text')
elm.classList.add('tl-text-measure')
elm.setAttribute('dir', 'auto')
elm.tabIndex = -1
for (const key of objectMapKeys(initialDefaultStyles)) {
elm.style.setProperty(key, initialDefaultStyles[key])
}
return elm
}
private resetElementStyles(el: HTMLElement, appliedStyleKeys: string[]) {
for (const key of appliedStyleKeys) {
if (key in initialDefaultStyles) {
el.style.setProperty(key, initialDefaultStyles[key as keyof typeof initialDefaultStyles])
} else {
el.style.removeProperty(key)
}
}
}
private setElementStyles(el: HTMLElement, styles: Record<string, string | undefined | null>) {
type StyleValue = string | null
type RestoreEntry = [prop: string, value: StyleValue]
const restore: RestoreEntry[] = []
for (const [key, nextValue] of Object.entries(styles)) {
const oldValue = el.style.getPropertyValue(key)
if (typeof nextValue === 'string') {
if (oldValue === nextValue) continue
restore.push([key, oldValue || null])
el.style.setProperty(key, nextValue)
} else {
if (!oldValue) continue
restore.push([key, oldValue])
el.style.removeProperty(key)
}
}
return () => {
for (const [key, value] of restore) {
if (value === null || value === '') el.style.removeProperty(key)
else el.style.setProperty(key, value)
}
}
}
private getMeasureStyles(opts: TLMeasureTextOpts): Record<string, string | undefined> {
return {
'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' : undefined,
'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
'overflow-wrap': opts.disableOverflowWrapBreaking ? 'normal' : 'break-word',
...opts.otherStyles,
}
}
dispose() {
this.elm.remove()
for (const { el } of this.poolElms) {
el.remove()
}
this.poolElms.length = 0
}
private ensurePoolSize(size: number) {
if (this.poolElms.length >= size) return
const fragment = this.editor.getContainerDocument().createDocumentFragment()
while (this.poolElms.length < size) {
const el = this.createMeasurementEl()
this.poolElms.push({ el, html: '', appliedStyleKeys: [] })
fragment.appendChild(el)
}
this.editor.getContainer().appendChild(fragment)
}
private getPoolItem(index: number): PoolItem {
this.ensurePoolSize(index + 1)
return this.poolElms[index]
}
measureHtmlBatch(requests: BatchMeasurementRequest[]): TLMeasuredTextSize[] {
if (requests.length === 0) return []
while (this.poolElms.length > requests.length) {
const { el } = this.poolElms.pop()!
el.remove()
}
for (let i = 0; i < requests.length; i++) {
const { html, opts } = requests[i]
const poolItem = this.getPoolItem(i)
const { el } = poolItem
this.resetElementStyles(el, poolItem.appliedStyleKeys)
const styles = this.getMeasureStyles(opts)
this.setElementStyles(el, styles)
poolItem.appliedStyleKeys = Object.keys(styles)
// Skip innerHTML parsing if the content hasn't changed
if (poolItem.html !== html) {
el.innerHTML = html
poolItem.html = html
}
}
const results: TLMeasuredTextSize[] = []
for (let i = 0; i < requests.length; i++) {
const el = this.getPoolItem(i).el
const scrollWidth = requests[i].opts.measureScrollWidth ? el.scrollWidth : 0
const rect = el.getBoundingClientRect()
results.push({
x: 0,
y: 0,
w: rect.width,
h: rect.height,
scrollWidth,
})
}
return results
}
measureText(textToMeasure: string, opts: TLMeasureTextOpts): TLMeasuredTextSize {
const div = this.editor.getContainerDocument().createElement('div')
div.textContent = normalizeTextForDom(textToMeasure)
return this.measureHtml(div.innerHTML, opts)
}
measureHtml(html: string, opts: TLMeasureTextOpts): TLMeasuredTextSize {
const { elm } = this
const restoreStyles = this.setElementStyles(elm, this.getMeasureStyles(opts))
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: HTMLElement,
{ shouldTruncateToFirstLine = false }: { shouldTruncateToFirstLine?: boolean } = {}
): { spans: { box: BoxModel; text: string }[]; didTruncate: boolean } {
const spans = []
// Measurements of individual spans are relative to the containing element
const elmBounds = element.getBoundingClientRect()
const offsetX = -elmBounds.left
const offsetY = -elmBounds.top
// we measure by creating a range that spans each character in the elements text node
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 ?? '') {
// place the range around the characters we're interested in
range.setStart(textNode, idx)
range.setEnd(textNode, idx + char.length)
// measure the range. some browsers return multiple rects for the
// first char in a new line - one for the line break, and one for
// the character itself. we're only interested in the character.
const rects = range.getClientRects()
const rect = rects[rects.length - 1]!
// calculate the position of the character relative to the element
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
) {
// ...then we're at a span boundary!
if (currentSpan) {
// if we're truncating to a single line & we just finished the first line, stop there
if (shouldTruncateToFirstLine && top !== prevCharTop) {
didTruncate = true
break
}
// otherwise add the span to the list ready to start a new one
spans.push(currentSpan)
}
// start a new span
currentSpan = {
box: { x: left, y: top, w: rect.width, h: rect.height },
text: char,
}
prevCharLeftForRTLTest = left
} else {
// Looks like we're in RTL mode, so we need to adjust the left position.
if (isRTL) {
currentSpan.box.x = left
}
// otherwise we just need to extend the current span with the next character
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
}
}
// Add the last span
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: string,
opts: TLMeasureTextSpanOpts
): { text: string; box: BoxModel }[] {
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' : 'break-word',
'word-break': shouldTruncateToFirstLine ? 'break-all' : 'normal',
...opts.otherStyles,
}
const restoreStyles = this.setElementStyles(elm, newStyles)
try {
const normalizedText = normalizeTextForDom(textToMeasure)
// Render the text into the measurement element:
elm.textContent = normalizedText
// actually measure the text:
const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine,
})
if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
// we need to measure the ellipsis to know how much space it takes up
elm.textContent = '…'
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:
elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
elm.textContent = normalizedText
const truncatedSpans = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine: true,
}).spans
// Finally, we add in our ellipsis at the end of the last span. We
// have to do this after measuring, not before, because adding the
// ellipsis changes how whitespace might be getting collapsed by the
// browser.
const lastSpan = truncatedSpans[truncatedSpans.length - 1]!
truncatedSpans.push({
text: '…',
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()
}
}
}