Skip to content

Commit 5e9b85d

Browse files
authored
add StateNode.addChild (tldraw#6485)
Adds an `addChild` method to `StateNode` for dynamically adding entries to the state tree. ### Change type - [x] `api` ### Release notes Adds an `addChild` method to `StateNode` for dynamically adding entries to the state tree. ### API Changes - Add `StateNode.addChild` for dynamically adding new state nodes to existing tools
1 parent 6e84d60 commit 5e9b85d

6 files changed

Lines changed: 367 additions & 50 deletions

File tree

.cursor/rules/repo.mdc

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
alwaysApply: true
3+
---
4+
5+
This is the monorepo for tldraw, an infinite canvas whiteboard SDK.
6+
7+
The monorepo uses yarn berry, and is organized using workspaces.
8+
9+
The workspaces are:
10+
11+
- apps/docs: the tldraw.dev next.js app, hosting our public facing website, written documentation, and generated reference documentation.
12+
- apps/examples: API examples showing how to use the tldraw sdk.
13+
- apps/dotcom/client: the front-end to the user-facing tldraw.com white-boarding app. the app adds file and user management functionality around the core tldraw whiteboard sdk.
14+
- apps/dotcom/sync-worker: the main backend cloudflare worker for tldraw.com.
15+
- apps/dotcom/asset-upload-worker: a cloudflare worker handling media asset uploads for tldraw.com.
16+
- apps/dotcom/image-resize-worker: a cloudflare worker handling image resizing and optimization for tldraw.com.
17+
- apps/analytics: our internal analytics service.
18+
- apps/bemo-worker: a cloudflare worker hosting a demo server for tldraw sync, our multi-player backend.
19+
- apps/vscode: the tldraw vscode extension.
20+
- internal/\*: internal utilities, mostly for running this repo.
21+
- packages/ai: the tldraw ai module, an sdk addon for working with LLMs.
22+
- packages/assets: the assets (fonts, icons, images) needed for tldraw.
23+
- packages/create-tldraw: `npm create tldraw` cli for quickly creating a tldraw app.
24+
- packages/dotcom-shared: shared utilities used by the `apps/dotcom` workspaces.
25+
- packages/editor: the core tldraw editor.
26+
- packages/state: our reactive signals library, used for state management throughout the repo.
27+
- packages/state-react: react bindings to our reactive signals library.
28+
- packages/store: the reactive client-side in-memory database used to store the tldraw document in the editor.
29+
- packages/sync & packages/sync-core: the tldraw sync multi-player sdk addon.
30+
- packages/tldraw: builds on the editor adding the specific shapes (arrows, boxes, etc), tools, and UI that are recognizably tldraw.
31+
- packages/tlschema: type definitions, validators, and migrations for the data used & stored by tldraw.
32+
- packages/utils: internal utilities and helpers. generic things we use a lot, but that aren't part of the public sdk api.
33+
- packages/validate: lightweight zod-inspired validation library.
34+
- templates/\*: starting points for building tldraw applications. some of these are minimal starters showing tldraw in different frameworks, but other are more comprehensive mini-apps showing how tldraw can be adapted to a specific domain / vertical.

.cursor/rules/tests.mdc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
alwaysApply: true
3+
---
4+
5+
Most tests in tldraw are written using jest. End-to-end tests are written using playwright.
6+
7+
## Jest tests
8+
9+
When writing unit-style jest tests, test files should be named after the file they are testing.
10+
For example, `src/lib/LicenseManager.ts` should be tested in `src/lib/LicenseManager.test.ts`.
11+
12+
When writing more integration-style jest test that touch several files, they should live in a separate `src/test/the-thing-you-are-testing.test.ts` file.
13+
For example, selection logic cuts across many files, so it's tested in `src/test/selection.test.ts`.
14+
15+
When testing the editor, if you need tldraw's default shapes and tools, write your tests in the tldraw workspace, not the editor workspace.
16+
17+
Tests for a specific package should be run using `yarn test` within that workspace's directory - ie in `packages/editor` to run editor tests.
18+
This is an alias for running `jest`, so you can filter down the tests you're running if needed.
19+
Running `yarn test` from the repo root will run all the tests for the entire repo.
20+
This can be slow, and can't be filtered as usual, so don't use it unless you have a good reason.

packages/editor/api-report.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,6 +2722,7 @@ export class Stadium2d extends Geometry2d {
27222722
// @public (undocumented)
27232723
export abstract class StateNode implements Partial<TLEventHandlers> {
27242724
constructor(editor: Editor, parent?: StateNode);
2725+
addChild(childConstructor: TLStateNodeConstructor): this;
27252726
// (undocumented)
27262727
static children?: () => TLStateNodeConstructor[];
27272728
// (undocumented)
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { createTLStore } from '../../config/createTLStore'
2+
import { Editor } from '../Editor'
3+
import { StateNode } from './StateNode'
4+
5+
describe('StateNode.addChild', () => {
6+
// Test state node classes for addChild tests
7+
class ParentState extends StateNode {
8+
static override id = 'parent'
9+
static override initial = 'child1'
10+
static override children() {
11+
return [ChildState1]
12+
}
13+
}
14+
15+
class ChildState1 extends StateNode {
16+
static override id = 'child1'
17+
}
18+
19+
class ChildState2 extends StateNode {
20+
static override id = 'child2'
21+
}
22+
23+
class ChildState3 extends StateNode {
24+
static override id = 'child3'
25+
}
26+
27+
class LeafState extends StateNode {
28+
static override id = 'leaf'
29+
}
30+
31+
class RootState extends StateNode {
32+
static override id = 'root'
33+
static override initial = 'child1'
34+
static override children() {
35+
return [ChildState1]
36+
}
37+
}
38+
39+
class RootStateWithoutChildren extends StateNode {
40+
static override id = 'rootWithoutChildren'
41+
}
42+
43+
let editor: Editor
44+
45+
beforeEach(() => {
46+
editor = new Editor({
47+
initialState: 'parent',
48+
shapeUtils: [],
49+
bindingUtils: [],
50+
tools: [
51+
ParentState,
52+
ChildState1,
53+
ChildState2,
54+
ChildState3,
55+
LeafState,
56+
RootState,
57+
RootStateWithoutChildren,
58+
],
59+
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
60+
getContainer: () => document.body,
61+
})
62+
})
63+
64+
it('should add a child to a branch state node', () => {
65+
const parentState = editor.root.children!['parent'] as ParentState
66+
67+
// Initially should have one child
68+
expect(Object.keys(parentState.children!)).toHaveLength(1)
69+
expect(parentState.children!['child1']).toBeDefined()
70+
71+
// Add a new child
72+
parentState.addChild(ChildState2)
73+
74+
// Should now have two children
75+
expect(Object.keys(parentState.children!)).toHaveLength(2)
76+
expect(parentState.children!['child1']).toBeDefined()
77+
expect(parentState.children!['child2']).toBeDefined()
78+
expect(parentState.children!['child2']).toBeInstanceOf(ChildState2)
79+
})
80+
81+
it('should add a child to a root state node', () => {
82+
const rootState = editor.root.children!['root'] as RootState
83+
84+
// Initially should have one child
85+
expect(Object.keys(rootState.children!)).toHaveLength(1)
86+
expect(rootState.children!['child1']).toBeDefined()
87+
88+
// Add a new child
89+
rootState.addChild(ChildState2)
90+
91+
// Should now have two children
92+
expect(Object.keys(rootState.children!)).toHaveLength(2)
93+
expect(rootState.children!['child1']).toBeDefined()
94+
expect(rootState.children!['child2']).toBeDefined()
95+
expect(rootState.children!['child2']).toBeInstanceOf(ChildState2)
96+
})
97+
98+
it('should throw an error when trying to add a child to a leaf state node', () => {
99+
const leafState = editor.root.children!['leaf'] as LeafState
100+
101+
// Leaf state should not have children
102+
expect(leafState.children).toBeUndefined()
103+
104+
// Should throw an error when trying to add a child
105+
expect(() => {
106+
leafState.addChild(ChildState2)
107+
}).toThrow('StateNode.addChild: cannot add child to a leaf node')
108+
})
109+
110+
it('should return the parent state node for chaining', () => {
111+
const parentState = editor.root.children!['parent'] as ParentState
112+
113+
const result = parentState.addChild(ChildState2)
114+
115+
expect(result).toBe(parentState)
116+
})
117+
118+
it('should create the child with the correct editor and parent', () => {
119+
const parentState = editor.root.children!['parent'] as ParentState
120+
121+
parentState.addChild(ChildState2)
122+
const childState = parentState.children!['child2'] as ChildState2
123+
124+
expect(childState.editor).toBe(editor)
125+
expect(childState.parent).toBe(parentState)
126+
})
127+
128+
it('should allow adding multiple children', () => {
129+
const parentState = editor.root.children!['parent'] as ParentState
130+
131+
// Add multiple children
132+
parentState.addChild(ChildState2).addChild(ChildState3)
133+
134+
// Should have three children
135+
expect(Object.keys(parentState.children!)).toHaveLength(3)
136+
expect(parentState.children!['child1']).toBeDefined()
137+
expect(parentState.children!['child2']).toBeDefined()
138+
expect(parentState.children!['child3']).toBeDefined()
139+
expect(parentState.children!['child2']).toBeInstanceOf(ChildState2)
140+
expect(parentState.children!['child3']).toBeInstanceOf(ChildState3)
141+
})
142+
143+
it('should allow transitioning to added children', () => {
144+
const parentState = editor.root.children!['parent'] as ParentState
145+
146+
// Add a new child
147+
parentState.addChild(ChildState2)
148+
149+
// Should be able to transition to the new child
150+
expect(() => {
151+
parentState.transition('child2')
152+
}).not.toThrow()
153+
154+
// The current state should be the new child
155+
expect(parentState.getCurrent()?.id).toBe('child2')
156+
})
157+
158+
it('should maintain existing children when adding new ones', () => {
159+
const parentState = editor.root.children!['parent'] as ParentState
160+
const originalChild = parentState.children!['child1']
161+
162+
// Add a new child
163+
parentState.addChild(ChildState2)
164+
165+
// Original child should still exist and be the same instance
166+
expect(parentState.children!['child1']).toBe(originalChild)
167+
expect(parentState.children!['child1']).toBeInstanceOf(ChildState1)
168+
})
169+
170+
it('should initialize children object for root nodes without static children', () => {
171+
// Create a StateNode directly as a root node (no parent)
172+
const mockEditor = {} as Editor
173+
const rootStateWithoutChildren = new RootStateWithoutChildren(mockEditor, undefined)
174+
175+
// Root state without static children should not have children initially
176+
expect(rootStateWithoutChildren.children).toBeUndefined()
177+
178+
// Adding a child should initialize the children object
179+
rootStateWithoutChildren.addChild(ChildState2)
180+
181+
// Should now have children object with the added child
182+
expect(rootStateWithoutChildren.children).toBeDefined()
183+
expect(Object.keys(rootStateWithoutChildren.children!)).toHaveLength(1)
184+
expect(rootStateWithoutChildren.children!['child2']).toBeDefined()
185+
expect(rootStateWithoutChildren.children!['child2']).toBeInstanceOf(ChildState2)
186+
})
187+
188+
it('should throw an error when trying to add a child with a duplicate ID', () => {
189+
const parentState = editor.root.children!['parent'] as ParentState
190+
191+
// Initially should have one child
192+
expect(Object.keys(parentState.children!)).toHaveLength(1)
193+
expect(parentState.children!['child1']).toBeDefined()
194+
195+
// Should throw an error when trying to add a child with the same ID
196+
expect(() => {
197+
parentState.addChild(ChildState1)
198+
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
199+
200+
// Should still have only one child
201+
expect(Object.keys(parentState.children!)).toHaveLength(1)
202+
expect(parentState.children!['child1']).toBeDefined()
203+
})
204+
205+
it('should throw an error when trying to add a child with a duplicate ID to a root state', () => {
206+
const rootState = editor.root.children!['root'] as RootState
207+
208+
// Initially should have one child
209+
expect(Object.keys(rootState.children!)).toHaveLength(1)
210+
expect(rootState.children!['child1']).toBeDefined()
211+
212+
// Should throw an error when trying to add a child with the same ID
213+
expect(() => {
214+
rootState.addChild(ChildState1)
215+
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
216+
217+
// Should still have only one child
218+
expect(Object.keys(rootState.children!)).toHaveLength(1)
219+
expect(rootState.children!['child1']).toBeDefined()
220+
})
221+
222+
it('should throw an error when trying to add a child with a duplicate ID to a root state without static children', () => {
223+
// Create a StateNode directly as a root node (no parent)
224+
const mockEditor = {} as Editor
225+
const rootStateWithoutChildren = new RootStateWithoutChildren(mockEditor, undefined)
226+
227+
// Add a child first
228+
rootStateWithoutChildren.addChild(ChildState1)
229+
230+
// Should throw an error when trying to add a child with the same ID
231+
expect(() => {
232+
rootStateWithoutChildren.addChild(ChildState1)
233+
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
234+
235+
// Should still have only one child
236+
expect(Object.keys(rootStateWithoutChildren.children!)).toHaveLength(1)
237+
expect(rootStateWithoutChildren.children!['child1']).toBeDefined()
238+
})
239+
})
240+
241+
describe('current tool id mask', () => {
242+
// Tool mask test classes
243+
class ToolA extends StateNode {
244+
static override id = 'A'
245+
}
246+
247+
class ToolB extends StateNode {
248+
static override id = 'B'
249+
}
250+
251+
class ToolC extends StateNode {
252+
static override id = 'C'
253+
254+
override onEnter() {
255+
this.setCurrentToolIdMask('A')
256+
}
257+
}
258+
259+
let toolMaskEditor: Editor
260+
261+
beforeEach(() => {
262+
toolMaskEditor = new Editor({
263+
initialState: 'A',
264+
shapeUtils: [],
265+
bindingUtils: [],
266+
tools: [ToolA, ToolB, ToolC],
267+
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
268+
getContainer: () => document.body,
269+
})
270+
})
271+
272+
it('starts with the correct tool id', () => {
273+
expect(toolMaskEditor.getCurrentToolId()).toBe('A')
274+
})
275+
276+
it('updates the current tool id', () => {
277+
toolMaskEditor.setCurrentTool('B')
278+
expect(toolMaskEditor.getCurrentToolId()).toBe('B')
279+
})
280+
281+
it('masks the current tool id', () => {
282+
toolMaskEditor.setCurrentTool('C')
283+
expect(toolMaskEditor.getCurrentToolId()).toBe('A')
284+
})
285+
})

packages/editor/src/lib/editor/tools/StateNode.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
6262

6363
this.parent = parent ?? ({} as any)
6464

65-
if (this.parent) {
65+
if (parent) {
6666
if (children && initial) {
6767
this.type = 'branch'
6868
this.initial = initial
@@ -238,6 +238,32 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
238238
this._currentToolIdMask.set(id)
239239
}
240240

241+
/**
242+
* Add a child node to this state node.
243+
*
244+
* @public
245+
*/
246+
addChild(childConstructor: TLStateNodeConstructor): this {
247+
if (this.type === 'leaf') {
248+
throw new Error('StateNode.addChild: cannot add child to a leaf node')
249+
}
250+
251+
// Initialize children if it's undefined (for root nodes without static children)
252+
if (!this.children) {
253+
this.children = {}
254+
}
255+
256+
const child = new childConstructor(this.editor, this)
257+
258+
// Check if a child with this ID already exists
259+
if (this.children[child.id]) {
260+
throw new Error(`StateNode.addChild: a child with id '${child.id}' already exists`)
261+
}
262+
263+
this.children[child.id] = child
264+
return this
265+
}
266+
241267
onWheel?(info: TLWheelEventInfo): void
242268
onPointerDown?(info: TLPointerEventInfo): void
243269
onPointerMove?(info: TLPointerEventInfo): void

0 commit comments

Comments
 (0)