side effects reference docs & examples (#3258)

Adds reference docs, guide in the "Editor" article, and examples for the
side effects manager.

There are 4 new examples:
1. Before create/update shape - constrains shapes to be places within a
circle
2. Before delete shape - prevent red shapes from being deleted
3. After create/update shape - make sure there's only ever one red shape
on the page at a time
4. After delete shape - delete frames after their last child is deleted

As these examples all require fairly specific configurations of shapes
(or are hard to understand without some visual hinting in the case of
placing shapes within a circle), I've included a `createDemoShapes`
function in each of these which makes sure the examples start with
shapes that will quickly show you the side effects in action. I've kept
these separate from the main code (in a function at the bottom), so
hopefully that won't be a source of confusion to anyone working from
these examples.


### Change Type
- [x] `docs` — Changes to the documentation, examples, or templates.
- [x] `improvement` — Improving existing features
This commit is contained in:
alex 2024-03-26 18:38:19 +00:00 committed by GitHub
parent 01ec8f1e98
commit 3593799d9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1357 additions and 10 deletions

View file

@ -170,6 +170,33 @@ export const BubbleToolUi = track(() => {
}) })
``` ```
## Side effects
The [Editor#sideEffects](?) object lets you register callbacks for key parts of the lifecycle of records in the [Store](#Store).
You can register callbacks for before or after a record is created, changed, or deleted.
These callbacks are useful for applying constraints, maintaining relationships, or checking the integrity of different records in the document.
For example, we use side effects to create a new [TLCamera](?) record every time a new page is made.
The "before" callbacks allow you to modify the record itself, but shouldn't be used for modifying other records.
You can create a different record in the place of what was asked, prevent a change (or make a different one) to an existing record, or stop something from being deleted.
The "after" callbacks let you make changes to other records in response to something happening.
You could create, update, or delete any related record, but you should avoid changing the same record that triggered the change.
For example, if you wanted to know every time a new arrow is created, you could register a handler like this:
```ts
editor.sideEffects.registerAfterCreateHandler('shape', (newShape) => {
if (newShape.type === 'arrow') {
console.log('A new arrow shape was created', newShape)
}
})
```
Side effect handlers are also given a `source` argument - either `"user"` or `"remote"`.
This indicates whether the change originated from the current user, or from another remote user in the same multiplayer room.
You could use this to e.g. prevent the current user from deleting shapes, but allow deletions from others in the same room.
## Inputs ## Inputs
The [Editor#inputs](?) object holds information about the user's current input state, including their cursor position (in page space _and_ screen space), which keys are pressed, what their multi-click state is, and whether they are dragging, pointing, pinching, and so on. The [Editor#inputs](?) object holds information about the user's current input state, including their cursor position (in page space _and_ screen space), which keys are pressed, what their multi-click state is, and whether they are dragging, pointing, pinching, and so on.

View file

@ -31,6 +31,7 @@ export function generateSection(section: InputSection, articles: Articles, index
for (const file of files) { for (const file of files) {
const filename = file.toString() const filename = file.toString()
if (filename.startsWith('.')) continue
const pathname = isExamples ? path.join(dir, filename, 'README.md') : path.join(dir, filename) const pathname = isExamples ? path.join(dir, filename, 'README.md') : path.join(dir, filename)
const fileContent = fs.readFileSync(pathname).toString() const fileContent = fs.readFileSync(pathname).toString()
const extension = path.extname(filename) const extension = path.extname(filename)

View file

@ -0,0 +1,72 @@
import { Editor, TLShape, TLShapeId, Tldraw, createShapeId } from 'tldraw'
// this function takes a shape ID, and if that shape is red, sets all other red shapes on the same
// page to black.
function ensureOnlyOneRedShape(editor: Editor, shapeId: TLShapeId) {
// grab the shape and check it's red:
const shape = editor.getShape(shapeId)!
if (!isRedShape(shape)) return
// get the ID of the page that shape belongs to:
const pageId = editor.getAncestorPageId(shape.id)!
// find any other red shapes on the same page:
const otherRedShapesOnPage = Array.from(editor.getPageShapeIds(pageId))
.map((id) => editor.getShape(id)!)
.filter((otherShape) => otherShape.id !== shape.id && isRedShape(otherShape))
// set the color of all those shapes to black:
editor.updateShapes(
otherRedShapesOnPage.map((shape) => ({
id: shape.id,
type: shape.type,
props: {
color: 'black',
},
}))
)
}
function isRedShape(shape: TLShape) {
return 'color' in shape.props && shape.props.color === 'red'
}
export default function AfterCreateUpdateShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// we can run our `ensureOnlyOneRedShape` function after any shape is created or
// changed. this means we can enforce our "only one red shape at a time" rule,
// whilst making sure that the shape most recently set to red is the one that
// stays red.
editor.sideEffects.registerAfterCreateHandler('shape', (shape) => {
ensureOnlyOneRedShape(editor, shape.id)
})
editor.sideEffects.registerAfterChangeHandler('shape', (prevShape, nextShape) => {
ensureOnlyOneRedShape(editor, nextShape.id)
})
createDemoShapes(editor)
}}
/>
</div>
)
}
// create some shapes to demonstrate the side-effects we added
function createDemoShapes(editor: Editor) {
editor
.createShapes(
'there can only be one red shape'.split(' ').map((word, i) => ({
id: createShapeId(),
type: 'text',
y: i * 30,
props: {
color: i === 5 ? 'red' : 'black',
text: word,
},
}))
)
.zoomToContent({ duration: 0 })
}

View file

@ -0,0 +1,14 @@
---
title: After create/update shape
component: ./AfterCreateUpdateShapeExample.tsx
category: editor-api
priority: 5
---
Register a handler to run after shapes are created or updated.
---
You can register handlers to run after any record is created or updated. This is most useful for
updating _other_ records in response to a particular record changing. In this example, we make sure
there's only ever one red shape on a page.

View file

@ -0,0 +1,62 @@
import { Editor, Tldraw, createShapeId } from 'tldraw'
export default function AfterDeleteShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// register a handler to run after any shape is deleted:
editor.sideEffects.registerAfterDeleteHandler('shape', (shape) => {
// grab the parent of the shape and check if it's a frame:
const parentShape = editor.getShape(shape.parentId)
if (parentShape && parentShape.type === 'frame') {
// if it is, get the IDs of all its remaining children:
const siblings = editor.getSortedChildIdsForParent(parentShape.id)
// if there are none (so the frame is empty), delete the frame:
if (siblings.length === 0) {
editor.deleteShape(parentShape.id)
}
}
})
createDemoShapes(editor)
}}
/>
</div>
)
}
// crate some demo shapes to show off the new side-effect we added
function createDemoShapes(editor: Editor) {
const frameId = createShapeId()
editor.createShapes([
{
id: frameId,
type: 'frame',
props: { w: 400, h: 200 },
},
{
id: createShapeId(),
type: 'text',
parentId: frameId,
x: 50,
y: 40,
props: {
text: 'Frames will be deleted when their last child is.',
w: 300,
autoSize: false,
},
},
...[50, 180, 310].map((x) => ({
id: createShapeId(),
type: 'geo',
parentId: frameId,
x,
y: 120,
props: { w: 40, h: 40 },
})),
])
editor.zoomToContent({ duration: 0 })
}

View file

@ -0,0 +1,13 @@
---
title: After delete shape
component: ./AfterDeleteShapeExample.tsx
category: editor-api
priority: 5
---
Register a handler to run after shapes are deleted.
---
You can register handlers to run after any record is deleted. In this example, we delete frames
after the last shape inside them is deleted.

View file

@ -0,0 +1,61 @@
import { Box, Editor, SVGContainer, TLShape, Tldraw, Vec, isShapeId } from 'tldraw'
// This function takes a shape and returns a new shape where the x/y origin is within `radius`
// distance of the center of the page. If the shape is already within `radius` (or isn't parented to
// the page) it returns the same shape.
function constrainShapeToRadius(editor: Editor, shape: TLShape, radius: number) {
// if the shape is parented to another shape (instead of the page) leave it as-is
if (isShapeId(shape.parentId)) return shape
// get the position of the shape
const shapePoint = Vec.From(shape)
const distanceFromCenter = shapePoint.len()
// if the shape is outside the radius, move it to the edge of the radius:
if (distanceFromCenter > radius) {
const newPoint = shapePoint.norm().mul(radius)
return {
...shape,
x: newPoint.x,
y: newPoint.y,
}
}
// otherwise, leave the shape as-is
return shape
}
export default function BeforeCreateUpdateShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// we can run our `constrainShapeToRadius` function before any shape is created
// or changed. These `sideEffects` handlers let us take modify the shape that
// will be created or updated by returning a new one to be used in its place.
editor.sideEffects.registerBeforeCreateHandler('shape', (shape) => {
return constrainShapeToRadius(editor, shape, 500)
})
editor.sideEffects.registerBeforeChangeHandler('shape', (prevShape, nextShape) => {
return constrainShapeToRadius(editor, nextShape, 500)
})
// center the camera on the area we're constraining shapes to
editor.zoomToBounds(new Box(-500, -500, 1000, 1000))
// lock the camera on that area
editor.updateInstanceState({ canMoveCamera: false })
}}
components={{
// to make it a little clearer what's going on in this example, we'll draw a
// circle on the canvas showing where shapes are being constrained to.
OnTheCanvas: () => (
<SVGContainer>
<circle cx={0} cy={0} r={500} fill="none" stroke="black" />
</SVGContainer>
),
}}
/>
</div>
)
}

