Measure individual words instead of just line breaks for text exports (#1397)

This diff fixes a number of issues with text export by completely
overhauling how we approach laying out text in exports.

Currently, we try to carefully replicate in-browser behaviour around
line breaks and whitespace collapsing. We do this using an iterative
algorithm that forces the browser to perform a layout for each word, and
attempting to re-implement how the browser does things like whitespace
collapsing & finding line break opportunities. Lots of export issues
come from the fact that this is almost impossible to do well (short of
sending a complete text layout algorithm & full unicode lookup tables).

Luckily, the browser already has a complete text layout algorithm and
full unicode lookup tables! In the new approach, we ask the browser to
lay the text out once. Then, we use the
[`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to
loop over every character in the rendered text and measure its position.
These character positions are then grouped into "spans". A span is a
contiguous range of either whitespace or non-whitespace characters,
uninterrupted by any browser-inserting line breaks. When we come to
render the SVG, each span gets its own `<tspan>` element, absolutely
positioned according to where it ended up in the user's browser.

This fixes a bunch of issues:

**Misaligned text due to whitespace collapsing at line breaks**
![Kapture 2023-05-17 at 12 07
30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a)

**Hyphenated text (or text with non-trivial/whitespace-based breaking
rules like Thai) not splitting correctly**
![Kapture 2023-05-17 at 12 21
40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e)

**Weird alignment issues in note shapes**
![Kapture 2023-05-17 at 12 24
59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e)

**Frame labels not respecting multiple spaces & not truncating
correctly**
![Kapture 2023-05-17 at 12 27
27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78)

#### Quick note on browser compatibility
This approach works well across all browsers, but in some cases actually
_increases_ x-browser variance. Consider these screenshots of the same
element (original above, export below):

![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de)

Notice how on chrome, the whitespace at the end of each line of
right-aligned text is preserved. On safari, it's collapsed. The safari
option looks better - so our manual line-breaking/white-space-collapsing
algorithm preferred safari's approach. That meant that in-app, this
shape looks very slightly different from browser to browser. But out of
the app, the exports would have been the same (although also note that
hyphenation is broken). Now, because these shapes look different across
browsers, the exports now look different across browsers too. We're
relying on the host-browsers text layout algorithm, which means we'll
faithfully reproduce any quirks/inconsistencies of that algorithm. I
think this is an acceptable tradeoff.

### Change Type

- [x] `patch` — Bug Fix

### Test Plan

* Comprehensive testing of text in exports, paying close attention to
details around white-space, line-breaking and alignment
* Consider setting `tldrawDebugSvg = true`
* Check text shapes, geo shapes with labels, arrow shapes with labels,
note shapes, frame labels
* Check different alignments and fonts (including vertical alignment)

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
alex 2023-05-22 16:10:03 +01:00 committed by GitHub
parent 97ffc168c1
commit d48e403ed1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 482 additions and 574 deletions

View file

@ -1,3 +1,4 @@
import { Box2dModel } from '@tldraw/editor'
import { runtime, ui } from '../helpers' import { runtime, ui } from '../helpers'
import { diffScreenshot, takeRegionScreenshot } from '../helpers/webdriver' import { diffScreenshot, takeRegionScreenshot } from '../helpers/webdriver'
import { describe, env, it } from '../mocha-ext' import { describe, env, it } from '../mocha-ext'
@ -13,7 +14,7 @@ describe('text', () => {
const tests = [ const tests = [
{ {
name: 'multiline (align center)', name: 'multiline (align center)',
fails: true, fails: false,
handler: async () => { handler: async () => {
await ui.tools.click('select') await ui.tools.click('select')
await ui.tools.click('text') await ui.tools.click('text')
@ -82,7 +83,6 @@ describe('text', () => {
describe('text measurement', () => { describe('text measurement', () => {
const measureTextOptions = { const measureTextOptions = {
text: 'testing',
width: 'fit-content', width: 'fit-content',
fontFamily: 'var(--tl-font-draw)', fontFamily: 'var(--tl-font-draw)',
fontSize: 24, fontSize: 24,
@ -93,11 +93,10 @@ describe('text measurement', () => {
maxWidth: 'auto', maxWidth: 'auto',
} }
const getTextLinesOptions = { const measureTextSpansOptions = {
text: 'testing',
width: 100, width: 100,
height: 1000, height: 1000,
wrap: true, overflow: 'wrap' as const,
padding: 0, padding: 0,
fontSize: 24, fontSize: 24,
fontWeight: 'normal', fontWeight: 'normal',
@ -107,186 +106,173 @@ describe('text measurement', () => {
textAlign: 'start' as 'start' | 'middle' | 'end', textAlign: 'start' as 'start' | 'middle' | 'end',
} }
function formatLines(spans: { box: Box2dModel; text: string }[]) {
const lines = []
let currentLine = null
let currentLineTop = null
for (const span of spans) {
if (currentLineTop !== span.box.y) {
if (currentLine !== null) {
lines.push(currentLine)
}
currentLine = []
currentLineTop = span.box.y
}
currentLine.push(span.text)
}
if (currentLine !== null) {
lines.push(currentLine)
}
return lines
}
env({}, () => { env({}, () => {
it('should measure text', async () => { it('should measure text', async () => {
await ui.app.setup() await ui.app.setup()
const { w, h } = await browser.execute((options) => { const { w, h } = await browser.execute((options) => {
return window.app.textMeasure.measureText({ return window.app.textMeasure.measureText('testing', options)
...options,
})
}, measureTextOptions) }, measureTextOptions)
expect(w).toBeCloseTo(85.828125, 1) expect(w).toBeCloseTo(85.828125, 0)
expect(h).toBeCloseTo(32.3984375, 1) expect(h).toBeCloseTo(32.3984375, 0)
}) })
it('should get a single text line', async () => { // The text-measurement tests below this point aren't super useful any
await ui.app.setup() // more. They were added when we had a different approach to text SVG
const lines = await browser.execute((options) => { // exports (trying to replicate browser decisions with our own code) to
return window.app.textMeasure.getTextLines({ // what we do now (letting the browser make those decisions then
...options, // measuring the results).
}) //
}, getTextLinesOptions) // It's hard to write better tests here (e.g. ones where we actually
// look at the measured values) because the specifics of text layout
// vary from browser to browser. The ideal thing would be to replace
// these with visual regression tests for text SVG exports, but we don't
// have a way of doing visual regression testing right now.
expect(lines).toEqual(['testing']) it('should get a single text span', async () => {
await ui.app.setup()
const spans = await browser.execute((options) => {
return window.app.textMeasure.measureTextSpans('testing', options)
}, measureTextSpansOptions)
expect(formatLines(spans)).toEqual([['testing']])
}) })
it('should wrap a word when it has to', async () => { it('should wrap a word when it has to', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('testing', { ...options, width: 50 })
...options, }, measureTextSpansOptions)
width: 50,
})
}, getTextLinesOptions)
expect(lines).toEqual(['test', 'ing']) expect(formatLines(spans)).toEqual([['test'], ['ing']])
}) })
it('should wrap between words when it has to', async () => { it('should wrap between words when it has to', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('testing testing', options)
...options, }, measureTextSpansOptions)
text: 'testing testing',
})
}, getTextLinesOptions)
expect(lines).toEqual(['testing', 'testing']) expect(formatLines(spans)).toEqual([['testing', ' '], ['testing']])
}) })
it('should strip whitespace at line breaks', async () => { it('should preserve whitespace at line breaks', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('testing testing', options)
...options, }, measureTextSpansOptions)
text: 'testing testing',
})
}, getTextLinesOptions)
expect(lines).toEqual(['testing', 'testing']) expect(formatLines(spans)).toEqual([['testing', ' '], ['testing']])
}) })
it('should strip whitespace at the end of wrapped lines', async () => { it('should preserve whitespace at the end of wrapped lines', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('testing testing ', options)
...options, }, measureTextSpansOptions)
text: 'testing testing ',
})
}, getTextLinesOptions)
expect(lines).toEqual(['testing', 'testing']) expect(formatLines(spans)).toEqual([
['testing', ' '],
['testing', ' '],
])
}) })
it('strips whitespace at the end of unwrapped lines', async () => { it('preserves whitespace at the end of unwrapped lines', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('testing testing ', {
...options, ...options,
width: 200, width: 200,
text: 'testing testing ',
}) })
}, getTextLinesOptions) }, measureTextSpansOptions)
expect(lines).toEqual(['testing testing']) expect(formatLines(spans)).toEqual([['testing', ' ', 'testing', ' ']])
}) })
it('preserves whitespace at the start of an unwrapped line', async () => { it('preserves whitespace at the start of an unwrapped line', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans(' testing testing', {
...options, ...options,
width: 200, width: 200,
text: ' testing testing',
}) })
}, getTextLinesOptions) }, measureTextSpansOptions)
expect(lines).toEqual([' testing testing']) expect(formatLines(spans)).toEqual([[' ', 'testing', ' ', 'testing']])
}) })
it('should place starting whitespace on its own line if it has to', async () => { it('should place starting whitespace on its own line if it has to', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans(' testing testing', options)
...options, }, measureTextSpansOptions)
text: ' testing testing',
})
}, getTextLinesOptions)
expect(lines).toEqual(['', 'testing', 'testing']) expect(formatLines(spans)).toEqual([[' '], ['testing', ' '], ['testing']])
})
it('trims ending whitespace', async () => {
await ui.app.setup()
const lines = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({
...options,
text: 'testing testing ',
})
}, getTextLinesOptions)
expect(lines).toEqual(['testing', 'testing'])
})
it('allows whitespace to cause breaks, however trims it at the end anyway', async () => {
await ui.app.setup()
const lines = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({
...options,
text: 'ok hi testing',
})
}, getTextLinesOptions)
expect(lines).toEqual(['ok hi', 'testing'])
})
it('respects leading whitespace', async () => {
await ui.app.setup()
const lines = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({
...options,
text: ' ok hi testing ',
})
}, getTextLinesOptions)
expect(lines).toEqual([' ok hi', 'testing'])
}) })
it('should handle multiline text', async () => { it('should handle multiline text', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans(' test\ning testing \n t', options)
...options, }, measureTextSpansOptions)
text: 'testing testing ',
})
}, getTextLinesOptions)
expect(lines).toEqual(['testing', 'testing']) expect(formatLines(spans)).toEqual([
[' ', 'test', '\n'],
['ing', ' '],
['testing', ' \n'],
[' ', 't'],
])
}) })
it('should break long strings of text', async () => { it('should break long strings of text', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans(
...options, 'testingtestingtestingtestingtestingtesting',
text: 'testingtestingtestingtestingtestingtesting', options
}) )
}, getTextLinesOptions) }, measureTextSpansOptions)
expect(lines).toEqual(['testingt', 'estingte', 'stingtes', 'tingtest', 'ingtesti', 'ng']) expect(formatLines(spans)).toEqual([
['testingt'],
['estingte'],
['stingtes'],
['tingtest'],
['ingtesti'],
['ng'],
])
}) })
it('should return an empty array if the text is empty', async () => { it('should return an empty array if the text is empty', async () => {
await ui.app.setup() await ui.app.setup()
const lines = await browser.execute((options) => { const spans = await browser.execute((options) => {
return window.app.textMeasure.getTextLines({ return window.app.textMeasure.measureTextSpans('', options)
...options, }, measureTextSpansOptions)
text: '',
})
}, getTextLinesOptions)
expect(lines).toEqual([]) expect(formatLines(spans)).toEqual([])
}) })
}) })
}) })

View file

@ -1,13 +1,7 @@
import { Box2dModel, TLAlignType } from '@tldraw/tlschema' import { Box2dModel, TLAlignType } from '@tldraw/tlschema'
import { uniqueId } from '../../utils/data' import { uniqueId } from '../../utils/data'
import { App } from '../App' import { App } from '../App'
import { INDENT, TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers' import { TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers'
// const wordSeparator = new RegExp(
// `${[0x0020, 0x00a0, 0x1361, 0x10100, 0x10101, 0x1039, 0x1091]
// .map((c) => String.fromCodePoint(c))
// .join('|')}`
// )
const textAlignmentsForLtr: Record<TLAlignType, string> = { const textAlignmentsForLtr: Record<TLAlignType, string> = {
start: 'left', start: 'left',
@ -15,6 +9,22 @@ const textAlignmentsForLtr: Record<TLAlignType, string> = {
end: 'right', end: 'right',
} }
type OverflowMode = 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
type MeasureTextSpanOpts = {
overflow: OverflowMode
width: number
height: number
padding: number
fontSize: number
fontWeight: string
fontFamily: string
fontStyle: string
lineHeight: number
textAlign: TLAlignType
}
const spaceCharacterRegex = /\s/
export class TextManager { export class TextManager {
constructor(public app: App) {} constructor(public app: App) {}
@ -33,18 +43,20 @@ export class TextManager {
return elm return elm
} }
measureText = (opts: { measureText = (
text: string textToMeasure: string,
fontStyle: string opts: {
fontWeight: string fontStyle: string
fontFamily: string fontWeight: string
fontSize: number fontFamily: string
lineHeight: number fontSize: number
width: string lineHeight: number
minWidth?: string width: string
maxWidth: string minWidth?: string
padding: string maxWidth: string
}): Box2dModel => { padding: string
}
): Box2dModel => {
const elm = this.getTextElement() const elm = this.getTextElement()
elm.setAttribute('dir', 'ltr') elm.setAttribute('dir', 'ltr')
@ -58,7 +70,7 @@ export class TextManager {
elm.style.setProperty('max-width', opts.maxWidth) elm.style.setProperty('max-width', opts.maxWidth)
elm.style.setProperty('padding', opts.padding) elm.style.setProperty('padding', opts.padding)
elm.textContent = TextHelpers.normalizeTextForDom(opts.text) elm.textContent = TextHelpers.normalizeTextForDom(textToMeasure)
const rect = elm.getBoundingClientRect() const rect = elm.getBoundingClientRect()
@ -70,191 +82,165 @@ export class TextManager {
} }
} }
getTextLines(opts: { /**
text: string * Given an html element, measure the position of each span of unbroken
wrap: boolean * word/white-space characters within any text nodes it contains.
width: number */
height: number measureElementTextNodeSpans(
padding: number element: HTMLElement,
fontSize: number { shouldTruncateToFirstLine = false }: { shouldTruncateToFirstLine?: boolean } = {}
fontWeight: string ): { spans: { box: Box2dModel; text: string }[]; didTruncate: boolean } {
fontFamily: string const spans = []
fontStyle: string
lineHeight: number
textAlign: TLAlignType
}): string[] {
const elm = this.getTextElement()
elm.style.setProperty('width', opts.width - opts.padding * 2 + 'px') // Measurements of individual spans are relative to the containing element
elm.style.setProperty('height', 'min-content') const elmBounds = element.getBoundingClientRect()
elm.style.setProperty('dir', 'ltr') const offsetX = -elmBounds.left
elm.style.setProperty('font-size', opts.fontSize + 'px') const offsetY = -elmBounds.top
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])
// Split the text into words // we measure by creating a range that spans each character in the elements text node
const words = opts.text const range = new Range()
// Replace all double spaces with tabs const textNode = element.childNodes[0]
.replace(/ {2}/g, '\t') let idx = 0
// Then split on spaces and tabs and new lines
.split(/( |\t|\n)/)
// Remove any empty strings
.filter(Boolean)
// Replacing the tabs with double spaces again.
.map((str) => (str === '\t' ? INDENT : str))
// Collect each line in an array of arrays let currentSpan = null
const lines: string[][] = [] let prevCharWasSpaceCharacter = null
let prevCharTop = 0
let didTruncate = false
for (const childNode of element.childNodes) {
if (childNode.nodeType !== Node.TEXT_NODE) continue
// The current line that we're adding words to for (const char of childNode.textContent ?? '') {
let currentLine: string[] = [] // 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]!
// Clear the text element // calculate the position of the character relative to the element
elm.textContent = '' const top = rect.top + offsetY
const left = rect.left + offsetX
const right = rect.right + offsetX
// Set initial values for the loop const isSpaceCharacter = spaceCharacterRegex.test(char)
let prevHeight = elm.offsetHeight if (
let prevTextContent = elm.textContent // 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!
for (let i = 0, n = words.length; i < n; i++) { if (currentSpan) {
const word = words[i] // 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)
}
// add the word to the text element // start a new span
elm.textContent += word currentSpan = {
box: { x: left, y: top, w: rect.width, h: rect.height },
// measure the text element's new height text: char,
const newHeight = elm.offsetHeight }
// If the height has not increased, then add the word to the current line
if (newHeight <= prevHeight) {
currentLine.push(word)
prevTextContent = elm.textContent
continue
}
/*
If the height HAS increased, then we've just caused a line break!
This could have been caused by two things:
1. we just encountered a newline character
2. the word we just added was too long to fit on the line
*/
if (word === '\n') {
// New lines are easy, just start a new line
currentLine = []
lines.push(currentLine)
prevTextContent = elm.textContent
continue
}
/*
If we have a newline because we're wrapping, then buckle the
fuck up because need to find out whether we can fit the word on a
single line or else break it into multiple lines in order to
replicate CSS's `break-word` for `overflow-wrap` and `word-wrap`.
For example:
_____________
| hello woooo|rld
Should become:
_____________
| hello |
| woooorld |
But:
_____________
| hello woooo|oooooooooooorld
Should become:
_____________
| hello | // first new line
| wooooooooo | // second new line
| ooooorld |
*/
// Save the state of the text content that caused the break to occur.
// We'll put this back again at the end of the loop, so that we can
// continue from this point.
const afterTextContent: string = elm.textContent
// Set the text content to the previous text content, before adding
// the word, so that we can begin to find line breaks.
elm.textContent = prevTextContent
// Force a new line, since we know that the text will break the line
// and we want to start measuring from the start of the line.
elm.textContent += '\n'
// Split the word into individual characters.
const chars = [...word]
// Add the first character to the measurement element's text content.
elm.textContent += chars[0]
// Set the "previous height" to the text element's offset height.
prevHeight = elm.offsetHeight
// Similar to how we're breaking with words, we're going to loop
// through each character looking for new lines within the word
// (sublines). We'll start with a collection of one subline that
// contains the first character in the word.
let currentSubLine: string[] = [chars[0]]
const subLines: string[][] = [currentSubLine]
// For each remaining character in the word...
for (let i = 1; i < chars.length; i++) {
const char = chars[i]
// ...add the character to the text element
elm.textContent += char
// ...and measure the height
const newHeight = elm.offsetHeight
if (newHeight > prevHeight) {
// If the height has increased, then we've triggered a "break-word".
// Create a new current subline containing the character, and add
// it to the sublines array.
currentSubLine = [char]
subLines.push(currentSubLine)
// Also update the prev height for next time
prevHeight = newHeight
} else { } else {
// If the height hasn't increased, then we're still on the same // otherwise we just need to extend the current span with the next character
// subline and can just push the char in. currentSpan.box.w = right - currentSpan.box.x
currentSubLine.push(char) currentSpan.text += char
} }
prevCharWasSpaceCharacter = isSpaceCharacter
prevCharTop = top
idx += char.length
} }
// Finally, turn each subline of characters into a string and push
// each line into the lines array.
const joinedSubLines = subLines.map((b) => [b.join('')])
lines.push(...joinedSubLines)
// Set the current line to the last subline
currentLine = lines[lines.length - 1]
// Restore the text content that caused the line break to occur
elm.textContent = afterTextContent
// And set prevHeight to the new height
prevHeight = elm.offsetHeight
prevTextContent = elm.textContent
} }
// We can remove the measurement div now. // Add the last span
elm.remove() if (currentSpan) {
spans.push(currentSpan)
}
// We're done! Join the words in each line. return { spans, didTruncate }
const result: string[] = lines.map((line) => line.join('').trimEnd()) }
return result /**
* 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: MeasureTextSpanOpts
): { text: string; box: Box2dModel }[] {
const shouldTruncateToFirstLine =
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) {
element.style.setProperty('overflow-wrap', 'anywhere')
element.style.setProperty('word-break', 'break-all')
}
// Render the text into the measurement element:
element.textContent = textToMeasure
// actually measure the text:
const { spans, didTruncate } = this.measureElementTextNodeSpans(element, {
shouldTruncateToFirstLine,
})
if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
// we need to measure the ellipsis to know how much space it takes up
element.textContent = '…'
const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(element).spans[0].box.w)
// then, we need to subtract that space from the width we have and measure again:
element.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
element.textContent = textToMeasure
const truncatedSpans = this.measureElementTextNodeSpans(element, {
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
}
element.remove()
return spans
} }
} }

View file

@ -29,8 +29,8 @@ import { computed, EMPTY_ARRAY } from 'signia'
import { SVGContainer } from '../../../components/SVGContainer' import { SVGContainer } from '../../../components/SVGContainer'
import { defineShape } from '../../../config/TLShapeDefinition' import { defineShape } from '../../../config/TLShapeDefinition'
import { ARROW_LABEL_FONT_SIZES, FONT_FAMILIES, TEXT_PROPS } from '../../../constants' import { ARROW_LABEL_FONT_SIZES, FONT_FAMILIES, TEXT_PROPS } from '../../../constants'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { getPerfectDashProps } from '../shared/getPerfectDashProps' import { getPerfectDashProps } from '../shared/getPerfectDashProps'
import { getTextSvgElement } from '../shared/getTextSvgElement'
import { getShapeFillSvg, ShapeFill } from '../shared/ShapeFill' import { getShapeFillSvg, ShapeFill } from '../shared/ShapeFill'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
import { import {
@ -845,9 +845,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
if (!info) return null if (!info) return null
if (!text.trim()) return null if (!text.trim()) return null
const { w, h } = this.app.textMeasure.measureText({ const { w, h } = this.app.textMeasure.measureText(text, {
...TEXT_PROPS, ...TEXT_PROPS,
text,
fontFamily: FONT_FAMILIES[font], fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size], fontSize: ARROW_LABEL_FONT_SIZES[size],
width: 'fit-content', width: 'fit-content',
@ -859,9 +858,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
if (bounds.width > bounds.height) { if (bounds.width > bounds.height) {
width = Math.max(Math.min(w, 64), Math.min(bounds.width - 64, w)) width = Math.max(Math.min(w, 64), Math.min(bounds.width - 64, w))
const { w: squishedWidth, h: squishedHeight } = this.app.textMeasure.measureText({ const { w: squishedWidth, h: squishedHeight } = this.app.textMeasure.measureText(text, {
...TEXT_PROPS, ...TEXT_PROPS,
text,
fontFamily: FONT_FAMILIES[font], fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size], fontSize: ARROW_LABEL_FONT_SIZES[size],
width: width + 'px', width: width + 'px',
@ -874,9 +872,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
if (width > 16 * ARROW_LABEL_FONT_SIZES[size]) { if (width > 16 * ARROW_LABEL_FONT_SIZES[size]) {
width = 16 * ARROW_LABEL_FONT_SIZES[size] width = 16 * ARROW_LABEL_FONT_SIZES[size]
const { w: squishedWidth, h: squishedHeight } = this.app.textMeasure.measureText({ const { w: squishedWidth, h: squishedHeight } = this.app.textMeasure.measureText(text, {
...TEXT_PROPS, ...TEXT_PROPS,
text,
fontFamily: FONT_FAMILIES[font], fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size], fontSize: ARROW_LABEL_FONT_SIZES[size],
width: width + 'px', width: width + 'px',
@ -1053,26 +1050,19 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
fontFamily: font, fontFamily: font,
padding: 0, padding: 0,
textAlign: 'middle' as const, textAlign: 'middle' as const,
width: labelSize.w - 8,
verticalTextAlign: 'middle' as const, verticalTextAlign: 'middle' as const,
width: labelSize.w,
height: labelSize.h, height: labelSize.h,
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: 'normal', fontWeight: 'normal',
overflow: 'wrap' as const,
} }
const lines = this.app.textMeasure.getTextLines({ const textElm = createTextSvgElementFromSpans(
text: shape.props.text, this.app,
wrap: true, this.app.textMeasure.measureTextSpans(shape.props.text, opts),
...opts, opts
width: labelSize.w - 8, )
})
const textElm = getTextSvgElement(this.app, {
lines,
...opts,
width: labelSize.w - 8,
})
textElm.setAttribute('fill', colors.fill[shape.props.labelColor]) textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
const children = Array.from(textElm.children) as unknown as SVGTSpanElement[] const children = Array.from(textElm.children) as unknown as SVGTSpanElement[]

View file

@ -7,9 +7,11 @@ import {
TLShapeId, TLShapeId,
TLShapeType, TLShapeType,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
import { last } from '@tldraw/utils'
import { SVGContainer } from '../../../components/SVGContainer' import { SVGContainer } from '../../../components/SVGContainer'
import { defineShape } from '../../../config/TLShapeDefinition' import { defineShape } from '../../../config/TLShapeDefinition'
import { defaultEmptyAs } from '../../../utils/string' import { defaultEmptyAs } from '../../../utils/string'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
import { TLBoxUtil } from '../TLBoxUtil' import { TLBoxUtil } from '../TLBoxUtil'
import { OnResizeEndHandler } from '../TLShapeUtil' import { OnResizeEndHandler } from '../TLShapeUtil'
@ -103,50 +105,35 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
fontSize: 12, fontSize: 12,
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
textAlign: 'start' as const, textAlign: 'start' as const,
width: shape.props.w + 16, width: shape.props.w,
height: 30, height: 32,
padding: 8, padding: 0,
lineHeight: 1, lineHeight: 1,
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: 'normal', fontWeight: 'normal',
overflow: 'truncate-ellipsis' as const,
verticalTextAlign: 'middle' as const,
} }
let textContent = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203) const spans = this.app.textMeasure.measureTextSpans(
defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203),
opts
)
const lines = this.app.textMeasure.getTextLines({ const firstSpan = spans[0]
text: textContent, const lastSpan = last(spans)!
wrap: true, const labelTextWidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
const text = createTextSvgElementFromSpans(this.app, spans, {
offsetY: -opts.height - 2,
...opts, ...opts,
}) })
textContent = lines.length > 1 ? lines[0] + '…' : lines[0]
const size = this.app.textMeasure.measureText({
fontSize: 12,
fontFamily: 'Inter, sans-serif',
lineHeight: 1,
fontStyle: 'normal',
fontWeight: 'normal',
text: textContent,
width: 'fit-content',
maxWidth: 'unset',
padding: '0px',
})
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
text.setAttribute('x', '0')
text.setAttribute('y', -(8 + size.h / 2) + 'px')
text.setAttribute('font-family', '"Inter", sans-serif')
text.setAttribute('font-size', '12px')
text.setAttribute('font-weight', '400')
text.style.setProperty('transform', labelTranslate) text.style.setProperty('transform', labelTranslate)
text.textContent = textContent
const textBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') const textBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
textBg.setAttribute('x', ' -4px') textBg.setAttribute('x', '-8px')
textBg.setAttribute('y', -(16 + size.h) + 'px') textBg.setAttribute('y', -opts.height - 4 + 'px')
textBg.setAttribute('width', size.w + 8 + 'px') textBg.setAttribute('width', labelTextWidth + 16 + 'px')
textBg.setAttribute('height', size.h + 8 + 'px') textBg.setAttribute('height', `${opts.height}px`)
textBg.setAttribute('rx', 4 + 'px') textBg.setAttribute('rx', 4 + 'px')
textBg.setAttribute('ry', 4 + 'px') textBg.setAttribute('ry', 4 + 'px')
textBg.setAttribute('fill', colors.background) textBg.setAttribute('fill', colors.background)

View file

@ -23,7 +23,7 @@ import { SVGContainer } from '../../../components/SVGContainer'
import { defineShape } from '../../../config/TLShapeDefinition' import { defineShape } from '../../../config/TLShapeDefinition'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants' import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
import { App } from '../../App' import { App } from '../../App'
import { getTextSvgElement } from '../shared/getTextSvgElement' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { TextLabel } from '../shared/TextLabel' import { TextLabel } from '../shared/TextLabel'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
@ -639,50 +639,33 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
if (props.text) { if (props.text) {
const bounds = this.bounds(shape) const bounds = this.bounds(shape)
const padding = 16
const opts = { const opts = {
fontSize: LABEL_FONT_SIZES[shape.props.size], fontSize: LABEL_FONT_SIZES[shape.props.size],
fontFamily: font, fontFamily: font,
textAlign: shape.props.align, textAlign: shape.props.align,
padding,
verticalTextAlign: shape.props.verticalAlign, verticalTextAlign: shape.props.verticalAlign,
padding: 16,
lineHeight: TEXT_PROPS.lineHeight, lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: 'normal', fontWeight: 'normal',
width: Math.ceil(bounds.width), width: Math.ceil(bounds.width),
height: Math.ceil(bounds.height), height: Math.ceil(bounds.height),
overflow: 'wrap' as const,
} }
const lines = this.app.textMeasure.getTextLines({ const spans = this.app.textMeasure.measureTextSpans(props.text, opts)
text: props.text,
wrap: true,
...opts,
})
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const labelSize = getLabelSize(this.app, shape) const textBgEl = createTextSvgElementFromSpans(this.app, spans, {
const textBgEl = getTextSvgElement(this.app, {
...opts, ...opts,
lines,
strokeWidth: 2, strokeWidth: 2,
stroke: colors.background, stroke: colors.background,
fill: colors.background, fill: colors.background,
width: labelSize.w,
}) })
switch (shape.props.align) {
case 'middle': {
textBgEl.setAttribute('transform', `translate(${(bounds.width - labelSize.w) / 2}, 0)`)
break
}
case 'end': {
textBgEl.setAttribute('transform', `translate(${bounds.width - labelSize.w}, 0)`)
break
}
}
const textElm = textBgEl.cloneNode(true) as SVGTextElement const textElm = textBgEl.cloneNode(true) as SVGTextElement
textElm.setAttribute('fill', colors.fill[shape.props.labelColor]) textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
textElm.setAttribute('stroke', 'none') textElm.setAttribute('stroke', 'none')
@ -939,9 +922,8 @@ function getLabelSize(app: App, shape: TLGeoShape) {
return { w: 0, h: 0 } return { w: 0, h: 0 }
} }
const minSize = app.textMeasure.measureText({ const minSize = app.textMeasure.measureText('w', {
...TEXT_PROPS, ...TEXT_PROPS,
text: 'w',
fontFamily: FONT_FAMILIES[shape.props.font], fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size], fontSize: LABEL_FONT_SIZES[shape.props.size],
width: 'fit-content', width: 'fit-content',
@ -956,9 +938,8 @@ function getLabelSize(app: App, shape: TLGeoShape) {
xl: 10, xl: 10,
} }
const size = app.textMeasure.measureText({ const size = app.textMeasure.measureText(text, {
...TEXT_PROPS, ...TEXT_PROPS,
text: text,
fontFamily: FONT_FAMILIES[shape.props.font], fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size], fontSize: LABEL_FONT_SIZES[shape.props.size],
width: 'fit-content', width: 'fit-content',

View file

@ -3,7 +3,7 @@ import { noteShapeMigrations, noteShapeTypeValidator, TLNoteShape } from '@tldra
import { defineShape } from '../../../config/TLShapeDefinition' import { defineShape } from '../../../config/TLShapeDefinition'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants' import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
import { App } from '../../App' import { App } from '../../App'
import { getTextSvgElement } from '../shared/getTextSvgElement' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { TextLabel } from '../shared/TextLabel' import { TextLabel } from '../shared/TextLabel'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
@ -143,21 +143,16 @@ export class TLNoteUtil extends TLShapeUtil<TLNoteShape> {
lineHeight: TEXT_PROPS.lineHeight, lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: 'normal', fontWeight: 'normal',
overflow: 'wrap' as const,
offsetX: 0,
} }
const lines = this.app.textMeasure.getTextLines({ const spans = this.app.textMeasure.measureTextSpans(shape.props.text, opts)
text: shape.props.text,
wrap: true,
...opts,
})
opts.padding = PADDING
opts.width = bounds.width opts.width = bounds.width
opts.padding = PADDING
const textElm = getTextSvgElement(this.app, { const textElm = createTextSvgElementFromSpans(this.app, spans, opts)
lines,
...opts,
})
textElm.setAttribute('fill', colors.text) textElm.setAttribute('fill', colors.text)
textElm.setAttribute('transform', `translate(0 ${PADDING})`) textElm.setAttribute('transform', `translate(0 ${PADDING})`)
g.appendChild(textElm) g.appendChild(textElm)
@ -213,9 +208,8 @@ export const TLNoteShapeDef = defineShape<TLNoteShape, TLNoteUtil>({
function getGrowY(app: App, shape: TLNoteShape, prevGrowY = 0) { function getGrowY(app: App, shape: TLNoteShape, prevGrowY = 0) {
const PADDING = 17 const PADDING = 17
const nextTextSize = app.textMeasure.measureText({ const nextTextSize = app.textMeasure.measureText(shape.props.text, {
...TEXT_PROPS, ...TEXT_PROPS,
text: shape.props.text,
fontFamily: FONT_FAMILIES[shape.props.font], fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size], fontSize: LABEL_FONT_SIZES[shape.props.size],
width: NOTE_SIZE - PADDING * 2 + 'px', width: NOTE_SIZE - PADDING * 2 + 'px',

View file

@ -7,7 +7,7 @@ import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../../../constants'
import { stopEventPropagation } from '../../../utils/dom' import { stopEventPropagation } from '../../../utils/dom'
import { WeakMapCache } from '../../../utils/WeakMapCache' import { WeakMapCache } from '../../../utils/WeakMapCache'
import { App } from '../../App' import { App } from '../../App'
import { getTextSvgElement } from '../shared/getTextSvgElement' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { resizeScaled } from '../shared/resizeScaled' import { resizeScaled } from '../shared/resizeScaled'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
import { useEditableText } from '../shared/useEditableText' import { useEditableText } from '../shared/useEditableText'
@ -174,25 +174,23 @@ export class TLTextUtil extends TLShapeUtil<TLTextShape> {
lineHeight: TEXT_PROPS.lineHeight, lineHeight: TEXT_PROPS.lineHeight,
fontStyle: 'normal', fontStyle: 'normal',
fontWeight: 'normal', fontWeight: 'normal',
overflow: 'wrap' as const,
} }
const lines = this.app.textMeasure.getTextLines({
text: text,
wrap: true,
...opts,
})
const color = colors.fill[shape.props.color] const color = colors.fill[shape.props.color]
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const textBgEl = getTextSvgElement(this.app, { const textBgEl = createTextSvgElementFromSpans(
lines, this.app,
...opts, this.app.textMeasure.measureTextSpans(text, opts),
stroke: colors.background, {
strokeWidth: 2, ...opts,
fill: colors.background, stroke: colors.background,
padding: 0, strokeWidth: 2,
}) fill: colors.background,
padding: 0,
}
)
const textElm = textBgEl.cloneNode(true) as SVGTextElement const textElm = textBgEl.cloneNode(true) as SVGTextElement
textElm.setAttribute('fill', color) textElm.setAttribute('fill', color)
@ -390,9 +388,8 @@ function getTextSize(app: App, props: TLTextShape['props']) {
: // `measureText` floors the number so we need to do the same here to avoid issues. : // `measureText` floors the number so we need to do the same here to avoid issues.
Math.floor(Math.max(minWidth, w)) + 'px' Math.floor(Math.max(minWidth, w)) + 'px'
const result = app.textMeasure.measureText({ const result = app.textMeasure.measureText(text, {
...TEXT_PROPS, ...TEXT_PROPS,
text,
fontFamily: FONT_FAMILIES[font], fontFamily: FONT_FAMILIES[font],
fontSize: fontSize, fontSize: fontSize,
width: cw, width: cw,

View file

@ -0,0 +1,94 @@
import { Box2d } from '@tldraw/primitives'
import { Box2dModel, TLAlignType, TLVerticalAlignType } from '@tldraw/tlschema'
import { correctSpacesToNbsp } from '../../../utils/string'
import { App } from '../../App'
/** Get an SVG element for a text shape. */
export function createTextSvgElementFromSpans(
app: App,
spans: { text: string; box: Box2dModel }[],
opts: {
fontSize: number
fontFamily: string
textAlign: TLAlignType
verticalTextAlign: TLVerticalAlignType
fontWeight: string
fontStyle: string
lineHeight: number
width: number
height: number
stroke?: string
strokeWidth?: number
fill?: string
padding?: number
offsetX?: number
offsetY?: number
}
) {
const { padding = 0 } = opts
// Create the text element
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
textElm.setAttribute('font-size', opts.fontSize + 'px')
textElm.setAttribute('font-family', opts.fontFamily)
textElm.setAttribute('font-style', opts.fontStyle)
textElm.setAttribute('font-weight', opts.fontWeight)
textElm.setAttribute('line-height', opts.lineHeight * opts.fontSize + 'px')
textElm.setAttribute('dominant-baseline', 'mathematical')
textElm.setAttribute('alignment-baseline', 'mathematical')
if (spans.length === 0) return textElm
const bounds = Box2d.From(spans[0].box)
for (const { box } of spans) {
bounds.union(box)
}
const offsetX = padding + (opts.offsetX ?? 0)
// const offsetY = (Math.ceil(opts.height) - bounds.height + opts.fontSize) / 2 + (opts.offsetY ?? 0)
const offsetY =
(opts.offsetY ?? 0) +
opts.fontSize / 2 +
(opts.verticalTextAlign === 'start'
? padding
: opts.verticalTextAlign === 'end'
? opts.height - padding - bounds.height
: (Math.ceil(opts.height) - bounds.height) / 2)
// Create text span elements for each word
let currentLineTop = null
for (const { text, box } of spans) {
// if we broke a line, add a line break span. This helps tools like
// figma import our exported svg correctly
const didBreakLine = currentLineTop !== null && box.y > currentLineTop
if (didBreakLine) {
const lineBreakTspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
lineBreakTspan.setAttribute('alignment-baseline', 'mathematical')
lineBreakTspan.setAttribute('x', offsetX + 'px')
lineBreakTspan.setAttribute('y', box.y + offsetY + 'px')
lineBreakTspan.textContent = '\n'
textElm.appendChild(lineBreakTspan)
}
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
tspan.setAttribute('alignment-baseline', 'mathematical')
tspan.setAttribute('x', box.x + offsetX + 'px')
tspan.setAttribute('y', box.y + offsetY + 'px')
const cleanText = correctSpacesToNbsp(text)
tspan.textContent = cleanText
textElm.appendChild(tspan)
currentLineTop = box.y
}
if (opts.stroke && opts.strokeWidth) {
textElm.setAttribute('stroke', opts.stroke)
textElm.setAttribute('stroke-width', opts.strokeWidth + 'px')
}
if (opts.fill) {
textElm.setAttribute('fill', opts.fill)
}
return textElm
}

View file

@ -1,133 +0,0 @@
import { TLAlignType, TLVerticalAlignType } from '@tldraw/tlschema'
import { TEXT_PROPS } from '../../../constants'
import { correctSpacesToNbsp } from '../../../utils/string'
import { App } from '../../App'
/** Get an SVG element for a text shape. */
export function getTextSvgElement(
app: App,
opts: {
lines: string[]
fontSize: number
fontFamily: string
textAlign: TLAlignType
verticalTextAlign: TLVerticalAlignType
fontWeight: string
fontStyle: string
lineHeight: number
width: number
height: number
stroke?: string
strokeWidth?: number
fill?: string
padding?: number
}
) {
const { padding = 0 } = opts
// Create the text element
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
textElm.setAttribute('font-size', opts.fontSize + 'px')
textElm.setAttribute('font-family', opts.fontFamily)
textElm.setAttribute('font-style', opts.fontStyle)
textElm.setAttribute('font-weight', opts.fontWeight)
textElm.setAttribute('line-height', opts.lineHeight * opts.fontSize + 'px')
textElm.setAttribute('dominant-baseline', 'mathematical')
textElm.setAttribute('alignment-baseline', 'mathematical')
const lines = opts.lines.map((line) => line)
const tspans: SVGElement[] = []
const innerHeight = lines.length * (opts.lineHeight * opts.fontSize)
const offsetX = padding
let offsetY: number
switch (opts.verticalTextAlign) {
case 'start': {
offsetY = padding
break
}
case 'end': {
offsetY = opts.height - padding - innerHeight
break
}
default: {
offsetY = (Math.ceil(opts.height) - innerHeight) / 2
}
}
// Create text span elements for each line
for (let i = 0; i < lines.length; i++) {
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
tspan.setAttribute('alignment-baseline', 'mathematical')
const cleanText = correctSpacesToNbsp(lines[i])
tspan.textContent = cleanText
if (lines.length > 1 && i < lines.length - 1) {
tspan.textContent += '\n'
}
tspan.setAttribute(
'y',
offsetY + opts.fontSize / 2 + opts.lineHeight * opts.fontSize * i + 'px'
)
textElm.appendChild(tspan)
tspans.push(tspan)
}
if (opts.stroke && opts.strokeWidth) {
textElm.setAttribute('stroke', opts.stroke)
textElm.setAttribute('stroke-width', opts.strokeWidth + 'px')
}
if (opts.fill) {
textElm.setAttribute('fill', opts.fill)
}
switch (opts.textAlign) {
case 'middle': {
textElm.setAttribute('text-align', 'center')
textElm.setAttribute('text-anchor', 'start')
tspans.forEach((tspan, i) => {
const w = app.textMeasure.measureText({
...TEXT_PROPS,
text: lines[i],
fontFamily: opts.fontFamily,
fontSize: opts.fontSize,
width: 'fit-content',
padding: `${padding}px`,
}).w
tspan.setAttribute('x', offsetX + (opts.width - w) / 2 + '')
})
break
}
case 'end': {
textElm.setAttribute('text-align', 'right')
textElm.setAttribute('text-anchor', 'start')
tspans.forEach((tspan, i) => {
const w = app.textMeasure.measureText({
...TEXT_PROPS,
text: lines[i],
fontFamily: opts.fontFamily,
fontSize: opts.fontSize,
width: 'fit-content',
padding: `${padding}px`,
}).w
tspan.setAttribute('x', offsetX + opts.width - w + '')
})
break
}
default: {
textElm.setAttribute('text-align', 'left')
textElm.setAttribute('text-anchor', 'start')
tspans.forEach((tspan) => tspan.setAttribute('x', offsetX + ''))
}
}
return textElm
}

View file

@ -72,17 +72,19 @@ export class TestApp extends App {
this.elm.getBoundingClientRect = () => this.bounds as DOMRect this.elm.getBoundingClientRect = () => this.bounds as DOMRect
document.body.appendChild(this.elm) document.body.appendChild(this.elm)
this.textMeasure.measureText = (opts: { this.textMeasure.measureText = (
text: string textToMeasure: string,
fontStyle: string opts: {
fontWeight: string fontStyle: string
fontFamily: string fontWeight: string
fontSize: number fontFamily: string
lineHeight: number fontSize: number
width: string lineHeight: number
maxWidth: string width: string
}): Box2dModel => { maxWidth: string
const breaks = opts.text.split('\n') }
): Box2dModel => {
const breaks = textToMeasure.split('\n')
const longest = breaks.reduce((acc, curr) => { const longest = breaks.reduce((acc, curr) => {
return curr.length > acc.length ? curr : acc return curr.length > acc.length ? curr : acc
}, '') }, '')
@ -99,6 +101,16 @@ export class TestApp extends App {
: breaks.length) * opts.fontSize, : breaks.length) * opts.fontSize,
} }
} }
this.textMeasure.measureTextSpans = (textToMeasure, opts) => {
const box = this.textMeasure.measureText(textToMeasure, {
...opts,
width: `${opts.width}px`,
padding: `${opts.padding}px`,
maxWidth: 'auto',
})
return [{ box, text: textToMeasure }]
}
} }
elm: HTMLDivElement elm: HTMLDivElement

