@mocanvas/editor 4.8.3 → 4.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/TOOLS.md ADDED
@@ -0,0 +1,655 @@
1
+ # Writing a tool
2
+
3
+ A tool is a small state machine that turns pointer and keyboard events into
4
+ document changes. By the end of this you will have a `card` tool with three
5
+ states, an Escape that leaves nothing behind, a cursor of its own, a toolbar
6
+ button and a key, registered on `<Mocanvas />` in two lines.
7
+
8
+ [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) covers the same ground from the other
9
+ side — it builds a shape and gives it a tool in §8. This guide is about the
10
+ machine itself: what the states are for, what each hook is handed, and which
11
+ parts of `StateNode` are load-bearing and which only look like they are.
12
+
13
+ Contents:
14
+
15
+ 1. [What a tool is](#1-what-a-tool-is)
16
+ 2. [The smallest tool: one state, one click](#2-the-smallest-tool)
17
+ 3. [What a handler is handed](#3-what-a-handler-is-handed)
18
+ 4. [Reading the pointer: `editor.inputs`](#4-reading-the-pointer)
19
+ 5. [Three states: idle, pointing, dragging](#5-three-states)
20
+ 6. [Cancelling, completing, and the history mark](#6-cancelling-and-completing)
21
+ 7. [Tool lock](#7-tool-lock)
22
+ 8. [Cursors](#8-cursors)
23
+ 9. [Registering the tool](#9-registering-the-tool)
24
+ 10. [A toolbar button and a keyboard shortcut](#10-a-toolbar-button-and-a-shortcut)
25
+ 11. [When to subclass `BaseBoxShapeTool` instead](#11-basebox)
26
+ 12. [Declared but inert](#12-declared-but-inert)
27
+ 13. [The whole file](#13-the-whole-file)
28
+
29
+ ---
30
+
31
+ ## 1. What a tool is
32
+
33
+ Every tool is a `StateNode`, and every `StateNode` is a node in one tree. The
34
+ editor builds a root node from the tools you register; each tool is a child of
35
+ that root, and each tool's own states are its children. `editor.root` is the top
36
+ of it.
37
+
38
+ ```ts
39
+ export interface StateNodeConstructor {
40
+ new (editor: Editor, parent?: StateNode): StateNode
41
+ id: string
42
+ initial?: string
43
+ children?(): StateNodeConstructor[]
44
+ isLockable?: boolean
45
+ useCoalescedEvents?: boolean
46
+ }
47
+ ```
48
+
49
+ A node with children is a *branch* and **must** declare `initial`, or the
50
+ constructor throws with `has children but no initial state`. A node without
51
+ children is a *leaf*, and leaves are where the work happens.
52
+
53
+ Events arrive at the root and walk down the active path: each node runs its own
54
+ handler first, then forwards to its active child — but only if that child is
55
+ still the active one, so a handler that transitions stops the event there rather
56
+ than delivering it to a state that has already exited. `editor.getPath()`
57
+ returns the live path as a string, e.g. `"root.card.dragging"`, and
58
+ `editor.isIn("card.dragging")` / `editor.isInAny(...)` test it.
59
+
60
+ Move between sibling states with `this.parent!.transition("id", info)`. The old
61
+ state's `onExit` runs, then the new one's `onEnter`, and `info` is passed
62
+ straight to that `onEnter`. `transition` also takes a dotted path —
63
+ `transition("select.idle")` enters `select` and then walks into its `idle` child
64
+ rather than stopping at whatever `select` calls initial. That is how one tool
65
+ hands control to a *specific* state of another.
66
+
67
+ ---
68
+
69
+ ## 2. The smallest tool
70
+
71
+ A tool that places something on click needs exactly one state:
72
+
73
+ ```ts
74
+ import { createShapeId, StateNode, type PointerEventInfo, type StateNodeConstructor } from "@mocanvas/editor"
75
+
76
+ class Idle extends StateNode {
77
+ static override id = "idle"
78
+
79
+ override onEnter(): void {
80
+ this.editor.setCursor({ type: "cross" })
81
+ }
82
+
83
+ override onPointerDown(info: PointerEventInfo): void {
84
+ if (info.button !== 0) return
85
+ const editor = this.editor
86
+ const { originPagePoint } = editor.inputs
87
+ editor.markHistoryStoppingPoint("create card")
88
+ const id = createShapeId()
89
+ editor.createShape({
90
+ id,
91
+ type: "geo",
92
+ x: originPagePoint.x - 130,
93
+ y: originPagePoint.y - 80,
94
+ props: { geo: "rectangle", w: 260, h: 160 },
95
+ })
96
+ editor.select(id)
97
+ if (!editor.getInstanceState().isToolLocked) editor.setCurrentTool("select")
98
+ }
99
+
100
+ override onCancel(): void {
101
+ this.editor.setCurrentTool("select")
102
+ }
103
+ }
104
+
105
+ /** Click to place a card. */
106
+ export class CardTool extends StateNode {
107
+ static override id = "card"
108
+ static override initial = "idle"
109
+ static override children = (): StateNodeConstructor[] => [Idle]
110
+ }
111
+ ```
112
+
113
+ `static id` is the whole identity of a tool: it is what `setCurrentTool("card")`
114
+ takes, what `getCurrentToolId()` returns, and the key it is registered under.
115
+ Registering two constructors with the same id replaces one with the other rather
116
+ than adding both.
117
+
118
+ Note what is *not* here. There is no `onExit` putting the cursor back and no
119
+ cleanup of any kind — the click either finishes the whole gesture or does
120
+ nothing, so there is nothing to unwind. `NoteTool` and `TextTool` are this tool
121
+ with a different `createShape` call.
122
+
123
+ ---
124
+
125
+ ## 3. What a handler is handed
126
+
127
+ Handlers are optional methods on `StateNode`. Implement the ones you need:
128
+
129
+ | Hook | Info type |
130
+ | --- | --- |
131
+ | `onEnter(info, from)` / `onExit(info, to)` | `Record<string, unknown>`, plus the sibling's id |
132
+ | `onPointerDown` / `onPointerMove` / `onPointerUp` | `PointerEventInfo` |
133
+ | `onRightClick` / `onMiddleClick` | `PointerEventInfo` |
134
+ | `onDoubleClick` | `ClickEventInfo` |
135
+ | `onKeyDown` / `onKeyUp` / `onKeyRepeat` | `KeyboardEventInfo` |
136
+ | `onWheel` | `WheelEventInfo` |
137
+ | `onCancel` / `onComplete` / `onInterrupt` | `CancelEventInfo` / `CompleteEventInfo` / `InterruptEventInfo` |
138
+ | `onTick` | `TickEventInfo` |
139
+
140
+ Every info object carries `shiftKey`, `altKey`, `ctrlKey`, `metaKey` and
141
+ `accelKey` — the last being the platform accelerator, `meta` on Apple and `ctrl`
142
+ elsewhere, so you never have to test the platform yourself.
143
+
144
+ `PointerEventInfo` adds `point` (viewport space), `pointerId`, `button`, `isPen`
145
+ and a discriminated `target`: `{ target: "canvas" }`, `{ target: "shape"; shape }`,
146
+ `{ target: "selection"; handle? }` or `{ target: "handle"; shape; handle }`. Test
147
+ `info.button !== 0` early — the middle button is already spoken for (the editor
148
+ pans with it whatever tool is selected) and the right button belongs to the
149
+ context menu.
150
+
151
+ Two hooks are worth knowing about before you reach for them. `onDoubleClick` is
152
+ the only multi-click the editor reports; triple and quadruple click were removed
153
+ because they were never distinguishable from a fast double click on a trackpad.
154
+ And `onTick` only fires if something dispatches a tick event into the tree —
155
+ nothing in the editor or the default canvas does. For per-frame work use
156
+ `editor.on("tick", (elapsed) => …)` instead, which the canvas's render loop does
157
+ emit.
158
+
159
+ ---
160
+
161
+ ## 4. Reading the pointer
162
+
163
+ `editor.inputs` is live pointer and modifier state, updated by the dispatch loop
164
+ *before* your handler runs. The fields a creation gesture actually needs:
165
+
166
+ | Field | Meaning |
167
+ | --- | --- |
168
+ | `originPagePoint` | Page-space point the gesture started at |
169
+ | `currentPagePoint` | Page-space pointer position now |
170
+ | `previousPagePoint` | Where it was at the previous event |
171
+ | `isDragging` | `true` once the pointer passed the drag threshold |
172
+ | `isPointing` | A pointer button is down |
173
+ | `shiftKey`, `altKey`, `ctrlKey`, `metaKey`, `accelKey` | Modifiers |
174
+ | `isPen` | The active pointer is a pen |
175
+
176
+ The `*ScreenPoint` twins are in **viewport** space — container-relative pixels,
177
+ which is the space canvas events arrive in — not window space. Use
178
+ `editor.pageToScreen(...)` if you need a window-relative point.
179
+
180
+ Every field also has a `get…()` accessor over the same storage:
181
+ `inputs.getCurrentPagePoint()` and `inputs.currentPagePoint` are the same value.
182
+ The accessors exist so the state can move behind a signal later without every
183
+ call site changing, so prefer them in new code. The built-in tools destructure
184
+ the fields directly, which is why the examples here do too.
185
+
186
+ One sharp edge: **`isDragging` is already cleared by the time `onPointerUp`
187
+ arrives.** A state that needs to know whether the gesture was a drag has to
188
+ remember it, which is what `ArrowTool` does with a private `dragged` flag. The
189
+ three-state machine below dodges the problem — being in `dragging` at all *is*
190
+ the flag.
191
+
192
+ ---
193
+
194
+ ## 5. Three states
195
+
196
+ The built-in tools use two states, `idle` and `pointing`, and let `pointing`
197
+ handle both outcomes by asking `isDragging` on every move. That is one fewer
198
+ state to keep in sync, and it is why `BaseBoxShapeTool` looks the way it does.
199
+ Three states — `idle`, `pointing`, `dragging` — cost one more `transition` and
200
+ buy back something worth having: each state has one job, and neither gesture
201
+ state has to ask what kind of gesture it is in.
202
+
203
+ | State | Holds | Leaves on |
204
+ | --- | --- | --- |
205
+ | `idle` | Nothing. Sets the cursor. | Pointer down with button 0 → `pointing` |
206
+ | `pointing` | Nothing yet — a press that ends here is a *click* | Drag threshold → `dragging`; pointer up → place and finish |
207
+ | `dragging` | The shape id and the history mark | Pointer up or complete → finish; cancel → bail to mark |
208
+
209
+ The hop between them is the whole of `pointing`'s move handler:
210
+
211
+ ```ts
212
+ override onPointerMove(): void {
213
+ if (this.editor.inputs.isDragging) this.parent!.transition("dragging")
214
+ }
215
+ ```
216
+
217
+ Creating nothing until the drag starts is what makes the two endings different.
218
+ A click places a default-sized card **centred on the point**, which reads as
219
+ dropping a stamp; a drag creates at the origin and sizes to the pointer, which
220
+ reads as drawing a rectangle. `GeoTool` and `BaseBoxShapeTool` both take the same
221
+ care, in one state instead of two.
222
+
223
+ `setCurrentTool` reaches the same `onEnter` channel from outside the tree:
224
+ `editor.setCurrentTool("geo", { geo: "ellipse" })` lands in `GeoTool.onEnter`,
225
+ which reads `info["geo"]`. Anything you can put in a plain object, a tool can be
226
+ entered with.
227
+
228
+ ---
229
+
230
+ ## 6. Cancelling and completing
231
+
232
+ Three editor methods dispatch an event with no pointer behind it:
233
+
234
+ - `editor.cancel()` — abandon the gesture. The root state already calls this on
235
+ Escape, provided nothing is being edited.
236
+ - `editor.complete()` — finish the gesture where it stands.
237
+ - `editor.interrupt()` — the gesture lost its pointer: the window blurred, a
238
+ dialog took over.
239
+
240
+ Only `cancel` has a caller inside the editor. `complete` and `interrupt` exist
241
+ for a host that can detect something the canvas cannot, and are worth handling
242
+ even though nothing in mocanvas dispatches them today. All three reach your tool
243
+ as `onCancel`, `onComplete` and `onInterrupt`. Two rules make them behave:
244
+
245
+ **Mark before the first mutation, bail to the mark on cancel.**
246
+ `markHistoryStoppingPoint(name)` returns a mark id; `bailToMark(id)` rolls the
247
+ document back to it. Together they make the whole creation gesture one undo step
248
+ and make Escape leave nothing behind. `ArrowTool` uses the same pair for a second
249
+ purpose — an arrow shorter than four page units bails on pointer *up*, so a
250
+ mis-click never leaves a zero-length arrow with live bindings.
251
+
252
+ **Treat `onComplete` as a pointer up.** In practice that is one line delegating
253
+ to whatever `onPointerUp` does. A state that ignores it can be left running by a
254
+ host that completes gestures.
255
+
256
+ Handle Escape in `onCancel`, not in `onKeyDown`. The root state turns Escape into
257
+ a cancel event and dispatches it *from inside* the keydown, so a tool that also
258
+ watched `onKeyDown` for `"Escape"` would see its own `onCancel` run first and
259
+ then get a keydown for a gesture that no longer exists.
260
+
261
+ ---
262
+
263
+ ## 7. Tool lock
264
+
265
+ Tool lock is a single boolean on the instance record,
266
+ `editor.getInstanceState().isToolLocked`, toggled by the padlock at the end of
267
+ the toolbar and by `Q`. It means: after this tool finishes one thing, stay armed
268
+ for the next instead of falling back to select.
269
+
270
+ Nothing enforces it. Each tool reads it and decides, which is the one piece of
271
+ etiquette a creation tool has to get right:
272
+
273
+ ```ts
274
+ function returnToIdle(node: StateNode): void {
275
+ const editor = node.editor
276
+ const locked = editor.getInstanceState().isToolLocked
277
+ node.parent!.transition("idle")
278
+ if (!locked) editor.setCurrentTool("select")
279
+ }
280
+ ```
281
+
282
+ Transition to `idle` *first*, then switch tools; the other order leaves the tool
283
+ you just left holding a stale current state. A tool with no "finished" moment —
284
+ select, hand — simply never reads the flag, and the toolbar hides the padlock for
285
+ those.
286
+
287
+ ---
288
+
289
+ ## 8. Cursors
290
+
291
+ The cursor lives on the instance record too. `setCursor` merges over the current
292
+ value and skips the write when nothing changed:
293
+
294
+ ```ts
295
+ this.editor.setCursor({ type: "cross" })
296
+ ```
297
+
298
+ The built-in tools spell it out as
299
+ `updateInstanceState({ cursor: { type: "cross", rotation: 0 } })`, which is the
300
+ same thing with more punctuation. The canvas maps seven names to CSS —
301
+ `default`, `cross` (`crosshair`), `grab`, `grabbing`, `move`, `pointer`, `text` —
302
+ and passes anything else through as a raw CSS cursor value, so
303
+ `setCursor({ type: "ns-resize" })` works and `setCursor({ type: "nonsense" })`
304
+ silently renders as nothing.
305
+
306
+ Two things to know:
307
+
308
+ - **Nothing resets it for you.** The convention every built-in tool follows is
309
+ that each tool's idle state sets its cursor in `onEnter`, and the select tool's
310
+ idle sets `default`. A tool whose idle state sets no cursor inherits whatever
311
+ the previous tool left.
312
+ - **`rotation` is ignored locally.** The canvas reads only `type`. `rotation` is
313
+ carried into presence, so collaborators see your cursor at the angle you set —
314
+ it is not dead, just not visible to you.
315
+
316
+ ---
317
+
318
+ ## 9. Registering the tool
319
+
320
+ `tools` on `<Mocanvas />` is **additive**: it is appended to the built-in
321
+ `defaultTools`, so do not re-list the built-ins.
322
+
323
+ ```tsx
324
+ import { Mocanvas } from "@mocanvas/mocanvas"
325
+ import { CardTool } from "./CardTool"
326
+
327
+ export function App() {
328
+ return <Mocanvas tools={[CardTool]} />
329
+ }
330
+ ```
331
+
332
+ `initialState` picks which tool the editor starts in; without it the editor
333
+ starts in whichever tool leads the list, which for the defaults is `select`.
334
+
335
+ For a *smaller* tool set, build the list yourself from the exported pieces —
336
+ `defaultTools` is everything, `defaultShapeTools` is only the tools that place a
337
+ shape — and hand the result to a bare `Editor`. Naming a constructor twice is
338
+ harmless, since registration is by `id`.
339
+
340
+ `editor.removeTool(id)` takes a tool out of service at runtime without rebuilding
341
+ the editor: the node stays in the tree, but it stops being selectable and an
342
+ editor sitting in it is moved to the first remaining tool. That is the supported
343
+ way to disable drawing in a review mode. `editor.hasTool(id)` asks whether one is
344
+ still in service.
345
+
346
+ ---
347
+
348
+ ## 10. A toolbar button and a shortcut
349
+
350
+ The chrome renders from a *data* list, not from a hard-coded set of buttons.
351
+ `TLUiOverrides.tools` rewrites that list, and everything downstream follows.
352
+
353
+ ```tsx
354
+ const overrides: TLUiOverrides = {
355
+ tools(editor, tools) {
356
+ tools["card"] = {
357
+ id: "card",
358
+ label: "Card",
359
+ icon: "geo-rectangle",
360
+ kbd: "c",
361
+ onSelect: () => void editor.setCurrentTool("card"),
362
+ }
363
+ return tools
364
+ },
365
+ }
366
+ ```
367
+
368
+ That is the whole of it, because of three things the default chrome already
369
+ does:
370
+
371
+ - The toolbar collects any item whose id is not part of its own layout and
372
+ renders it after a divider, so a tool added this way gets a place rather than
373
+ being silently dropped.
374
+ - An icon name the set does not have falls back to the label's first letter, so
375
+ your tool gets a pressable, labelled button without shipping artwork into
376
+ mocanvas. `geo-rectangle` above is a real name; `"card"` would render a `C`.
377
+ - `kbd` is bound by the tool-shortcut hook the default UI mounts. Only plain
378
+ single-key bindings are handled there — anything with a `+` belongs to the
379
+ action shortcuts, and binding it in both places would fire it twice.
380
+ Comma-separated alternatives let one tool answer to several keys (`"c,k"`); the
381
+ first declaration of a key wins, so a later item cannot steal a key an earlier
382
+ one already answers to.
383
+
384
+ `label` is display text, not a string id — mocanvas ships no strings of its own,
385
+ so localise it yourself. (`overrides.translations` and `helpers.msg` exist for
386
+ apps whose own chrome wants one dictionary.)
387
+
388
+ Two smaller notes. `readonlyOk: true` keeps a shortcut live on a read-only
389
+ editor; leave it off for anything that writes. And re-selecting the tool you are
390
+ already in is a no-op — `setCurrentTool` returns early — so a tool that carries an
391
+ argument, the way the geo items carry a kind, has to pass `{ force: true }` to
392
+ re-enter.
393
+
394
+ There is one `overrides` slot. An app composing two of them chains them itself,
395
+ inner first, because an override that adds a shortcut to a tool the inner one
396
+ registers has to run second.
397
+
398
+ For an editor with no default chrome, drive the tool from your own component with
399
+ `useEditor()` and `track()` — see [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) §9 — or
400
+ replace the `Toolbar` slot through `components`, which [UI.md](UI.md) covers.
401
+
402
+ ---
403
+
404
+ ## 11. `BaseBoxShapeTool`
405
+
406
+ If your tool drags out a box shape — anything with `w` and `h` props — the state
407
+ chart above already exists as a base class, and subclassing it is four lines:
408
+
409
+ ```ts
410
+ import { BaseBoxShapeTool } from "@mocanvas/mocanvas"
411
+
412
+ class SectionTool extends BaseBoxShapeTool {
413
+ static override id = "section"
414
+ static override initial = "idle"
415
+ override shapeType = "section"
416
+ }
417
+ ```
418
+
419
+ It gives you drag-to-size with Shift-to-square, click-to-place centred on the
420
+ point, the history mark and the cancel, and tool lock. Three hooks customise it:
421
+ `minSize` (the smallest a drag may produce), `getDefaultSize()` (what a click
422
+ places, defaulting to the shape util's own `getDefaultProps()` so the tool and a
423
+ programmatic `createShape` agree on what "a new one" means), and
424
+ `getCreateProps()` (props to seed the shape with, for identity decided at
425
+ placement time — a frame's `Frame 3`).
426
+
427
+ `onCreate(shape)` runs once the shape is final. Its default implementation *is*
428
+ the tool-lock behaviour, so an override replaces that too and has to repeat
429
+ whichever ending it wants. `FrameTool` is the worked example: it adopts the
430
+ shapes the new frame encloses, then repeats the lock check.
431
+
432
+ Write the machine by hand when the gesture is not a box — a freehand stroke, an
433
+ arrow with a bound end, a marquee that creates nothing.
434
+
435
+ ---
436
+
437
+ ## 12. Declared but inert
438
+
439
+ Three members of the tool API exist on the type but are read by nothing in the
440
+ editor. They are listed here so you do not spend an afternoon wondering why
441
+ setting them changed nothing.
442
+
443
+ - **`static isLockable`**, and the `isLockableTool` getter over it. Declared on
444
+ `StateNode`, defaulting to `true`, and never consulted. Tool lock is read by
445
+ each tool from `getInstanceState().isToolLocked`, as in §7; the toolbar hides
446
+ its padlock from a hard-coded list of tool ids, not from this flag.
447
+ - **`static useCoalescedEvents`**. Declared, never consulted. Pointer events are
448
+ not coalesced.
449
+ - **`shapeType`**. A real field, but only `BaseBoxShapeTool` reads it, from its
450
+ own subclass. On a hand-written tool it is documentation for your own code.
451
+
452
+ `setCurrentToolIdMask` is likewise a stub with an empty body, reserved for a tool
453
+ that wants to report an id other than its own.
454
+
455
+ ---
456
+
457
+ ## 13. The whole file
458
+
459
+ ```ts
460
+ import {
461
+ createShapeId,
462
+ StateNode,
463
+ type Editor,
464
+ type PointerEventInfo,
465
+ type ShapeId,
466
+ type StateNodeConstructor,
467
+ } from "@mocanvas/editor"
468
+
469
+ /** What a click, as opposed to a drag, places. */
470
+ const DEFAULT_CARD = { w: 260, h: 160 }
471
+ /** Smallest card a drag may produce, in page units. */
472
+ const MIN_CARD = 16
473
+
474
+ /** One card, with the look that makes it a card rather than a bare rectangle. */
475
+ function createCard(editor: Editor, x: number, y: number, w: number, h: number): ShapeId {
476
+ const id = createShapeId()
477
+ editor.createShape({
478
+ id,
479
+ type: "geo",
480
+ x,
481
+ y,
482
+ props: { geo: "rectangle", fill: "semi", color: "blue", dash: "solid", w, h },
483
+ })
484
+ editor.select(id)
485
+ return id
486
+ }
487
+
488
+ /**
489
+ * Back to idle, and back to select unless tool lock says to stay armed. Idle
490
+ * first: switching tools before the transition leaves this tool holding a stale
491
+ * current state.
492
+ */
493
+ function returnToIdle(node: StateNode): void {
494
+ const editor = node.editor
495
+ const locked = editor.getInstanceState().isToolLocked
496
+ node.parent!.transition("idle")
497
+ if (!locked) editor.setCurrentTool("select")
498
+ }
499
+
500
+ class Idle extends StateNode {
501
+ static override id = "idle"
502
+
503
+ override onEnter(): void {
504
+ this.editor.setCursor({ type: "cross" })
505
+ }
506
+
507
+ override onPointerDown(info: PointerEventInfo): void {
508
+ if (info.button === 0) this.parent!.transition("pointing", info)
509
+ }
510
+
511
+ override onCancel(): void {
512
+ this.editor.setCurrentTool("select")
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Button down, not yet a drag. Nothing is created here: a press that ends in
518
+ * this state is a click, and a click places a default-sized card CENTRED on the
519
+ * point — dropping a stamp, not starting a rectangle at the cursor.
520
+ */
521
+ class Pointing extends StateNode {
522
+ static override id = "pointing"
523
+
524
+ override onPointerMove(): void {
525
+ if (this.editor.inputs.isDragging) this.parent!.transition("dragging")
526
+ }
527
+
528
+ override onPointerUp(): void {
529
+ this.place()
530
+ }
531
+
532
+ override onComplete(): void {
533
+ this.place()
534
+ }
535
+
536
+ override onCancel(): void {
537
+ this.parent!.transition("idle")
538
+ }
539
+
540
+ private place(): void {
541
+ const editor = this.editor
542
+ const { originPagePoint } = editor.inputs
543
+ editor.markHistoryStoppingPoint("create card")
544
+ const { w, h } = DEFAULT_CARD
545
+ createCard(editor, originPagePoint.x - w / 2, originPagePoint.y - h / 2, w, h)
546
+ returnToIdle(this)
547
+ }
548
+ }
549
+
550
+ /** Past the drag threshold: the card exists and follows the pointer. */
551
+ class Dragging extends StateNode {
552
+ static override id = "dragging"
553
+ private shapeId: ShapeId | null = null
554
+ private markId = ""
555
+
556
+ override onEnter(): void {
557
+ const editor = this.editor
558
+ // One undo entry for the whole gesture, and the point Escape rolls back to.
559
+ this.markId = editor.markHistoryStoppingPoint("create card")
560
+ const { originPagePoint } = editor.inputs
561
+ this.shapeId = createCard(editor, originPagePoint.x, originPagePoint.y, MIN_CARD, MIN_CARD)
562
+ this.resize()
563
+ }
564
+
565
+ override onPointerMove(): void {
566
+ this.resize()
567
+ }
568
+
569
+ override onPointerUp(): void {
570
+ this.finish()
571
+ }
572
+
573
+ override onComplete(): void {
574
+ this.finish()
575
+ }
576
+
577
+ override onCancel(): void {
578
+ if (this.markId) this.editor.bailToMark(this.markId)
579
+ this.shapeId = null
580
+ this.parent!.transition("idle")
581
+ }
582
+
583
+ private resize(): void {
584
+ const editor = this.editor
585
+ const id = this.shapeId
586
+ if (!id) return
587
+ const shape = editor.getShape(id)
588
+ if (!shape) return
589
+ const { originPagePoint, currentPagePoint, shiftKey } = editor.inputs
590
+ let w = currentPagePoint.x - originPagePoint.x
591
+ let h = currentPagePoint.y - originPagePoint.y
592
+ if (shiftKey) {
593
+ const m = Math.max(Math.abs(w), Math.abs(h))
594
+ w = Math.sign(w || 1) * m
595
+ h = Math.sign(h || 1) * m
596
+ }
597
+ // Dragging up or left moves the origin instead of producing a negative box.
598
+ let x = originPagePoint.x
599
+ let y = originPagePoint.y
600
+ if (w < 0) x += w
601
+ if (h < 0) y += h
602
+ editor.updateShape({
603
+ id: shape.id,
604
+ type: "geo",
605
+ x,
606
+ y,
607
+ props: { w: Math.max(MIN_CARD, Math.abs(w)), h: Math.max(MIN_CARD, Math.abs(h)) },
608
+ })
609
+ }
610
+
611
+ private finish(): void {
612
+ this.shapeId = null
613
+ returnToIdle(this)
614
+ }
615
+ }
616
+
617
+ /** Drag out a card, or click to place a default-sized one. */
618
+ export class CardTool extends StateNode {
619
+ static override id = "card"
620
+ static override initial = "idle"
621
+ static override children = (): StateNodeConstructor[] => [Idle, Pointing, Dragging]
622
+ override shapeType = "geo"
623
+ }
624
+ ```
625
+
626
+ And the mount:
627
+
628
+ ```tsx
629
+ import { Mocanvas, type TLUiOverrides } from "@mocanvas/mocanvas"
630
+ import "@mocanvas/mocanvas/mocanvas.css"
631
+ import { CardTool } from "./CardTool"
632
+
633
+ const overrides: TLUiOverrides = {
634
+ tools(editor, tools) {
635
+ tools["card"] = {
636
+ id: "card",
637
+ label: "Card",
638
+ icon: "geo-rectangle",
639
+ kbd: "c",
640
+ onSelect: () => void editor.setCurrentTool("card"),
641
+ }
642
+ return tools
643
+ },
644
+ }
645
+
646
+ export function App() {
647
+ return <Mocanvas tools={[CardTool]} overrides={overrides} />
648
+ }
649
+ ```
650
+
651
+ ---
652
+
653
+ See also: [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) for the shape a tool creates,
654
+ [UI.md](UI.md) for the chrome the toolbar button lands in, and
655
+ [ARCHITECTURE.md](ARCHITECTURE.md) for where the dispatch loop sits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mocanvas/editor",
3
- "version": "4.8.3",
3
+ "version": "4.8.5",
4
4
  "description": "The mocanvas editor core: Editor, ShapeUtil, StateNode, geometry, bindings and the <Canvas /> renderer.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Symbio Digital",
@@ -32,12 +32,16 @@
32
32
  "dist",
33
33
  "README.md",
34
34
  "LICENSE",
35
+ "AGENTS.md",
35
36
  "ARCHITECTURE.md",
36
37
  "BENCHMARK.md",
37
38
  "CLEAN_ROOM.md",
38
39
  "COMPAT.md",
39
40
  "CUSTOM_SHAPES.md",
41
+ "FILES.md",
40
42
  "MIGRATION.md",
43
+ "SYNC.md",
44
+ "TOOLS.md",
41
45
  "UI.md"
42
46
  ],
43
47
  "sideEffects": false,
@@ -54,9 +58,9 @@
54
58
  "access": "public"
55
59
  },
56
60
  "dependencies": {
57
- "@mocanvas/store": "4.8.3",
58
- "@mocanvas/wasm": "4.8.3",
59
- "@mocanvas/state": "4.8.3"
61
+ "@mocanvas/store": "4.8.5",
62
+ "@mocanvas/wasm": "4.8.5",
63
+ "@mocanvas/state": "4.8.5"
60
64
  },
61
65
  "devDependencies": {
62
66
  "@types/react": "^19.1.0",