snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
409
apps/examples/e2e/tests/test-text.spec.ts
Normal file
409
apps/examples/e2e/tests/test-text.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import test, { Page, expect } from '@playwright/test'
|
||||
import {
|
||||
BoxModel,
|
||||
Editor,
|
||||
TLMeasureTextOpts,
|
||||
TLMeasureTextSpanOpts,
|
||||
TLNoteShape,
|
||||
TLShapeId,
|
||||
} from 'tldraw'
|
||||
import { EndToEndApi } from '../../src/misc/EndToEndApi'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
declare const tldrawApi: EndToEndApi
|
||||
|
||||
const measureTextOptions: TLMeasureTextOpts = {
|
||||
fontFamily: 'var(--tl-font-draw)',
|
||||
fontSize: 24,
|
||||
lineHeight: 1.35,
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
padding: '0px',
|
||||
maxWidth: null,
|
||||
}
|
||||
|
||||
const measureTextSpansOptions: TLMeasureTextSpanOpts = {
|
||||
fontFamily: 'var(--tl-font-draw)',
|
||||
fontSize: 24,
|
||||
lineHeight: 1.35,
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
padding: 0,
|
||||
width: 100,
|
||||
height: 1000,
|
||||
overflow: 'wrap' as const,
|
||||
textAlign: 'start' as 'start' | 'middle' | 'end',
|
||||
}
|
||||
|
||||
function formatLines(spans: { box: BoxModel; text: string }[]) {
|
||||
const lines = []
|
||||
|
||||
let currentLine: string[] | null = 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
|
||||
}
|
||||
|
||||
declare const editor: Editor
|
||||
let page: Page
|
||||
|
||||
test.describe('text measurement', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('measures text', async () => {
|
||||
const { w, h } = await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(
|
||||
async (options) => editor.textMeasure.measureText('testing', options),
|
||||
measureTextOptions
|
||||
)
|
||||
|
||||
// works on github actions
|
||||
expect(w).toBeCloseTo(87, 0)
|
||||
expect(h).toBeCloseTo(32.3984375, 0)
|
||||
})
|
||||
|
||||
// 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.
|
||||
|
||||
test('should get a single text span', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('testing', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([['testing']])
|
||||
})
|
||||
|
||||
test('should wrap a word when it has to', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('testing', { ...options, width: 50 }),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([['tes'], ['ting']])
|
||||
})
|
||||
|
||||
test('should preserve whitespace at line breaks', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('testing testing', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([['testing', ' '], ['testing']])
|
||||
})
|
||||
|
||||
test('should preserve whitespace at the end of wrapped lines', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('testing testing ', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([
|
||||
['testing', ' '],
|
||||
['testing', ' '],
|
||||
])
|
||||
})
|
||||
|
||||
test('preserves whitespace at the end of unwrapped lines', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) =>
|
||||
editor.textMeasure.measureTextSpans('testing testing ', { ...options, width: 200 }),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([['testing', ' ', 'testing', ' ']])
|
||||
})
|
||||
|
||||
test('preserves whitespace at the start of an unwrapped line', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) =>
|
||||
editor.textMeasure.measureTextSpans(' testing testing', { ...options, width: 200 }),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)[0]?.[0]).toEqual(' ')
|
||||
})
|
||||
|
||||
test('should place starting whitespace on its own line if it has to', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans(' testing testing', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([[' '], ['testing', ' '], ['testing']])
|
||||
})
|
||||
|
||||
test('should handle multiline text', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) =>
|
||||
editor.textMeasure.measureTextSpans(' test\ning testing \n t', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([
|
||||
[' ', 'test', '\n'],
|
||||
['ing', ' '],
|
||||
['testing', ' \n'],
|
||||
[' ', 't'],
|
||||
])
|
||||
})
|
||||
|
||||
test('should break long strings of text', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) =>
|
||||
editor.textMeasure.measureTextSpans('testingtestingtestingtestingtestingtesting', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([
|
||||
['testingt'],
|
||||
['estingt'],
|
||||
['estingt'],
|
||||
['estingt'],
|
||||
['estingt'],
|
||||
['esting'],
|
||||
])
|
||||
})
|
||||
|
||||
test('should return an empty array if the text is empty', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(async (options) => editor.textMeasure.measureTextSpans('', options), measureTextSpansOptions)
|
||||
|
||||
expect(formatLines(spans)).toEqual([])
|
||||
})
|
||||
|
||||
test('should handle trailing newlines', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('hi\n\n\n', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([['hi', '\n'], [' \n'], [' \n'], [' ']])
|
||||
})
|
||||
|
||||
test('should handle only newlines', async () => {
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(
|
||||
async (options) => editor.textMeasure.measureTextSpans('\n\n\n', options),
|
||||
measureTextSpansOptions
|
||||
)
|
||||
|
||||
expect(formatLines(spans)).toEqual([[' \n'], [' \n'], [' \n'], [' ']])
|
||||
})
|
||||
|
||||
test('should keep emoji grapheme clusters together', async () => {
|
||||
// 👨👩👧 is several code points joined by zero-width joiners; it must be
|
||||
// measured as one grapheme so no code points get dropped from the span text.
|
||||
const family = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
|
||||
const spans = await page.evaluate<
|
||||
{ text: string; box: BoxModel }[],
|
||||
typeof measureTextSpansOptions
|
||||
>(async (options) => {
|
||||
const familyEmoji = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
|
||||
return editor.textMeasure.measureTextSpans(`a${familyEmoji}b`, { ...options, width: 200 })
|
||||
}, measureTextSpansOptions)
|
||||
|
||||
expect(formatLines(spans).flat().join('')).toBe(`a${family}b`)
|
||||
})
|
||||
|
||||
test('for auto-font-sizing shapes, should do normal font size for text that does not have long words', async () => {
|
||||
const shape = await page.evaluate(() => {
|
||||
const id = 'shape:testShape' as TLShapeId
|
||||
editor.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText('this is just some regular text'),
|
||||
size: 'xl',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return editor.getShape(id) as TLNoteShape
|
||||
})
|
||||
|
||||
expect(shape.props.fontSizeAdjustment).toEqual(1)
|
||||
})
|
||||
|
||||
test('for auto-font-sizing shapes, should auto-size text that have slightly long words', async () => {
|
||||
const shape = await page.evaluate(() => {
|
||||
const id = 'shape:testShape' as TLShapeId
|
||||
editor.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText('Amsterdam'),
|
||||
size: 'xl',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return editor.getShape(id) as TLNoteShape
|
||||
})
|
||||
|
||||
expect(shape.props.fontSizeAdjustment).toEqual(0.84375)
|
||||
})
|
||||
|
||||
test('for auto-font-sizing shapes, should auto-size text that have long words', async () => {
|
||||
const shape = await page.evaluate(() => {
|
||||
const id = 'shape:testShape' as TLShapeId
|
||||
editor.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText('this is a tentoonstelling'),
|
||||
size: 'xl',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return editor.getShape(id) as TLNoteShape
|
||||
})
|
||||
|
||||
expect(shape.props.fontSizeAdjustment).toEqual(0.625)
|
||||
})
|
||||
|
||||
test('for auto-font-sizing shapes, should wrap text that has words that are way too long', async () => {
|
||||
const shape = await page.evaluate(() => {
|
||||
const id = 'shape:testShape' as TLShapeId
|
||||
editor.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(
|
||||
'a very long dutch word like ziekenhuisinrichtingsmaatschappij'
|
||||
),
|
||||
size: 'xl',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return editor.getShape(id) as TLNoteShape
|
||||
})
|
||||
|
||||
expect(shape.props.fontSizeAdjustment).toEqual(0.4375)
|
||||
})
|
||||
|
||||
test('should use custom renderMethod for text measurement', async () => {
|
||||
const { w, h } = await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(
|
||||
async (options) => {
|
||||
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
|
||||
},
|
||||
measureTextOptions
|
||||
)
|
||||
|
||||
// Assuming the custom render method affects the width and height
|
||||
// Adjust these expected values based on how renderMethod is supposed to affect the output
|
||||
// Works on github actions, annoyingly
|
||||
expect(w).toBeGreaterThanOrEqual(165)
|
||||
expect(w).toBeLessThanOrEqual(167)
|
||||
expect(h).toBeCloseTo(32.390625, 0)
|
||||
})
|
||||
|
||||
test('element should have no leftover properties', async () => {
|
||||
const measure = page.locator('div.tl-text-measure')
|
||||
|
||||
await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(async (options) => {
|
||||
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
|
||||
}, measureTextOptions)
|
||||
|
||||
const firstStyle = (await measure.getAttribute('style')) ?? ''
|
||||
|
||||
expect(await measure.getAttribute('style')).toMatch(firstStyle)
|
||||
|
||||
await page.evaluate(() => {
|
||||
const id = 'shape:testShape' as TLShapeId
|
||||
editor.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(
|
||||
'a very long dutch word like ziekenhuisinrichtingsmaatschappij'
|
||||
),
|
||||
size: 'xl',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(async (options) => {
|
||||
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
|
||||
}, measureTextOptions)
|
||||
|
||||
expect(await page.locator('div.tl-text-measure').getAttribute('style')).toMatch(firstStyle)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user