First draft of the new bindings API. We'll follow this up with some API
refinements, tests, documentation, and examples.

Bindings are a new record type for establishing relationships between
two shapes so they can update at the same time.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Release Notes

#### Breaking changes
- The `start` and `end` properties on `TLArrowShape` no longer have
`type: point | binding`. Instead, they're always a point, which may be
out of date if a binding exists. To check for & retrieve arrow bindings,
use `getArrowBindings(editor, shape)` instead.
- `getArrowTerminalsInArrowSpace` must be passed a `TLArrowBindings` as
a third argument: `getArrowTerminalsInArrowSpace(editor, shape,
getArrowBindings(editor, shape))`
- The following types have been renamed:
    - `ShapeProps` -> `RecordProps`
    - `ShapePropsType` -> `RecordPropsType`
    - `TLShapePropsMigrations` -> `TLPropsMigrations`
    - `SchemaShapeInfo` -> `SchemaPropsInfo`

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
alex 2024-05-08 13:37:31 +01:00 committed by GitHub
parent 0a7816e34d
commit da35f2bd75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 4087 additions and 2446 deletions

View file

@ -69,8 +69,8 @@ test.describe('Export snapshots', () => {
fill: fill,
arrowheadStart: 'square',
arrowheadEnd: 'dot',
start: { type: 'point', x: 0, y: 0 },
end: { type: 'point', x: 100, y: 100 },
start: { x: 0, y: 0 },
end: { x: 100, y: 100 },
bend: 20,
},
},
@ -149,8 +149,8 @@ test.describe('Export snapshots', () => {
arrowheadStart: 'square',
arrowheadEnd: 'arrow',
font,
start: { type: 'point', x: 0, y: 0 },
end: { type: 'point', x: 100, y: 100 },
start: { x: 0, y: 0 },
end: { x: 100, y: 100 },
bend: 20,
text: 'test',
},
@ -167,8 +167,8 @@ test.describe('Export snapshots', () => {
arrowheadStart: 'square',
arrowheadEnd: 'arrow',
font,
start: { type: 'point', x: 0, y: 0 },
end: { type: 'point', x: 100, y: 100 },
start: { x: 0, y: 0 },
end: { x: 100, y: 100 },
bend: 20,
text: 'test',
},

View file

@ -2,8 +2,8 @@ import {
BaseBoxShapeUtil,
BoundsSnapGeometry,
HTMLContainer,
RecordProps,
Rectangle2d,
ShapeProps,
T,
TLBaseShape,
} from 'tldraw'
@ -23,7 +23,7 @@ type IPlayingCard = TLBaseShape<
export class PlayingCardUtil extends BaseBoxShapeUtil<IPlayingCard> {
// [2]
static override type = 'PlayingCard' as const
static override props: ShapeProps<IPlayingCard> = {
static override props: RecordProps<IPlayingCard> = {
w: T.number,
h: T.number,
suit: T.string,

View file

@ -1,8 +1,8 @@
import { DefaultColorStyle, ShapeProps, T } from 'tldraw'
import { DefaultColorStyle, RecordProps, T } from 'tldraw'
import { ICardShape } from './card-shape-types'
// Validation for our custom card shape's props, using one of tldraw's default styles
export const cardShapeProps: ShapeProps<ICardShape> = {
export const cardShapeProps: RecordProps<ICardShape> = {
w: T.number,
h: T.number,
color: DefaultColorStyle,

View file

@ -1,8 +1,8 @@
import {
Geometry2d,
HTMLContainer,
RecordProps,
Rectangle2d,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
@ -28,7 +28,7 @@ type ICustomShape = TLBaseShape<
export class MyShapeUtil extends ShapeUtil<ICustomShape> {
// [a]
static override type = 'my-custom-shape' as const
static override props: ShapeProps<ICustomShape> = {
static override props: RecordProps<ICustomShape> = {
w: T.number,
h: T.number,
text: T.string,

View file

@ -1,7 +1,7 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
ShapeProps,
RecordProps,
T,
TLBaseShape,
TLOnEditEndHandler,
@ -23,7 +23,7 @@ type IMyEditableShape = TLBaseShape<
export class EditableShapeUtil extends BaseBoxShapeUtil<IMyEditableShape> {
static override type = 'my-editable-shape' as const
static override props: ShapeProps<IMyEditableShape> = {
static override props: RecordProps<IMyEditableShape> = {
w: T.number,
h: T.number,
animal: T.number,

View file

@ -9,6 +9,7 @@ import {
TldrawSelectionBackground,
TldrawSelectionForeground,
TldrawUi,
defaultBindingUtils,
defaultEditorAssetUrls,
defaultShapeTools,
defaultShapeUtils,
@ -45,6 +46,7 @@ export default function ExplodedExample() {
<TldrawEditor
initialState="select"
shapeUtils={defaultShapeUtils}
bindingUtils={defaultBindingUtils}
tools={[...defaultTools, ...defaultShapeTools]}
components={defaultComponents}
persistenceKey="exploded-example"

View file

@ -1,4 +1,4 @@
import { BaseBoxShapeUtil, HTMLContainer, ShapeProps, T, TLBaseShape } from 'tldraw'
import { BaseBoxShapeUtil, HTMLContainer, RecordProps, T, TLBaseShape } from 'tldraw'
// There's a guide at the bottom of this file!
@ -14,7 +14,7 @@ type IMyInteractiveShape = TLBaseShape<
export class myInteractiveShape extends BaseBoxShapeUtil<IMyInteractiveShape> {
static override type = 'my-interactive-shape' as const
static override props: ShapeProps<IMyInteractiveShape> = {
static override props: RecordProps<IMyInteractiveShape> = {
w: T.number,
h: T.number,
checked: T.boolean,

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import {
BaseBoxShapeUtil,
HTMLContainer,
ShapeProps,
RecordProps,
T,
TLBaseShape,
stopEventPropagation,
@ -20,7 +20,7 @@ type IMyPopupShape = TLBaseShape<
export class PopupShapeUtil extends BaseBoxShapeUtil<IMyPopupShape> {
static override type = 'my-popup-shape' as const
static override props: ShapeProps<IMyPopupShape> = {
static override props: RecordProps<IMyPopupShape> = {
w: T.number,
h: T.number,
animal: T.number,

View file

@ -1,9 +1,9 @@
import { useCallback } from 'react'
import {
Geometry2d,
RecordProps,
Rectangle2d,
SVGContainer,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
@ -24,7 +24,7 @@ export type SlideShape = TLBaseShape<
export class SlideShapeUtil extends ShapeUtil<SlideShape> {
static override type = 'slide' as const
static override props: ShapeProps<SlideShape> = {
static override props: RecordProps<SlideShape> = {
w: T.number,
h: T.number,
}

View file

@ -8,7 +8,7 @@ import {
Geometry2d,
LABEL_FONT_SIZES,
Polygon2d,
ShapePropsType,
RecordPropsType,
ShapeUtil,
T,
TEXT_PROPS,
@ -52,7 +52,7 @@ export const speechBubbleShapeProps = {
tail: vecModelValidator,
}
export type SpeechBubbleShapeProps = ShapePropsType<typeof speechBubbleShapeProps>
export type SpeechBubbleShapeProps = RecordPropsType<typeof speechBubbleShapeProps>
export type SpeechBubbleShape = TLBaseShape<'speech-bubble', SpeechBubbleShapeProps>
export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {

View file

@ -0,0 +1,9 @@
---
title: Sticker (bindings)
component: ./StickerExample.tsx
category: shapes/tools
---
A sticker shape, using bindings to attach shapes to one and other
---

View file

@ -0,0 +1,235 @@
import {
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
BindingUtil,
Box,
DefaultToolbar,
DefaultToolbarContent,
RecordProps,
Rectangle2d,
ShapeUtil,
StateNode,
TLBaseBinding,
TLBaseShape,
TLEventHandlers,
TLOnTranslateEndHandler,
TLOnTranslateStartHandler,
TLUiComponents,
TLUiOverrides,
Tldraw,
TldrawUiMenuItem,
VecModel,
createShapeId,
invLerp,
lerp,
useIsToolSelected,
useTools,
} from 'tldraw'
// eslint-disable-next-line @typescript-eslint/ban-types
type StickerShape = TLBaseShape<'sticker', {}>
const offsetX = -16
const offsetY = -26
class StickerShapeUtil extends ShapeUtil<StickerShape> {
static override type = 'sticker' as const
static override props: RecordProps<StickerShape> = {}
override getDefaultProps() {
return {}
}
override canBind = () => false
override canEdit = () => false
override canResize = () => false
override hideRotateHandle = () => true
override isAspectRatioLocked = () => true
override getGeometry() {
return new Rectangle2d({
width: 32,
height: 32,
x: offsetX,
y: offsetY,
isFilled: true,
})
}
override component() {
return (
<div
style={{
width: '100%',
height: '100%',
marginLeft: offsetX,
marginTop: offsetY,
fontSize: '26px',
textAlign: 'center',
}}
>
</div>
)
}
override indicator() {
return <rect width={32} height={32} x={offsetX} y={offsetY} />
}
override onTranslateStart: TLOnTranslateStartHandler<StickerShape> = (shape) => {
const bindings = this.editor.getBindingsFromShape(shape, 'sticker')
this.editor.deleteBindings(bindings)
}
override onTranslateEnd: TLOnTranslateEndHandler<StickerShape> = (initial, sticker) => {
const pageAnchor = this.editor.getShapePageTransform(sticker).applyToPoint({ x: 0, y: 0 })
const target = this.editor.getShapeAtPoint(pageAnchor, {
hitInside: true,
filter: (shape) => shape.id !== sticker.id,
})
if (!target) return
const targetBounds = Box.ZeroFix(this.editor.getShapeGeometry(target)!.bounds)
const pointInTargetSpace = this.editor.getPointInShapeSpace(target, pageAnchor)
const anchor = {
x: invLerp(targetBounds.minX, targetBounds.maxX, pointInTargetSpace.x),
y: invLerp(targetBounds.minY, targetBounds.maxY, pointInTargetSpace.y),
}
this.editor.createBinding({
type: 'sticker',
fromId: sticker.id,
toId: target.id,
props: {
anchor,
},
})
}
}
type StickerBinding = TLBaseBinding<
'sticker',
{
anchor: VecModel
}
>
class StickerBindingUtil extends BindingUtil<StickerBinding> {
static override type = 'sticker' as const
override getDefaultProps() {
return {
anchor: { x: 0.5, y: 0.5 },
}
}
// when the shape we're stuck to changes, update the sticker's position
override onAfterChangeToShape({
binding,
shapeAfter,
}: BindingOnShapeChangeOptions<StickerBinding>): void {
const sticker = this.editor.getShape<StickerShape>(binding.fromId)!
const shapeBounds = this.editor.getShapeGeometry(shapeAfter)!.bounds
const shapeAnchor = {
x: lerp(shapeBounds.minX, shapeBounds.maxX, binding.props.anchor.x),
y: lerp(shapeBounds.minY, shapeBounds.maxY, binding.props.anchor.y),
}
const pageAnchor = this.editor.getShapePageTransform(shapeAfter).applyToPoint(shapeAnchor)
const stickerParentAnchor = this.editor
.getShapeParentTransform(sticker)
.invert()
.applyToPoint(pageAnchor)
this.editor.updateShape({
id: sticker.id,
type: 'sticker',
x: stickerParentAnchor.x,
y: stickerParentAnchor.y,
})
}
// when the thing we're stuck to is deleted, delete the sticker too
override onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions<StickerBinding>): void {
const sticker = this.editor.getShape<StickerShape>(binding.fromId)
if (sticker) this.editor.deleteShape(sticker.id)
}
}
class StickerTool extends StateNode {
static override id = 'sticker'
override onEnter = () => {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
const { currentPagePoint } = this.editor.inputs
const stickerId = createShapeId()
this.editor.mark(`creating:${stickerId}`)
this.editor.createShape({
id: stickerId,
type: 'sticker',
x: currentPagePoint.x,
y: currentPagePoint.y,
})
this.editor.setSelectedShapes([stickerId])
this.editor.setCurrentTool('select.translating', {
...info,
target: 'shape',
shape: this.editor.getShape(stickerId),
isCreating: true,
onInteractionEnd: 'sticker',
onCreate: () => {
this.editor.setCurrentTool('sticker')
},
})
}
}
const overrides: TLUiOverrides = {
tools(editor, schema) {
schema['sticker'] = {
id: 'sticker',
label: 'Sticker',
icon: 'heart-icon',
kbd: 'p',
onSelect: () => {
editor.setCurrentTool('sticker')
},
}
return schema
},
}
const components: TLUiComponents = {
Toolbar: (...props) => {
const sticker = useTools().sticker
const isStickerSelected = useIsToolSelected(sticker)
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...sticker} isSelected={isStickerSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
}
export default function StickerExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
;(window as any).editor = editor
}}
shapeUtils={[StickerShapeUtil]}
bindingUtils={[StickerBindingUtil]}
tools={[StickerTool]}
overrides={overrides}
components={components}
/>
</div>
)
}