Selection bounds
Visualize the difference between getSelectionPageBounds() and getSelectionRotatedPageBounds() for the current selection.
import { Editor, TLComponents, Tldraw, createShapeId, toRichText, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import './selection-bounds.css'
// There's a guide at the bottom of this file!
const DEMO_IDS = {
blue: createShapeId('selection-bounds-blue'),
greenA: createShapeId('selection-bounds-green-a'),
greenB: createShapeId('selection-bounds-green-b'),
greenGroup: createShapeId('selection-bounds-green-group'),
purple: createShapeId('selection-bounds-purple'),
red: createShapeId('selection-bounds-red'),
hint: createShapeId('selection-bounds-hint'),
} as const
const ALL_DEMO_IDS = Object.values(DEMO_IDS)
const SAME_ROTATION = Math.PI / 6
const DEMO_SHAPES: Parameters<Editor['createShapes']>[0] = [
{
id: DEMO_IDS.blue,
type: 'geo' as const,
x: 120,
y: 250,
rotation: SAME_ROTATION,
props: {
geo: 'rectangle' as const,
w: 180,
h: 110,
color: 'blue' as const,
fill: 'semi' as const,
dash: 'draw' as const,
},
},
{
id: DEMO_IDS.greenA,
type: 'geo' as const,
x: 430,
y: 205,
props: {
geo: 'rectangle' as const,
w: 140,
h: 80,
color: 'green' as const,
fill: 'semi' as const,
dash: 'solid' as const,
},
},
{
id: DEMO_IDS.greenB,
type: 'geo' as const,
x: 610,
y: 245,
props: {
geo: 'rectangle' as const,
w: 120,
h: 70,
color: 'green' as const,
fill: 'semi' as const,
dash: 'solid' as const,
},
},
{
id: DEMO_IDS.purple,
type: 'geo' as const,
x: 880,
y: 205,
rotation: Math.PI / 8,
props: {
geo: 'rectangle' as const,
w: 130,
h: 80,
color: 'violet' as const,
fill: 'semi' as const,
dash: 'solid' as const,
},
},
{
id: DEMO_IDS.red,
type: 'geo' as const,
x: 1040,
y: 260,
rotation: -Math.PI / 10,
props: {
geo: 'rectangle' as const,
w: 130,
h: 80,
color: 'red' as const,
fill: 'semi' as const,
dash: 'solid' as const,
},
},
]
function seedDemoContent(editor: Editor) {
const existingDemoShapeIds = ALL_DEMO_IDS.filter((id) => editor.getShape(id))
if (existingDemoShapeIds.length) {
editor.deleteShapes(existingDemoShapeIds)
}
editor.createShapes([
...DEMO_SHAPES,
{
id: DEMO_IDS.hint,
type: 'text' as const,
x: 880,
y: 340,
props: {
richText: toRichText('⬆ Select both boxes,\nthen rotate them!'),
color: 'grey' as const,
size: 's' as const,
autoSize: true,
},
},
])
editor.groupShapes([DEMO_IDS.greenA, DEMO_IDS.greenB], {
groupId: DEMO_IDS.greenGroup,
select: false,
})
editor.updateShapes([{ id: DEMO_IDS.greenGroup, type: 'group', rotation: SAME_ROTATION }])
editor.select(DEMO_IDS.blue)
editor.zoomToFit({ animation: { duration: 0 } })
}
const SelectionBoundsOverlay = track(() => {
const editor = useEditor()
const axisAlignedBounds = editor.getSelectionPageBounds() // [1]
const rotatedBounds = editor.getSelectionRotatedPageBounds() // [2]
if (!axisAlignedBounds || !rotatedBounds) return null
// [3]
const zoom = editor.getZoomLevel()
const rotation = editor.getSelectionRotation()
const axisAlignedViewportPoint = editor.pageToViewport(axisAlignedBounds.point)
const rotatedViewportPoint = editor.pageToViewport(rotatedBounds.point)
return (
<>
<div
className="selection-bounds-box selection-bounds-box--axis"
style={{
width: axisAlignedBounds.width * zoom,
height: axisAlignedBounds.height * zoom,
transform: `translate(${axisAlignedViewportPoint.x}px, ${axisAlignedViewportPoint.y}px)`,
}}
/>
<div
className="selection-bounds-box selection-bounds-box--rotated"
style={{
width: rotatedBounds.width * zoom,
height: rotatedBounds.height * zoom,
transform: `translate(${rotatedViewportPoint.x}px, ${rotatedViewportPoint.y}px) rotate(${rotation}rad)`,
}}
/>
</>
)
})
function BoundsKey() {
return (
<div className="selection-bounds-key">
<div className="selection-bounds-key-item">
<div className="selection-bounds-key-swatch selection-bounds-key-swatch--axis" />
<span>getSelectionPageBounds — smallest upright rectangle</span>
</div>
<div className="selection-bounds-key-item">
<div className="selection-bounds-key-swatch selection-bounds-key-swatch--rotated" />
<span>getSelectionRotatedPageBounds — aligned to selection rotation</span>
</div>
</div>
)
}
const components: TLComponents = {
InFrontOfTheCanvas: SelectionBoundsOverlay,
TopPanel: BoundsKey,
}
export default function SelectionBoundsExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="selection-bounds-example"
components={components}
onMount={(editor) => {
if (ALL_DEMO_IDS.every((id) => editor.getShape(id))) {
return
}
seedDemoContent(editor)
}}
/>
</div>
)
}
/*
The editor keeps two kinds of bounds for the current selection.
[1]
`getSelectionPageBounds()` is the axis-aligned bounding box (blue dashed). It has zero rotation and is
the smallest upright rectangle containing every selected shape. It's used for hit testing and culling,
not for the visible selection UI.
[2]
`getSelectionRotatedPageBounds()` is the rotated selection box (amber solid). When the selected shapes
share one rotation, this box follows it, which is why the resize handles on two equally rotated shapes
line up with the shapes' own axes. When rotations differ there's no sensible shared angle, so it falls
back to the axis-aligned box.
[3]
Both boxes are in page space. `pageToViewport()` converts their origins to viewport pixels, and the sizes
are scaled by the zoom level, so plain HTML elements can be laid over the canvas. The component is
wrapped in `track` so it re-renders whenever the selection or camera changes.
Try selecting each demo:
1. The blue shape: a single rotated shape, where the two boxes differ.
2. The green group: two shapes with a shared rotation, so the rotated box still follows their angle.
3. The purple and red pair: two ungrouped shapes with different rotations, so the rotated box collapses
to the axis-aligned one. Rotate one of them on its own to see how the axis-aligned box changes while
the rotated box stays aligned to the page.
*/
getSelectionPageBounds() returns the smallest upright rectangle around the selection; getSelectionRotatedPageBounds() returns a box aligned to the selection's shared rotation, which is what the visible selection handles use. This example draws both over the canvas with an InFrontOfTheCanvas component that converts page bounds to viewport pixels using pageToViewport() and the zoom level.
Select the single rotated shape, the group with a shared rotation, and the two ungrouped shapes with different rotations to see when the two boxes differ and when the rotated box falls back to axis-aligned.
Is this page helpful?
Prev
Performance hooksNext
Easter egg styles