Layout constraints example (#3996)

Adds an example of implementing layout contraints using bindings.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [x] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [x] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Adds an example of how to use bindings to create layout constraints

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-06-28 14:49:54 +01:00 committed by GitHub
parent 8250112061
commit 978de17a12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 595 additions and 0 deletions

View file

@ -0,0 +1,373 @@
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeChangeOptions,
BindingUtil,
HTMLContainer,
IndexKey,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLBaseBinding,
TLBaseShape,
TLOnTranslateHandler,
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 = () => false
override canResize = () => false
override hideRotateHandle = () => true
override isAspectRatioLocked = () => 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 = () => false
override canResize = () => false
override hideRotateHandle = () => true
override isAspectRatioLocked = () => 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
}
private 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: TLOnTranslateHandler<ElementShape> | undefined = (
_,
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>
)
}

View file

@ -0,0 +1,12 @@
---
title: Layout constraints (bindings)
component: ./LayoutExample.tsx
category: shapes/tools
keywords: [constraints, group, shape, custom, bindings, drag, drop, position]
---
How to constrain shapes to a layout using bindings.
---
You can use bindings to make shapes respond to changes to other shapes. This is useful for enforcing layout constraints

View file

@ -0,0 +1,210 @@
{
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
},
"shape:f4LKGB_8M2qsyWGpHR5Dq": {
"x": 30.9375,
"y": 69.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"type": "container",
"parentId": "page:page",
"index": "a1",
"props": {
"width": 644,
"height": 148
},
"typeName": "shape"
},
"shape:2oThF4kJ4v31xqKN5lvq2": {
"x": 550.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:2oThF4kJ4v31xqKN5lvq2",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a2",
"typeName": "shape"
},
"shape:K2vk_VTaNh-ANaRNOAvgY": {
"x": 426.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:K2vk_VTaNh-ANaRNOAvgY",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a3",
"typeName": "shape"
},
"shape:6uouhIK7PvyIRNQHACf-d": {
"x": 302.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:6uouhIK7PvyIRNQHACf-d",
"type": "element",
"props": {
"color": "#FFFFFF"
},
"parentId": "page:page",
"index": "a4",
"typeName": "shape"
},
"shape:GTQq2qxkWPHEK7KMIRtsh": {
"x": 54.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:GTQq2qxkWPHEK7KMIRtsh",
"type": "element",
"props": {
"color": "#5BCEFA"
},
"parentId": "page:page",
"index": "a5",
"typeName": "shape"
},
"shape:05jMujN6A0sIp6zzHMpbV": {
"x": 178.9375,
"y": 93.48828125,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:05jMujN6A0sIp6zzHMpbV",
"type": "element",
"props": {
"color": "#F5A9B8"
},
"parentId": "page:page",
"index": "a6",
"typeName": "shape"
},
"binding:iOBENBUHvzD8N7mBdIM5l": {
"meta": {},
"id": "binding:iOBENBUHvzD8N7mBdIM5l",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:05jMujN6A0sIp6zzHMpbV",
"props": {
"index": "a2",
"placeholder": false
},
"typeName": "binding"
},
"binding:YTIeOALEmHJk6dczRpQmE": {
"meta": {},
"id": "binding:YTIeOALEmHJk6dczRpQmE",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:GTQq2qxkWPHEK7KMIRtsh",
"props": {
"index": "a1",
"placeholder": false
},
"typeName": "binding"
},
"binding:n4LY_pVuLfjV1qpOTZX-U": {
"meta": {},
"id": "binding:n4LY_pVuLfjV1qpOTZX-U",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:6uouhIK7PvyIRNQHACf-d",
"props": {
"index": "a3",
"placeholder": false
},
"typeName": "binding"
},
"binding:8XayRsWB_nxAH2833SYg1": {
"meta": {},
"id": "binding:8XayRsWB_nxAH2833SYg1",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:2oThF4kJ4v31xqKN5lvq2",
"props": {
"index": "a5",
"placeholder": false
},
"typeName": "binding"
},
"binding:MTYuIRiEVTn2DyVChthry": {
"meta": {},
"id": "binding:MTYuIRiEVTn2DyVChthry",
"type": "layout",
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
"toId": "shape:K2vk_VTaNh-ANaRNOAvgY",
"props": {
"index": "a4",
"placeholder": false
},
"typeName": "binding"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 4,
"com.tldraw.asset.video": 4,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.note": 7,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.image": 3,
"com.tldraw.shape.video": 2,
"com.tldraw.shape.container": 0,
"com.tldraw.shape.element": 0,
"com.tldraw.binding.arrow": 0,
"com.tldraw.binding.layout": 0
}
}
}