Skip to content

Commit

Permalink
rework canBind callback (#3797)
Browse files Browse the repository at this point in the history
This PR reworks the `canBind` callback to work with customizable
bindings. It now accepts an object with a the shape, the other shape
(optional - it may not exist yet), the direction, and the type of the
binding. Devs can use this to create shapes that only participate in
certain binding types, can have bindings from but not to them, etc.

If you're implementing a binding, you can see if binding two shapes is
allowed using `editor.canBindShapes(fromShape, toShape, 'my binding
type')`

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Release Notes

#### Breaking changes
The `canBind` flag now accepts an options object instead of just the
shape in question. If you're relying on its arguments, you need to
change from `canBind(shape) {}` to `canBind({shape}) {}`.
  • Loading branch information
SomeHats committed May 23, 2024
1 parent 2b2778b commit 87e3d60
Show file tree
Hide file tree
Showing 24 changed files with 188 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export class CardShapeUtil extends ShapeUtil<ICardShape> {
// [3]
override isAspectRatioLocked = (_shape: ICardShape) => false
override canResize = (_shape: ICardShape) => true
override canBind = (_shape: ICardShape) => true

// [4]
getDefaultProps(): ICardShape['props'] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export class MyShapeUtil extends ShapeUtil<ICustomShape> {
}

// [c]
override canBind = () => true
override canEdit = () => false
override canResize = () => true
override isAspectRatioLocked = () => false
Expand Down
10 changes: 8 additions & 2 deletions apps/examples/src/examples/pin-bindings/PinExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TLOnTranslateEndHandler,
TLOnTranslateStartHandler,
TLShapeId,
TLShapeUtilCanBindOpts,
TLUiComponents,
TLUiOverrides,
Tldraw,
Expand All @@ -44,7 +45,10 @@ class PinShapeUtil extends ShapeUtil<PinShape> {
return {}
}

override canBind = () => false
override canBind({ toShapeType }: TLShapeUtilCanBindOpts<PinShape>) {
// bindings can go _from_ pins to other shapes, but not the other way round
return toShapeType !== 'pin'
}
override canEdit = () => false
override canResize = () => false
override hideRotateHandle = () => true
Expand Down Expand Up @@ -93,7 +97,9 @@ class PinShapeUtil extends ShapeUtil<PinShape> {
.getShapesAtPoint(pageAnchor, { hitInside: true })
.filter(
(shape) =>
shape.type !== 'pin' && shape.parentId === pin.parentId && shape.index < pin.index
this.editor.canBindShapes({ fromShape: pin, toShape: shape, binding: 'pin' }) &&
shape.parentId === pin.parentId &&
shape.index < pin.index
)

for (const target of targets) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {

override canResize = (_shape: SpeechBubbleShape) => true

override canBind = (_shape: SpeechBubbleShape) => true

override canEdit = () => true

// [3]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TLEventHandlers,
TLOnTranslateEndHandler,
TLOnTranslateStartHandler,
TLShapeUtilCanBindOpts,
TLUiComponents,
TLUiOverrides,
Tldraw,
Expand All @@ -40,7 +41,10 @@ class StickerShapeUtil extends ShapeUtil<StickerShape> {
return {}
}

override canBind = () => false
override canBind({ toShapeType }: TLShapeUtilCanBindOpts<StickerShape>) {
// bindings can go _from_ stickers to other shapes, but not the other way round
return toShapeType !== 'sticker'
}
override canEdit = () => false
override canResize = () => false
override hideRotateHandle = () => true
Expand Down Expand Up @@ -86,7 +90,8 @@ class StickerShapeUtil extends ShapeUtil<StickerShape> {
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,
filter: (shape) =>
this.editor.canBindShapes({ fromShape: sticker, toShape: shape, binding: 'sticker' }),
})

if (!target) return
Expand Down
40 changes: 31 additions & 9 deletions packages/editor/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ import { TLAssetId } from '@tldraw/tlschema';
import { TLAssetPartial } from '@tldraw/tlschema';
import { TLBaseShape } from '@tldraw/tlschema';
import { TLBinding } from '@tldraw/tlschema';
import { TLBindingCreate } from '@tldraw/tlschema';
import { TLBindingId } from '@tldraw/tlschema';
import { TLBindingPartial } from '@tldraw/tlschema';
import { TLBindingUpdate } from '@tldraw/tlschema';
import { TLBookmarkAsset } from '@tldraw/tlschema';
import { TLCamera } from '@tldraw/tlschema';
import { TLCursor } from '@tldraw/tlschema';
Expand Down Expand Up @@ -702,6 +703,18 @@ export class Editor extends EventEmitter<TLEventMap> {
};
bringForward(shapes: TLShape[] | TLShapeId[]): this;
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
// (undocumented)
canBindShapes({ fromShape, toShape, binding, }: {
binding: {
type: TLBinding['type'];
} | TLBinding | TLBinding['type'];
fromShape: {
type: TLShape['type'];
} | TLShape | TLShape['type'];
toShape: {
type: TLShape['type'];
} | TLShape | TLShape['type'];
}): boolean;
cancel(): this;
cancelDoubleClick(): void;
// @internal (undocumented)
Expand All @@ -715,9 +728,9 @@ export class Editor extends EventEmitter<TLEventMap> {
crash(error: unknown): this;
createAssets(assets: TLAsset[]): this;
// (undocumented)
createBinding(partial: RequiredKeys<TLBindingPartial, 'fromId' | 'toId' | 'type'>): this;
createBinding<B extends TLBinding = TLBinding>(partial: TLBindingCreate<B>): this;
// (undocumented)
createBindings(partials: RequiredKeys<TLBindingPartial, 'fromId' | 'toId' | 'type'>[]): this;
createBindings(partials: TLBindingCreate[]): this;
// @internal (undocumented)
createErrorAnnotations(origin: string, willCrashApp: 'unknown' | boolean): {
extras: {
Expand Down Expand Up @@ -788,7 +801,9 @@ export class Editor extends EventEmitter<TLEventMap> {
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: S | TLBindingPartial<S>): BindingUtil<S>;
getBindingUtil<S extends TLUnknownBinding>(binding: {
type: S['type'];
} | S): BindingUtil<S>;
// (undocumented)
getBindingUtil<S extends TLUnknownBinding>(type: S['type']): BindingUtil<S>;
// (undocumented)
Expand Down Expand Up @@ -1043,15 +1058,15 @@ export class Editor extends EventEmitter<TLEventMap> {
ungroupShapes(ids: TLShape[]): this;
updateAssets(assets: TLAssetPartial[]): this;
// (undocumented)
updateBinding(partial: TLBindingPartial): this;
updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>): this;
// (undocumented)
updateBindings(partials: (null | TLBindingPartial | undefined)[]): this;
updateBindings(partials: (null | TLBindingUpdate | undefined)[]): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions): this;
// (undocumented)
_updateCurrentPageState: (partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions) => void;
updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
updatePage(partial: RequiredKeys<Partial<TLPage>, 'id'>): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[]): this;
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
Expand Down Expand Up @@ -1706,7 +1721,7 @@ export function refreshPage(): void;
export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent<Element>): void;

// @public (undocumented)
export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
export type RequiredKeys<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;

// @public (undocumented)
export function resizeBox(shape: TLBaseBoxShape, info: {
Expand Down Expand Up @@ -1785,7 +1800,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
// @internal
backgroundComponent?(shape: Shape): any;
canBeLaidOut: TLShapeUtilFlag<Shape>;
canBind: <K>(_shape: Shape, _otherShape?: K) => boolean;
canBind(opts: TLShapeUtilCanBindOpts<Shape>): boolean;
canCrop: TLShapeUtilFlag<Shape>;
canDropShapes(shape: Shape, shapes: TLShape[]): boolean;
canEdit: TLShapeUtilFlag<Shape>;
Expand Down Expand Up @@ -2686,6 +2701,13 @@ export interface TLShapeIndicatorProps {
shapeId: TLShapeId;
}

// @public
export interface TLShapeUtilCanBindOpts<Shape extends TLUnknownShape = TLShape> {
bindingType: string;
fromShapeType: string;
toShapeType: string;
}

// @public (undocumented)
export interface TLShapeUtilCanvasSvgDef {
// (undocumented)
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export {
type TLOnTranslateStartHandler,
type TLResizeInfo,
type TLResizeMode,
type TLShapeUtilCanBindOpts,
type TLShapeUtilCanvasSvgDef,
type TLShapeUtilConstructor,
type TLShapeUtilFlag,
Expand Down
61 changes: 50 additions & 11 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import {
TLAssetId,
TLAssetPartial,
TLBinding,
TLBindingCreate,
TLBindingId,
TLBindingPartial,
TLBindingUpdate,
TLCamera,
TLCursor,
TLCursorType,
Expand Down Expand Up @@ -853,7 +854,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
getBindingUtil<S extends TLUnknownBinding>(binding: S | TLBindingPartial<S>): BindingUtil<S>
getBindingUtil<S extends TLUnknownBinding>(binding: S | { type: S['type'] }): BindingUtil<S>
getBindingUtil<S extends TLUnknownBinding>(type: S['type']): BindingUtil<S>
getBindingUtil<T extends BindingUtil>(
type: T extends BindingUtil<infer R> ? R['type'] : string
Expand Down Expand Up @@ -3619,7 +3620,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
updatePage(partial: RequiredKeys<TLPage, 'id'>): this {
updatePage(partial: RequiredKeys<Partial<TLPage>, 'id'>): this {
if (this.getInstanceState().isReadonly) return this

const prev = this.getPage(partial.id)
Expand Down Expand Up @@ -5146,27 +5147,36 @@ export class Editor extends EventEmitter<TLEventMap> {
return result.filter((b) => b.type === type) as Binding[]
}

createBindings(partials: RequiredKeys<TLBindingPartial, 'type' | 'toId' | 'fromId'>[]) {
const bindings = partials.map((partial) => {
createBindings(partials: TLBindingCreate[]) {
const bindings: TLBinding[] = []
for (const partial of partials) {
const fromShape = this.getShape(partial.fromId)
const toShape = this.getShape(partial.toId)
if (!fromShape || !toShape) continue
if (!this.canBindShapes({ fromShape, toShape, binding: partial })) continue

const util = this.getBindingUtil<TLUnknownBinding>(partial.type)
const defaultProps = util.getDefaultProps()
return this.store.schema.types.binding.create({
const binding = this.store.schema.types.binding.create({
...partial,
id: partial.id ?? createBindingId(),
props: {
...defaultProps,
...partial.props,
},
})
})
}) as TLBinding

bindings.push(binding)
}

this.store.put(bindings)
return this
}
createBinding(partial: RequiredKeys<TLBindingPartial, 'type' | 'fromId' | 'toId'>) {
createBinding<B extends TLBinding = TLBinding>(partial: TLBindingCreate<B>) {
return this.createBindings([partial])
}

updateBindings(partials: (TLBindingPartial | null | undefined)[]) {
updateBindings(partials: (TLBindingUpdate | null | undefined)[]) {
const updated: TLBinding[] = []

for (const partial of partials) {
Expand All @@ -5178,6 +5188,11 @@ export class Editor extends EventEmitter<TLEventMap> {
const updatedBinding = applyPartialToRecordWithProps(current, partial)
if (updatedBinding === current) continue

const fromShape = this.getShape(updatedBinding.fromId)
const toShape = this.getShape(updatedBinding.toId)
if (!fromShape || !toShape) continue
if (!this.canBindShapes({ fromShape, toShape, binding: updatedBinding })) continue

updated.push(updatedBinding)
}

Expand All @@ -5186,7 +5201,7 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}

updateBinding(partial: TLBindingPartial) {
updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>) {
return this.updateBindings([partial])
}

Expand All @@ -5198,6 +5213,30 @@ export class Editor extends EventEmitter<TLEventMap> {
deleteBinding(binding: TLBinding | TLBindingId) {
return this.deleteBindings([binding])
}
canBindShapes({
fromShape,
toShape,
binding,
}: {
fromShape: TLShape | { type: TLShape['type'] } | TLShape['type']
toShape: TLShape | { type: TLShape['type'] } | TLShape['type']
binding: TLBinding | { type: TLBinding['type'] } | TLBinding['type']
}): boolean {
const fromShapeType = typeof fromShape === 'string' ? fromShape : fromShape.type
const toShapeType = typeof toShape === 'string' ? toShape : toShape.type
const bindingType = typeof binding === 'string' ? binding : binding.type

const canBindOpts = { fromShapeType, toShapeType, bindingType }

if (fromShapeType === toShapeType) {
return this.getShapeUtil(fromShapeType).canBind(canBindOpts)
}

return (
this.getShapeUtil(fromShapeType).canBind(canBindOpts) &&
this.getShapeUtil(toShapeType).canBind(canBindOpts)
)
}

/* -------------------- Commands -------------------- */

Expand Down
22 changes: 19 additions & 3 deletions packages/editor/src/lib/editor/shapes/ShapeUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export interface TLShapeUtilConstructor<
/** @public */
export type TLShapeUtilFlag<T> = (shape: T) => boolean

/**
* Options passed to {@link ShapeUtil.canBind}. A binding that could be made. At least one of
* `fromShapeType` or `toShapeType` will belong to this shape util.
*
* @public
*/
export interface TLShapeUtilCanBindOpts<Shape extends TLUnknownShape = TLShape> {
/** The type of shape referenced by the `fromId` of the binding. */
fromShapeType: string
/** The type of shape referenced by the `toId` of the binding. */
toShapeType: string
/** The type of binding. */
bindingType: string
}

/** @public */
export interface TLShapeUtilCanvasSvgDef {
key: string
Expand Down Expand Up @@ -97,12 +112,13 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
canScroll: TLShapeUtilFlag<Shape> = () => false

/**
* Whether the shape can be bound to by an arrow.
* Whether the shape can be bound to. See {@link TLShapeUtilCanBindOpts} for details.
*
* @param _otherShape - The other shape attempting to bind to this shape.
* @public
*/
canBind = <K>(_shape: Shape, _otherShape?: K) => true
canBind(opts: TLShapeUtilCanBindOpts<Shape>): boolean {
return true
}

/**
* Whether the shape can be double clicked to edit.
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/lib/editor/types/misc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Box } from '../../primitives/Box'
import { VecLike } from '../../primitives/Vec'

/** @public */
export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
export type RequiredKeys<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>
/** @public */
export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

Expand Down
5 changes: 2 additions & 3 deletions packages/tldraw/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import { TLSelectionHandle } from '@tldraw/editor';
import { TLShape } from '@tldraw/editor';
import { TLShapeId } from '@tldraw/editor';
import { TLShapePartial } from '@tldraw/editor';
import { TLShapeUtilCanBindOpts } from '@tldraw/editor';
import { TLShapeUtilCanvasSvgDef } from '@tldraw/editor';
import { TLShapeUtilFlag } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor';
Expand Down Expand Up @@ -169,7 +170,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// (undocumented)
canBeLaidOut: TLShapeUtilFlag<TLArrowShape>;
// (undocumented)
canBind: () => boolean;
canBind({ toShapeType }: TLShapeUtilCanBindOpts<TLArrowShape>): boolean;
// (undocumented)
canEdit: () => boolean;
// (undocumented)
Expand Down Expand Up @@ -618,8 +619,6 @@ export class FrameShapeTool extends BaseBoxShapeTool {

// @public (undocumented)
export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented)
canBind: () => boolean;
// (undocumented)
canDropShapes: (shape: TLFrameShape, _shapes: TLShape[]) => boolean;
// (undocumented)
Expand Down
Loading

0 comments on commit 87e3d60

Please sign in to comment.