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 */} - + ) },