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:
parent
97ffc168c1
commit
d48e403ed1
14 changed files with 482 additions and 574 deletions
|
@ -1,3 +1,4 @@
|
|||
import { Box2dModel } from '@tldraw/editor'
|
||||
import { runtime, ui } from '../helpers'
|
||||
import { diffScreenshot, takeRegionScreenshot } from '../helpers/webdriver'
|
||||
import { describe, env, it } from '../mocha-ext'
|
||||
|
@ -13,7 +14,7 @@ describe('text', () => {
|
|||
const tests = [
|
||||
{
|
||||
name: 'multiline (align center)',
|
||||
fails: true,
|
||||
fails: false,
|
||||
handler: async () => {
|
||||
await ui.tools.click('select')
|
||||
await ui.tools.click('text')
|
||||
|
@ -82,7 +83,6 @@ describe('text', () => {
|
|||
|
||||
describe('text measurement', () => {
|
||||
const measureTextOptions = {
|
||||
text: 'testing',
|
||||
width: 'fit-content',
|
||||
fontFamily: 'var(--tl-font-draw)',
|
||||
fontSize: 24,
|
||||
|
@ -93,11 +93,10 @@ describe('text measurement', () => {
|
|||
maxWidth: 'auto',
|
||||
}
|
||||
|
||||
const getTextLinesOptions = {
|
||||
text: 'testing',
|
||||
const measureTextSpansOptions = {
|
||||
width: 100,
|
||||
height: 1000,
|
||||
wrap: true,
|
||||
overflow: 'wrap' as const,
|
||||
padding: 0,
|
||||
fontSize: 24,
|
||||
fontWeight: 'normal',
|
||||
|
@ -107,186 +106,173 @@ describe('text measurement', () => {
|
|||
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({}, () => {
|
||||
it('should measure text', async () => {
|
||||
await ui.app.setup()
|
||||
const { w, h } = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureText({
|
||||
...options,
|
||||
})
|
||||
return window.app.textMeasure.measureText('testing', options)
|
||||
}, measureTextOptions)
|
||||
|
||||
expect(w).toBeCloseTo(85.828125, 1)
|
||||
expect(h).toBeCloseTo(32.3984375, 1)
|
||||
expect(w).toBeCloseTo(85.828125, 0)
|
||||
expect(h).toBeCloseTo(32.3984375, 0)
|
||||
})
|
||||
|
||||
it('should get a single text line', async () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
// The text-measurement tests below this point aren't super useful any
|
||||
// more. They were added when we had a different approach to text SVG
|
||||
// exports (trying to replicate browser decisions with our own code) to
|
||||
// what we do now (letting the browser make those decisions then
|
||||
// measuring the results).
|
||||
//
|
||||
// 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 () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
width: 50,
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('testing', { ...options, width: 50 })
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
expect(lines).toEqual(['test', 'ing'])
|
||||
expect(formatLines(spans)).toEqual([['test'], ['ing']])
|
||||
})
|
||||
|
||||
it('should wrap between words when it has to', async () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: 'testing testing',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('testing testing', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
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()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: 'testing testing',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('testing testing', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
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()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: 'testing testing ',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('testing testing ', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
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()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('testing testing ', {
|
||||
...options,
|
||||
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 () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans(' testing testing', {
|
||||
...options,
|
||||
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 () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: ' testing testing',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans(' testing testing', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
expect(lines).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'])
|
||||
expect(formatLines(spans)).toEqual([[' '], ['testing', ' '], ['testing']])
|
||||
})
|
||||
|
||||
it('should handle multiline text', async () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: 'testing testing ',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans(' test\ning testing \n t', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
expect(lines).toEqual(['testing', 'testing'])
|
||||
expect(formatLines(spans)).toEqual([
|
||||
[' ', 'test', '\n'],
|
||||
['ing', ' '],
|
||||
['testing', ' \n'],
|
||||
[' ', 't'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should break long strings of text', async () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: 'testingtestingtestingtestingtestingtesting',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans(
|
||||
'testingtestingtestingtestingtestingtesting',
|
||||
options
|
||||
)
|
||||
}, 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 () => {
|
||||
await ui.app.setup()
|
||||
const lines = await browser.execute((options) => {
|
||||
return window.app.textMeasure.getTextLines({
|
||||
...options,
|
||||
text: '',
|
||||
})
|
||||
}, getTextLinesOptions)
|
||||
const spans = await browser.execute((options) => {
|
||||
return window.app.textMeasure.measureTextSpans('', options)
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
expect(lines).toEqual([])
|
||||
expect(formatLines(spans)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import { Box2dModel, TLAlignType } from '@tldraw/tlschema'
|
||||
import { uniqueId } from '../../utils/data'
|
||||
import { App } from '../App'
|
||||
import { INDENT, TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers'
|
||||
|
||||
// const wordSeparator = new RegExp(
|
||||
// `${[0x0020, 0x00a0, 0x1361, 0x10100, 0x10101, 0x1039, 0x1091]
|
||||
// .map((c) => String.fromCodePoint(c))
|
||||
// .join('|')}`
|
||||
// )
|
||||
import { TextHelpers } from '../shapeutils/TLTextUtil/TextHelpers'
|
||||
|
||||
const textAlignmentsForLtr: Record<TLAlignType, string> = {
|
||||
start: 'left',
|
||||
|
@ -15,6 +9,22 @@ const textAlignmentsForLtr: Record<TLAlignType, string> = {
|
|||
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 {
|
||||
constructor(public app: App) {}
|
||||
|
||||
|
@ -33,18 +43,20 @@ export class TextManager {
|
|||
return elm
|
||||
}
|
||||
|
||||
measureText = (opts: {
|
||||
text: string
|
||||
fontStyle: string
|
||||
fontWeight: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
width: string
|
||||
minWidth?: string
|
||||
maxWidth: string
|
||||
padding: string
|
||||
}): Box2dModel => {
|
||||
measureText = (
|
||||
textToMeasure: string,
|
||||
opts: {
|
||||
fontStyle: string
|
||||
fontWeight: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
width: string
|
||||
minWidth?: string
|
||||
maxWidth: string
|
||||
padding: string
|
||||
}
|
||||
): Box2dModel => {
|
||||
const elm = this.getTextElement()
|
||||
|
||||
elm.setAttribute('dir', 'ltr')
|
||||
|
@ -58,7 +70,7 @@ export class TextManager {
|
|||
elm.style.setProperty('max-width', opts.maxWidth)
|
||||
elm.style.setProperty('padding', opts.padding)
|
||||
|
||||
elm.textContent = TextHelpers.normalizeTextForDom(opts.text)
|
||||
elm.textContent = TextHelpers.normalizeTextForDom(textToMeasure)
|
||||
|
||||
const rect = elm.getBoundingClientRect()
|
||||
|
||||
|
@ -70,191 +82,165 @@ export class TextManager {
|
|||
}
|
||||
}
|
||||
|
||||
getTextLines(opts: {
|
||||
text: string
|
||||
wrap: boolean
|
||||
width: number
|
||||
height: number
|
||||
padding: number
|
||||
fontSize: number
|
||||
fontWeight: string
|
||||
fontFamily: string
|
||||
fontStyle: string
|
||||
lineHeight: number
|
||||
textAlign: TLAlignType
|
||||
}): string[] {
|
||||
const elm = this.getTextElement()
|
||||
/**
|
||||
* 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: Box2dModel; text: string }[]; didTruncate: boolean } {
|
||||
const spans = []
|
||||
|
||||
elm.style.setProperty('width', opts.width - opts.padding * 2 + '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])
|
||||
// Measurements of individual spans are relative to the containing element
|
||||
const elmBounds = element.getBoundingClientRect()
|
||||
const offsetX = -elmBounds.left
|
||||
const offsetY = -elmBounds.top
|
||||
|
||||
// Split the text into words
|
||||
const words = opts.text
|
||||
// Replace all double spaces with tabs
|
||||
.replace(/ {2}/g, '\t')
|
||||
// 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))
|
||||
// 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
|
||||
|
||||
// Collect each line in an array of arrays
|
||||
const lines: string[][] = []
|
||||
let currentSpan = null
|
||||
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
|
||||
let currentLine: string[] = []
|
||||
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]!
|
||||
|
||||
// Clear the text element
|
||||
elm.textContent = ''
|
||||
// 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
|
||||
|
||||
// Set initial values for the loop
|
||||
let prevHeight = elm.offsetHeight
|
||||
let prevTextContent = elm.textContent
|
||||
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!
|
||||
|
||||
for (let i = 0, n = words.length; i < n; i++) {
|
||||
const word = words[i]
|
||||
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)
|
||||
}
|
||||
|
||||
// add the word to the text element
|
||||
elm.textContent += word
|
||||
|
||||
// measure the text element's new height
|
||||
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
|
||||
// start a new span
|
||||
currentSpan = {
|
||||
box: { x: left, y: top, w: rect.width, h: rect.height },
|
||||
text: char,
|
||||
}
|
||||
} else {
|
||||
// If the height hasn't increased, then we're still on the same
|
||||
// subline and can just push the char in.
|
||||
currentSubLine.push(char)
|
||||
// otherwise we just need to extend the current span with the next character
|
||||
currentSpan.box.w = right - currentSpan.box.x
|
||||
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.
|
||||
elm.remove()
|
||||
// Add the last span
|
||||
if (currentSpan) {
|
||||
spans.push(currentSpan)
|
||||
}
|
||||
|
||||
// We're done! Join the words in each line.
|
||||
const result: string[] = lines.map((line) => line.join('').trimEnd())
|
||||
return { spans, didTruncate }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,8 +29,8 @@ import { computed, EMPTY_ARRAY } from 'signia'
|
|||
import { SVGContainer } from '../../../components/SVGContainer'
|
||||
import { defineShape } from '../../../config/TLShapeDefinition'
|
||||
import { ARROW_LABEL_FONT_SIZES, FONT_FAMILIES, TEXT_PROPS } from '../../../constants'
|
||||
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
|
||||
import { getPerfectDashProps } from '../shared/getPerfectDashProps'
|
||||
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||
import { getShapeFillSvg, ShapeFill } from '../shared/ShapeFill'
|
||||
import { TLExportColors } from '../shared/TLExportColors'
|
||||
import {
|
||||
|
@ -845,9 +845,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
|
|||
if (!info) 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,
|
||||
fontFamily: FONT_FAMILIES[font],
|
||||
fontSize: ARROW_LABEL_FONT_SIZES[size],
|
||||
width: 'fit-content',
|
||||
|
@ -859,9 +858,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
|
|||
if (bounds.width > bounds.height) {
|
||||
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,
|
||||
fontFamily: FONT_FAMILIES[font],
|
||||
fontSize: ARROW_LABEL_FONT_SIZES[size],
|
||||
width: width + 'px',
|
||||
|
@ -874,9 +872,8 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
|
|||
if (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,
|
||||
fontFamily: FONT_FAMILIES[font],
|
||||
fontSize: ARROW_LABEL_FONT_SIZES[size],
|
||||
width: width + 'px',
|
||||
|
@ -1053,26 +1050,19 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
|
|||
fontFamily: font,
|
||||
padding: 0,
|
||||
textAlign: 'middle' as const,
|
||||
width: labelSize.w - 8,
|
||||
verticalTextAlign: 'middle' as const,
|
||||
width: labelSize.w,
|
||||
height: labelSize.h,
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 'normal',
|
||||
overflow: 'wrap' as const,
|
||||
}
|
||||
|
||||
const lines = this.app.textMeasure.getTextLines({
|
||||
text: shape.props.text,
|
||||
wrap: true,
|
||||
...opts,
|
||||
width: labelSize.w - 8,
|
||||
})
|
||||
|
||||
const textElm = getTextSvgElement(this.app, {
|
||||
lines,
|
||||
...opts,
|
||||
width: labelSize.w - 8,
|
||||
})
|
||||
|
||||
const textElm = createTextSvgElementFromSpans(
|
||||
this.app,
|
||||
this.app.textMeasure.measureTextSpans(shape.props.text, opts),
|
||||
opts
|
||||
)
|
||||
textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
|
||||
|
||||
const children = Array.from(textElm.children) as unknown as SVGTSpanElement[]
|
||||
|
|
|
@ -7,9 +7,11 @@ import {
|
|||
TLShapeId,
|
||||
TLShapeType,
|
||||
} from '@tldraw/tlschema'
|
||||
import { last } from '@tldraw/utils'
|
||||
import { SVGContainer } from '../../../components/SVGContainer'
|
||||
import { defineShape } from '../../../config/TLShapeDefinition'
|
||||
import { defaultEmptyAs } from '../../../utils/string'
|
||||
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
|
||||
import { TLExportColors } from '../shared/TLExportColors'
|
||||
import { TLBoxUtil } from '../TLBoxUtil'
|
||||
import { OnResizeEndHandler } from '../TLShapeUtil'
|
||||
|
@ -103,50 +105,35 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
|
|||
fontSize: 12,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
textAlign: 'start' as const,
|
||||
width: shape.props.w + 16,
|
||||
height: 30,
|
||||
padding: 8,
|
||||
width: shape.props.w,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
fontStyle: '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({
|
||||
text: textContent,
|
||||
wrap: true,
|
||||
const firstSpan = spans[0]
|
||||
const lastSpan = last(spans)!
|
||||
const labelTextWidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
|
||||
const text = createTextSvgElementFromSpans(this.app, spans, {
|
||||
offsetY: -opts.height - 2,
|
||||
...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.textContent = textContent
|
||||
|
||||
const textBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
||||
textBg.setAttribute('x', ' -4px')
|
||||
textBg.setAttribute('y', -(16 + size.h) + 'px')
|
||||
textBg.setAttribute('width', size.w + 8 + 'px')
|
||||
textBg.setAttribute('height', size.h + 8 + 'px')
|
||||
textBg.setAttribute('x', '-8px')
|
||||
textBg.setAttribute('y', -opts.height - 4 + 'px')
|
||||
textBg.setAttribute('width', labelTextWidth + 16 + 'px')
|
||||
textBg.setAttribute('height', `${opts.height}px`)
|
||||
textBg.setAttribute('rx', 4 + 'px')
|
||||
textBg.setAttribute('ry', 4 + 'px')
|
||||
textBg.setAttribute('fill', colors.background)
|
||||
|
|
|
@ -23,7 +23,7 @@ import { SVGContainer } from '../../../components/SVGContainer'
|
|||
import { defineShape } from '../../../config/TLShapeDefinition'
|
||||
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
|
||||
import { App } from '../../App'
|
||||
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
|
||||
import { HyperlinkButton } from '../shared/HyperlinkButton'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { TLExportColors } from '../shared/TLExportColors'
|
||||
|
@ -639,50 +639,33 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
|
|||
|
||||
if (props.text) {
|
||||
const bounds = this.bounds(shape)
|
||||
const padding = 16
|
||||
|
||||
const opts = {
|
||||
fontSize: LABEL_FONT_SIZES[shape.props.size],
|
||||
fontFamily: font,
|
||||
textAlign: shape.props.align,
|
||||
padding,
|
||||
verticalTextAlign: shape.props.verticalAlign,
|
||||
padding: 16,
|
||||
lineHeight: TEXT_PROPS.lineHeight,
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 'normal',
|
||||
width: Math.ceil(bounds.width),
|
||||
height: Math.ceil(bounds.height),
|
||||
overflow: 'wrap' as const,
|
||||
}
|
||||
|
||||
const lines = this.app.textMeasure.getTextLines({
|
||||
text: props.text,
|
||||
wrap: true,
|
||||
...opts,
|
||||
})
|
||||
const spans = this.app.textMeasure.measureTextSpans(props.text, opts)
|
||||
|
||||
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||
|
||||
const labelSize = getLabelSize(this.app, shape)
|
||||
|
||||
const textBgEl = getTextSvgElement(this.app, {
|
||||
const textBgEl = createTextSvgElementFromSpans(this.app, spans, {
|
||||
...opts,
|
||||
lines,
|
||||
strokeWidth: 2,
|
||||
stroke: 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
|
||||
textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
|
||||
textElm.setAttribute('stroke', 'none')
|
||||
|
@ -939,9 +922,8 @@ function getLabelSize(app: App, shape: TLGeoShape) {
|
|||
return { w: 0, h: 0 }
|
||||
}
|
||||
|
||||
const minSize = app.textMeasure.measureText({
|
||||
const minSize = app.textMeasure.measureText('w', {
|
||||
...TEXT_PROPS,
|
||||
text: 'w',
|
||||
fontFamily: FONT_FAMILIES[shape.props.font],
|
||||
fontSize: LABEL_FONT_SIZES[shape.props.size],
|
||||
width: 'fit-content',
|
||||
|
@ -956,9 +938,8 @@ function getLabelSize(app: App, shape: TLGeoShape) {
|
|||
xl: 10,
|
||||
}
|
||||
|
||||
const size = app.textMeasure.measureText({
|
||||
const size = app.textMeasure.measureText(text, {
|
||||
...TEXT_PROPS,
|
||||
text: text,
|
||||
fontFamily: FONT_FAMILIES[shape.props.font],
|
||||
fontSize: LABEL_FONT_SIZES[shape.props.size],
|
||||
width: 'fit-content',
|
||||
|
|
|
@ -3,7 +3,7 @@ import { noteShapeMigrations, noteShapeTypeValidator, TLNoteShape } from '@tldra
|
|||
import { defineShape } from '../../../config/TLShapeDefinition'
|
||||
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
|
||||
import { App } from '../../App'
|
||||
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
|
||||
import { HyperlinkButton } from '../shared/HyperlinkButton'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { TLExportColors } from '../shared/TLExportColors'
|
||||
|
@ -143,21 +143,16 @@ export class TLNoteUtil extends TLShapeUtil<TLNoteShape> {
|
|||
lineHeight: TEXT_PROPS.lineHeight,
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 'normal',
|
||||
overflow: 'wrap' as const,
|
||||
offsetX: 0,
|
||||
}
|
||||
|
||||
const lines = this.app.textMeasure.getTextLines({
|
||||
text: shape.props.text,
|
||||
wrap: true,
|
||||
...opts,
|
||||
})
|
||||
const spans = this.app.textMeasure.measureTextSpans(shape.props.text, opts)
|
||||
|
||||
opts.padding = PADDING
|
||||
opts.width = bounds.width
|
||||
opts.padding = PADDING
|
||||
|
||||
const textElm = getTextSvgElement(this.app, {
|
||||
lines,
|
||||
...opts,
|
||||
})
|
||||
const textElm = createTextSvgElementFromSpans(this.app, spans, opts)
|
||||
textElm.setAttribute('fill', colors.text)
|
||||
textElm.setAttribute('transform', `translate(0 ${PADDING})`)
|
||||
g.appendChild(textElm)
|
||||
|
@ -213,9 +208,8 @@ export const TLNoteShapeDef = defineShape<TLNoteShape, TLNoteUtil>({
|
|||
function getGrowY(app: App, shape: TLNoteShape, prevGrowY = 0) {
|
||||
const PADDING = 17
|
||||
|
||||
const nextTextSize = app.textMeasure.measureText({
|
||||
const nextTextSize = app.textMeasure.measureText(shape.props.text, {
|
||||
...TEXT_PROPS,
|
||||
text: shape.props.text,
|
||||
fontFamily: FONT_FAMILIES[shape.props.font],
|
||||
fontSize: LABEL_FONT_SIZES[shape.props.size],
|
||||
width: NOTE_SIZE - PADDING * 2 + 'px',
|
||||
|
|
|
@ -7,7 +7,7 @@ import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../../../constants'
|
|||
import { stopEventPropagation } from '../../../utils/dom'
|
||||
import { WeakMapCache } from '../../../utils/WeakMapCache'
|
||||
import { App } from '../../App'
|
||||
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
|
||||
import { resizeScaled } from '../shared/resizeScaled'
|
||||
import { TLExportColors } from '../shared/TLExportColors'
|
||||
import { useEditableText } from '../shared/useEditableText'
|
||||
|
@ -174,25 +174,23 @@ export class TLTextUtil extends TLShapeUtil<TLTextShape> {
|
|||
lineHeight: TEXT_PROPS.lineHeight,
|
||||
fontStyle: '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 groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||
|
||||
const textBgEl = getTextSvgElement(this.app, {
|
||||
lines,
|
||||
...opts,
|
||||
stroke: colors.background,
|
||||
strokeWidth: 2,
|
||||
fill: colors.background,
|
||||
padding: 0,
|
||||
})
|
||||
const textBgEl = createTextSvgElementFromSpans(
|
||||
this.app,
|
||||
this.app.textMeasure.measureTextSpans(text, opts),
|
||||
{
|
||||
...opts,
|
||||
stroke: colors.background,
|
||||
strokeWidth: 2,
|
||||
fill: colors.background,
|
||||
padding: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const textElm = textBgEl.cloneNode(true) as SVGTextElement
|
||||
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.
|
||||
Math.floor(Math.max(minWidth, w)) + 'px'
|
||||
|
||||
const result = app.textMeasure.measureText({
|
||||
const result = app.textMeasure.measureText(text, {
|
||||
...TEXT_PROPS,
|
||||
text,
|
||||
fontFamily: FONT_FAMILIES[font],
|
||||
fontSize: fontSize,
|
||||
width: cw,
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -72,17 +72,19 @@ export class TestApp extends App {
|
|||
this.elm.getBoundingClientRect = () => this.bounds as DOMRect
|
||||
document.body.appendChild(this.elm)
|
||||
|
||||
this.textMeasure.measureText = (opts: {
|
||||
text: string
|
||||
fontStyle: string
|
||||
fontWeight: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
width: string
|
||||
maxWidth: string
|
||||
}): Box2dModel => {
|
||||
const breaks = opts.text.split('\n')
|
||||
this.textMeasure.measureText = (
|
||||
textToMeasure: string,
|
||||
opts: {
|
||||
fontStyle: string
|
||||
fontWeight: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
width: string
|
||||
maxWidth: string
|
||||
}
|
||||
): Box2dModel => {
|
||||
const breaks = textToMeasure.split('\n')
|
||||
const longest = breaks.reduce((acc, curr) => {
|
||||
return curr.length > acc.length ? curr : acc
|
||||
}, '')
|
||||
|
@ -99,6 +101,16 @@ export class TestApp extends App {
|
|||
: 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
|
||||
|
|
|
@ -10,5 +10,5 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
|
|||
</g>
|
||||
</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>
|
||||
</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 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 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>"
|
||||
`;
|
||||
|
|
|
@ -117,6 +117,8 @@ export class Box2d {
|
|||
// (undocumented)
|
||||
translate(delta: VecLike): this;
|
||||
// (undocumented)
|
||||
union(box: Box2dModel): this;
|
||||
// (undocumented)
|
||||
w: number;
|
||||
// (undocumented)
|
||||
get width(): number;
|
||||
|
|
|
@ -312,6 +312,20 @@ export class Box2d {
|
|||
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) {
|
||||
return new Box2d(box.x, box.y, box.w, box.h)
|
||||
}
|
||||
|
|
|
@ -85,9 +85,8 @@ export async function pastePlainText(app: App, text: string, point?: VecLike) {
|
|||
align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle'
|
||||
}
|
||||
|
||||
const rawSize = app.textMeasure.measureText({
|
||||
const rawSize = app.textMeasure.measureText(textToPaste, {
|
||||
...TEXT_PROPS,
|
||||
text: textToPaste,
|
||||
fontFamily: FONT_FAMILIES[defaultProps.font],
|
||||
fontSize: FONT_SIZES[defaultProps.size],
|
||||
width: 'fit-content',
|
||||
|
@ -99,9 +98,8 @@ export async function pastePlainText(app: App, text: string, point?: VecLike) {
|
|||
)
|
||||
|
||||
if (rawSize.w > minWidth) {
|
||||
const shrunkSize = app.textMeasure.measureText({
|
||||
const shrunkSize = app.textMeasure.measureText(textToPaste, {
|
||||
...TEXT_PROPS,
|
||||
text: textToPaste,
|
||||
fontFamily: FONT_FAMILIES[defaultProps.font],
|
||||
fontSize: FONT_SIZES[defaultProps.size],
|
||||
width: minWidth + 'px',
|
||||
|
|
Loading…
Reference in a new issue