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
195 lines
8.0 KiB
Plaintext
195 lines
8.0 KiB
Plaintext
---
|
|
title: Text measurement
|
|
created_at: 12/20/2025
|
|
updated_at: 12/20/2025
|
|
keywords:
|
|
- text
|
|
- measurement
|
|
- TextManager
|
|
- fonts
|
|
- dimensions
|
|
- wrapping
|
|
- FontManager
|
|
- spans
|
|
status: published
|
|
date: 12/20/2025
|
|
readability: 8
|
|
voice: 8
|
|
completeness: 9
|
|
accuracy: 9
|
|
notes: Example links should use relative paths instead of GitHub URLs. measureHtml needs its own example. Performance section is dense - use bullet formatting.
|
|
---
|
|
|
|
The editor measures text to calculate shape bounds, handle text wrapping, and position labels. Two managers handle this: [TextManager](?) measures text dimensions using a hidden DOM element, and [FontManager](?) loads custom fonts before measurement so dimensions are accurate. Access them through `editor.textMeasure` and `editor.fonts`.
|
|
|
|
## How text measurement works
|
|
|
|
The `TextManager` creates a hidden measurement element on initialization and appends it to the editor container. This element stays in the DOM throughout the editor's lifecycle, so repeated measurements don't pay the cost of creating and removing elements.
|
|
|
|
```typescript
|
|
// Simplified for clarity - see TextManager.ts for full implementation
|
|
const elm = this.editor.getContainerDocument().createElement('div')
|
|
elm.classList.add('tl-text', 'tl-text-measure')
|
|
elm.setAttribute('dir', 'auto')
|
|
this.editor.getContainer().appendChild(elm)
|
|
```
|
|
|
|
The element is absolutely positioned and hidden from users, but the browser still lays it out, so measurements are accurate.
|
|
|
|
### Measuring text
|
|
|
|
The `measureText` method calculates text dimensions. Pass in text content and [TLMeasureTextOpts](?), and it returns a box model with width and height.
|
|
|
|
```typescript
|
|
const dimensions = editor.textMeasure.measureText('Hello world', {
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
fontWeight: 'normal',
|
|
fontStyle: 'normal',
|
|
lineHeight: 1.35,
|
|
maxWidth: null, // No wrapping
|
|
padding: '4px',
|
|
})
|
|
// Returns: { x: 0, y: 0, w: 85, h: 22, scrollWidth: 0 }
|
|
```
|
|
|
|
The method applies styles to the measurement element, reads the computed dimensions, then restores the previous styles. This means rapid successive measurements don't interfere with each other.
|
|
|
|
You can also use `measureHtml` to measure HTML content directly instead of plain text, or `measureHtmlBatch` to measure many pieces of HTML in one layout pass using a pool of elements.
|
|
|
|
### Text wrapping
|
|
|
|
Set `maxWidth` to a number and the browser wraps text to fit within that width. The `TextManager` uses the browser's native text layout algorithm rather than implementing its own wrapping logic.
|
|
|
|
```typescript
|
|
const wrapped = editor.textMeasure.measureText('This is a long line of text', {
|
|
fontFamily: 'Inter',
|
|
fontSize: 16,
|
|
fontWeight: 'normal',
|
|
fontStyle: 'normal',
|
|
lineHeight: 1.35,
|
|
maxWidth: 100, // Wrap at 100px
|
|
padding: '4px',
|
|
})
|
|
// Returns dimensions accounting for multiple lines
|
|
```
|
|
|
|
Set `maxWidth: null` to preserve explicit line breaks and spaces without wrapping. This is useful for measuring single-line text or when wrapping is handled elsewhere.
|
|
|
|
## Measuring text spans
|
|
|
|
For SVG export or precise text selection, `measureTextSpans` breaks text into individual spans based on line breaks and word boundaries. It takes [TLMeasureTextSpanOpts](?):
|
|
|
|
```typescript
|
|
const spans = editor.textMeasure.measureTextSpans('Hello world\nSecond line', {
|
|
width: 200,
|
|
height: 100,
|
|
padding: 8,
|
|
fontSize: 16,
|
|
fontWeight: 'normal',
|
|
fontFamily: 'Inter',
|
|
fontStyle: 'normal',
|
|
lineHeight: 1.35,
|
|
textAlign: 'start',
|
|
overflow: 'wrap',
|
|
})
|
|
```
|
|
|
|
Each span includes the text content and its bounding box. Runs of whitespace become their own spans (widths are illustrative):
|
|
|
|
```typescript
|
|
;[
|
|
{ text: 'Hello', box: { x: 0, y: 0, w: 40, h: 22 } },
|
|
{ text: ' ', box: { x: 40, y: 0, w: 5, h: 22 } },
|
|
{ text: 'world', box: { x: 45, y: 0, w: 40, h: 22 } },
|
|
{ text: 'Second', box: { x: 0, y: 22, w: 47, h: 22 } },
|
|
{ text: ' ', box: { x: 47, y: 22, w: 5, h: 22 } },
|
|
{ text: 'line', box: { x: 52, y: 22, w: 32, h: 22 } },
|
|
]
|
|
```
|
|
|
|
The algorithm positions a `Range` around each grapheme, measures it with `getClientRects()`, then groups graphemes into spans wherever the line position changes or the text switches between whitespace and non-whitespace.
|
|
|
|
### Truncation handling
|
|
|
|
The required `overflow` option controls how text exceeding the available space is handled:
|
|
|
|
| Value | Behavior |
|
|
| ------------------- | ----------------------------------------------------- |
|
|
| `wrap` | Text wraps to multiple lines |
|
|
| `truncate-clip` | Text truncates to the first line, no visual indicator |
|
|
| `truncate-ellipsis` | Text truncates with an ellipsis character |
|
|
|
|
When using `truncate-ellipsis`, the algorithm first measures the ellipsis width, then subtracts it from the available width and remeasures to find the cut point.
|
|
|
|
## Font loading
|
|
|
|
The `FontManager` loads custom fonts before text measurement. If you measure text before its font loads, you get incorrect dimensions and layout shifts when the font finally becomes available.
|
|
|
|
### Declaring font requirements
|
|
|
|
Shapes declare which fonts they need by overriding [ShapeUtil#getFontFaces](?) and returning [TLFontFace](?) objects:
|
|
|
|
```typescript
|
|
class MyTextShapeUtil extends ShapeUtil<MyTextShape> {
|
|
override getFontFaces(shape: MyTextShape): TLFontFace[] {
|
|
return [
|
|
{
|
|
family: 'MyCustomFont',
|
|
src: { url: '/fonts/my-custom-font.woff2', format: 'woff2' },
|
|
weight: 'normal',
|
|
style: 'normal',
|
|
},
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
The `FontManager` tracks these font requirements and loads them before the shape renders.
|
|
|
|
### Loading fonts
|
|
|
|
Use `ensureFontIsLoaded` to load a specific font, or `requestFonts` to batch multiple font loading requests:
|
|
|
|
```typescript
|
|
// Load a single font
|
|
await editor.fonts.ensureFontIsLoaded(fontFace)
|
|
|
|
// Batch load multiple fonts (batched into a single microtask)
|
|
editor.fonts.requestFonts([fontFace1, fontFace2])
|
|
```
|
|
|
|
The manager caches font loading state to avoid redundant loading. Multiple concurrent requests for the same font share a single loading promise.
|
|
|
|
### Tracking fonts reactively
|
|
|
|
To make a computation re-run once a shape's fonts load, call `trackFontsForShape` inside it. [Editor#getShapeGeometry](?) already does this for you, so you only need it in your own caches, like a text size cache:
|
|
|
|
```typescript
|
|
editor.fonts.trackFontsForShape(shape)
|
|
```
|
|
|
|
### Loading fonts for the current page
|
|
|
|
Use `loadRequiredFontsForCurrentPage` to load all fonts needed by shapes on the current page. The editor calls it before the first render and before exports:
|
|
|
|
```typescript
|
|
await editor.fonts.loadRequiredFontsForCurrentPage()
|
|
// All fonts for shapes on the current page are now loaded
|
|
```
|
|
|
|
Pass a `limit` argument to skip the wait entirely when the page needs more than that many fonts. The editor uses the `maxFontsToLoadBeforeRender` option (see [TldrawOptions](?), default `Infinity`) for this, so a page with many fonts doesn't block the canvas.
|
|
|
|
## Performance considerations
|
|
|
|
The `TextManager` doesn't cache measurements itself. Shape utilities cache their own results using reactive computed values, so text is only remeasured when font properties or content change.
|
|
|
|
The `FontManager` computes each shape's font faces once and caches them until the shape's props or meta change. Concurrent requests for the same font share one loading promise, and `requestFonts` batches requests into a single microtask.
|
|
|
|
The measurement element sets `overflow-wrap: break-word` so long words can break, `width` and `max-width` to control wrapping, and `dir="auto"` for mixed LTR/RTL content. Line height is resolved to a whole pixel with [resolveLineHeightPx](?) so measurement, on-canvas rendering, and export agree across browsers, which otherwise disagree on fractional line boxes. Use the same helper anywhere you render text from a custom shape.
|
|
|
|
## Related examples
|
|
|
|
- [Speech bubble](/examples/shapes/tools/speech-bubble) - A custom shape that measures its text with `editor.textMeasure` to grow to fit.
|
|
- [Rich text with font options](/examples/shapes/tools/rich-text-font-extensions) - Extend the TipTap text editor with font-family and font-size options.
|