diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index e7958e4d7..cb1a00c31 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -24,5 +24,5 @@ ".next/types/**/*.ts", "watcher.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] } diff --git a/apps/examples/src/examples/custom-config/README.md b/apps/examples/src/examples/custom-config/README.md index 3cfbaff33..7121de7b0 100644 --- a/apps/examples/src/examples/custom-config/README.md +++ b/apps/examples/src/examples/custom-config/README.md @@ -1,8 +1,8 @@ --- -title: Custom shapes / tools +title: Custom shape and tool component: ./CustomConfigExample.tsx category: shapes/tools -priority: 1 +priority: 3 --- Create custom shapes / tools diff --git a/apps/examples/src/examples/custom-shape/CustomShapeExample.tsx b/apps/examples/src/examples/custom-shape/CustomShapeExample.tsx new file mode 100644 index 000000000..7660a8282 --- /dev/null +++ b/apps/examples/src/examples/custom-shape/CustomShapeExample.tsx @@ -0,0 +1,145 @@ +import { + Geometry2d, + HTMLContainer, + Rectangle2d, + ShapeProps, + ShapeUtil, + T, + TLBaseShape, + TLOnResizeHandler, + Tldraw, + resizeBox, +} from 'tldraw' +import 'tldraw/tldraw.css' + +// There's a guide at the bottom of this file! + +// [1] +type ICustomShape = TLBaseShape< + 'my-custom-shape', + { + w: number + h: number + text: string + } +> + +// [2] +export class MyShapeUtil extends ShapeUtil { + // [a] + static override type = 'my-custom-shape' as const + static override props: ShapeProps = { + w: T.number, + h: T.number, + text: T.string, + } + + // [b] + getDefaultProps(): ICustomShape['props'] { + return { + w: 200, + h: 200, + text: "I'm a shape!", + } + } + + // [c] + override canBind = () => true + override canEdit = () => false + override canResize = () => true + override isAspectRatioLocked = () => false + + // [d] + getGeometry(shape: ICustomShape): Geometry2d { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }) + } + + // [e] + override onResize: TLOnResizeHandler = (shape, info) => { + return resizeBox(shape, info) + } + + // [f] + component(shape: ICustomShape) { + return {shape.props.text} + } + + // [g] + indicator(shape: ICustomShape) { + return + } +} + +// [3] +const customShape = [MyShapeUtil] +export default function CustomShapeExample() { + return ( +
+ { + editor.createShape({ type: 'my-custom-shape', x: 100, y: 100 }) + }} + /> +
+ ) +} + +/* +Introduction: + +You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component. +In this example, we'll create a custom shape that is a simple rectangle with some text inside of it. + +[1] +Define the shape type. This is a type that extend the `TLBaseShape` generic and defines the shape's +props. We need to pass in a unique string literal for the shape's type and an object that defines the +shape's props. + +[2] +This is our shape util. In tldraw shape utils are classes that define how a shape behaves and renders. +We can extend the ShapeUtil class and provide the shape type as a generic. If we extended the +BaseBoxShapeUtil class instead, we wouldn't have define methods such as `getGeometry` and `onResize`. + + [a] + This is where we define out shape's props and type for the editor. It's important to use the same + string for the type as we did in [1]. We need to define the shape's props using tldraw's validator + library. The validator will help make sure the store always has shape data we can trust. + + [b] + This is a method that returns the default props for our shape. + + [c] + Some handy methods for controlling different shape behaviour. You don't have to define these, and + they're only shown here so you know they exist. Check out the editable shape example to learn more + about creating an editable shape. + + [d] + The getGeometry method is what the editor uses for hit-testing, binding etc. We're using the + Rectangle2d class from tldraw's geometry library to create a rectangle shape. If we extended the + BaseBoxShapeUtil class, we wouldn't have to define this method. + + [e] + We're using the resizeBox utility method to handle resizing our shape. If we extended the + BaseBoxShapeUtil class, we wouldn't have to define this method. + + [f] + The component method defines how our shape renders. We're returning an HTMLContainer here, which + is a handy component that tldraw exports. It's essentially a div with some special css. There's a + lot of flexibility here, and you can use any React hooks you want and return any valid JSX. + + [g] + The indicator is the blue outline around a selected shape. We're just returning a rectangle with the + same width and height as the shape here. You can return any valid JSX here. + +[3] +This is where we render the Tldraw component with our custom shape. We're passing in our custom shape +util as an array to the shapeUtils prop. We're also using the onMount callback to create a shape on +the canvas. If you want to learn how to add a tool for your shape, check out the custom config example. +If you want to learn how to programmatically control the canvas, check out the Editor API examples. + +*/ diff --git a/apps/examples/src/examples/custom-shape/README.md b/apps/examples/src/examples/custom-shape/README.md new file mode 100644 index 000000000..d76037361 --- /dev/null +++ b/apps/examples/src/examples/custom-shape/README.md @@ -0,0 +1,13 @@ +--- +title: Custom shape +component: ./CustomShapeExample.tsx +category: shapes/tools +priority: 1 +--- + +A simple custom shape. + +--- + +You can create custom shapes in tldraw by creating a shape util and passing it to the Tldraw component. +In this example, we'll create a custom shape that is a simple rectangle with some text inside of it. diff --git a/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx b/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx index 8f52e841a..f7f698fb0 100644 --- a/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx +++ b/apps/examples/src/examples/editable-shape/EditableShapeExample.tsx @@ -1,26 +1,19 @@ import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' -import { MyshapeTool } from './my-shape/my-shape-tool' -import { MyshapeUtil } from './my-shape/my-shape-util' -import { components, uiOverrides } from './ui-overrides' +import { EditableShapeUtil } from './EditableShapeUtil' -// [1] -const customShapeUtils = [MyshapeUtil] -const customTools = [MyshapeTool] +const customShapeUtils = [EditableShapeUtil] -//[2] export default function EditableShapeExample() { return (
{ + editor.createShape({ type: 'my-editable-shape', x: 100, y: 100 }) + }} />
) @@ -31,24 +24,12 @@ Introduction: In Tldraw shapes can exist in an editing state. When shapes are in the editing state they are focused and can't be dragged, resized or rotated. Shapes enter this state -when they are double-clicked, this means that users can drag and resize shapes without -accidentally entering the editing state. In our default shapes we mostly use this for -editing text, but it's also used in our video shape. In this example we'll create a -shape that you could use for a game of Go, but instead of black and white stones, we'll -use cats and dogs. +when they are double-clicked. In our default shapes we mostly use this for editing text. +In this example we'll create a shape that renders an emoji and allows the user to change +the emoji when the shape is in the editing state. -Most of the relevant code for this is in the my-shape-util.tsx file. We also define a -very simple tool in my-shape-tool.tsx, and make our new tool appear on the toolbar in -ui-overrides.ts. +Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more +in-depth explanation of the shape util, check out the custom shape example. -[1] -We have to define our array of custom shapes and tools outside of the component. So it -doesn't get redefined every time the component re-renders. We'll pass that in to the -editors props. - -[2] -We pass in our custom shape classes to the Tldraw component as props. We also pass in -any uiOverrides we want to use, this is to make sure that our custom tool appears on -the toolbar. */ diff --git a/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx b/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx new file mode 100644 index 000000000..8e1f85652 --- /dev/null +++ b/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx @@ -0,0 +1,126 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + ShapeProps, + T, + TLBaseShape, + TLOnEditEndHandler, + stopEventPropagation, +} from 'tldraw' + +// There's a guide at the bottom of this file! + +const ANIMAL_EMOJIS = ['🐶', '🐱', '🐨', '🐮', '🐴'] + +type IMyEditableShape = TLBaseShape< + 'my-editable-shape', + { + w: number + h: number + animal: number + } +> + +export class EditableShapeUtil extends BaseBoxShapeUtil { + static override type = 'my-editable-shape' as const + static override props: ShapeProps = { + w: T.number, + h: T.number, + animal: T.number, + } + + // [1] !!! + override canEdit = () => true + + getDefaultProps(): IMyEditableShape['props'] { + return { + w: 200, + h: 200, + animal: 0, + } + } + + // [2] + component(shape: IMyEditableShape) { + // [a] + const isEditing = this.editor.getEditingShapeId() === shape.id + + return ( + + {ANIMAL_EMOJIS[shape.props.animal]} + {/* [c] */} + {isEditing ? ( + + ) : ( + // [d] when not editing... +

Double Click to Edit

+ )} +
+ ) + } + + indicator(shape: IMyEditableShape) { + return + } + + // [3] + override onEditEnd: TLOnEditEndHandler = (shape) => { + this.editor.animateShape( + { ...shape, rotation: shape.rotation + Math.PI * 2 }, + { duration: 250 } + ) + } +} + +/* + +This is our shape util, which defines how our shape renders and behaves. For +more information on the shape util, check out the custom shape example. + +[1] +We override the canEdit method to allow the shape to enter the editing state. + +[2] +We want to conditionally render the component based on whether it is being +edited or not. + + [a] We can check whether our shape is being edited by comparing the + editing shape id to the shape's id. + + [b] We want to allow pointer events when the shape is being edited, + and stop event propagation on pointer down. Check out the interactive + shape example for more information on this. + + [c] We render a button to change the animal emoji when the shape is being + edited. + + [e] We also render a message when the shape is not being edited. + +[3] +The onEditEnd method is called when the shape exits the editing state. In this +case we rotate the shape 360 degrees. + +*/ diff --git a/apps/examples/src/examples/editable-shape/README.md b/apps/examples/src/examples/editable-shape/README.md index ffe63e71a..64917545b 100644 --- a/apps/examples/src/examples/editable-shape/README.md +++ b/apps/examples/src/examples/editable-shape/README.md @@ -2,11 +2,18 @@ title: Editable shape component: ./EditableShapeExample.tsx category: shapes/tools -priority: 1 +priority: 2 --- A custom shape that you can edit by double-clicking it. --- -Learn how you can have a shape that enters an editing state, and have a side effect run when editing has finished. +In Tldraw, the Editor can have one editing shape at a time. When in its editing state, the editor will ignore events until the user exits the editing state by pressing Escape or clicking on the canvas. + +Only shapes with a `canEdit` flag that returns true may become editable. A user may begin editing a shape by double clicking on the editable shape, or selecting the editable shape and pressing enter. + +Many of our shapes use editing to allow for interactions inside of the shape. For example, a text shape behaves like a text graphic until the user begins editing it—and only then can the user use their keyboard to edit the text. Note that a shape can be interactive regardless of whether it's the editor's editing shape—the "editing" mechanic is just a way of managing a common pattern in canvas appliations. + +In this example we'll create a shape that renders an emoji and allows the user to change the emoji when the shape is in the editing state. +Most of the relevant code for this is in the EditableShapeUtil.tsx file. If you want a more in-depth explanation of the shape util, check out the custom shape example. 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 deleted file mode 100644 index 2d1db2087..000000000 --- a/apps/examples/src/examples/editable-shape/my-shape/my-shape-tool.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { BaseBoxShapeTool } from 'tldraw' -export class MyshapeTool extends BaseBoxShapeTool { - static override id = 'Myshape' - static override initial = 'idle' - override shapeType = 'Myshape' -} - -/* -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 -of a tool with more custom functionality, check out the screenshot-tool 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 deleted file mode 100644 index e9a2837f5..000000000 --- a/apps/examples/src/examples/editable-shape/my-shape/my-shape-util.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import { useState } from 'react' -import { - HTMLContainer, - Rectangle2d, - ShapeProps, - ShapeUtil, - T, - TLBaseShape, - TLOnEditEndHandler, - TLOnResizeHandler, - resizeBox, - structuredClone, - useIsEditing, -} from 'tldraw' - -// There's a guide at the bottom of this file! - -// [1] -type IMyshape = TLBaseShape< - 'Myshape', - { - w: number - h: number - } -> - -export class MyshapeUtil extends ShapeUtil { - // [2] - static override type = 'Myshape' as const - static override props: ShapeProps = { - w: T.number, - h: T.number, - } - - // [3] - override isAspectRatioLocked = (_shape: IMyshape) => true - override canResize = (_shape: IMyshape) => true - override canBind = (_shape: IMyshape) => true - - // [4] - override canEdit = () => true - - // [5] - getDefaultProps(): IMyshape['props'] { - return { - w: 170, - h: 165, - } - } - - // [6] - getGeometry(shape: IMyshape) { - return new Rectangle2d({ - width: shape.props.w, - height: shape.props.h, - isFilled: true, - }) - } - - // [7] - component(shape: IMyshape) { - // [a] - const isEditing = useIsEditing(shape.id) - - const [animal, setAnimal] = useState(true) - - // [b] - return ( - -
- -

