Bounds snapping shape (#2909)

This PR adds an example of how to use the new getBoundsSnapGeometry
method to get custom snapping behaviour on your shapes.

![2024-02-21 at 15 58 23 - Purple
Bovid](https://github.com/tldraw/tldraw/assets/98838967/8d7e73bb-ea29-45f6-98ed-141a8ce17065)


- [x] `documentation` — Changes to the documentation only[^2]


### Release Notes

- Adds a custom bounds snapping shape
pull/2926/head
Taha 2024-02-22 17:10:50 +00:00 zatwierdzone przez GitHub
rodzic 8bc108462a
commit eb3706e918
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
10 zmienionych plików z 440 dodań i 29 usunięć

Wyświetl plik

@ -0,0 +1,75 @@
import { Editor, Tldraw } from '@tldraw/tldraw'
import { PlayingCardTool } from './PlayingCardShape/playing-card-tool'
import { PlayingCardUtil } from './PlayingCardShape/playing-card-util'
import snapshot from './snapshot.json'
import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file!
// [1]
const customShapes = [PlayingCardUtil]
const customTools = [PlayingCardTool]
export default function BoundsSnappingShapeExample() {
// [2]
const handleMount = (editor: Editor) => {
editor.user.updateUserPreferences({ isSnapMode: true })
}
// [3]
return (
<div className="tldraw__editor">
<Tldraw
//[a]
shapeUtils={customShapes}
tools={customTools}
// [b]
overrides={uiOverrides}
components={components}
// [c]
onMount={handleMount}
// [d]
snapshot={snapshot}
/>
</div>
)
}
/*
Introduction:
This example shows how to create a shape with custom snapping geometry.
When shapes are moved around in snap mode, they will snap to the bounds
of other shapes by default. However a shape can return custom snapping
geometry to snap to instead. This example creates a playing card shape.
The cards are designed to snap together so that the top-left icon
remains visible when stacked, similar to a hand of cards in a game.
The most relevant code for this customisation is in playing-card-util.tsx.
[1]
We define the custom shape and util arrays we'll pass to the Tldraw component.
It's important to do this outside of the component so that the arrays don't
change on every render.
This is where we define the Tldraw component and pass in all our customisations.
[2]
We define a handleMount function that will be called when the editor mounts.
We're using it to set the snap mode to true in the user preferences. This is
just to help demonstrate the custom snapping geometry feature. Without snap
mode being set in this way the user can still enter it by holding cmd/ctrl
while dragging.
[3]
This is where we're passing in all our customisations to the Tldraw component.
Check out the associated files for more information on what's being passed in.
[a] Firstly our custom shape (playing-card-util.tsx) and tool (playing-card-tool.tsx)
This tells the editor about our custom shape and tool.
[b] Then our the uiOverrides and custom keyboard shortcuts component (ui-overrides.tsx),
this makes sure that an icon for our tool appears in the toolbar and the shortcut
for it appears in the dialog.
[c] We pass in our handleMount function so that it's called when the editor mounts.
[d] Finally we pass in a snapshot so that the editor starts with some shapes in it.
This isn't necessary, it just makes the example clearer on first glance.
*/

Wyświetl plik

@ -0,0 +1,15 @@
import { BaseBoxShapeTool } from '@tldraw/tldraw'
export class PlayingCardTool extends BaseBoxShapeTool {
static override id = 'PlayingCard'
static override initial = 'idle'
override shapeType = 'PlayingCard'
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "PlayingCard".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in our own way by overriding methods like onDoubleClick. For an example
of a tool with more custom functionality, check out the screenshot-tool example.
*/

Wyświetl plik

@ -0,0 +1,138 @@
import {
BaseBoxShapeUtil,
BoundsSnapGeometry,
HTMLContainer,
Rectangle2d,
ShapeProps,
T,
TLBaseShape,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
// [1]
type IPlayingCard = TLBaseShape<
'PlayingCard',
{
w: number
h: number
suit: string
}
>
export class PlayingCardUtil extends BaseBoxShapeUtil<IPlayingCard> {
// [2]
static override type = 'PlayingCard' as const
static override props: ShapeProps<IPlayingCard> = {
w: T.number,
h: T.number,
suit: T.string,
}
// [3]
override isAspectRatioLocked = (_shape: IPlayingCard) => true
// [4]
getDefaultProps(): IPlayingCard['props'] {
const cardSuitsArray: string[] = ['♠️', '♣️', '♥️', '♦️']
const randomSuit = cardSuitsArray[Math.floor(Math.random() * cardSuitsArray.length)]
return {
w: 270,
h: 370,
suit: randomSuit,
}
}
// [5]
override getBoundsSnapGeometry(shape: IPlayingCard): BoundsSnapGeometry {
return new Rectangle2d({
width: shape.props.h / 4.5,
height: shape.props.h / 4.5,
isFilled: true,
})
}
// [7]
component(shape: IPlayingCard) {
return (
<HTMLContainer
style={{
height: shape.props.h,
width: shape.props.w,
backgroundColor: 'white',
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.2)',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 8,
}}
id={shape.id}
>
<span
style={{
position: 'absolute',
top: 0,
left: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: shape.props.h / 4.5,
width: shape.props.h / 4.5,
fontSize: shape.props.h / 5,
}}
>
{shape.props.suit}
</span>
<div style={{ fontSize: shape.props.h / 3 }}>{shape.props.suit}</div>
</HTMLContainer>
)
}
// [7]
indicator(shape: IPlayingCard) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
/*
This is a utility class for the PlayingCard shape. This is where you define the shape's behavior,
how it renders (its component and indicator), and how it handles different events. The most relevant
part of the code to custom snapping can be found in [6].
[1]
This is where we define the shape's type for Typescript. We can extend the TLBaseShape type,
providing a unique string to identify the shape and the shape's props. We only need height
and width for this shape.
[2]
We define the shape's type and props for the editor. We can use tldraw's validator library to
make sure that the store always has shape data we can trust. In this case, we define the width
and height properties as numbers and assign a validator from tldraw's library to them.
[3]
We're going to lock the aspect ratio of this shape.
[4]
getDefaultProps determines what our shape looks like when click-creating one. In this case, we
want the shape to be 270x370 pixels and generate a suit for the card at random.
[5]
This is the important part for custom snapping. We define the getBoundsSnapGeometry method. This
method returns the geometry that the shape will snap to. In this case, we want the shape to snap
to a rectangle in the top left that contains the suit of the card. We can use the Rectangle2d helper
again here and set it to the same width and height as the span containing the suit which is defined
in [6].
[6]
We define the component method. This controls what the shape looks like and it returns JSX. It
generates a random suit for the card and returns a div with the suit in the center and a span with
the suit in the top left. The HTMLContainer component is a helpful wrapper that the tldraw library
exports, it's a div that comes with a css class.
[7]
The indicator is the blue box that appears around the shape when it's selected. We're just returning
a rectangle with the same width and height as the shape here.
*/

Wyświetl plik

@ -0,0 +1,13 @@
---
title: Bounds Snapping Shape
component: ./BoundsSnappingShape.tsx
category: shapes/tools
priority: 2
---
Custom shapes with special bounds snapping behaviour.
---
This example shows how to create a shape with custom snapping geometry. When shapes are moved around in snap mode, they will snap to the bounds of other shapes by default. However a shape can return custom snapping geometry to snap to instead. This example creates a playing card shape. The cards are designed to snap together so that the top-left icon remains visible when stacked, similar to a hand of cards in a game.The most relevant code for this customisation is in playing-card-util.tsx.

Wyświetl plik

@ -0,0 +1,110 @@
{
"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:lO3gDT8toaxNdM8RZyKqm": {
"x": 436.99609375,
"y": 317.43359375,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:lO3gDT8toaxNdM8RZyKqm",
"type": "PlayingCard",
"parentId": "page:page",
"index": "a1",
"props": {
"w": 270,
"h": 370,
"suit": "♠️"
},
"typeName": "shape"
},
"shape:db5wxGPcC0KNtdDMrSimx": {
"x": 519.2183159722222,
"y": 317.43359375,
"rotation": 0,
"isLocked": false,
"opacity": 1,
"meta": {},
"id": "shape:db5wxGPcC0KNtdDMrSimx",
"type": "PlayingCard",
"parentId": "page:page",
"index": "a2",
"props": {
"w": 270,
"h": 370,
"suit": "♣️"
},
"typeName": "shape"
}
},
"schema": {
"schemaVersion": 1,
"storeVersion": 4,
"recordVersions": {
"asset": {
"version": 1,
"subTypeKey": "type",
"subTypeVersions": {
"image": 3,
"video": 3,
"bookmark": 1
}
},
"camera": {
"version": 1
},
"document": {
"version": 2
},
"instance": {
"version": 24
},
"instance_page_state": {
"version": 5
},
"page": {
"version": 1
},
"shape": {
"version": 3,
"subTypeKey": "type",
"subTypeVersions": {
"group": 0,
"text": 1,
"bookmark": 2,
"draw": 1,
"geo": 8,
"note": 5,
"line": 3,
"frame": 0,
"arrow": 3,
"highlight": 0,
"embed": 4,
"image": 3,
"video": 2,
"PlayingCard": 0
}
},
"instance_presence": {
"version": 5
},
"pointer": {
"version": 1
}
}
}
}

Wyświetl plik

@ -0,0 +1,60 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.PlayingCard = {
id: 'PlayingCard',
icon: 'color',
label: 'PlayingCard',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('PlayingCard')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// Add the tool item from the context to the toolbar.
toolbar.splice(4, 0, toolbarItem(tools.PlayingCard))
return toolbar
},
}
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
{/* Ideally, we'd interleave this into the tools group */}
<TldrawUiMenuItem {...tools['PlayingCard']} />
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools
to the toolbar and the keyboard shortcuts menu.
We do this by providing a custom toolbar override to the Tldraw component. This override is a
function that takes the current editor, the default toolbar items, and the default tools.
It returns the new toolbar items. We use the toolbarItem helper to create a new toolbar item
for our custom tool. We then splice it into the toolbar items array at the 4th index. This puts
it after the eraser tool. We'll pass our overrides object into the Tldraw component's `overrides`
prop.
*/

Wyświetl plik

@ -1,12 +1,12 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { CatDogTool } from './my-shape/my-shape-tool'
import { CatDogUtil } from './my-shape/my-shape-util'
import { MyshapeTool } from './my-shape/my-shape-tool'
import { MyshapeUtil } from './my-shape/my-shape-util'
import { components, uiOverrides } from './ui-overrides'
// [1]
const customShapeUtils = [CatDogUtil]
const customTools = [CatDogTool]
const customShapeUtils = [MyshapeUtil]
const customTools = [MyshapeTool]
//[2]
export default function EditableShapeExample() {

Wyświetl plik

@ -1,12 +1,12 @@
import { BaseBoxShapeTool } from '@tldraw/tldraw'
export class CatDogTool extends BaseBoxShapeTool {
static override id = 'catdog'
export class MyshapeTool extends BaseBoxShapeTool {
static override id = 'Myshape'
static override initial = 'idle'
override shapeType = 'catdog'
override shapeType = 'Myshape'
}
/*
This file contains our custom tool. The tool is a StateNode with the `id` "catdog".
This file contains our custom tool. The tool is a StateNode with the `id` "Myshape".
We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can
handle events in our own way by overriding methods like onDoubleClick. For an example

Wyświetl plik

@ -17,32 +17,32 @@ import { useState } from 'react'
// There's a guide at the bottom of this file!
// [1]
type ICatDog = TLBaseShape<
'catdog',
type IMyshape = TLBaseShape<
'Myshape',
{
w: number
h: number
}
>
export class CatDogUtil extends ShapeUtil<ICatDog> {
export class MyshapeUtil extends ShapeUtil<IMyshape> {
// [2]
static override type = 'catdog' as const
static override props: ShapeProps<ICatDog> = {
static override type = 'Myshape' as const
static override props: ShapeProps<IMyshape> = {
w: T.number,
h: T.number,
}
// [3]
override isAspectRatioLocked = (_shape: ICatDog) => true
override canResize = (_shape: ICatDog) => true
override canBind = (_shape: ICatDog) => true
override isAspectRatioLocked = (_shape: IMyshape) => true
override canResize = (_shape: IMyshape) => true
override canBind = (_shape: IMyshape) => true
// [4]
override canEdit = () => true
// [5]
getDefaultProps(): ICatDog['props'] {
getDefaultProps(): IMyshape['props'] {
return {
w: 170,
h: 165,
@ -50,7 +50,7 @@ export class CatDogUtil extends ShapeUtil<ICatDog> {
}
// [6]
getGeometry(shape: ICatDog) {
getGeometry(shape: IMyshape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
@ -59,7 +59,7 @@ export class CatDogUtil extends ShapeUtil<ICatDog> {
}
// [7]
component(shape: ICatDog) {
component(shape: IMyshape) {
// [a]
const isEditing = useIsEditing(shape.id)
@ -111,18 +111,18 @@ export class CatDogUtil extends ShapeUtil<ICatDog> {
}
// [8]
indicator(shape: ICatDog) {
indicator(shape: IMyshape) {
const isEditing = useIsEditing(shape.id)
return <rect stroke={isEditing ? 'red' : 'blue'} width={shape.props.w} height={shape.props.h} />
}
// [9]
override onResize: TLOnResizeHandler<ICatDog> = (shape, info) => {
override onResize: TLOnResizeHandler<IMyshape> = (shape, info) => {
return resizeBox(shape, info)
}
// [10]
override onEditEnd: TLOnEditEndHandler<ICatDog> = (shape) => {
override onEditEnd: TLOnEditEndHandler<IMyshape> = (shape) => {
const frame1 = structuredClone(shape)
const frame2 = structuredClone(shape)
@ -142,7 +142,7 @@ export class CatDogUtil extends ShapeUtil<ICatDog> {
}
/*
This is a utility class for the catdog shape. This is where you define the shape's behavior,
This is a utility class for the Myshape shape. This is where you define the shape's behavior,
how it renders (its component and indicator), and how it handles different events.
[1]

Wyświetl plik

@ -13,20 +13,20 @@ import {
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.catdog = {
id: 'catdog',
tools.Myshape = {
id: 'Myshape',
icon: 'color',
label: 'Catdog',
label: 'Myshape',
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('catdog')
editor.setCurrentTool('Myshape')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// Add the tool item from the context to the toolbar.
toolbar.splice(4, 0, toolbarItem(tools.catdog))
toolbar.splice(4, 0, toolbarItem(tools.Myshape))
return toolbar
},
}
@ -38,7 +38,7 @@ export const components: TLComponents = {
<DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
{/* Ideally, we'd interleave this into the tools group */}
<TldrawUiMenuItem {...tools['catdog']} />
<TldrawUiMenuItem {...tools['Myshape']} />
</DefaultKeyboardShortcutsDialog>
)
},