View file

@ -10,5 +10,5 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
</g> </g>
</mask><pattern id=\\"hash_pattern\\" width=\\"8\\" height=\\"8\\" patternUnits=\\"userSpaceOnUse\\"> </mask><pattern id=\\"hash_pattern\\" width=\\"8\\" height=\\"8\\" patternUnits=\\"userSpaceOnUse\\">
<rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"\\" mask=\\"url(#hash_pattern_mask)\\"></rect> <rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"\\" mask=\\"url(#hash_pattern_mask)\\"></rect>
</pattern><style></style></defs><g transform=\\"matrix(1, 0, 0, 1, 0, 0)\\" opacity=\\"1\\"><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\"></path><g><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\" text-align=\\"center\\" text-anchor=\\"start\\" transform=\\"translate(-26.5, 0)\\"></text><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\" text-align=\\"center\\" text-anchor=\\"start\\" transform=\\"translate(-26.5, 0)\\" fill=\\"\\" stroke=\\"none\\"></text></g></g><path d=\\"M0, 0L50, 0,50, 50,0, 50Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 100, 100)\\" opacity=\\"1\\"></path><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 400, 400)\\" opacity=\\"1\\"></path></svg>" </pattern><style></style></defs><g transform=\\"matrix(1, 0, 0, 1, 0, 0)\\" opacity=\\"1\\"><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\"></path><g><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\"><tspan alignment-baseline=\\"mathematical\\" x=\\"16px\\" y=\\"-181px\\">Hello&nbsp;world</tspan></text><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\" fill=\\"\\" stroke=\\"none\\"><tspan alignment-baseline=\\"mathematical\\" x=\\"16px\\" y=\\"-181px\\">Hello&nbsp;world</tspan></text></g></g><path d=\\"M0, 0L50, 0,50, 50,0, 50Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 100, 100)\\" opacity=\\"1\\"></path><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 400, 400)\\" opacity=\\"1\\"></path></svg>"
`; `;

