diff --git a/apps/examples/src/examples/layout-bindings/LayoutExample.tsx b/apps/examples/src/examples/layout-bindings/LayoutExample.tsx new file mode 100644 index 000000000..5b337de4a --- /dev/null +++ b/apps/examples/src/examples/layout-bindings/LayoutExample.tsx @@ -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 { + static override type = 'container' as const + static override props: RecordProps = { 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 ( + + ) + } + + override indicator(shape: ContainerShape) { + return + } +} + +// The element shapes that can be placed inside the container shapes + +type ElementShape = TLBaseShape<'element', { color: string }> + +class ElementShapeUtil extends ShapeUtil { + static override type = 'element' as const + static override props: RecordProps = { + 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 + } + + override indicator() { + return + } + + 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(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(shape, 'layout').map((binding) => ({ + ...binding, + props: { ...binding.props, placeholder: true }, + })) + ) + } + + override onTranslate: TLOnTranslateHandler | 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(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(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({ + ...existingBinding, + props: { + ...existingBinding.props, + placeholder: true, + index, + }, + }) + } else { + // ...otherwise, create a new one + this.editor.createBinding({ + 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(shape, 'layout')) + + // ...and then create a new one + this.editor.createBinding({ + 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 { + static override type = 'layout' as const + + override getDefaultProps() { + return { + index: 'a1' as IndexKey, + placeholder: true, + } + } + + override onAfterCreate({ binding }: BindingOnCreateOptions): void { + this.updateElementsForContainer(binding) + } + + override onAfterChange({ bindingAfter }: BindingOnChangeOptions): void { + this.updateElementsForContainer(bindingAfter) + } + + override onAfterChangeFromShape({ binding }: BindingOnShapeChangeOptions): void { + this.updateElementsForContainer(binding) + } + + override onAfterDelete({ binding }: BindingOnDeleteOptions): 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(containerId) + if (!container) return + + const bindings = this.editor + .getBindingsFromShape(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(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 ( +
+ { + ;(window as any).editor = editor + }} + shapeUtils={[ContainerShapeUtil, ElementShapeUtil]} + bindingUtils={[LayoutBindingUtil]} + /> +
+ ) +} diff --git a/apps/examples/src/examples/layout-bindings/README.md b/apps/examples/src/examples/layout-bindings/README.md new file mode 100644 index 000000000..d1db19664 --- /dev/null +++ b/apps/examples/src/examples/layout-bindings/README.md @@ -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 diff --git a/apps/examples/src/examples/layout-bindings/snapshot.json b/apps/examples/src/examples/layout-bindings/snapshot.json new file mode 100644 index 000000000..1b80b16e8 --- /dev/null +++ b/apps/examples/src/examples/layout-bindings/snapshot.json @@ -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 + } + } +}