Bindings (#3326)
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:
parent
0a7816e34d
commit
da35f2bd75
95 changed files with 4087 additions and 2446 deletions
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
9
apps/examples/src/examples/sticker-bindings/README.md
Normal file
9
apps/examples/src/examples/sticker-bindings/README.md
Normal 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
|
||||
|
||||
---
|
235
apps/examples/src/examples/sticker-bindings/StickerExample.tsx
Normal file
235
apps/examples/src/examples/sticker-bindings/StickerExample.tsx
Normal 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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue