create-substrate 0.1.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/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +127 -0
- package/dist/prompts.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +395 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/surfaces/3d-scene.d.ts +3 -0
- package/dist/surfaces/3d-scene.d.ts.map +1 -0
- package/dist/surfaces/3d-scene.js +184 -0
- package/dist/surfaces/3d-scene.js.map +1 -0
- package/dist/surfaces/animation.d.ts +3 -0
- package/dist/surfaces/animation.d.ts.map +1 -0
- package/dist/surfaces/animation.js +211 -0
- package/dist/surfaces/animation.js.map +1 -0
- package/dist/surfaces/blank.d.ts +3 -0
- package/dist/surfaces/blank.d.ts.map +1 -0
- package/dist/surfaces/blank.js +72 -0
- package/dist/surfaces/blank.js.map +1 -0
- package/dist/surfaces/canvas-2d.d.ts +3 -0
- package/dist/surfaces/canvas-2d.d.ts.map +1 -0
- package/dist/surfaces/canvas-2d.js +139 -0
- package/dist/surfaces/canvas-2d.js.map +1 -0
- package/dist/surfaces/data-vis.d.ts +3 -0
- package/dist/surfaces/data-vis.d.ts.map +1 -0
- package/dist/surfaces/data-vis.js +175 -0
- package/dist/surfaces/data-vis.js.map +1 -0
- package/dist/surfaces/image-gen.d.ts +3 -0
- package/dist/surfaces/image-gen.d.ts.map +1 -0
- package/dist/surfaces/image-gen.js +193 -0
- package/dist/surfaces/image-gen.js.map +1 -0
- package/dist/surfaces/index.d.ts +4 -0
- package/dist/surfaces/index.d.ts.map +1 -0
- package/dist/surfaces/index.js +17 -0
- package/dist/surfaces/index.js.map +1 -0
- package/dist/surfaces/node-editor.d.ts +3 -0
- package/dist/surfaces/node-editor.d.ts.map +1 -0
- package/dist/surfaces/node-editor.js +211 -0
- package/dist/surfaces/node-editor.js.map +1 -0
- package/dist/surfaces/types.d.ts +22 -0
- package/dist/surfaces/types.d.ts.map +1 -0
- package/dist/surfaces/types.js +10 -0
- package/dist/surfaces/types.js.map +1 -0
- package/dist/utils/detect-pm.d.ts +5 -0
- package/dist/utils/detect-pm.d.ts.map +1 -0
- package/dist/utils/detect-pm.js +20 -0
- package/dist/utils/detect-pm.js.map +1 -0
- package/dist/utils/fs.d.ts +7 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +52 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +15 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/shell.d.ts +7 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +28 -0
- package/dist/utils/shell.js.map +1 -0
- package/package.json +35 -0
- package/skills/3d-scene/SKILL.md +172 -0
- package/skills/animation/SKILL.md +194 -0
- package/skills/canvas-2d/SKILL.md +132 -0
- package/skills/composing-panels/SKILL.md +309 -0
- package/skills/create-custom-tool/SKILL.md +157 -0
- package/skills/data-visualisation/SKILL.md +228 -0
- package/skills/image-generation/SKILL.md +211 -0
- package/skills/scaffold-playground/SKILL.md +141 -0
- package/skills/substrate-canvas/SKILL.md +217 -0
- package/skills/substrate-controls/SKILL.md +242 -0
- package/skills/substrate-feedback/SKILL.md +219 -0
- package/skills/substrate-interaction/SKILL.md +286 -0
- package/skills/substrate-nodes/SKILL.md +208 -0
- package/skills/substrate-scaffold/SKILL.md +206 -0
- package/skills/theming/SKILL.md +117 -0
- package/skills/wire-interactions/SKILL.md +155 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# 2D canvas
|
|
2
|
+
|
|
3
|
+
Guide to building spatial 2D tools with Substrate — shape editors, diagram builders, whiteboard apps, and Figma-like design surfaces.
|
|
4
|
+
|
|
5
|
+
## When to use this surface
|
|
6
|
+
|
|
7
|
+
Use a 2D canvas when the app involves objects on an infinite pannable/zoomable plane: design tools, diagram editors, whiteboard apps, map builders, node graphs, or any tool where users place, select, and manipulate flat shapes and elements.
|
|
8
|
+
|
|
9
|
+
## Surface setup
|
|
10
|
+
|
|
11
|
+
This is Substrate's default surface. The `DesignCanvas` component provides a full 2D rendering pipeline out of the box.
|
|
12
|
+
|
|
13
|
+
### Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx shadcn@latest add https://substrate.georgedrury.co.uk/r/design-canvas
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This pulls in the canvas renderer, hit-test system, interaction hooks, transform utilities, and all required stores.
|
|
20
|
+
|
|
21
|
+
### Mount
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { DesignCanvas } from "@/registry/substrate/canvas/design-canvas"
|
|
25
|
+
|
|
26
|
+
export default function PlaygroundPage() {
|
|
27
|
+
return (
|
|
28
|
+
<div className="h-screen w-screen bg-canvas">
|
|
29
|
+
<DesignCanvas />
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Architecture
|
|
36
|
+
|
|
37
|
+
The 2D canvas uses a layered pipeline:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
Pure renderer functions (canvas/renderer.ts)
|
|
41
|
+
→ useCanvasRenderer (rAF loop with dirty flag)
|
|
42
|
+
→ useCanvasInteraction (state machine for pointer events)
|
|
43
|
+
→ useCanvasDrag (ref-based drag tracking, no re-renders)
|
|
44
|
+
→ useCanvasTransform (screen ↔ world coordinate conversion)
|
|
45
|
+
→ useHitTest (spatial queries)
|
|
46
|
+
→ useDocumentStore (element CRUD + undo/redo)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Rendering
|
|
50
|
+
|
|
51
|
+
The renderer uses the Canvas 2D API directly — no React reconciliation in the render loop. Renderer functions are pure: they take a context, camera, and element data, and draw. The `useCanvasRenderer` hook runs a `requestAnimationFrame` loop that only repaints when `markDirty()` has been called.
|
|
52
|
+
|
|
53
|
+
### Coordinate systems
|
|
54
|
+
|
|
55
|
+
- **Screen space** — pixel coordinates relative to the canvas element
|
|
56
|
+
- **World space** — infinite canvas coordinates, affected by camera pan/zoom
|
|
57
|
+
|
|
58
|
+
Always convert pointer events to world space before hit-testing or element manipulation. Use `screenToWorld()` and `worldToScreen()` from `useCanvasTransform`.
|
|
59
|
+
|
|
60
|
+
### State
|
|
61
|
+
|
|
62
|
+
`useDocumentStore` holds the element map, element order, selection set, and history stack. All mutations go through store actions. Call `pushSnapshot()` before any mutation to enable undo/redo.
|
|
63
|
+
|
|
64
|
+
## Wiring to Substrate chrome
|
|
65
|
+
|
|
66
|
+
### Toolbar
|
|
67
|
+
|
|
68
|
+
The default tool set for a 2D canvas:
|
|
69
|
+
|
|
70
|
+
| Tool | Purpose | Shortcut |
|
|
71
|
+
| --------- | ----------------------------- | -------- |
|
|
72
|
+
| Select | Click/drag to select elements | V |
|
|
73
|
+
| Hand | Pan the canvas | H |
|
|
74
|
+
| Rectangle | Draw rectangles | R |
|
|
75
|
+
| Ellipse | Draw ellipses | O |
|
|
76
|
+
| Line | Draw lines | L |
|
|
77
|
+
| Text | Place text elements | T |
|
|
78
|
+
| Frame | Draw frame containers | F |
|
|
79
|
+
|
|
80
|
+
### Panels
|
|
81
|
+
|
|
82
|
+
See the [composing panels](./composing-panels.md) skill for full details. In summary:
|
|
83
|
+
|
|
84
|
+
- **Left panel** — layers list (render order, visibility toggles, selection)
|
|
85
|
+
- **Right panel** — properties inspector (transform, fill, stroke, opacity, type-specific properties)
|
|
86
|
+
|
|
87
|
+
### Interactions
|
|
88
|
+
|
|
89
|
+
See the [wire interactions](./wire-interactions.md) skill for the full state machine. Key patterns:
|
|
90
|
+
|
|
91
|
+
- Click to select, shift-click to multi-select
|
|
92
|
+
- Drag on empty space with select tool → marquee selection
|
|
93
|
+
- Drag on selected element → move
|
|
94
|
+
- Drag on resize handle → resize
|
|
95
|
+
- Drag with shape tool → create new element
|
|
96
|
+
- Middle-click or hand tool → pan
|
|
97
|
+
|
|
98
|
+
## Extending the canvas
|
|
99
|
+
|
|
100
|
+
### Custom elements
|
|
101
|
+
|
|
102
|
+
To add a new element type (e.g. star, arrow, image), follow the [create custom tool](./create-custom-tool.md) skill. The pipeline is: type definition → factory → renderer → hit-test → interaction → shortcut → cursor → toolbar.
|
|
103
|
+
|
|
104
|
+
### Custom rendering
|
|
105
|
+
|
|
106
|
+
For specialised visuals (gradients, patterns, blend modes, filters), modify the renderer functions in `canvas/renderer.ts`. Each element type has its own render case — add drawing logic there.
|
|
107
|
+
|
|
108
|
+
### Canvas overlays
|
|
109
|
+
|
|
110
|
+
For UI elements that sit on top of the canvas (selection indicators, context menus, floating labels), render them as absolutely positioned DOM elements rather than drawing them on the canvas. Convert world positions to screen positions for placement.
|
|
111
|
+
|
|
112
|
+
## Theming
|
|
113
|
+
|
|
114
|
+
The canvas background is driven by the `--canvas` CSS variable, read at render time via `getComputedStyle`. Grid dots use `--canvas-foreground`. See the [theming](./theming.md) skill for the full token table.
|
|
115
|
+
|
|
116
|
+
## What to build
|
|
117
|
+
|
|
118
|
+
Some examples of what this surface enables:
|
|
119
|
+
|
|
120
|
+
- **Design tool** — shape creation, styling, alignment, export (the default Substrate experience)
|
|
121
|
+
- **Diagram editor** — nodes and connectors with auto-routing, swimlanes, labels
|
|
122
|
+
- **Whiteboard** — freeform drawing, sticky notes, image placement, real-time collaboration
|
|
123
|
+
- **Map builder** — tile-based or freeform map editor for games or floor plans
|
|
124
|
+
- **Node graph** — visual programming interface with typed ports and connections
|
|
125
|
+
- **Wireframe tool** — UI component blocks with snap-to-grid and responsive breakpoints
|
|
126
|
+
|
|
127
|
+
## Related reference skills
|
|
128
|
+
|
|
129
|
+
- **substrate-canvas** — full catalogue of spatial hooks (useViewport, useSelection, useDragReorder) and utilities (snapToGrid, boundingBox, math)
|
|
130
|
+
- **substrate-scaffold** — Toolbar, Panel, StatusBar, ZoomControls for the app shell
|
|
131
|
+
- **substrate-controls** — NumberInput, Slider, ColourPicker, ActionControls for property panes
|
|
132
|
+
- **substrate-interaction** — useUndoable, useClipboard, createKeyboardShortcuts, LayerList
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Composing panels
|
|
2
|
+
|
|
3
|
+
Guide to building property panels, layer lists, and inspector UIs using Substrate's panel component system.
|
|
4
|
+
|
|
5
|
+
## Component hierarchy
|
|
6
|
+
|
|
7
|
+
Substrate's panel system is a composition of four components:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Panel (side panel shell — left or right)
|
|
11
|
+
└── Pane (collapsible content section)
|
|
12
|
+
├── PaneHeader
|
|
13
|
+
│ ├── PaneLabel (section title)
|
|
14
|
+
│ └── PaneAction (header button)
|
|
15
|
+
└── Action (form field row)
|
|
16
|
+
├── ActionLabel
|
|
17
|
+
└── ActionControls
|
|
18
|
+
└── Slider, input, colour picker, etc.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx shadcn@latest add https://substrate.georgedrury.co.uk/r/panel
|
|
25
|
+
npx shadcn@latest add https://substrate.georgedrury.co.uk/r/pane
|
|
26
|
+
npx shadcn@latest add https://substrate.georgedrury.co.uk/r/action
|
|
27
|
+
npx shadcn@latest add https://substrate.georgedrury.co.uk/r/slider
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Panel positioning
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { Panel } from "@/registry/substrate/components/panel"
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
/* Left panel — layers, tool options */
|
|
37
|
+
}
|
|
38
|
+
;<Panel position="left">{/* Panes go here */}</Panel>
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
/* Right panel — properties inspector */
|
|
42
|
+
}
|
|
43
|
+
;<Panel position="right">{/* Panes go here */}</Panel>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Panels are absolutely positioned and sized via CSS. They sit alongside the canvas in the playground layout.
|
|
47
|
+
|
|
48
|
+
## Building a properties inspector
|
|
49
|
+
|
|
50
|
+
A typical properties panel reads from `useDocumentStore` and writes back via `updateElement`:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
"use client"
|
|
54
|
+
|
|
55
|
+
import { useDocumentStore } from "@/registry/substrate/stores/document-store"
|
|
56
|
+
import {
|
|
57
|
+
Pane,
|
|
58
|
+
PaneHeader,
|
|
59
|
+
PaneLabel,
|
|
60
|
+
PaneAction,
|
|
61
|
+
} from "@/registry/substrate/components/pane"
|
|
62
|
+
import {
|
|
63
|
+
Action,
|
|
64
|
+
ActionLabel,
|
|
65
|
+
ActionControls,
|
|
66
|
+
} from "@/registry/substrate/components/action"
|
|
67
|
+
import { Slider } from "@/registry/substrate/components/slider"
|
|
68
|
+
|
|
69
|
+
export function PropertiesPanel() {
|
|
70
|
+
const selectedIds = useDocumentStore((s) => s.selectedIds)
|
|
71
|
+
const elements = useDocumentStore((s) => s.elements)
|
|
72
|
+
const updateElement = useDocumentStore((s) => s.updateElement)
|
|
73
|
+
const pushSnapshot = useDocumentStore((s) => s.pushSnapshot)
|
|
74
|
+
|
|
75
|
+
// Single selection only for properties
|
|
76
|
+
if (selectedIds.size !== 1) return null
|
|
77
|
+
const element = elements[[...selectedIds][0]]
|
|
78
|
+
if (!element) return null
|
|
79
|
+
|
|
80
|
+
const handleChange = (changes: Record<string, unknown>) => {
|
|
81
|
+
pushSnapshot()
|
|
82
|
+
updateElement(element.id, changes)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
{/* Position and size */}
|
|
88
|
+
<Pane>
|
|
89
|
+
<PaneHeader>
|
|
90
|
+
<PaneLabel>Transform</PaneLabel>
|
|
91
|
+
</PaneHeader>
|
|
92
|
+
<Action>
|
|
93
|
+
<ActionLabel>X</ActionLabel>
|
|
94
|
+
<ActionControls>
|
|
95
|
+
<input
|
|
96
|
+
type="number"
|
|
97
|
+
value={Math.round(element.x)}
|
|
98
|
+
onChange={(e) => handleChange({ x: Number(e.target.value) })}
|
|
99
|
+
className="w-full bg-transparent text-xs tabular-nums"
|
|
100
|
+
/>
|
|
101
|
+
</ActionControls>
|
|
102
|
+
</Action>
|
|
103
|
+
<Action>
|
|
104
|
+
<ActionLabel>Y</ActionLabel>
|
|
105
|
+
<ActionControls>
|
|
106
|
+
<input
|
|
107
|
+
type="number"
|
|
108
|
+
value={Math.round(element.y)}
|
|
109
|
+
onChange={(e) => handleChange({ y: Number(e.target.value) })}
|
|
110
|
+
className="w-full bg-transparent text-xs tabular-nums"
|
|
111
|
+
/>
|
|
112
|
+
</ActionControls>
|
|
113
|
+
</Action>
|
|
114
|
+
<Action>
|
|
115
|
+
<ActionLabel>W</ActionLabel>
|
|
116
|
+
<ActionControls>
|
|
117
|
+
<input
|
|
118
|
+
type="number"
|
|
119
|
+
value={Math.round(element.width)}
|
|
120
|
+
onChange={(e) => handleChange({ width: Number(e.target.value) })}
|
|
121
|
+
className="w-full bg-transparent text-xs tabular-nums"
|
|
122
|
+
/>
|
|
123
|
+
</ActionControls>
|
|
124
|
+
</Action>
|
|
125
|
+
<Action>
|
|
126
|
+
<ActionLabel>H</ActionLabel>
|
|
127
|
+
<ActionControls>
|
|
128
|
+
<input
|
|
129
|
+
type="number"
|
|
130
|
+
value={Math.round(element.height)}
|
|
131
|
+
onChange={(e) => handleChange({ height: Number(e.target.value) })}
|
|
132
|
+
className="w-full bg-transparent text-xs tabular-nums"
|
|
133
|
+
/>
|
|
134
|
+
</ActionControls>
|
|
135
|
+
</Action>
|
|
136
|
+
</Pane>
|
|
137
|
+
|
|
138
|
+
{/* Opacity */}
|
|
139
|
+
<Pane>
|
|
140
|
+
<PaneHeader>
|
|
141
|
+
<PaneLabel>Opacity</PaneLabel>
|
|
142
|
+
</PaneHeader>
|
|
143
|
+
<Action>
|
|
144
|
+
<ActionLabel>{Math.round(element.opacity * 100)}%</ActionLabel>
|
|
145
|
+
<ActionControls>
|
|
146
|
+
<Slider
|
|
147
|
+
value={[element.opacity * 100]}
|
|
148
|
+
onValueChange={([v]) => handleChange({ opacity: v / 100 })}
|
|
149
|
+
max={100}
|
|
150
|
+
step={1}
|
|
151
|
+
/>
|
|
152
|
+
</ActionControls>
|
|
153
|
+
</Action>
|
|
154
|
+
</Pane>
|
|
155
|
+
</>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Building a layers panel
|
|
161
|
+
|
|
162
|
+
A layers panel lists elements in render order (bottom to top) and lets users select, reorder, and toggle visibility:
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
"use client"
|
|
166
|
+
|
|
167
|
+
import { useDocumentStore } from "@/registry/substrate/stores/document-store"
|
|
168
|
+
import {
|
|
169
|
+
Pane,
|
|
170
|
+
PaneHeader,
|
|
171
|
+
PaneLabel,
|
|
172
|
+
} from "@/registry/substrate/components/pane"
|
|
173
|
+
|
|
174
|
+
export function LayersPanel() {
|
|
175
|
+
const elements = useDocumentStore((s) => s.elements)
|
|
176
|
+
const elementOrder = useDocumentStore((s) => s.elementOrder)
|
|
177
|
+
const selectedIds = useDocumentStore((s) => s.selectedIds)
|
|
178
|
+
const select = useDocumentStore((s) => s.select)
|
|
179
|
+
const updateElement = useDocumentStore((s) => s.updateElement)
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Pane>
|
|
183
|
+
<PaneHeader>
|
|
184
|
+
<PaneLabel>Layers</PaneLabel>
|
|
185
|
+
</PaneHeader>
|
|
186
|
+
<div className="flex flex-col-reverse">
|
|
187
|
+
{elementOrder.map((id) => {
|
|
188
|
+
const el = elements[id]
|
|
189
|
+
if (!el) return null
|
|
190
|
+
return (
|
|
191
|
+
<button
|
|
192
|
+
key={id}
|
|
193
|
+
onClick={(e) => select([id], e.shiftKey)}
|
|
194
|
+
data-selected={selectedIds.has(id) || undefined}
|
|
195
|
+
className="flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-accent data-[selected]:bg-accent"
|
|
196
|
+
>
|
|
197
|
+
<span className="truncate">{el.name}</span>
|
|
198
|
+
<button
|
|
199
|
+
onClick={(e) => {
|
|
200
|
+
e.stopPropagation()
|
|
201
|
+
updateElement(id, { visible: !el.visible })
|
|
202
|
+
}}
|
|
203
|
+
className="ml-auto opacity-50 hover:opacity-100"
|
|
204
|
+
>
|
|
205
|
+
{el.visible ? "👁" : "—"}
|
|
206
|
+
</button>
|
|
207
|
+
</button>
|
|
208
|
+
)
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
</Pane>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Wiring panels to the store
|
|
217
|
+
|
|
218
|
+
Use `usePanelStore` to control panel visibility:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { usePanelStore } from "@/registry/substrate/stores/panel-store"
|
|
222
|
+
|
|
223
|
+
const leftPanel = usePanelStore((s) => s.leftPanel)
|
|
224
|
+
const rightPanel = usePanelStore((s) => s.rightPanel)
|
|
225
|
+
const toggleLeft = usePanelStore((s) => s.toggleLeft)
|
|
226
|
+
const toggleRight = usePanelStore((s) => s.toggleRight)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Then conditionally render:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
{
|
|
233
|
+
leftPanel && (
|
|
234
|
+
<Panel position="left">
|
|
235
|
+
<LayersPanel />
|
|
236
|
+
</Panel>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
rightPanel && (
|
|
242
|
+
<Panel position="right">
|
|
243
|
+
<PropertiesPanel />
|
|
244
|
+
</Panel>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Creating custom panes
|
|
250
|
+
|
|
251
|
+
Follow the compound component pattern:
|
|
252
|
+
|
|
253
|
+
1. **Wrap in `<Pane>`** for consistent spacing
|
|
254
|
+
2. **Use `<PaneHeader>` + `<PaneLabel>`** for the section title
|
|
255
|
+
3. **Use `<PaneAction>`** for header buttons (add, remove, toggle)
|
|
256
|
+
4. **Use `<Action>` + `<ActionLabel>` + `<ActionControls>`** for each property row
|
|
257
|
+
5. **Read from `useDocumentStore`** with granular selectors
|
|
258
|
+
6. **Call `pushSnapshot()` before mutations** to enable undo/redo
|
|
259
|
+
7. **Use `updateElement(id, changes)`** to write back
|
|
260
|
+
|
|
261
|
+
## Patterns
|
|
262
|
+
|
|
263
|
+
### Conditional panes by element type
|
|
264
|
+
|
|
265
|
+
Show different properties based on the selected element's type:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
{
|
|
269
|
+
element.type === "rectangle" && (
|
|
270
|
+
<Pane>
|
|
271
|
+
<PaneHeader>
|
|
272
|
+
<PaneLabel>Corner radius</PaneLabel>
|
|
273
|
+
</PaneHeader>
|
|
274
|
+
<Action>
|
|
275
|
+
<ActionControls>
|
|
276
|
+
<Slider
|
|
277
|
+
value={[element.cornerRadius]}
|
|
278
|
+
onValueChange={([v]) => handleChange({ cornerRadius: v })}
|
|
279
|
+
max={100}
|
|
280
|
+
/>
|
|
281
|
+
</ActionControls>
|
|
282
|
+
</Action>
|
|
283
|
+
</Pane>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Multi-selection
|
|
289
|
+
|
|
290
|
+
When multiple elements are selected, show shared properties or a summary:
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
if (selectedIds.size > 1) {
|
|
294
|
+
return (
|
|
295
|
+
<Pane>
|
|
296
|
+
<PaneHeader>
|
|
297
|
+
<PaneLabel>{selectedIds.size} elements selected</PaneLabel>
|
|
298
|
+
</PaneHeader>
|
|
299
|
+
{/* Show shared opacity, alignment buttons, etc. */}
|
|
300
|
+
</Pane>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Related reference skills
|
|
306
|
+
|
|
307
|
+
- **substrate-scaffold** — full catalogue of Panel, Pane, CollapsiblePane, PaneHeader, and layout components
|
|
308
|
+
- **substrate-controls** — full catalogue of ActionControls, NumberInput, Slider, NumberSlider, Select, Toggle, ColourPicker, and the data-slot composition pattern
|
|
309
|
+
- **substrate-interaction** — HistoryControls (auto-positioned in PaneHeader), LayerList, TreeList
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Create a custom tool
|
|
2
|
+
|
|
3
|
+
End-to-end guide for adding a new canvas tool to Substrate — from type definition through to toolbar entry and keyboard shortcut.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A tool in Substrate touches these files:
|
|
8
|
+
|
|
9
|
+
1. **`types.ts`** — add to the `ToolType` union
|
|
10
|
+
2. **`lib/element-factory.ts`** — add a factory function (if the tool creates elements)
|
|
11
|
+
3. **`canvas/renderer.ts`** — add rendering logic
|
|
12
|
+
4. **`canvas/hit-test.ts`** — add hit-testing (if clickable)
|
|
13
|
+
5. **`hooks/use-canvas-interaction.ts`** — handle pointer events for the tool
|
|
14
|
+
6. **`hooks/use-keyboard-shortcuts.ts`** — bind a key
|
|
15
|
+
7. **`canvas/design-canvas.tsx`** — add cursor mapping
|
|
16
|
+
8. **Toolbar** — add a toggle in your page
|
|
17
|
+
|
|
18
|
+
## Step 1: Define the type
|
|
19
|
+
|
|
20
|
+
In `types.ts`, extend the `ToolType` union:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
export type ToolType =
|
|
24
|
+
| "select"
|
|
25
|
+
| "hand"
|
|
26
|
+
| "rectangle"
|
|
27
|
+
| "ellipse"
|
|
28
|
+
| "line"
|
|
29
|
+
| "text"
|
|
30
|
+
| "frame"
|
|
31
|
+
| "your-tool" // ← add here
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If the tool creates a new element type, also extend `ElementType` and create a corresponding interface:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export type ElementType = "rectangle" | "ellipse" | "text" | "line" | "frame" | "your-element"
|
|
38
|
+
|
|
39
|
+
export interface YourElement extends BaseElement {
|
|
40
|
+
type: "your-element"
|
|
41
|
+
// Custom properties
|
|
42
|
+
someProperty: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DesignElement =
|
|
46
|
+
| RectangleElement
|
|
47
|
+
| EllipseElement
|
|
48
|
+
| TextElement
|
|
49
|
+
| LineElement
|
|
50
|
+
| FrameElement
|
|
51
|
+
| YourElement
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Step 2: Element factory (if creating elements)
|
|
55
|
+
|
|
56
|
+
In `lib/element-factory.ts`, add a factory:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export function createYourElement(x: number, y: number, width: number, height: number): YourElement {
|
|
60
|
+
return {
|
|
61
|
+
id: generateId(),
|
|
62
|
+
type: "your-element",
|
|
63
|
+
name: "Your Element",
|
|
64
|
+
x, y, width, height,
|
|
65
|
+
rotation: 0,
|
|
66
|
+
opacity: 1,
|
|
67
|
+
fill: { type: "solid", color: "#6366f1", opacity: 1 },
|
|
68
|
+
stroke: null,
|
|
69
|
+
locked: false,
|
|
70
|
+
visible: true,
|
|
71
|
+
someProperty: 42,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Step 3: Renderer
|
|
77
|
+
|
|
78
|
+
In `canvas/renderer.ts`, add a rendering case. The renderer uses the Canvas 2D API directly:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
case "your-element": {
|
|
82
|
+
ctx.save()
|
|
83
|
+
// Transform, draw, restore
|
|
84
|
+
ctx.restore()
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Step 4: Hit-test (if clickable)
|
|
90
|
+
|
|
91
|
+
In `canvas/hit-test.ts`, add detection logic in the `hitTestElement` function:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
case "your-element":
|
|
95
|
+
return pointInBounds(localPoint, { x: 0, y: 0, width: el.width, height: el.height })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Step 5: Interaction hook
|
|
99
|
+
|
|
100
|
+
In `hooks/use-canvas-interaction.ts`, handle the tool in the pointer event state machine. The key areas are:
|
|
101
|
+
|
|
102
|
+
- **`onPointerDown`** — detect what was clicked, start the appropriate drag type
|
|
103
|
+
- **`onPointerMove`** — update drag state, preview creation
|
|
104
|
+
- **`onPointerUp`** — finalise the action (create element, commit move, etc.)
|
|
105
|
+
|
|
106
|
+
For a creation tool, the pattern matches the existing rectangle/ellipse flow — start a "create" drag on pointer down, preview during move, commit on pointer up.
|
|
107
|
+
|
|
108
|
+
## Step 6: Keyboard shortcut
|
|
109
|
+
|
|
110
|
+
In `hooks/use-keyboard-shortcuts.ts`, add a key binding in the switch statement:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
case "y": setTool("your-tool"); return
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Step 7: Cursor
|
|
117
|
+
|
|
118
|
+
In `canvas/design-canvas.tsx`, add to `CURSOR_MAP`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const CURSOR_MAP: Record<string, string> = {
|
|
122
|
+
// ...existing entries
|
|
123
|
+
"your-tool": "crosshair",
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Step 8: Toolbar entry
|
|
128
|
+
|
|
129
|
+
In your playground page, add a toggle:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
<ToolbarToggle value="your-tool" tooltip="Your Tool" shortcut="Y">
|
|
133
|
+
<YourIcon className="size-4" />
|
|
134
|
+
</ToolbarToggle>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Tools that don't create elements
|
|
138
|
+
|
|
139
|
+
Not every tool creates elements. For tools like a measurement tool, colour picker, or zoom tool:
|
|
140
|
+
|
|
141
|
+
- Skip steps 2 (factory) and 4 (hit-test for new element type)
|
|
142
|
+
- In the interaction hook, handle the tool's pointer events directly (e.g. measure distance between pointer down and up)
|
|
143
|
+
- Store any tool-specific state in a dedicated Zustand store if needed
|
|
144
|
+
|
|
145
|
+
## Testing your tool
|
|
146
|
+
|
|
147
|
+
1. Select the tool from the toolbar or press its shortcut key
|
|
148
|
+
2. Verify the cursor changes
|
|
149
|
+
3. Test the full interaction cycle: pointer down → move → up
|
|
150
|
+
4. Check undo/redo works correctly (ensure `pushSnapshot()` is called before mutations)
|
|
151
|
+
5. Verify the element renders and can be selected/moved/resized
|
|
152
|
+
|
|
153
|
+
## Related reference skills
|
|
154
|
+
|
|
155
|
+
- **substrate-canvas** — useViewport, snap, boundingBox, hit testing, coordinate maths
|
|
156
|
+
- **substrate-scaffold** — Toolbar (for adding your tool button), StatusBar
|
|
157
|
+
- **substrate-interaction** — createKeyboardShortcuts (for binding a shortcut key), useUndoable
|