View file

@ -0,0 +1,13 @@
---
title: Before create/update shape
component: ./BeforeCreateUpdateShapeExample.tsx
category: editor-api
priority: 4
---
Register a handler to run before shapes are created or updated.
---
You can intercept the creation or update of any record in the store and return a new record to be
used in it place. In this example, we lock shapes to a circle in the center of the screen.

View file

@ -0,0 +1,48 @@
import { Editor, Tldraw, createShapeId } from 'tldraw'
export default function BeforeDeleteShapeExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// register a handler to run before any shape is deleted:
editor.sideEffects.registerBeforeDeleteHandler('shape', (shape) => {
// if the shape is red, prevent the deletion:
if ('color' in shape.props && shape.props.color === 'red') {
return false
}
return
})
createDemoShapes(editor)
}}
/>
</div>
)
}
// create some shapes to demonstrate the side-effect we added
function createDemoShapes(editor: Editor) {
editor
.createShapes([
{
id: createShapeId(),
type: 'text',
props: {
text: "Red shapes can't be deleted",
color: 'red',
},
},
{
id: createShapeId(),
type: 'text',
y: 30,
props: {
text: 'but other shapes can',
color: 'black',
},
},
])
.zoomToContent({ duration: 0 })
}

View file

@ -0,0 +1,14 @@
---
title: Before delete shape
component: ./BeforeDeleteShapeExample.tsx
category: editor-api
priority: 4
---
Register a handler to run before shapes are deleted.
---
You can intercept the creation of any record in the store. This example intercepts arrow creation to
make sure each arrow has a label. You can do the same thing to change the props of any newly created
shape.

View file

@ -937,7 +937,7 @@ export class Editor extends EventEmitter<TLEventMap> {
targetZoom?: number; targetZoom?: number;
inset?: number; inset?: number;
} & TLAnimationOptions): this; } & TLAnimationOptions): this;
zoomToContent(): this; zoomToContent(opts?: TLAnimationOptions): this;
zoomToFit(animation?: TLAnimationOptions): this; zoomToFit(animation?: TLAnimationOptions): this;
zoomToSelection(animation?: TLAnimationOptions): this; zoomToSelection(animation?: TLAnimationOptions): this;
} }
@ -1696,6 +1696,37 @@ export class SharedStyleMap extends ReadonlySharedStyleMap {
// @public // @public
export function shortAngleDist(a0: number, a1: number): number; export function shortAngleDist(a0: number, a1: number): number;
// @public
export class SideEffectManager<CTX extends {
store: TLStore;
history: {
onBatchComplete: () => void;
};
}> {
constructor(editor: CTX);
// (undocumented)
editor: CTX;
registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & {
typeName: T;
}>): () => void;
registerAfterCreateHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterCreateHandler<TLRecord & {
typeName: T;
}>): () => void;
registerAfterDeleteHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterDeleteHandler<TLRecord & {
typeName: T;
}>): () => void;
registerBatchCompleteHandler(handler: TLBatchCompleteHandler): () => void;
registerBeforeChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLBeforeChangeHandler<TLRecord & {
typeName: T;
}>): () => void;
registerBeforeCreateHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLBeforeCreateHandler<TLRecord & {
typeName: T;
}>): () => void;
registerBeforeDeleteHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLBeforeDeleteHandler<TLRecord & {
typeName: T;
}>): () => void;
}
export { Signal } export { Signal }
// @public (undocumented) // @public (undocumented)

