Layout constraints (bindings)
You can use bindings to make shapes respond to changes to other shapes. This is useful for enforcing layout constraints
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeChangeOptions,
BindingUtil,
HTMLContainer,
IndexKey,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLBaseBinding,
TLBaseShape,
Tldraw,
Vec,
clamp,
createBindingId,
getIndexBetween,
} from 'tldraw'
import snapShot from './snapshot.json'
// The container shapes that can contain element shapes
const CONTAINER_PADDING = 24
type ContainerShape = TLBaseShape<'element', { height: number; width: number }>
class ContainerShapeUtil extends ShapeUtil<ContainerShape> {
static override type = 'container' as const
static override props: RecordProps<ContainerShape> = { height: T.number, width: T.number }
override getDefaultProps() {
return {
width: 100 + CONTAINER_PADDING * 2,
height: 100 + CONTAINER_PADDING * 2,
}
}
override canBind({
fromShapeType,
toShapeType,
bindingType,
}: {
fromShapeType: string
toShapeType: string
bindingType: string
}) {
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === 'layout'
}
override canEdit() {
return false
}
override canResize() {
return false
}
override hideRotateHandle() {
return true
}
override isAspectRatioLocked() {
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 indicator(shape: ContainerShape) {
return <rect width={shape.props.width} height={shape.props.height} />
}
}
// The element shapes that can be placed inside the container shapes
type ElementShape = TLBaseShape<'element', { color: string }>
class ElementShapeUtil extends ShapeUtil<ElementShape> {
static override type = 'element' as const
static override props: RecordProps<ElementShape> = {
color: T.string,
}
override getDefaultProps() {
return {
color: '#AEC6CF',
}
}
override canBind({
fromShapeType,
toShapeType,
bindingType,
}: {
fromShapeType: string
toShapeType: string
bindingType: string
}) {
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === 'layout'
}
override canEdit() {
return false
}
override canResize() {
return false
}
override hideRotateHandle() {
return true
}
override isAspectRatioLocked() {
return true
}
override getGeometry() {
return new Rectangle2d({
width: 100,
height: 100,
isFilled: true,
})
}
override component(shape: ElementShape) {
return <HTMLContainer style={{ backgroundColor: shape.props.color }}></HTMLContainer>
}
override indicator() {
return <rect width={100} height={100} />
}
private getTargetContainer(shape: ElementShape, pageAnchor: Vec) {
// Find the container shape that the element is being dropped on
return this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (otherShape) =>
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: 'layout' }),
}) as ContainerShape | undefined
}
getBindingIndexForPosition(shape: ElementShape, container: ContainerShape, pageAnchor: Vec) {
// All the layout bindings from the container
const allBindings = this.editor
.getBindingsFromShape<LayoutBinding>(container, 'layout')
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
// Those bindings that don't involve the element
const siblings = allBindings.filter((b) => b.toId !== shape.id)
// Get the relative x position of the element center in the container
// Where should the element be placed? min index at left, max index + 1
const order = clamp(
Math.round((pageAnchor.x - container.x - CONTAINER_PADDING) / (100 + CONTAINER_PADDING)),
0,
siblings.length + 1
)
// Get a fractional index between the two siblings
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
}
override onTranslateStart(shape: ElementShape) {
// Update all the layout bindings for this shape to be placeholders
this.editor.updateBindings(
this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout').map((binding) => ({
...binding,
props: { ...binding.props, placeholder: true },
}))
)
}
override onTranslate(_: ElementShape, shape: ElementShape) {
// Find the center of the element shape
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
// Find the container shape that the element is being dropped on
const targetContainer = this.getTargetContainer(shape, pageAnchor)
if (!targetContainer) {
// Delete all the bindings to the element
const bindings = this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout')
this.editor.deleteBindings(bindings)
return
}
// Get the index for the new binding
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
// Is there an existing binding already between the container and the shape?
const existingBinding = this.editor
.getBindingsFromShape<LayoutBinding>(targetContainer, 'layout')
.find((b) => b.toId === shape.id)
if (existingBinding) {
// If a binding already exists, update it
if (existingBinding.props.index === index) return
this.editor.updateBinding<LayoutBinding>({
...existingBinding,
props: {
...existingBinding.props,
placeholder: true,
index,
},
})
} else {
// ...otherwise, create a new one
this.editor.createBinding<LayoutBinding>({
id: createBindingId(),
type: 'layout',
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: true,
},
})
}
}
override onTranslateEnd(_: ElementShape, shape: ElementShape) {
// Find the center of the element shape
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
// Find the container shape that the element is being dropped on
const targetContainer = this.getTargetContainer(shape, pageAnchor)
// No target container? no problem
if (!targetContainer) return
// get the index for the new binding
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
// delete all the previous bindings for this shape
this.editor.deleteBindings(this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout'))
// ...and then create a new one
this.editor.createBinding<LayoutBinding>({
id: createBindingId(),
type: 'layout',
fromId: targetContainer.id,
toId: shape.id,
props: {
index,
placeholder: false,
},
})
}
}
// The binding between the element shapes and the container shapes
type LayoutBinding = TLBaseBinding<
'layout',
{
index: IndexKey
placeholder: boolean
}
>
class LayoutBindingUtil extends BindingUtil<LayoutBinding> {
static override type = 'layout' as const
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)
}
private updateElementsForContainer({
props: { placeholder },
fromId: containerId,
toId,
}: LayoutBinding) {
// Get all of the bindings from the layout container
const container = this.editor.getShape<ContainerShape>(containerId)
if (!container) return
const bindings = this.editor
.getBindingsFromShape<LayoutBinding>(container, 'layout')
.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]
if (toId === binding.toId && placeholder) continue
const offset = new Vec(CONTAINER_PADDING + i * (100 + 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',
x: point.x,
y: point.y,
})
}
}
const width =
CONTAINER_PADDING +
(bindings.length * 100 + (bindings.length - 1) * CONTAINER_PADDING) +
CONTAINER_PADDING
const height = CONTAINER_PADDING + 100 + CONTAINER_PADDING
if (width !== container.props.width || height !== container.props.height) {
this.editor.updateShape({
id: container.id,
type: 'container',
props: { width, height },
})
}
}
}
export default function LayoutExample() {
return (
<div className="tldraw__editor">
<Tldraw
// @ts-ignore
snapshot={snapShot}
onMount={(editor) => {
;(window as any).editor = editor
}}
shapeUtils={[ContainerShapeUtil, ElementShapeUtil]}
bindingUtils={[LayoutBindingUtil]}
/>
</div>
)
}
Prev
Toasts and dialogsNext
Pin (bindings)