Bindings documentation (#3812)

Adds docs (reference material and a guide) for the bindings API. Also,
the unbind reason enum is now a union of strings.

### Change Type

- [x] `docs` — Changes to the documentation, examples, or templates.
- [x] `improvement` — Improving existing features
This commit is contained in:
alex 2024-06-10 14:16:21 +01:00 committed by GitHub
parent ccb6b918c5
commit f5a6ed7b91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 352 additions and 62 deletions

View file

@ -387,6 +387,108 @@ The camera may be in two states, `idle` or `moving`.
You can get the current camera state with [Editor#getCameraState](?).
# Bindings
A binding is a relationship from one [shape](/docs/shapes) to another. They're used to connect
shapes so they can update together or depend on one and other. For example: tldraw's default arrow
shape uses bindings to connect the ends of the arrows to the shapes they're pointing to, one binding
for each end of the arrow.
You can create different types of binding that do all sorts of different things with relationships
between shapes. For example, you could create a [sticker
shape](/examples/shapes/tools/sticker-bindings) that sticks to any other shape it's dropped onto.
## The binding object
Bindings are records (JSON objects) that live in the [store](/docs/editor#store). For example,
here's a binding record for one end of an arrow shape:
```json
{
"id": "binding:someId",
"typeName": "binding"
"type": "arrow",
"fromId": "shape:arrowId",
"toId": "shape:someOtherShapeId",
"props": {
"terminal": "end"
"isPrecise": true,
"isExact": false,
"normalizedAnchor": {
"x": 0.5,
"y": 0.5
},
},
"meta": {},
}
```
Every binding contains some base information - its ID & the type of binding, as well as the ID of
the shape that a binding comes _from_ and the ID of the shape that the binding goes _to_. These two
properties work the same way, but it's often useful to have an explicit direction in a binding
relationship. For example, an arrow binding always goes _from_ an arrow _to_ another shape.
Bindings contain their own type-specific information in the `props` object. Each type of binding can
have different props.
Bindings also have a `meta` property which can be used by your application to add data to bindings
you haven't built yourself. You can read more about the meta property
[here](/docs/shapes#Meta-information).
## Custom bindings
To create a binding of your own, you can define a custom binding.
### The binding type
First, you need to create a type that describes what the binding object will look like:
```ts
import { TLBaseBinding } from 'tldraw'
type StickerBinding = TLBaseBinding<'sticker', { x: number; y: number }>
```
With [TLBaseBinding](?) we define the binding's type (`sticker`) and `props` property (`{x: number,
y: number}`). The type can be any string, and the props must be a plain JSON object (ie not a class
instance).
The [TLBaseBinding](?) helper adds the other base properties like `id`, `toId`, and `fromId`.
### The `BindingUtil` class
While bindings themselves are plain JSON objects, we use
[`BindingUtil`](/reference/editor/BindingUtil) classes to define how bindings should work. They tell
tldraw the structure of a binding, and what to do when it or the shapes it involves are created,
updated, or deleted.
```ts
import { BindingUtil } from 'tldraw'
class StickerBindingUtil extends BindingUtil<StickerBinding> {
static override type = 'sticker' as const
override getDefaultProps() {
return { x: 0.5, y: 0.5 }
}
override onAfterChangeToShape({ binding }) {
const sticker = this.editor.getShape(binding.fromShape)
// move the sticker so it stays attached to the to shape
}
}
```
Each binding util needs a `type` and `getDefaultProps` to define its basic structure. You can also
define a number of different callbacks describing how the binding should behave. See the
[BindingUtil](?) reference or a [complete example of a
binding](/examples/shapes/tools/sticker-bindings) for details.
If the strucutre of your binding needs to change over time, you can provide
[migrations](/docs/persistence#Shape-props-migrations) describing how old stored bindings can be
brought up to date.
# Common things to do with the editor
### Create a shape id

View file

@ -175,50 +175,39 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
onResize: TLOnResizeHandler<any>;
}
// @public (undocumented)
// @public
export interface BindingOnChangeOptions<Binding extends TLUnknownBinding> {
// (undocumented)
bindingAfter: Binding;
// (undocumented)
bindingBefore: Binding;
}
// @public (undocumented)
// @public
export interface BindingOnCreateOptions<Binding extends TLUnknownBinding> {
// (undocumented)
binding: Binding;
}
// @public (undocumented)
// @public
export interface BindingOnDeleteOptions<Binding extends TLUnknownBinding> {
// (undocumented)
binding: Binding;
}
// @public (undocumented)
// @public
export interface BindingOnShapeChangeOptions<Binding extends TLUnknownBinding> {
// (undocumented)
binding: Binding;
// (undocumented)
shapeAfter: TLShape;
// (undocumented)
shapeBefore: TLShape;
}
// @public (undocumented)
// @public
export interface BindingOnShapeDeleteOptions<Binding extends TLUnknownBinding> {
// (undocumented)
binding: Binding;
// (undocumented)
shape: TLShape;
}
// @public (undocumented)
// @public
export interface BindingOnShapeIsolateOptions<Binding extends TLUnknownBinding> {
// (undocumented)
binding: Binding;
// (undocumented)
shape: TLShape;
removedShape: TLShape;
}
// @public (undocumented)
@ -229,31 +218,18 @@ export abstract class BindingUtil<Binding extends TLUnknownBinding = TLUnknownBi
abstract getDefaultProps(): Partial<Binding['props']>;
// (undocumented)
static migrations?: TLPropsMigrations;
// (undocumented)
onAfterChange?(options: BindingOnChangeOptions<Binding>): void;
// (undocumented)
onAfterChangeFromShape?(options: BindingOnShapeChangeOptions<Binding>): void;
// (undocumented)
onAfterChangeToShape?(options: BindingOnShapeChangeOptions<Binding>): void;
// (undocumented)
onAfterCreate?(options: BindingOnCreateOptions<Binding>): void;
// (undocumented)
onAfterDelete?(options: BindingOnDeleteOptions<Binding>): void;
// (undocumented)
onBeforeChange?(options: BindingOnChangeOptions<Binding>): Binding | void;
// (undocumented)
onBeforeCreate?(options: BindingOnCreateOptions<Binding>): Binding | void;
// (undocumented)
onBeforeDelete?(options: BindingOnDeleteOptions<Binding>): Binding | void;
// (undocumented)
onBeforeDelete?(options: BindingOnDeleteOptions<Binding>): void;
onBeforeDeleteFromShape?(options: BindingOnShapeDeleteOptions<Binding>): void;
// (undocumented)
onBeforeDeleteToShape?(options: BindingOnShapeDeleteOptions<Binding>): void;
// (undocumented)
onBeforeIsolateFromShape?(options: BindingOnShapeIsolateOptions<Binding>): void;
// (undocumented)
onBeforeIsolateToShape?(options: BindingOnShapeIsolateOptions<Binding>): void;
// (undocumented)
onOperationComplete?(): void;
// (undocumented)
static props?: RecordProps<TLUnknownBinding>;
@ -790,9 +766,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// @internal (undocumented)
crash(error: unknown): this;
createAssets(assets: TLAsset[]): this;
// (undocumented)
createBinding<B extends TLBinding = TLBinding>(partial: TLBindingCreate<B>): this;
// (undocumented)
createBindings(partials: TLBindingCreate[]): this;
// @internal (undocumented)
createErrorAnnotations(origin: string, willCrashApp: 'unknown' | boolean): {
@ -811,9 +785,7 @@ export class Editor extends EventEmitter<TLEventMap> {
createShape<T extends TLUnknownShape>(shape: OptionalKeys<TLShapePartial<T>, 'id'>): this;
createShapes<T extends TLUnknownShape>(shapes: OptionalKeys<TLShapePartial<T>, 'id'>[]): this;
deleteAssets(assets: TLAsset[] | TLAssetId[]): this;
// (undocumented)
deleteBinding(binding: TLBinding | TLBindingId, opts?: Parameters<this['deleteBindings']>[1]): this;
// (undocumented)
deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes }?: {
isolateShapes?: boolean | undefined;
}): this;
@ -860,13 +832,9 @@ export class Editor extends EventEmitter<TLEventMap> {
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
getAssets(): (TLBookmarkAsset | TLImageAsset | TLVideoAsset)[];
getBaseZoom(): number;
// (undocumented)
getBinding(id: TLBindingId): TLBinding | undefined;
// (undocumented)
getBindingsFromShape<Binding extends TLUnknownBinding = TLBinding>(shape: TLShape | TLShapeId, type: Binding['type']): Binding[];
// (undocumented)
getBindingsInvolvingShape<Binding extends TLUnknownBinding = TLBinding>(shape: TLShape | TLShapeId, type?: Binding['type']): Binding[];
// (undocumented)
getBindingsToShape<Binding extends TLUnknownBinding = TLBinding>(shape: TLShape | TLShapeId, type: Binding['type']): Binding[];
getBindingUtil<S extends TLUnknownBinding>(binding: {
type: S['type'];
@ -1141,9 +1109,7 @@ export class Editor extends EventEmitter<TLEventMap> {
select: boolean;
}>): this;
updateAssets(assets: TLAssetPartial[]): this;
// (undocumented)
updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>): this;
// (undocumented)
updateBindings(partials: (null | TLBindingUpdate | undefined)[]): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions): this;
// (undocumented)
@ -2181,9 +2147,7 @@ export interface TLBaseEventInfo {
export interface TLBindingUtilConstructor<T extends TLUnknownBinding, U extends BindingUtil<T> = BindingUtil<T>> {
// (undocumented)
new (editor: Editor): U;
// (undocumented)
migrations?: TLPropsMigrations;
// (undocumented)
props?: RecordProps<T>;
// (undocumented)
type: T['type'];

View file

@ -492,10 +492,10 @@ export class Editor extends EventEmitter<TLEventMap> {
deleteBindingIds.push(binding.id)
const util = this.getBindingUtil(binding)
if (binding.fromId === shape.id) {
util.onBeforeIsolateToShape?.({ binding, shape })
util.onBeforeIsolateToShape?.({ binding, removedShape: shape })
util.onBeforeDeleteFromShape?.({ binding, shape })
} else {
util.onBeforeIsolateFromShape?.({ binding, shape })
util.onBeforeIsolateFromShape?.({ binding, removedShape: shape })
util.onBeforeDeleteToShape?.({ binding, shape })
}
}
@ -5152,10 +5152,17 @@ export class Editor extends EventEmitter<TLEventMap> {
})
}
/**
* Get a binding from the store by its ID if it exists.
*/
getBinding(id: TLBindingId): TLBinding | undefined {
return this.store.get(id) as TLBinding | undefined
}
/**
* Get all bindings of a certain type _from_ a particular shape. These are the bindings whose
* `fromId` matched the shape's ID.
*/
getBindingsFromShape<Binding extends TLUnknownBinding = TLBinding>(
shape: TLShape | TLShapeId,
type: Binding['type']
@ -5165,6 +5172,11 @@ export class Editor extends EventEmitter<TLEventMap> {
(b) => b.fromId === id && b.type === type
) as Binding[]
}
/**
* Get all bindings of a certain type _to_ a particular shape. These are the bindings whose
* `toId` matches the shape's ID.
*/
getBindingsToShape<Binding extends TLUnknownBinding = TLBinding>(
shape: TLShape | TLShapeId,
type: Binding['type']
@ -5174,6 +5186,11 @@ export class Editor extends EventEmitter<TLEventMap> {
(b) => b.toId === id && b.type === type
) as Binding[]
}
/**
* Get all bindings involving a particular shape. This includes bindings where the shape is the
* `fromId` or `toId`. If a type is provided, only bindings of that type are returned.
*/
getBindingsInvolvingShape<Binding extends TLUnknownBinding = TLBinding>(
shape: TLShape | TLShapeId,
type?: Binding['type']
@ -5184,6 +5201,10 @@ export class Editor extends EventEmitter<TLEventMap> {
return result.filter((b) => b.type === type) as Binding[]
}
/**
* Create bindings from a list of partial bindings. You can omit the ID and most props of a
* binding, but the `type`, `toId`, and `fromId` must all be provided.
*/
createBindings(partials: TLBindingCreate[]) {
const bindings: TLBinding[] = []
for (const partial of partials) {
@ -5209,10 +5230,20 @@ export class Editor extends EventEmitter<TLEventMap> {
this.store.put(bindings)
return this
}
/**
* Create a single binding from a partial. You can omit the ID and most props of a binding, but
* the `type`, `toId`, and `fromId` must all be provided.
*/
createBinding<B extends TLBinding = TLBinding>(partial: TLBindingCreate<B>) {
return this.createBindings([partial])
}
/**
* Update bindings from a list of partial bindings. Each partial must include an ID, which will
* be used to match the binding to it's existing record. If there is no existing record, that
* binding is skipped. The changes from the partial are merged into the existing record.
*/
updateBindings(partials: (TLBindingUpdate | null | undefined)[]) {
const updated: TLBinding[] = []
@ -5238,10 +5269,18 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* Update a binding from a partial binding. Each partial must include an ID, which will be used
* to match the binding to it's existing record. If there is no existing record, that binding is
* skipped. The changes from the partial are merged into the existing record.
*/
updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>) {
return this.updateBindings([partial])
}
/**
* Delete several bindings by their IDs. If a binding ID doesn't exist, it's ignored.
*/
deleteBindings(bindings: (TLBinding | TLBindingId)[], { isolateShapes = false } = {}) {
const ids = bindings.map((binding) => (typeof binding === 'string' ? binding : binding.id))
if (isolateShapes) {
@ -5250,8 +5289,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const binding = this.getBinding(id)
if (!binding) continue
const util = this.getBindingUtil(binding)
util.onBeforeIsolateFromShape?.({ binding, shape: this.getShape(binding.fromId)! })
util.onBeforeIsolateToShape?.({ binding, shape: this.getShape(binding.toId)! })
util.onBeforeIsolateFromShape?.({ binding, removedShape: this.getShape(binding.toId)! })
util.onBeforeIsolateToShape?.({ binding, removedShape: this.getShape(binding.fromId)! })
this.store.remove([id])
}
})
@ -5260,6 +5299,9 @@ export class Editor extends EventEmitter<TLEventMap> {
}
return this
}
/**
* Delete a binding by its ID. If the binding doesn't exist, it's ignored.
*/
deleteBinding(binding: TLBinding | TLBindingId, opts?: Parameters<this['deleteBindings']>[1]) {
return this.deleteBindings([binding], opts)
}

View file

@ -8,42 +8,110 @@ export interface TLBindingUtilConstructor<
> {
new (editor: Editor): U
type: T['type']
/** Validations for this binding's props. */
props?: RecordProps<T>
/** Migrations for this binding's props. */
migrations?: TLPropsMigrations
}
/** @public */
/**
* Options passed to {@link BindingUtil.onBeforeCreate} and {@link BindingUtil.onAfterCreate},
* describing a the creating a binding.
*
* @public
*/
export interface BindingOnCreateOptions<Binding extends TLUnknownBinding> {
/** The binding being created. */
binding: Binding
}
/** @public */
/**
* Options passed to {@link BindingUtil.onBeforeChange} and {@link BindingUtil.onAfterChange},
* describing the data associated with a binding being changed.
*
* @public
*/
export interface BindingOnChangeOptions<Binding extends TLUnknownBinding> {
/** The binding record before the change is made. */
bindingBefore: Binding
/** The binding record after the change is made. */
bindingAfter: Binding
}
/** @public */
/**
* Options passed to {@link BindingUtil.onBeforeDelete} and {@link BindingUtil.onAfterDelete},
* describing a binding being deleted.
*
* @public
*/
export interface BindingOnDeleteOptions<Binding extends TLUnknownBinding> {
/** The binding being deleted. */
binding: Binding
}
/** @public */
/**
* Options passed to {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape}, describing a bound shape being changed.
*
* @public
*/
export interface BindingOnShapeChangeOptions<Binding extends TLUnknownBinding> {
/** The binding record linking these two shapes. */
binding: Binding
/** The shape record before the change is made. */
shapeBefore: TLShape
/** The shape record after the change is made. */
shapeAfter: TLShape
}
/** @public */
/**
* Options passed to {@link BindingUtil.onBeforeIsolateFromShape} and
* {@link BindingUtil.onBeforeIsolateToShape}, describing a shape that is about to be isolated from
* the one that it's bound to.
*
* Isolation happens whenever two bound shapes are separated. For example
* 1. One is deleted, but the other is not.
* 1. One is copied, but the other is not.
* 1. One is duplicated, but the other is not.
*
* In each of these cases, if the remaining shape depends on the binding for its rendering, it may
* now be in an inconsistent state. For example, tldraw's arrow shape depends on the binding to know
* where the end of the arrow is. If we removed the binding without doing anything else, the arrow
* would suddenly be pointing to the wrong location. Instead, when the shape the arrow is pointing
* to is deleted, or the arrow is copied/duplicated, we use an isolation callback. The callback
* updates the arrow based on the binding that's about to be removed, so it doesn't end up pointing
* to the wrong place.
*
* For this style of consistency update, use isolation callbacks. For actions specific to deletion
* (like deleting a sticker when the shape it's bound to is removed), use the delete callbacks
* ({@link BindingUtil.onBeforeDeleteFromShape} and {@link BindingUtil.onBeforeDeleteToShape})
* instead.
*
* @public
*/
export interface BindingOnShapeIsolateOptions<Binding extends TLUnknownBinding> {
/** The binding record that refers to the shape in question. */
binding: Binding
shape: TLShape
/**
* The shape being removed. For deletion, this is the deleted shape. For copy/duplicate, this is
* the shape that _isn't_ being copied/duplicated and is getting left behind.
*/
removedShape: TLShape
}
/** @public */
/**
* Options passed to {@link BindingUtil.onBeforeDeleteFromShape} and
* {@link BindingUtil.onBeforeDeleteToShape}, describing a bound shape that is about to be deleted.
*
* See {@link BindingOnShapeIsolateOptions} for discussion on when to use the delete vs. the isolate
* callbacks.
*
* @public
*/
export interface BindingOnShapeDeleteOptions<Binding extends TLUnknownBinding> {
/** The binding record that refers to the shape in question. */
binding: Binding
/** The shape that is about to be deleted. */
shape: TLShape
}
@ -67,22 +135,132 @@ export abstract class BindingUtil<Binding extends TLUnknownBinding = TLUnknownBi
*/
abstract getDefaultProps(): Partial<Binding['props']>
/**
* Called whenever a store operation involving this binding type has completed. This is useful
* for working with networks of related bindings that may need to update together.
*
* @example
* ```ts
* class MyBindingUtil extends BindingUtil<MyBinding> {
* changedBindingIds = new Set<TLBindingId>()
*
* onOperationComplete() {
* doSomethingWithChangedBindings(this.changedBindingIds)
* this.changedBindingIds.clear()
* }
*
* onAfterChange({ bindingAfter }: BindingOnChangeOptions<MyBinding>) {
* this.changedBindingIds.add(bindingAfter.id)
* }
* }
* ```
*
* @public
*/
onOperationComplete?(): void
// self lifecycle hooks
/**
* Called when a binding is about to be created. See {@link BindingOnCreateOptions} for details.
*
* You can optionally return a new binding to replace the one being created - for example, to
* set different initial props.
*
* @public
*/
onBeforeCreate?(options: BindingOnCreateOptions<Binding>): Binding | void
/**
* Called after a binding has been created. See {@link BindingOnCreateOptions} for details.
*
* @public
*/
onAfterCreate?(options: BindingOnCreateOptions<Binding>): void
/**
* Called when a binding is about to be changed. See {@link BindingOnChangeOptions} for details.
*
* Note that this only fires when the binding record is changing, not when the shapes
* associated change. Use {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape} for that.
*
* You can optionally return a new binding to replace the one being changed - for example, to
* enforce constraints on the binding's props.
*
* @public
*/
onBeforeChange?(options: BindingOnChangeOptions<Binding>): Binding | void
/**
* Called after a binding has been changed. See {@link BindingOnChangeOptions} for details.
*
* Note that this only fires when the binding record is changing, not when the shapes
* associated change. Use {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape} for that.
*
* @public
*/
onAfterChange?(options: BindingOnChangeOptions<Binding>): void
onBeforeDelete?(options: BindingOnDeleteOptions<Binding>): Binding | void
/**
* Called when a binding is about to be deleted. See {@link BindingOnDeleteOptions} for details.
*
* @public
*/
onBeforeDelete?(options: BindingOnDeleteOptions<Binding>): void
/**
* Called after a binding has been deleted. See {@link BindingOnDeleteOptions} for details.
*
* @public
*/
onAfterDelete?(options: BindingOnDeleteOptions<Binding>): void
/**
* Called after the shape referenced in a binding's `fromId` is changed. Use this to propagate
* any changes to the binding itself or the other shape as needed. See
* {@link BindingOnShapeChangeOptions} for details.
*
* @public
*/
onAfterChangeFromShape?(options: BindingOnShapeChangeOptions<Binding>): void
/**
* Called after the shape referenced in a binding's `toId` is changed. Use this to propagate any
* changes to the binding itself or the other shape as needed. See
* {@link BindingOnShapeChangeOptions} for details.
*
* @public
*/
onAfterChangeToShape?(options: BindingOnShapeChangeOptions<Binding>): void
onBeforeIsolateFromShape?(options: BindingOnShapeIsolateOptions<Binding>): void
onBeforeIsolateToShape?(options: BindingOnShapeIsolateOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `fromId` is about to be deleted. Use this
* with care - you may want to use {@link BindingUtil.onBeforeIsolateToShape} instead. See
* {@link BindingOnShapeDeleteOptions} for details.
*
* @public
*/
onBeforeDeleteFromShape?(options: BindingOnShapeDeleteOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `toId` is about to be deleted. Use this
* with care - you may want to use {@link BindingUtil.onBeforeIsolateFromShape} instead. See
* {@link BindingOnShapeDeleteOptions} for details.
*
* @public
*/
onBeforeDeleteToShape?(options: BindingOnShapeDeleteOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `fromId` is about to be isolated from the
* shape referenced in `toId`. See {@link BindingOnShapeIsolateOptions} for discussion on what
* isolation means, and when/how to use this callback.
*/
onBeforeIsolateFromShape?(options: BindingOnShapeIsolateOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `toId` is about to be isolated from the
* shape referenced in `fromId`. See {@link BindingOnShapeIsolateOptions} for discussion on what
* isolation means, and when/how to use this callback.
*/
onBeforeIsolateToShape?(options: BindingOnShapeIsolateOptions<Binding>): void
}

View file

@ -959,7 +959,7 @@ export type TLBindingCreate<T extends TLBinding = TLBinding> = Expand<{
typeName?: T['typeName'];
}>;
// @public (undocumented)
// @public
export type TLBindingId = RecordId<TLUnknownBinding>;
// @public (undocumented)

View file

@ -55,7 +55,11 @@ export type TLBindingCreate<T extends TLBinding = TLBinding> = Expand<{
meta?: Partial<T['meta']>
}>
/** @public */
/**
* An ID for a {@link TLBinding}.
*
* @public
*/
export type TLBindingId = RecordId<TLUnknownBinding>
/** @public */