View file

@ -18083,7 +18083,7 @@
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!Editor#sideEffects:member", "canonicalReference": "@tldraw/editor!Editor#sideEffects:member",
"docComment": "/**\n * A manager for side effects and correct state enforcement.\n *\n * @public\n */\n", "docComment": "/**\n * A manager for side effects and correct state enforcement. See {@link SideEffectManager} for details.\n *\n * @public\n */\n",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
@ -18092,7 +18092,7 @@
{ {
"kind": "Reference", "kind": "Reference",
"text": "SideEffectManager", "text": "SideEffectManager",
"canonicalReference": "@tldraw/editor!~SideEffectManager:class" "canonicalReference": "@tldraw/editor!SideEffectManager:class"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -19753,7 +19753,16 @@
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "zoomToContent(): " "text": "zoomToContent(opts?: "
},
{
"kind": "Reference",
"text": "TLAnimationOptions",
"canonicalReference": "@tldraw/editor!TLAnimationOptions:type"
},
{
"kind": "Content",
"text": "): "
}, },
{ {
"kind": "Content", "kind": "Content",
@ -19766,13 +19775,22 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 1, "startIndex": 3,
"endIndex": 2 "endIndex": 4
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
"overloadIndex": 1, "overloadIndex": 1,
"parameters": [], "parameters": [
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": true
}
],
"isOptional": false, "isOptional": false,
"isAbstract": false, "isAbstract": false,
"name": "zoomToContent" "name": "zoomToContent"
@ -32465,6 +32483,845 @@
], ],
"name": "shortAngleDist" "name": "shortAngleDist"
}, },
{
"kind": "Class",
"canonicalReference": "@tldraw/editor!SideEffectManager:class",
"docComment": "/**\n * The side effect manager (aka a \"correct state enforcer\") is responsible for making sure that the editor's state is always correct. This includes things like: deleting a shape if its parent is deleted; unbinding arrows when their binding target is deleted; etc.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare class SideEffectManager<CTX extends "
},
{
"kind": "Content",
"text": "{\n store: "
},
{
"kind": "Reference",
"text": "TLStore",
"canonicalReference": "@tldraw/tlschema!TLStore:type"
},
{
"kind": "Content",
"text": ";\n history: {\n onBatchComplete: () => void;\n };\n}"
},
{
"kind": "Content",
"text": "> "
}
],
"fileUrlPath": "packages/editor/src/lib/editor/managers/SideEffectManager.ts",
"releaseTag": "Public",
"typeParameters": [
{
"typeParameterName": "CTX",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isAbstract": false,
"name": "SideEffectManager",
"preserveMemberOrder": false,
"members": [
{
"kind": "Constructor",
"canonicalReference": "@tldraw/editor!SideEffectManager:constructor(1)",
"docComment": "/**\n * Constructs a new instance of the `SideEffectManager` class\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "constructor(editor: "
},
{
"kind": "Content",
"text": "CTX"
},
{
"kind": "Content",
"text": ");"
}
],
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "editor",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
]
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!SideEffectManager#editor:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "editor: "
},
{
"kind": "Content",
"text": "CTX"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "editor",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!SideEffectManager#history:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "history: "
},
{
"kind": "Content",
"text": "{\n onBatchComplete: () => void;\n }"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "history",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerAfterChangeHandler:member(1)",
"docComment": "/**\n * Register a handler to be called after a record is changed. This is useful for side-effects that would update _other_ records - if you want to modify the record being changed, use {@link SideEffectManager.registerBeforeChangeHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {\n * if (next.props.color === 'red') {\n * // there can only be one red shape at a time:\n * const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id)\n * editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}})))\n * }\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerAfterChangeHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLAfterChangeHandler",
"canonicalReference": "@tldraw/editor!TLAfterChangeHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerAfterChangeHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerAfterCreateHandler:member(1)",
"docComment": "/**\n * Register a handler to be called after a record is created. This is useful for side-effects that would update _other_ records. If you want to modify the record being created use {@link SideEffectManager.registerBeforeCreateHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerAfterCreateHandler('page', (page, source) => {\n * // Automatically create a shape when a page is created\n * editor.createShape({\n * id: createShapeId(),\n * type: 'text',\n * props: { text: page.name },\n * })\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerAfterCreateHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLAfterCreateHandler",
"canonicalReference": "@tldraw/editor!TLAfterCreateHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerAfterCreateHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerAfterDeleteHandler:member(1)",
"docComment": "/**\n * Register a handler to be called after a record is deleted. This is useful for side-effects that would update _other_ records - if you want to block the deletion of the record itself, use {@link SideEffectManager.registerBeforeDeleteHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {\n * // if the last shape in a frame is deleted, delete the frame too:\n * const parentFrame = editor.getShape(shape.parentId)\n * if (!parentFrame || parentFrame.type !== 'frame') return\n *\n * const siblings = editor.getSortedChildIdsForParent(parentFrame)\n * if (siblings.length === 0) {\n * editor.deleteShape(parentFrame.id)\n * }\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerAfterDeleteHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLAfterDeleteHandler",
"canonicalReference": "@tldraw/editor!TLAfterDeleteHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerAfterDeleteHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerBatchCompleteHandler:member(1)",
"docComment": "/**\n * Register a handler to be called when a store completes a batch.\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * let count = 0\n *\n * editor.cleanup.registerBatchCompleteHandler(() => count++)\n *\n * editor.selectAll()\n * expect(count).toBe(1)\n *\n * editor.batch(() => {\n * \teditor.selectNone()\n * \teditor.selectAll()\n * })\n *\n * expect(count).toBe(2)\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerBatchCompleteHandler(handler: "
},
{
"kind": "Reference",
"text": "TLBatchCompleteHandler",
"canonicalReference": "@tldraw/editor!TLBatchCompleteHandler:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerBatchCompleteHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeChangeHandler:member(1)",
"docComment": "/**\n * Register a handler to be called before a record is changed. The handler is given the old and new record - you can return a modified record to apply a different update, or the old record to block the update entirely.\n *\n * Use this handler only for intercepting updates to the record itself. If you want to update other records in response to a change, use {@link SideEffectManager.registerAfterChangeHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {\n * if (next.isLocked && !prev.isLocked) {\n * // prevent shapes from ever being locked:\n * return prev\n * }\n * // other types of change are allowed\n * return next\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerBeforeChangeHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLBeforeChangeHandler",
"canonicalReference": "@tldraw/editor!TLBeforeChangeHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerBeforeChangeHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeCreateHandler:member(1)",
"docComment": "/**\n * Register a handler to be called before a record of a certain type is created. Return a modified record from the handler to change the record that will be created.\n *\n * Use this handle only to modify the creation of the record itself. If you want to trigger a side-effect on a different record (for example, moving one shape when another is created), use {@link SideEffectManager.registerAfterCreateHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {\n * // only modify shapes created by the user\n * if (source !== 'user') return shape\n *\n * //by default, arrow shapes have no label. Let's make sure they always have a label.\n * if (shape.type === 'arrow') {\n * return {...shape, props: {...shape.props, text: 'an arrow'}}\n * }\n *\n * // other shapes get returned unmodified\n * return shape\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerBeforeCreateHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLBeforeCreateHandler",
"canonicalReference": "@tldraw/editor!TLBeforeCreateHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerBeforeCreateHandler"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeDeleteHandler:member(1)",
"docComment": "/**\n * Register a handler to be called before a record is deleted. The handler can return `false` to prevent the deletion.\n *\n * Use this handler only for intercepting deletions of the record itself. If you want to do something to other records in response to a deletion, use {@link SideEffectManager.registerAfterDeleteHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {\n * if (shape.props.color === 'red') {\n * // prevent red shapes from being deleted\n * \t return false\n * }\n * })\n * ```\n *\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "registerBeforeDeleteHandler<T extends "
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": "['typeName']"
},
{
"kind": "Content",
"text": ">(typeName: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", handler: "
},
{
"kind": "Reference",
"text": "TLBeforeDeleteHandler",
"canonicalReference": "@tldraw/editor!TLBeforeDeleteHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLRecord",
"canonicalReference": "@tldraw/tlschema!TLRecord:type"
},
{
"kind": "Content",
"text": " & {\n typeName: T;\n }>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "typeName",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"isOptional": false
},
{
"parameterName": "handler",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 10
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "registerBeforeDeleteHandler"
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!SideEffectManager#store:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "store: "
},
{
"kind": "Reference",
"text": "TLStore",
"canonicalReference": "@tldraw/tlschema!TLStore:type"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "store",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
}
],
"implementsTokenRanges": []
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!SIN:function(1)", "canonicalReference": "@tldraw/editor!SIN:function(1)",

