From eb3706e9181418962e284063fdf60250ff24a086 Mon Sep 17 00:00:00 2001
From: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com>
Date: Thu, 22 Feb 2024 17:10:50 +0000
Subject: [PATCH] Bounds snapping shape (#2909)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../BoundsSnappingShape.tsx | 75 ++++++++++
.../PlayingCardShape/playing-card-tool.tsx | 15 ++
.../PlayingCardShape/playing-card-util.tsx | 138 ++++++++++++++++++
.../examples/bounds-snapping-shape/README.md | 13 ++
.../bounds-snapping-shape/snapshot.json | 110 ++++++++++++++
.../bounds-snapping-shape/ui-overrides.tsx | 60 ++++++++
.../editable-shape/EditableShapeExample.tsx | 8 +-
.../editable-shape/my-shape/my-shape-tool.tsx | 8 +-
.../editable-shape/my-shape/my-shape-util.tsx | 30 ++--
.../examples/editable-shape/ui-overrides.tsx | 12 +-
10 files changed, 440 insertions(+), 29 deletions(-)
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/BoundsSnappingShape.tsx
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-tool.tsx
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-util.tsx
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/README.md
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/snapshot.json
create mode 100644 apps/examples/src/examples/bounds-snapping-shape/ui-overrides.tsx
diff --git a/apps/examples/src/examples/bounds-snapping-shape/BoundsSnappingShape.tsx b/apps/examples/src/examples/bounds-snapping-shape/BoundsSnappingShape.tsx
new file mode 100644
index 000000000..52a318db9
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/BoundsSnappingShape.tsx
@@ -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 (
+
+
+
+ )
+}
+
+/*
+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.
+*/
diff --git a/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-tool.tsx b/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-tool.tsx
new file mode 100644
index 000000000..03d919b4d
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-tool.tsx
@@ -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.
+
+*/
diff --git a/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-util.tsx b/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-util.tsx
new file mode 100644
index 000000000..545e61f49
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/PlayingCardShape/playing-card-util.tsx
@@ -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 {
+ // [2]
+ static override type = 'PlayingCard' as const
+ static override props: ShapeProps = {
+ 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 (
+
+
+ {shape.props.suit}
+
+ {shape.props.suit}
+
+ )
+ }
+
+ // [7]
+ indicator(shape: IPlayingCard) {
+ return
+ }
+}
+
+/*
+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.
+
+
+*/
diff --git a/apps/examples/src/examples/bounds-snapping-shape/README.md b/apps/examples/src/examples/bounds-snapping-shape/README.md
new file mode 100644
index 000000000..5241bf981
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/README.md
@@ -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.
diff --git a/apps/examples/src/examples/bounds-snapping-shape/snapshot.json b/apps/examples/src/examples/bounds-snapping-shape/snapshot.json
new file mode 100644
index 000000000..003b4a604
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/snapshot.json
@@ -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
+ }
+ }
+ }
+}
diff --git a/apps/examples/src/examples/bounds-snapping-shape/ui-overrides.tsx b/apps/examples/src/examples/bounds-snapping-shape/ui-overrides.tsx
new file mode 100644
index 000000000..636684b9e
--- /dev/null
+++ b/apps/examples/src/examples/bounds-snapping-shape/ui-overrides.tsx
@@ -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 (
+
+ {/* Ideally, we'd interleave this into the tools group */}
+
+
+
+ )
+ },
+}
+
+/*
+
+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.
+
+
+*/
diff --git a/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx b/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx
index ed1fcfeb7..8928842b9 100644
--- a/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx
+++ b/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx
@@ -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() {
diff --git a/apps/examples/src/examples/editable-shape/my-shape/my-shape-tool.tsx b/apps/examples/src/examples/editable-shape/my-shape/my-shape-tool.tsx
index bb1eac667..5a3b58dd9 100644
--- a/apps/examples/src/examples/editable-shape/my-shape/my-shape-tool.tsx
+++ b/apps/examples/src/examples/editable-shape/my-shape/my-shape-tool.tsx
@@ -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
diff --git a/apps/examples/src/examples/editable-shape/my-shape/my-shape-util.tsx b/apps/examples/src/examples/editable-shape/my-shape/my-shape-util.tsx
index 615a24f8f..e4574b1af 100644
--- a/apps/examples/src/examples/editable-shape/my-shape/my-shape-util.tsx
+++ b/apps/examples/src/examples/editable-shape/my-shape/my-shape-util.tsx
@@ -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 {
+export class MyshapeUtil extends ShapeUtil {
// [2]
- static override type = 'catdog' as const
- static override props: ShapeProps = {
+ static override type = 'Myshape' as const
+ static override props: ShapeProps = {
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 {
}
// [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 {
}
// [7]
- component(shape: ICatDog) {
+ component(shape: IMyshape) {
// [a]
const isEditing = useIsEditing(shape.id)
@@ -111,18 +111,18 @@ export class CatDogUtil extends ShapeUtil {
}
// [8]
- indicator(shape: ICatDog) {
+ indicator(shape: IMyshape) {
const isEditing = useIsEditing(shape.id)
return
}
// [9]
- override onResize: TLOnResizeHandler = (shape, info) => {
+ override onResize: TLOnResizeHandler = (shape, info) => {
return resizeBox(shape, info)
}
// [10]
- override onEditEnd: TLOnEditEndHandler = (shape) => {
+ override onEditEnd: TLOnEditEndHandler = (shape) => {
const frame1 = structuredClone(shape)
const frame2 = structuredClone(shape)
@@ -142,7 +142,7 @@ export class CatDogUtil extends ShapeUtil {
}
/*
-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]
diff --git a/apps/examples/src/examples/editable-shape/ui-overrides.tsx b/apps/examples/src/examples/editable-shape/ui-overrides.tsx
index a37d8e304..f583e0e8c 100644
--- a/apps/examples/src/examples/editable-shape/ui-overrides.tsx
+++ b/apps/examples/src/examples/editable-shape/ui-overrides.tsx
@@ -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 = {
{/* Ideally, we'd interleave this into the tools group */}
-
+
)
},