@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.
- package/ARCHITECTURE.md +421 -0
- package/BENCHMARK.md +519 -0
- package/CLEAN_ROOM.md +50 -0
- package/COMPAT.md +282 -0
- package/CUSTOM_SHAPES.md +880 -0
- package/LICENSE +110 -16
- package/MIGRATION.md +807 -0
- package/README.md +24 -5
- package/UI.md +256 -0
- package/dist/index.d.ts +13091 -1419
- package/dist/index.js +12748 -2126
- package/dist/index.js.map +1 -1
- package/package.json +14 -16
package/CUSTOM_SHAPES.md
ADDED
|
@@ -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.
|