@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/AGENTS.md +476 -0
- package/CUSTOM_SHAPES.md +7 -2
- package/FILES.md +554 -0
- package/MIGRATION.md +1 -1
- package/SYNC.md +490 -0
- package/TOOLS.md +655 -0
- package/package.json +8 -4
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
|
+
"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.
|
|
58
|
-
"@mocanvas/wasm": "4.8.
|
|
59
|
-
"@mocanvas/state": "4.8.
|
|
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",
|