View file

@ -117,6 +117,8 @@ export class Box2d {
// (undocumented) // (undocumented)
translate(delta: VecLike): this; translate(delta: VecLike): this;
// (undocumented) // (undocumented)
union(box: Box2dModel): this;
// (undocumented)
w: number; w: number;
// (undocumented) // (undocumented)
get width(): number; get width(): number;

View file

@ -312,6 +312,20 @@ export class Box2d {
this.height = Math.abs(b1y - b0y) this.height = Math.abs(b1y - b0y)
} }
union(box: Box2dModel) {
const minX = Math.min(this.minX, box.x)
const minY = Math.min(this.minY, box.y)
const maxX = Math.max(this.maxX, box.x + box.w)
const maxY = Math.max(this.maxY, box.y + box.h)
this.x = minX
this.y = minY
this.width = maxX - minX
this.height = maxY - minY
return this
}
static From(box: Box2dModel) { static From(box: Box2dModel) {
return new Box2d(box.x, box.y, box.w, box.h) return new Box2d(box.x, box.y, box.w, box.h)
} }

View file

@ -85,9 +85,8 @@ export async function pastePlainText(app: App, text: string, point?: VecLike) {
align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle' align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle'
} }
const rawSize = app.textMeasure.measureText({ const rawSize = app.textMeasure.measureText(textToPaste, {
...TEXT_PROPS, ...TEXT_PROPS,
text: textToPaste,
fontFamily: FONT_FAMILIES[defaultProps.font], fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size], fontSize: FONT_SIZES[defaultProps.size],
width: 'fit-content', width: 'fit-content',
@ -99,9 +98,8 @@ export async function pastePlainText(app: App, text: string, point?: VecLike) {
) )
if (rawSize.w > minWidth) { if (rawSize.w > minWidth) {
const shrunkSize = app.textMeasure.measureText({ const shrunkSize = app.textMeasure.measureText(textToPaste, {
...TEXT_PROPS, ...TEXT_PROPS,
text: textToPaste,
fontFamily: FONT_FAMILIES[defaultProps.font], fontFamily: FONT_FAMILIES[defaultProps.font],
fontSize: FONT_SIZES[defaultProps.size], fontSize: FONT_SIZES[defaultProps.size],
width: minWidth + 'px', width: minWidth + 'px',