Layout constraints (bindings)
Lay out shapes in a row using bindings that hold each shape's position in a container.
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeChangeOptions,
BindingUtil,
HTMLContainer,
IndexKey,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLBinding,
TLShape,
TLShapeUtilCanBindOpts,
TLStoreSnapshot,
Tldraw,
Vec,
clamp,
getIndexBetween,
} from 'tldraw'
import 'tldraw/tldraw.css'
import snapshot from './snapshot.json'
// There's a guide at the bottom of this file!
const CONTAINER_TYPE = 'container'
const ELEMENT_TYPE = 'element'
const LAYOUT_TYPE = 'layout'
const ELEMENT_SIZE = 100
const CONTAINER_PADDING = 24
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[CONTAINER_TYPE]: { height: number; width: number }
[ELEMENT_TYPE]: { color: string }
}
}
// [1]
type ContainerShape = TLShape<typeof CONTAINER_TYPE>
class ContainerShapeUtil extends ShapeUtil<ContainerShape> {
static override type = CONTAINER_TYPE
static override props: RecordProps<ContainerShape> = { height: T.number, width: T.number }
override getDefaultProps() {
return {
width: ELEMENT_SIZE + CONTAINER_PADDING * 2,
height: ELEMENT_SIZE + CONTAINER_PADDING * 2,
}
}
// [2]
override canBind({ fromShape, toShape, bindingType }: TLShapeUtilCanBindOpts<ContainerShape>) {
return (
fromShape.type === CONTAINER_TYPE &&
toShape.type === ELEMENT_TYPE &&
bindingType === LAYOUT_TYPE
)
}
override canEdit(shape: ContainerShape) {
return false
}
override canResize(shape: ContainerShape) {
return false
}
override hideRotateHandle(shape: ContainerShape) {
return true
}
override isAspectRatioLocked(shape: ContainerShape) {
return true
}
override getGeometry(shape: ContainerShape) {
return new Rectangle2d({
width: shape.props.width,
height: shape.props.height,
isFilled: true,
})
}
override component(shape: ContainerShape) {
return (
<HTMLContainer
style={{
backgroundColor: '#efefef',
width: shape.props.width,
height: shape.props.height,
}}
/>
)
}
override getIndicatorPath(shape: ContainerShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.width, shape.props.height)
return path
}
}
type ElementShape = TLShape<typeof ELEMENT_TYPE>
class ElementShapeUtil extends ShapeUtil<ElementShape> {
static override type = ELEMENT_TYPE
static override props: RecordProps<ElementShape> = {
color: T.string,
}
override getDefaultProps() {
return {
color: '#AEC6CF',
}
}
override canBind({ fromShape, toShape, bindingType }: TLShapeUtilCanBindOpts<ElementShape>) {
return (
fromShape.type === CONTAINER_TYPE &&
toShape.type === ELEMENT_TYPE &&
bindingType === LAYOUT_TYPE
)
}
override canEdit(shape: ElementShape) {
return false
}
override canResize(shape: ElementShape) {
return false
}
override hideRotateHandle(shape: ElementShape) {
return true
}
override isAspectRatioLocked(shape: ElementShape) {
return true
}
override getGeometry() {
return new Rectangle2d({
width: ELEMENT_SIZE,
height: ELEMENT_SIZE,
isFilled: true,
})
}
override component(shape: ElementShape) {
return <HTMLContainer style={{ backgroundColor: shape.props.color }}></HTMLContainer>
}
override getIndicatorPath() {
const path = new Path2D()
path.rect(0, 0, ELEMENT_SIZE, ELEMENT_SIZE)
return path
}
private getElementCenter(shape: ElementShape) {
return this.editor
.getShapePageTransform(shape)
.applyToPoint({ x: ELEMENT_SIZE / 2, y: ELEMENT_SIZE / 2 })
}
private getTargetContainer(shape: ElementShape, pageAnchor: Vec) {
return this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (otherShape) =>
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: LAYOUT_TYPE }),
}) as ContainerShape | undefined
}
// [3]
private getBindingIndexForPosition(
shape: ElementShape,
container: ContainerShape,
pageAnchor: Vec
) {
const allBindings = this.editor
.getBindingsFromShape(container, LAYOUT_TYPE)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
const siblings = allBindings.filter((b) => b.toId !== shape.id)
// Which slot is the element's center closest to?
const order = clamp(
Math.round(
(pageAnchor.x - container.x - CONTAINER_PADDING) / (ELEMENT_SIZE + CONTAINER_PADDING)
),
0,
siblings.length + 1
)
const belowSib = allBindings[order - 1]
const aboveSib = allBindings[order]
let index: IndexKey
if (belowSib?.toId === shape.id) {
index = belowSib.props.index
} else if (aboveSib?.toId === shape.id) {
index = aboveSib.props.index
} else {
index = getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
}
return index
}
// [4]
override onTranslateStart(shape: ElementShape) {
this.editor.updateBindings(
this.editor.getBindingsToShape(shape, LAYOUT_TYPE).map((binding) => ({
...binding,
props: { ...binding.props, placeholder: true },
}))
)
}
override onTranslate(_: ElementShape, shape: ElementShape) {
const pageAnchor = this.getElementCenter(shape)
const targetContainer = this.getTargetContainer(shape, pageAnchor)
if (!targetContainer) {
this.editor.deleteBindings(this.editor.getBindingsToShape(shape, LAYOUT_TYPE))
return
}
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
const existingBinding = this.editor
.getBindingsFromShape(targetContainer, LAYOUT_TYPE)
.find((b) => b.toId === shape.id)
if (existingBinding) {
if (existingBinding.props.index === index) return
this.editor.updateBinding({
...existingBinding,
props: {
...existingBinding.props,
placeholder: true,
index,
},
})
} else {
this.editor.createBinding({
type: LAYOUT_TYPE,
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: true,
},
})
}
}
// [5]
override onTranslateEnd(_: ElementShape, shape: ElementShape) {
const pageAnchor = this.getElementCenter(shape)
const targetContainer = this.getTargetContainer(shape, pageAnchor)
if (!targetContainer) return
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
this.editor.deleteBindings(this.editor.getBindingsToShape(shape, LAYOUT_TYPE))
this.editor.createBinding({
type: LAYOUT_TYPE,
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: false,
},
})
}
}
// [6]
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
[LAYOUT_TYPE]: {
index: IndexKey
placeholder: boolean
}
}
}
type LayoutBinding = TLBinding<typeof LAYOUT_TYPE>
class LayoutBindingUtil extends BindingUtil<LayoutBinding> {
static override type = LAYOUT_TYPE
override getDefaultProps() {
return {
index: 'a1' as IndexKey,
placeholder: true,
}
}
override onAfterCreate({ binding }: BindingOnCreateOptions<LayoutBinding>): void {
this.updateElementsForContainer(binding)
}
override onAfterChange({ bindingAfter }: BindingOnChangeOptions<LayoutBinding>): void {
this.updateElementsForContainer(bindingAfter)
}
override onAfterChangeFromShape({ binding }: BindingOnShapeChangeOptions<LayoutBinding>): void {
this.updateElementsForContainer(binding)
}
override onAfterDelete({ binding }: BindingOnDeleteOptions<LayoutBinding>): void {
this.updateElementsForContainer(binding)
}
// [7]
private updateElementsForContainer({
props: { placeholder },
fromId: containerId,
toId,
}: LayoutBinding) {
const container = this.editor.getShape<ContainerShape>(containerId)
if (!container) return
const bindings = this.editor
.getBindingsFromShape(container, LAYOUT_TYPE)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
if (bindings.length === 0) return
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
// The element being dragged keeps following the pointer; only its slot is reserved
if (toId === binding.toId && placeholder) continue
const offset = new Vec(
CONTAINER_PADDING + i * (ELEMENT_SIZE + CONTAINER_PADDING),
CONTAINER_PADDING
)
const shape = this.editor.getShape<ElementShape>(binding.toId)
if (!shape) continue
const point = this.editor.getPointInParentSpace(
shape,
this.editor.getShapePageTransform(container)!.applyToPoint(offset)
)
if (shape.x !== point.x || shape.y !== point.y) {
this.editor.updateShape({
id: binding.toId,
type: ELEMENT_TYPE,
x: point.x,
y: point.y,
})
}
}
const width =
CONTAINER_PADDING +
(bindings.length * ELEMENT_SIZE + (bindings.length - 1) * CONTAINER_PADDING) +
CONTAINER_PADDING
const height = CONTAINER_PADDING + ELEMENT_SIZE + CONTAINER_PADDING
if (width !== container.props.width || height !== container.props.height) {
this.editor.updateShape({
id: container.id,
type: CONTAINER_TYPE,
props: { width, height },
})
}
}
}
const shapeUtils = [ContainerShapeUtil, ElementShapeUtil]
const bindingUtils = [LayoutBindingUtil]
export default function LayoutExample() {
return (
<div className="tldraw__editor">
<Tldraw
snapshot={snapshot as unknown as TLStoreSnapshot}
shapeUtils={shapeUtils}
bindingUtils={bindingUtils}
/>
</div>
)
}
/*
Introduction:
A container shape lays out element shapes in a row. The relationship between a container and
each element is a `layout` binding whose `index` prop is a fractional index giving the
element's position in the row. The binding util reacts to changes by re-laying-out the row.
[1]
The container has no children in the tldraw parent/child sense; the elements stay on the page
and are related to it only through bindings. Its size is derived from how many bindings it has.
[2]
`canBind` on both shapes only allows `layout` bindings from a container to an element. The
element util's translate handlers use `editor.canBindShapes` (which consults both sides) to
find drop targets, so this is the single source of truth for what can go where.
[3]
Turn a drop position into a fractional index: work out which slot the element's center is
nearest, then use `getIndexBetween` on the neighbouring bindings. Reusing the element's own
index when it hasn't moved slots avoids churning bindings while dragging.
[4]
While an element is being dragged its binding is marked `placeholder`, which tells the layout
to reserve the slot but not snap the element into it, so it keeps following the pointer.
`onTranslate` moves the placeholder between containers and slots as the pointer moves.
[5]
On drop, replace whatever bindings the element has with a single non-placeholder binding to
the container under it. Creating the binding triggers `onAfterCreate`, which snaps the element
into place.
[6]
The binding props: `index` orders the elements, `placeholder` marks an in-progress drag.
[7]
Every binding lifecycle hook that can affect a row (create, change, container moved, delete)
runs the same layout: position each bound element in its slot and resize the container to fit.
Comparing before updating keeps this idempotent so it doesn't loop.
*/
Bindings let one shape respond to changes in another. Here a container shape and its element shapes are related by layout bindings, each carrying a fractional index. LayoutBindingUtil re-runs the layout whenever a binding is created, changed, or deleted, or the container moves: elements snap into their slots and the container resizes to fit. The element shape util's onTranslateStart, onTranslate, and onTranslateEnd create and move a placeholder binding while dragging so the row makes room before the drop.
Try dragging an element to a different slot in the container, or out onto the page and back in.
Is this page helpful?
Prev
DOM-based shape sizeNext
Persistent iframe shape