{animal ? '🐶' : '🐱'}

-
-
- ) - } - - // [8] - indicator(shape: IMyshape) { - const isEditing = useIsEditing(shape.id) - return - } - - // [9] - override onResize: TLOnResizeHandler = (shape, info) => { - return resizeBox(shape, info) - } - - // [10] - override onEditEnd: TLOnEditEndHandler = (shape) => { - const frame1 = structuredClone(shape) - const frame2 = structuredClone(shape) - - frame1.x = shape.x + 1.2 - frame2.x = shape.x - 1.2 - - this.editor.animateShape(frame1, { duration: 50 }) - - setTimeout(() => { - this.editor.animateShape(frame2, { duration: 50 }) - }, 100) - - setTimeout(() => { - this.editor.animateShape(shape, { duration: 100 }) - }, 200) - } -} - -/* -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] -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 -define the shape's properties. In this case, we define the width and height properties as numbers. - -[3] -Some methods we can override to define specific beahviour for the shape. For this shape, we don't -want the aspect ratio to change, we want it to resize, and sure it can bind, why not. Who doesn't -love arrows? - -[4] -This is the important one. We set canEdit to true. This means that the shape can enter the editing -state. - -[5] -This will be the default props for the shape when you create it via clicking. - -[6] -We define the getGeometry method. This method returns the geometry of the shape. In this case, -a Rectangle2d object. - -[7] -We define the component method. This controls what the shape looks like and it returns JSX. - - [a] We can use the useIsEditing hook to check if the shape is in the editing state. If it is, - we want our shape to render differently. - - [b] The HTML container is a really handy wrapper for custom shapes, it essentially creates a - div with some helpful css for you. We can use the isEditing variable to conditionally - render the shape. We also use the useState hook to toggle between a cat and a dog. - -[8] -The indicator method is the blue box that appears around the shape when it's selected. We can -make it appear red if the shape is in the editing state by using the useIsEditing hook. - -[9] -The onResize method is where we handle the resizing of the shape. We use the resizeBox helper -to handle the resizing for us. - -[10] -The onEditEnd method is called when the shape exits the editing state. In the tldraw codebase we -mostly use this for trimming text fields in shapes. In this case, we use it to animate the shape -when it exits the editing state. - -*/ diff --git a/apps/examples/src/examples/editable-shape/ui-overrides.tsx b/apps/examples/src/examples/editable-shape/ui-overrides.tsx deleted file mode 100644 index 14c57dd16..000000000 --- a/apps/examples/src/examples/editable-shape/ui-overrides.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - DefaultKeyboardShortcutsDialog, - DefaultKeyboardShortcutsDialogContent, - TLComponents, - TLUiOverrides, - TldrawUiMenuItem, - toolbarItem, - useTools, -} from '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.Myshape = { - id: 'Myshape', - icon: 'color', - label: 'Myshape', - kbd: 'c', - onSelect: () => { - 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.Myshape)) - 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/interactive-shape/InteractiveShapeExample.tsx b/apps/examples/src/examples/interactive-shape/InteractiveShapeExample.tsx new file mode 100644 index 000000000..871394948 --- /dev/null +++ b/apps/examples/src/examples/interactive-shape/InteractiveShapeExample.tsx @@ -0,0 +1,33 @@ +import { Tldraw } from 'tldraw' +import 'tldraw/tldraw.css' + +import { myInteractiveShape } from './my-interactive-shape-util' + +// There's a guide at the bottom of this file! + +// [1] +const customShapeUtils = [myInteractiveShape] + +//[2] +export default function InteractiveShapeExample() { + return ( +
+ { + editor.createShape({ type: 'my-interactive-shape', x: 100, y: 100 }) + }} + /> +
+ ) +} + +/* +By default the editor handles pointer events, but sometimes you want to handle +interactions on your shape in your own ways, for example via a button. You can do this +by using the css property `pointer events: all` and stopping event propagation. In +this example we want our todo shape to have a checkbox so the user can mark them as +done. + +Check out my-interactive-shape-util.tsx to see how we create the shape. + */ diff --git a/apps/examples/src/examples/interactive-shape/README.md b/apps/examples/src/examples/interactive-shape/README.md new file mode 100644 index 000000000..eb14cc354 --- /dev/null +++ b/apps/examples/src/examples/interactive-shape/README.md @@ -0,0 +1,14 @@ +--- +title: Interactive shape +component: ./InteractiveShapeExample.tsx +category: shapes/tools +priority: 1 +--- + +A custom shape that has its own onClick interactions. + +--- + +By default the editor handles pointer events, but sometimes you want to handle interactions on your shape in your own ways, for example via a button. You can do this by using the css property `pointer events: all` and stopping event propagation. In this example we want our todo shape to have a checkbox so the user can mark them as done. + +Check out my-interactive-shape-util.tsx to see how we create the shape. diff --git a/apps/examples/src/examples/interactive-shape/my-interactive-shape-util.tsx b/apps/examples/src/examples/interactive-shape/my-interactive-shape-util.tsx new file mode 100644 index 000000000..8eebe1d41 --- /dev/null +++ b/apps/examples/src/examples/interactive-shape/my-interactive-shape-util.tsx @@ -0,0 +1,108 @@ +import { BaseBoxShapeUtil, HTMLContainer, ShapeProps, T, TLBaseShape } from 'tldraw' + +// There's a guide at the bottom of this file! + +type IMyInteractiveShape = TLBaseShape< + 'my-interactive-shape', + { + w: number + h: number + checked: boolean + text: string + } +> + +export class myInteractiveShape extends BaseBoxShapeUtil { + static override type = 'my-interactive-shape' as const + static override props: ShapeProps = { + w: T.number, + h: T.number, + checked: T.boolean, + text: T.string, + } + + getDefaultProps(): IMyInteractiveShape['props'] { + return { + w: 230, + h: 230, + checked: false, + text: '', + } + } + + // [1] + component(shape: IMyInteractiveShape) { + return ( + + + this.editor.updateShape({ + id: shape.id, + type: 'my-interactive-shape', + props: { checked: !shape.props.checked }, + }) + } + // [b] This is where we stop event propagation + onPointerDown={(e) => e.stopPropagation()} + /> + + this.editor.updateShape({ + id: shape.id, + type: 'my-interactive-shape', + props: { text: e.currentTarget.value }, + }) + } + // [c] + onPointerDown={(e) => { + if (!shape.props.checked) { + e.stopPropagation() + } + }} + /> + + ) + } + + // [5] + indicator(shape: IMyInteractiveShape) { + return + } +} + +/* +This is a custom shape, for a more in-depth look at how to create a custom shape, +see our custom shape example. + +[1] +This is where we describe how our shape will render + + [a] We need to set pointer-events to all so that we can interact with our shape. This CSS property is + set to "none" off by default. We need to manually opt-in to accepting pointer events by setting it to + 'all' or 'auto'. + + [b] We need to stop event propagation so that the editor doesn't select the shape + when we click on the checkbox. The 'canvas container' forwards events that it receives + on to the editor, so stopping propagation here prevents the event from reaching the canvas. + + [c] If the shape is not checked, we stop event propagation so that the editor doesn't + select the shape when we click on the input. If the shape is checked then we allow that event to + propagate to the canvas and then get sent to the editor, triggering clicks or drags as usual. + +*/