@mocanvas/editor 1.0.0 → 4.0.0

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.
@@ -0,0 +1,880 @@
1
+ # Writing a custom shape
2
+
3
+ A from-scratch tutorial. We build a **callout**: a rounded-ish body with a
4
+ pointed tail, a text label, a draggable handle for the tail tip, resize
5
+ behaviour, two style props, and a tool that creates it by dragging.
6
+
7
+ Everything here uses public API from `@mocanvas/mocanvas` / `@mocanvas/editor`. Read
8
+ [ARCHITECTURE.md](ARCHITECTURE.md) first if you want to know why the rendering
9
+ split looks the way it does.
10
+
11
+ Contents:
12
+
13
+ 1. [The record: type and props](#1-the-record-type-and-props)
14
+ 2. [Geometry](#2-geometry)
15
+ 3. [Rendering: `getRenderStyle`, `component`, `indicator`](#3-rendering)
16
+ 4. [The overlay label](#4-the-overlay-label)
17
+ 5. [Handles: `getHandles` and `onHandleDrag`](#5-handles)
18
+ 6. [Resize with `BaseBoxShapeUtil`](#6-resize)
19
+ 7. [Styles with `static props`](#7-styles)
20
+ 8. [The tool: a `StateNode` with Idle and Pointing](#8-the-tool)
21
+ 9. [Registering both](#9-registering-both)
22
+ 10. [The whole file](#10-the-whole-file)
23
+
24
+ ---
25
+
26
+ ## 1. The record: type and props
27
+
28
+ A shape is a record. `BaseShape<Type, Props>` supplies everything except your
29
+ props:
30
+
31
+ ```ts
32
+ import type { BaseShape, DefaultColorStyle, DefaultSizeStyle } from "@mocanvas/editor"
33
+
34
+ export interface CalloutShapeProps {
35
+ w: number
36
+ h: number
37
+ /** Tail tip, in shape-local coordinates. */
38
+ tailX: number
39
+ tailY: number
40
+ text: string
41
+ color: DefaultColorStyle
42
+ size: DefaultSizeStyle
43
+ }
44
+
45
+ export type CalloutShape = BaseShape<"callout", CalloutShapeProps>
46
+ ```
47
+
48
+ `BaseShape` adds `id`, `typeName: "shape"`, `type`, `x`, `y`, `rotation`,
49
+ `index`, `parentId`, `isLocked`, `opacity`, `props` and `meta`. Props must be
50
+ JSON-serializable — they go into `.tldr` files verbatim.
51
+
52
+ `DefaultColorStyle` and `DefaultSizeStyle` are exported twice under one name:
53
+ as a **type** (the value union, `"black" | "grey" | ...` and `"s" | "m" | "l" |
54
+ "xl"`) and as a **value** (the `StyleProp` instance you will use in §7). One
55
+ import gives you both.
56
+
57
+ Naming the props `w` and `h` is not cosmetic: it is what lets the shape extend
58
+ `BaseBoxShapeUtil` in §6.
59
+
60
+ The util declares the type once:
61
+
62
+ ```ts
63
+ export class CalloutShapeUtil extends ShapeUtil<CalloutShape> {
64
+ static override type = "callout" as const
65
+
66
+ getDefaultProps(): CalloutShapeProps {
67
+ return { w: 220, h: 120, tailX: 40, tailY: 160, text: "", color: "blue", size: "m" }
68
+ }
69
+ }
70
+ ```
71
+
72
+ `getDefaultProps` fills in props the creator did not supply, so
73
+ `editor.createShape({ type: "callout", x, y })` is enough to make a valid shape.
74
+
75
+ ---
76
+
77
+ ## 2. Geometry
78
+
79
+ `getGeometry(shape)` returns a `Geometry2d` in **shape-local space** — the
80
+ origin is the shape's `(x, y)` before rotation. It is used for hit-testing,
81
+ bounds, snapping, the selection box, and (when the shape is on the GPU) as the
82
+ outline that gets tessellated. Get it right once and everything else follows.
83
+
84
+ The classes you can build with:
85
+
86
+ | Class | Constructor | Notes |
87
+ | ----- | ----------- | ----- |
88
+ | `Rectangle2d` | `{ x?, y?, width, height, isFilled, isLabel? }` | axis-aligned box |
89
+ | `Ellipse2d` | `{ width, height, isFilled }` | serialized as four cubics |
90
+ | `Circle2d` | `{ radius, isFilled }` | an `Ellipse2d` |
91
+ | `Polygon2d` | `{ points, isFilled }` | closed |
92
+ | `Polyline2d` | `{ points }` | open, never filled |
93
+ | `Edge2d` | `{ start, end }` | a two-point `Polyline2d` |
94
+ | `CubicSpline2d` | `{ segments, isClosed?, isFilled? }` | segments of `{ p0, c1, c2, p1 }` |
95
+ | `Group2d` | `{ children }` | several geometries as one shape |
96
+
97
+ `Group2d` is filled if any child is filled, closed if any child is closed, and
98
+ its `toPathWords()` concatenates its children's paths — **skipping children
99
+ whose `isLabel` is true**, so a label rectangle contributes to hit-testing and
100
+ bounds without being drawn.
101
+
102
+ The callout is a body plus a tail triangle:
103
+
104
+ ```ts
105
+ import { Group2d, Polygon2d, Rectangle2d, type Geometry2d } from "@mocanvas/editor"
106
+
107
+ const TAIL_WIDTH = 28
108
+
109
+ getGeometry(shape: CalloutShape): Geometry2d {
110
+ const { w, h, tailX, tailY } = shape.props
111
+ const anchor = Math.max(0, Math.min(w - TAIL_WIDTH, tailX - TAIL_WIDTH / 2))
112
+ return new Group2d({
113
+ children: [
114
+ new Rectangle2d({ width: w, height: h, isFilled: true }),
115
+ new Polygon2d({
116
+ points: [
117
+ { x: anchor, y: h },
118
+ { x: anchor + TAIL_WIDTH, y: h },
119
+ { x: tailX, y: tailY },
120
+ ],
121
+ isFilled: true,
122
+ }),
123
+ ],
124
+ })
125
+ }
126
+ ```
127
+
128
+ Every `Geometry2d` gives you `vertices`, `bounds`, `center`, `nearestPoint`,
129
+ `distanceToPoint` and `hitTestPoint` on the TypeScript side, so a tool can ask
130
+ geometry questions without a round trip to the engine.
131
+
132
+ ---
133
+
134
+ ## 3. Rendering
135
+
136
+ mocanvas draws shapes two ways: as **GPU meshes** tessellated from
137
+ `getGeometry`, or as **React components in a DOM overlay** above the canvas. A
138
+ `ShapeUtil` picks per shape.
139
+
140
+ ### `getRenderStyle` — the GPU path
141
+
142
+ ```ts
143
+ getRenderStyle(shape: T): StyleWords | null
144
+ ```
145
+
146
+ The base implementation returns `null`, which means "render me through
147
+ `component` in the DOM overlay". Return a `StyleWords` to draw the geometry on
148
+ the GPU instead:
149
+
150
+ ```ts
151
+ interface StyleWords {
152
+ /** 0xRRGGBBAA, alpha 0 = none */
153
+ fill: number
154
+ stroke: number
155
+ strokeWidth: number
156
+ dash: number
157
+ opacity: number
158
+ /** host texture id; 0 or undefined = none */
159
+ texture?: number
160
+ }
161
+ ```
162
+
163
+ Colours are packed integers, not CSS strings. `hexToRgba(hex, alpha = 1)`
164
+ converts `#rgb`, `#rrggbb` or `#rrggbbaa`. `dash` is `0` solid, `1` dashed, `2`
165
+ dotted, `3` draw. `strokeWidth` is in page units and scales with zoom, so the
166
+ mesh is never retessellated while zooming.
167
+
168
+ `LIGHT_THEME` maps each colour value to `{ solid, semi, pattern, fill, note,
169
+ highlight }`, and `STROKE_SIZES` / `FONT_SIZES` map each size value to a number
170
+ (`STROKE_SIZES` is `{ s: 2, m: 3.5, l: 5, xl: 10 }`).
171
+
172
+ ```ts
173
+ import { hexToRgba, LIGHT_THEME, STROKE_SIZES, type StyleWords } from "@mocanvas/editor"
174
+
175
+ override getRenderStyle(shape: CalloutShape): StyleWords {
176
+ const theme = LIGHT_THEME[shape.props.color]
177
+ return {
178
+ fill: hexToRgba(theme.semi),
179
+ stroke: hexToRgba(theme.solid),
180
+ strokeWidth: STROKE_SIZES[shape.props.size],
181
+ dash: 0,
182
+ opacity: 1,
183
+ }
184
+ }
185
+ ```
186
+
187
+ ### `component` — the DOM overlay
188
+
189
+ ```ts
190
+ abstract component(shape: T): ReactNode
191
+ ```
192
+
193
+ `component` is always required, even for a GPU shape: it is the fallback when
194
+ the shape is promoted to the overlay. The overlay wraps it in an absolutely
195
+ positioned `<div>` carrying the shape's page transform, sized to the shape's
196
+ geometry bounds, with `pointerEvents: "none"` unless the shape is being edited.
197
+ Render in shape-local space starting at `(0, 0)`; do not apply the camera or
198
+ the shape transform yourself.
199
+
200
+ ### `getIndicatorPath` — the selection outline
201
+
202
+ ```ts
203
+ getIndicatorPath?(shape: T): Path2D | TLIndicatorPath | undefined
204
+ ```
205
+
206
+ Return a `Path2D` in shape-local space. The compositor applies the shape's page
207
+ transform and supplies the stroke — colour from the theme's selection colour,
208
+ width in CSS pixels so it stays a hairline at any zoom — so an indicator is
209
+ usually just the outline:
210
+
211
+ ```ts
212
+ import { svgPath } from "@mocanvas/mocanvas"
213
+
214
+ override getIndicatorPath(shape: CalloutShape): Path2D {
215
+ return svgPath(pathWordsToSvgD(this.getGeometry(shape).toPathWords()))
216
+ }
217
+ ```
218
+
219
+ Return a `TLIndicatorPath` when the outline needs a hole punched in it:
220
+
221
+ ```ts
222
+ { path: outline, clipPath: labelRect, additionalPaths: [tail] }
223
+ ```
224
+
225
+ `clipPath` is applied even-odd *before* stroking `path`, so an outer rectangle
226
+ plus a label rectangle leaves the label uncovered. `additionalPaths` are stroked
227
+ afterwards, without the clip.
228
+
229
+ Returning `undefined` means **no outline**, not "use the default". A shape that
230
+ wants the plain bounds rectangle simply does not implement the method.
231
+
232
+ Indicators are drawn on a canvas overlay, not as React elements. A util that
233
+ throws here does not blank the whole selection layer — the compositor catches it
234
+ per shape — but do not rely on that.
235
+
236
+ > **Coming from mocanvas 1.x**, `indicator(shape): ReactNode` still works and is
237
+ > deprecated. A util that implements only the old one is routed to the SVG
238
+ > layer, so nothing breaks; a util that implements both is drawn once, on the
239
+ > canvas.
240
+
241
+ ---
242
+
243
+ ## 4. The overlay label
244
+
245
+ Two hooks decide whether `component` runs for a GPU shape.
246
+
247
+ ```ts
248
+ needsOverlay(shape: T): boolean // default: editor.getEditingShapeId() === shape.id
249
+ hasOverlayLabel(shape: T): boolean // default: false
250
+ ```
251
+
252
+ - `needsOverlay` **replaces** the GPU mesh with the DOM component. The default
253
+ does that while the shape is being edited.
254
+ - `hasOverlayLabel` draws the GPU mesh **and** reports the shape to the overlay,
255
+ so `component` can put a label on top of it.
256
+
257
+ A callout keeps its body on the GPU at all times and only wants a label:
258
+
259
+ ```tsx
260
+ override needsOverlay(_shape: CalloutShape): boolean {
261
+ return false
262
+ }
263
+
264
+ override hasOverlayLabel(shape: CalloutShape): boolean {
265
+ return shape.props.text.trim().length > 0 || this.editor.getEditingShapeId() === shape.id
266
+ }
267
+
268
+ override canEdit(_shape: CalloutShape): boolean {
269
+ return true
270
+ }
271
+
272
+ component(shape: CalloutShape): ReactNode {
273
+ const { w, h, text, color, size } = shape.props
274
+ return (
275
+ <div
276
+ style={{
277
+ width: w,
278
+ height: h,
279
+ display: "grid",
280
+ placeItems: "center",
281
+ padding: 12,
282
+ boxSizing: "border-box",
283
+ fontSize: FONT_SIZES[size] / 2,
284
+ color: LIGHT_THEME[color].solid,
285
+ textAlign: "center",
286
+ pointerEvents: "none",
287
+ }}
288
+ >
289
+ {text}
290
+ </div>
291
+ )
292
+ }
293
+ ```
294
+
295
+ `canEdit` returning `true` is what lets the select tool put the shape into
296
+ editing state (`editor.setEditingShape(id)`). There is also `getText(shape)`,
297
+ which the editor and export use to read a shape's plain text:
298
+
299
+ ```ts
300
+ override getText(shape: CalloutShape): string {
301
+ return shape.props.text
302
+ }
303
+ ```
304
+
305
+ One more hook in this family: `isClipShape(shape)` (default `false`) makes the
306
+ shape clip all of its descendants to its page-space geometry bounds. Frames are
307
+ the intended user; no built-in enables it yet.
308
+
309
+ ---
310
+
311
+ ## 5. Handles
312
+
313
+ `getHandles` returns the shape's own draggable points. They are drawn by the
314
+ selection layer when exactly one shape is selected, and dragged through
315
+ `onHandleDrag`.
316
+
317
+ ```ts
318
+ interface ShapeHandle {
319
+ id: string
320
+ type: "vertex" | "virtual" | "create" | "clone"
321
+ index: string
322
+ x: number
323
+ y: number
324
+ }
325
+ ```
326
+
327
+ `x` / `y` are in shape-local space. `vertex` handles draw as a large light dot,
328
+ `virtual` as a small translucent one (built-ins use `virtual` for midpoints that
329
+ become real points when dragged). `index` orders handles among themselves; a
330
+ single handle can use any stable string.
331
+
332
+ The callout has one handle, the tail tip:
333
+
334
+ ```ts
335
+ import type { ShapeHandle } from "@mocanvas/editor"
336
+
337
+ override getHandles(shape: CalloutShape): ShapeHandle[] {
338
+ return [{ id: "tail", type: "vertex", index: "a1", x: shape.props.tailX, y: shape.props.tailY }]
339
+ }
340
+
341
+ override onHandleDrag(shape: CalloutShape, info: { handle: ShapeHandle }): Partial<CalloutShape> {
342
+ return { props: { ...shape.props, tailX: info.handle.x, tailY: info.handle.y } }
343
+ }
344
+ ```
345
+
346
+ The full signature is
347
+ `onHandleDrag(shape: T, info: { handle: ShapeHandle; isPrecise: boolean; initial?: T }): Partial<T> | void`
348
+ — `isPrecise` is set when the user asked for an unsnapped drag, and `initial` is
349
+ the shape as it was when the drag began. Destructure only what you need. Return
350
+ a partial shape (or nothing, to ignore the drag).
351
+
352
+ There is also `onDoubleClickHandle(shape, handle)` if you want double-click on a
353
+ handle to do something, such as resetting the tail.
354
+
355
+ ---
356
+
357
+ ## 6. Resize
358
+
359
+ `ShapeUtil.onResize` is called by the select tool while the user drags a
360
+ selection handle:
361
+
362
+ ```ts
363
+ onResize?(shape: T, info: ResizeInfo<T>): Partial<T> | void
364
+
365
+ interface ResizeInfo<T extends UnknownShape> {
366
+ newPoint: VecLike
367
+ handle: SelectionHandle
368
+ mode: "scale_shape" | "resize_bounds"
369
+ scaleX: number
370
+ scaleY: number
371
+ initialBounds: { x: number; y: number; w: number; h: number }
372
+ initialShape: T
373
+ }
374
+ ```
375
+
376
+ For a shape with `w` and `h` props you do not have to write it. Extend
377
+ `BaseBoxShapeUtil` instead, which implements exactly this:
378
+
379
+ ```ts
380
+ export abstract class BaseBoxShapeUtil<
381
+ T extends UnknownShape & { props: { w: number; h: number } },
382
+ > extends ShapeUtil<T> {
383
+ override onResize(shape: T, info: ResizeInfo<T>): Partial<T> {
384
+ const { scaleX, scaleY, initialShape, newPoint } = info
385
+ const w = Math.max(1, Math.abs(initialShape.props.w * scaleX))
386
+ const h = Math.max(1, Math.abs(initialShape.props.h * scaleY))
387
+ return { x: newPoint.x, y: newPoint.y, props: { ...shape.props, w, h } } as Partial<T>
388
+ }
389
+ }
390
+ ```
391
+
392
+ The callout has one prop the base version does not know about — the tail — so it
393
+ extends `BaseBoxShapeUtil` and scales the tail on top of `super.onResize`:
394
+
395
+ ```ts
396
+ import { BaseBoxShapeUtil, type ResizeInfo } from "@mocanvas/editor"
397
+
398
+ export class CalloutShapeUtil extends BaseBoxShapeUtil<CalloutShape> {
399
+ override onResize(shape: CalloutShape, info: ResizeInfo<CalloutShape>): Partial<CalloutShape> {
400
+ const next = super.onResize(shape, info)
401
+ const props = next.props as CalloutShapeProps
402
+ return {
403
+ ...next,
404
+ props: {
405
+ ...props,
406
+ tailX: info.initialShape.props.tailX * info.scaleX,
407
+ tailY: info.initialShape.props.tailY * info.scaleY,
408
+ },
409
+ }
410
+ }
411
+ }
412
+ ```
413
+
414
+ Related predicates, all defaulting sensibly: `canResize` (default `true`),
415
+ `isAspectRatioLocked`, `hideResizeHandles`, `hideRotateHandle`,
416
+ `hideSelectionBoundsBg`, `hideSelectionBoundsFg`. `onResizeStart(shape)` and
417
+ `onResizeEnd(initial, current)` bracket the interaction.
418
+
419
+ ---
420
+
421
+ ## 7. Styles
422
+
423
+ A *style* is a prop shared across shape types: it is remembered for the next
424
+ shape you create, edited for a whole selection at once, and surfaced by the
425
+ style panel. Declare styles on the util's `static props` map, keyed by the prop
426
+ name:
427
+
428
+ ```ts
429
+ import { DefaultColorStyle, DefaultSizeStyle } from "@mocanvas/editor"
430
+
431
+ export class CalloutShapeUtil extends BaseBoxShapeUtil<CalloutShape> {
432
+ static override type = "callout" as const
433
+ static override props = {
434
+ color: DefaultColorStyle,
435
+ size: DefaultSizeStyle,
436
+ }
437
+ }
438
+ ```
439
+
440
+ That is the whole wiring. `editor.getStylePropsForType("callout")` now reports
441
+ both, `editor.getSharedStyles()` includes them when a callout is selected, and
442
+ `editor.setStyleForSelectedShapes(DefaultColorStyle, "red")` rewrites
443
+ `props.color` on selected callouts.
444
+
445
+ The built-in style props, all exported from `@mocanvas/editor`:
446
+ `DefaultColorStyle` (`mocanvas:color`), `DefaultLabelColorStyle`,
447
+ `DefaultFillStyle`, `DefaultDashStyle`, `DefaultSizeStyle`, `DefaultFontStyle`,
448
+ `DefaultHorizontalAlignStyle`, `DefaultVerticalAlignStyle`, and
449
+ `GeoShapeGeoStyle`.
450
+
451
+ To define your own, use `StyleProp` and namespace the id:
452
+
453
+ ```ts
454
+ import { StyleProp } from "@mocanvas/editor"
455
+
456
+ export const CalloutTailStyle = StyleProp.defineEnum("myapp:calloutTail", {
457
+ defaultValue: "sharp",
458
+ values: ["sharp", "round"] as const,
459
+ })
460
+ export type CalloutTail = (typeof CalloutTailStyle)["values"][number]
461
+ ```
462
+
463
+ `defineEnum` returns an `EnumStyleProp` whose `validate` throws on a value
464
+ outside `values`. For a non-enum style there is
465
+ `StyleProp.define<T>(id, { defaultValue, validate? })`.
466
+
467
+ Reading and writing styles from code:
468
+
469
+ ```ts
470
+ editor.getStyleForNextShape(DefaultColorStyle) // the value for the next created shape
471
+ editor.setStyleForNextShapes(DefaultColorStyle, "violet")
472
+ editor.setStyleForSelectedShapes(DefaultColorStyle, "violet")
473
+
474
+ const shared = editor.getSharedStyles() // SharedStyleMap
475
+ shared.getAsKnownValue(DefaultColorStyle) // value, or undefined when mixed
476
+ shared.get(DefaultColorStyle) // { type: "shared", value } | { type: "mixed" }
477
+ ```
478
+
479
+ ---
480
+
481
+ ## 8. The tool
482
+
483
+ Tools are trees of `StateNode`s: the tool itself is a branch with an `initial`
484
+ child, and each child is a state. The conventional shape is `Idle` (waiting)
485
+ plus `Pointing` (a gesture is in progress).
486
+
487
+ ```ts
488
+ export interface StateNodeConstructor {
489
+ new (editor: Editor, parent?: StateNode): StateNode
490
+ id: string
491
+ initial?: string
492
+ children?(): StateNodeConstructor[]
493
+ isLockable?: boolean
494
+ useCoalescedEvents?: boolean
495
+ }
496
+ ```
497
+
498
+ A branch node with children **must** declare `initial`, or the constructor
499
+ throws. Move between siblings with `this.parent!.transition(id, info)`; the old
500
+ child's `onExit` and the new child's `onEnter` run as part of the transition.
501
+
502
+ ### Idle
503
+
504
+ ```ts
505
+ import { StateNode, type PointerEventInfo } from "@mocanvas/editor"
506
+
507
+ class Idle extends StateNode {
508
+ static override id = "idle"
509
+
510
+ override onEnter(): void {
511
+ this.editor.updateInstanceState({ cursor: { type: "cross", rotation: 0 } })
512
+ }
513
+
514
+ override onPointerDown(info: PointerEventInfo): void {
515
+ if (info.button === 0) this.parent!.transition("pointing", info)
516
+ }
517
+
518
+ override onCancel(): void {
519
+ this.editor.setCurrentTool("select")
520
+ }
521
+ }
522
+ ```
523
+
524
+ ### Pointing
525
+
526
+ `Pointing` creates the shape on the first drag move, resizes it as the pointer
527
+ moves, and finishes on pointer up. A click without a drag gets a
528
+ default-sized shape centred on the click.
529
+
530
+ `editor.inputs` carries the pointer state you need: `originPagePoint` (where
531
+ the gesture started), `currentPagePoint`, `previousPagePoint`, `isDragging`
532
+ (true once the pointer passed the drag threshold), plus `shiftKey`, `altKey`,
533
+ `ctrlKey`, `metaKey` and `accelKey`.
534
+
535
+ ```ts
536
+ import { createShapeId, StateNode, type ShapeId } from "@mocanvas/editor"
537
+
538
+ class Pointing extends StateNode {
539
+ static override id = "pointing"
540
+ private shapeId: ShapeId | null = null
541
+ private markId = ""
542
+
543
+ override onEnter(): void {
544
+ this.shapeId = null
545
+ }
546
+
547
+ override onPointerMove(): void {
548
+ if (!this.editor.inputs.isDragging) return
549
+ if (!this.shapeId) this.create()
550
+ this.resize()
551
+ }
552
+
553
+ override onPointerUp(): void {
554
+ if (!this.shapeId) {
555
+ this.create()
556
+ const shape = this.editor.getShape<CalloutShape>(this.shapeId!)!
557
+ const { w, h } = shape.props
558
+ const { originPagePoint } = this.editor.inputs
559
+ this.editor.updateShape<CalloutShape>({
560
+ id: shape.id,
561
+ type: "callout",
562
+ x: originPagePoint.x - w / 2,
563
+ y: originPagePoint.y - h / 2,
564
+ })
565
+ }
566
+ this.finish()
567
+ }
568
+
569
+ override onCancel(): void {
570
+ if (this.markId) this.editor.bailToMark(this.markId)
571
+ this.parent!.transition("idle")
572
+ }
573
+
574
+ override onComplete(): void {
575
+ this.finish()
576
+ }
577
+
578
+ private create(): void {
579
+ const editor = this.editor
580
+ // One undo entry for the whole creation gesture.
581
+ this.markId = editor.markHistoryStoppingPoint("create callout")
582
+ const id = createShapeId()
583
+ const { originPagePoint } = editor.inputs
584
+ editor.createShape<CalloutShape>({
585
+ id,
586
+ type: "callout",
587
+ x: originPagePoint.x,
588
+ y: originPagePoint.y,
589
+ props: { w: 20, h: 20 },
590
+ })
591
+ editor.select(id)
592
+ this.shapeId = id
593
+ }
594
+
595
+ private resize(): void {
596
+ const editor = this.editor
597
+ const shape = editor.getShape<CalloutShape>(this.shapeId!)
598
+ if (!shape) return
599
+ const { originPagePoint, currentPagePoint, shiftKey } = editor.inputs
600
+ let w = currentPagePoint.x - originPagePoint.x
601
+ let h = currentPagePoint.y - originPagePoint.y
602
+ if (shiftKey) {
603
+ const m = Math.max(Math.abs(w), Math.abs(h))
604
+ w = Math.sign(w || 1) * m
605
+ h = Math.sign(h || 1) * m
606
+ }
607
+ const x = w < 0 ? originPagePoint.x + w : originPagePoint.x
608
+ const y = h < 0 ? originPagePoint.y + h : originPagePoint.y
609
+ w = Math.max(1, Math.abs(w))
610
+ h = Math.max(1, Math.abs(h))
611
+ editor.updateShape<CalloutShape>({
612
+ id: shape.id,
613
+ type: "callout",
614
+ x,
615
+ y,
616
+ props: { w, h, tailX: w / 2, tailY: h + 40 },
617
+ })
618
+ }
619
+
620
+ private finish(): void {
621
+ const editor = this.editor
622
+ const locked = editor.getInstanceState().isToolLocked
623
+ this.parent!.transition("idle")
624
+ if (!locked) editor.setCurrentTool("select")
625
+ }
626
+ }
627
+ ```
628
+
629
+ Two conventions worth copying from the built-in tools:
630
+
631
+ - `markHistoryStoppingPoint(name)` before the first mutation, `bailToMark(id)`
632
+ on cancel. The whole gesture becomes one undo step, and Escape leaves no
633
+ debris.
634
+ - Honour `getInstanceState().isToolLocked`: when it is off, return to the select
635
+ tool after one shape; when it is on, stay armed for the next one.
636
+
637
+ ### The tool node
638
+
639
+ ```ts
640
+ import { StateNode, type StateNodeConstructor } from "@mocanvas/editor"
641
+
642
+ /** Drag to place a callout. */
643
+ export class CalloutTool extends StateNode {
644
+ static override id = "callout"
645
+ static override initial = "idle"
646
+ static override children = (): StateNodeConstructor[] => [Idle, Pointing]
647
+ override shapeType = "callout"
648
+ }
649
+ ```
650
+
651
+ `static id` is what `editor.setCurrentTool("callout")` takes and what
652
+ `editor.getCurrentToolId()` returns; `editor.getPath()` gives the full active
653
+ path, e.g. `"root.callout.pointing"`, and `editor.isIn("callout.pointing")` /
654
+ `editor.isInAny(...)` test it. `shapeType` records which shape kind the tool
655
+ creates.
656
+
657
+ Handlers available on any `StateNode`: `onEnter`, `onExit`, `onPointerDown`,
658
+ `onPointerMove`, `onPointerUp`, `onRightClick`, `onMiddleClick`,
659
+ `onDoubleClick`, `onKeyDown`, `onKeyUp`,
660
+ `onKeyRepeat`, `onWheel`, `onCancel`, `onComplete`, `onInterrupt`, `onTick`.
661
+
662
+ ---
663
+
664
+ ## 9. Registering both
665
+
666
+ `shapeUtils` and `tools` on `<Mocanvas />` are **additive** — they are appended
667
+ to the built-in `defaultShapeUtils` and `defaultTools`, so do not re-list the
668
+ built-ins:
669
+
670
+ ```tsx
671
+ import { Mocanvas } from "@mocanvas/mocanvas"
672
+ import { CalloutShapeUtil } from "./CalloutShapeUtil"
673
+ import { CalloutTool } from "./CalloutTool"
674
+
675
+ export function App() {
676
+ return (
677
+ <Mocanvas
678
+ shapeUtils={[CalloutShapeUtil]}
679
+ tools={[CalloutTool]}
680
+ onMount={(editor) => {
681
+ editor.createShape({ type: "callout", x: 120, y: 120, props: { text: "Hi" } })
682
+ }}
683
+ />
684
+ )
685
+ }
686
+ ```
687
+
688
+ The default toolbar does not grow a button for your tool. Until UI slots exist,
689
+ drive it from your own chrome rendered as `children` of `<Mocanvas>`:
690
+
691
+ ```tsx
692
+ <Mocanvas shapeUtils={[CalloutShapeUtil]} tools={[CalloutTool]}>
693
+ <MyToolbar />
694
+ </Mocanvas>
695
+ ```
696
+
697
+ …where `MyToolbar` uses `useEditor()` to get the editor and `track()` (both
698
+ re-exported from `@mocanvas/mocanvas`) so it re-renders when the active tool changes:
699
+
700
+ ```tsx
701
+ import { track, useEditor } from "@mocanvas/mocanvas"
702
+
703
+ const MyToolbar = track(function MyToolbar() {
704
+ const editor = useEditor()
705
+ const active = editor.getCurrentToolId() === "callout"
706
+ return (
707
+ <button type="button" aria-pressed={active} onClick={() => editor.setCurrentTool("callout")}>
708
+ Callout
709
+ </button>
710
+ )
711
+ })
712
+ ```
713
+
714
+ For a bare editor with no default shapes, tools or UI, construct `Editor`
715
+ yourself with `loadEngine()` and `createStore()` and render `<Canvas editor={editor} />`.
716
+
717
+ ---
718
+
719
+ ## 10. The whole file
720
+
721
+ `CalloutShapeUtil.tsx`:
722
+
723
+ ```tsx
724
+ import {
725
+ BaseBoxShapeUtil,
726
+ DefaultColorStyle,
727
+ DefaultSizeStyle,
728
+ FONT_SIZES,
729
+ Group2d,
730
+ hexToRgba,
731
+ LIGHT_THEME,
732
+ Polygon2d,
733
+ Rectangle2d,
734
+ STROKE_SIZES,
735
+ type BaseShape,
736
+ type Geometry2d,
737
+ type ResizeInfo,
738
+ type ShapeHandle,
739
+ type StyleWords,
740
+ } from "@mocanvas/editor"
741
+ import { pathWordsToSvgD } from "@mocanvas/mocanvas"
742
+ import type { ReactNode } from "react"
743
+
744
+ // `DefaultColorStyle` / `DefaultSizeStyle` are imported once, in the value
745
+ // list: each name is both the `StyleProp` value and the value-union type.
746
+ export interface CalloutShapeProps {
747
+ w: number
748
+ h: number
749
+ tailX: number
750
+ tailY: number
751
+ text: string
752
+ color: DefaultColorStyle
753
+ size: DefaultSizeStyle
754
+ }
755
+
756
+ export type CalloutShape = BaseShape<"callout", CalloutShapeProps>
757
+
758
+ const TAIL_WIDTH = 28
759
+
760
+ export class CalloutShapeUtil extends BaseBoxShapeUtil<CalloutShape> {
761
+ static override type = "callout" as const
762
+ static override props = {
763
+ color: DefaultColorStyle,
764
+ size: DefaultSizeStyle,
765
+ }
766
+
767
+ getDefaultProps(): CalloutShapeProps {
768
+ return { w: 220, h: 120, tailX: 40, tailY: 160, text: "", color: "blue", size: "m" }
769
+ }
770
+
771
+ getGeometry(shape: CalloutShape): Geometry2d {
772
+ const { w, h, tailX, tailY } = shape.props
773
+ const anchor = Math.max(0, Math.min(w - TAIL_WIDTH, tailX - TAIL_WIDTH / 2))
774
+ return new Group2d({
775
+ children: [
776
+ new Rectangle2d({ width: w, height: h, isFilled: true }),
777
+ new Polygon2d({
778
+ points: [
779
+ { x: anchor, y: h },
780
+ { x: anchor + TAIL_WIDTH, y: h },
781
+ { x: tailX, y: tailY },
782
+ ],
783
+ isFilled: true,
784
+ }),
785
+ ],
786
+ })
787
+ }
788
+
789
+ override getRenderStyle(shape: CalloutShape): StyleWords {
790
+ const theme = LIGHT_THEME[shape.props.color]
791
+ return {
792
+ fill: hexToRgba(theme.semi),
793
+ stroke: hexToRgba(theme.solid),
794
+ strokeWidth: STROKE_SIZES[shape.props.size],
795
+ dash: 0,
796
+ opacity: 1,
797
+ }
798
+ }
799
+
800
+ override needsOverlay(_shape: CalloutShape): boolean {
801
+ return false
802
+ }
803
+
804
+ override hasOverlayLabel(shape: CalloutShape): boolean {
805
+ return shape.props.text.trim().length > 0 || this.editor.getEditingShapeId() === shape.id
806
+ }
807
+
808
+ component(shape: CalloutShape): ReactNode {
809
+ const { w, h, text, color, size } = shape.props
810
+ return (
811
+ <div
812
+ style={{
813
+ width: w,
814
+ height: h,
815
+ display: "grid",
816
+ placeItems: "center",
817
+ padding: 12,
818
+ boxSizing: "border-box",
819
+ fontSize: FONT_SIZES[size] / 2,
820
+ color: LIGHT_THEME[color].solid,
821
+ textAlign: "center",
822
+ pointerEvents: "none",
823
+ }}
824
+ >
825
+ {text}
826
+ </div>
827
+ )
828
+ }
829
+
830
+ override getIndicatorPath(shape: CalloutShape): Path2D {
831
+ return svgPath(pathWordsToSvgD(this.getGeometry(shape).toPathWords()))
832
+ }
833
+
834
+ override canEdit(_shape: CalloutShape): boolean {
835
+ return true
836
+ }
837
+
838
+ override getText(shape: CalloutShape): string {
839
+ return shape.props.text
840
+ }
841
+
842
+ override getHandles(shape: CalloutShape): ShapeHandle[] {
843
+ return [{ id: "tail", type: "vertex", index: "a1", x: shape.props.tailX, y: shape.props.tailY }]
844
+ }
845
+
846
+ override onHandleDrag(shape: CalloutShape, info: { handle: ShapeHandle }): Partial<CalloutShape> {
847
+ return { props: { ...shape.props, tailX: info.handle.x, tailY: info.handle.y } }
848
+ }
849
+
850
+ override onResize(shape: CalloutShape, info: ResizeInfo<CalloutShape>): Partial<CalloutShape> {
851
+ const next = super.onResize(shape, info)
852
+ const props = next.props as CalloutShapeProps
853
+ return {
854
+ ...next,
855
+ props: {
856
+ ...props,
857
+ tailX: info.initialShape.props.tailX * info.scaleX,
858
+ tailY: info.initialShape.props.tailY * info.scaleY,
859
+ },
860
+ }
861
+ }
862
+ }
863
+ ```
864
+
865
+ The tool lives in its own file, exactly as written in §8.
866
+
867
+ ---
868
+
869
+ ## Where to go next
870
+
871
+ - [COMPAT.md](COMPAT.md) — the full `ShapeUtil` / `StateNode` / `Editor`
872
+ surface, member by member.
873
+ - [MIGRATION.md](MIGRATION.md) — porting shapes that already exist in a
874
+ tldraw app, and the current known gaps.
875
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — the command buffer, the frame buffers,
876
+ and why `getRenderStyle` exists.
877
+ - The built-in utils under `packages/mocanvas/src/shapes/` are the reference
878
+ implementations: `GeoShapeUtil` for GPU body plus label, `LineShapeUtil` for
879
+ vertex and virtual handles, `FrameShapeUtil` for a container, `ImageShapeUtil`
880
+ for an asset-backed overlay shape.