View file

@ -135,6 +135,7 @@ export {
type TLResizeShapeOptions, type TLResizeShapeOptions,
} from './lib/editor/Editor' } from './lib/editor/Editor'
export type { export type {
SideEffectManager,
TLAfterChangeHandler, TLAfterChangeHandler,
TLAfterCreateHandler, TLAfterCreateHandler,
TLAfterDeleteHandler, TLAfterDeleteHandler,

View file

@ -717,7 +717,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getContainer: () => HTMLElement getContainer: () => HTMLElement
/** /**
* A manager for side effects and correct state enforcement. * A manager for side effects and correct state enforcement. See {@link SideEffectManager} for details.
* *
* @public * @public
*/ */
@ -2198,11 +2198,11 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @public * @public
*/ */
zoomToContent(): this { zoomToContent(opts: TLAnimationOptions = { duration: 220 }): this {
const bounds = this.getSelectionPageBounds() ?? this.getCurrentPageBounds() const bounds = this.getSelectionPageBounds() ?? this.getCurrentPageBounds()
if (bounds) { if (bounds) {
this.zoomToBounds(bounds, { targetZoom: Math.min(1, this.getZoomLevel()), duration: 220 }) this.zoomToBounds(bounds, { targetZoom: Math.min(1, this.getZoomLevel()), ...opts })
} }
return this return this

View file

@ -161,6 +161,33 @@ export class SideEffectManager<
private _batchCompleteHandlers: TLBatchCompleteHandler[] = [] private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
/**
* Register a handler to be called before a record of a certain type is created. Return a
* modified record from the handler to change the record that will be created.
*
* Use this handle only to modify the creation of the record itself. If you want to trigger a
* side-effect on a different record (for example, moving one shape when another is created),
* use {@link SideEffectManager.registerAfterCreateHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {
* // only modify shapes created by the user
* if (source !== 'user') return shape
*
* //by default, arrow shapes have no label. Let's make sure they always have a label.
* if (shape.type === 'arrow') {
* return {...shape, props: {...shape.props, text: 'an arrow'}}
* }
*
* // other shapes get returned unmodified
* return shape
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerBeforeCreateHandler<T extends TLRecord['typeName']>( registerBeforeCreateHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLBeforeCreateHandler<TLRecord & { typeName: T }> handler: TLBeforeCreateHandler<TLRecord & { typeName: T }>
@ -171,6 +198,26 @@ export class SideEffectManager<
return () => remove(this._beforeCreateHandlers[typeName]!, handler) return () => remove(this._beforeCreateHandlers[typeName]!, handler)
} }
/**
* Register a handler to be called after a record is created. This is useful for side-effects
* that would update _other_ records. If you want to modify the record being created use
* {@link SideEffectManager.registerBeforeCreateHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterCreateHandler('page', (page, source) => {
* // Automatically create a shape when a page is created
* editor.createShape({
* id: createShapeId(),
* type: 'text',
* props: { text: page.name },
* })
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerAfterCreateHandler<T extends TLRecord['typeName']>( registerAfterCreateHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLAfterCreateHandler<TLRecord & { typeName: T }> handler: TLAfterCreateHandler<TLRecord & { typeName: T }>
@ -181,6 +228,30 @@ export class SideEffectManager<
return () => remove(this._afterCreateHandlers[typeName]!, handler) return () => remove(this._afterCreateHandlers[typeName]!, handler)
} }
/**
* Register a handler to be called before a record is changed. The handler is given the old and
* new record - you can return a modified record to apply a different update, or the old record
* to block the update entirely.
*
* Use this handler only for intercepting updates to the record itself. If you want to update
* other records in response to a change, use
* {@link SideEffectManager.registerAfterChangeHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {
* if (next.isLocked && !prev.isLocked) {
* // prevent shapes from ever being locked:
* return prev
* }
* // other types of change are allowed
* return next
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerBeforeChangeHandler<T extends TLRecord['typeName']>( registerBeforeChangeHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLBeforeChangeHandler<TLRecord & { typeName: T }> handler: TLBeforeChangeHandler<TLRecord & { typeName: T }>
@ -191,6 +262,25 @@ export class SideEffectManager<
return () => remove(this._beforeChangeHandlers[typeName]!, handler) return () => remove(this._beforeChangeHandlers[typeName]!, handler)
} }
/**
* Register a handler to be called after a record is changed. This is useful for side-effects
* that would update _other_ records - if you want to modify the record being changed, use
* {@link SideEffectManager.registerBeforeChangeHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {
* if (next.props.color === 'red') {
* // there can only be one red shape at a time:
* const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id)
* editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}})))
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerAfterChangeHandler<T extends TLRecord['typeName']>( registerAfterChangeHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLAfterChangeHandler<TLRecord & { typeName: T }> handler: TLAfterChangeHandler<TLRecord & { typeName: T }>
@ -201,6 +291,27 @@ export class SideEffectManager<
return () => remove(this._afterChangeHandlers[typeName]!, handler) return () => remove(this._afterChangeHandlers[typeName]!, handler)
} }
/**
* Register a handler to be called before a record is deleted. The handler can return `false` to
* prevent the deletion.
*
* Use this handler only for intercepting deletions of the record itself. If you want to do
* something to other records in response to a deletion, use
* {@link SideEffectManager.registerAfterDeleteHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {
* if (shape.props.color === 'red') {
* // prevent red shapes from being deleted
* return false
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerBeforeDeleteHandler<T extends TLRecord['typeName']>( registerBeforeDeleteHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLBeforeDeleteHandler<TLRecord & { typeName: T }> handler: TLBeforeDeleteHandler<TLRecord & { typeName: T }>
@ -211,6 +322,28 @@ export class SideEffectManager<
return () => remove(this._beforeDeleteHandlers[typeName]!, handler) return () => remove(this._beforeDeleteHandlers[typeName]!, handler)
} }
/**
* Register a handler to be called after a record is deleted. This is useful for side-effects
* that would update _other_ records - if you want to block the deletion of the record itself,
* use {@link SideEffectManager.registerBeforeDeleteHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {
* // if the last shape in a frame is deleted, delete the frame too:
* const parentFrame = editor.getShape(shape.parentId)
* if (!parentFrame || parentFrame.type !== 'frame') return
*
* const siblings = editor.getSortedChildIdsForParent(parentFrame)
* if (siblings.length === 0) {
* editor.deleteShape(parentFrame.id)
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*/
registerAfterDeleteHandler<T extends TLRecord['typeName']>( registerAfterDeleteHandler<T extends TLRecord['typeName']>(
typeName: T, typeName: T,
handler: TLAfterDeleteHandler<TLRecord & { typeName: T }> handler: TLAfterDeleteHandler<TLRecord & { typeName: T }>