@fieldnotes/core 0.34.0 → 0.36.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/README.md +656 -636
- package/dist/index.cjs +624 -113
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +624 -113
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,636 +1,656 @@
|
|
|
1
|
-
# @fieldnotes/core
|
|
2
|
-
|
|
3
|
-
A lightweight, framework-agnostic infinite canvas SDK for the web — with first-class support for embedding interactive HTML elements.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- **Infinite canvas** — pan, zoom, pinch-to-zoom
|
|
8
|
-
- **Freehand drawing** — pencil tool with stroke smoothing and pressure-sensitive width
|
|
9
|
-
- **Sticky notes** — editable text notes with customizable colors
|
|
10
|
-
- **Arrows** — curved bezier arrows with element binding
|
|
11
|
-
- **Shapes** — rectangles, ellipses with fill and stroke
|
|
12
|
-
- **Text** — standalone text elements with font size and alignment
|
|
13
|
-
- **Images** — drag & drop or programmatic placement (canvas-rendered for proper layer ordering)
|
|
14
|
-
- **HTML embedding** — add any DOM element as a fully interactive canvas citizen
|
|
15
|
-
- **Layers** — named layers with visibility, locking, and absolute ordering
|
|
16
|
-
- **Select & multi-select** — click, drag box, move, resize (layer-aware)
|
|
17
|
-
- **Undo / redo** — full history stack with configurable depth
|
|
18
|
-
- **State serialization** — export/import JSON snapshots with automatic migration
|
|
19
|
-
- **Grids** — square and hex grid overlays for D&D maps and alignment
|
|
20
|
-
- **Export** — PNG export with scale, padding, background, and element filter options
|
|
21
|
-
- **Performance instrumentation** — `getRenderStats()` and `logPerformance()` for frame timing
|
|
22
|
-
- **Touch & tablet** — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
|
|
23
|
-
- **Zero dependencies** — vanilla TypeScript, no framework required
|
|
24
|
-
- **Tree-shakeable** — ESM + CJS output
|
|
25
|
-
|
|
26
|
-
## Install
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
npm install @fieldnotes/core
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Quick Start
|
|
33
|
-
|
|
34
|
-
```typescript
|
|
35
|
-
import {
|
|
36
|
-
Viewport,
|
|
37
|
-
HandTool,
|
|
38
|
-
SelectTool,
|
|
39
|
-
PencilTool,
|
|
40
|
-
EraserTool,
|
|
41
|
-
ArrowTool,
|
|
42
|
-
NoteTool,
|
|
43
|
-
} from '@fieldnotes/core';
|
|
44
|
-
|
|
45
|
-
// Mount on any container element
|
|
46
|
-
const viewport = new Viewport(document.getElementById('canvas'), {
|
|
47
|
-
background: { pattern: 'dots', spacing: 24 },
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Register tools
|
|
51
|
-
viewport.toolManager.register(new HandTool());
|
|
52
|
-
viewport.toolManager.register(new SelectTool());
|
|
53
|
-
viewport.toolManager.register(new PencilTool({ color: '#1a1a1a', width: 2 }));
|
|
54
|
-
viewport.toolManager.register(new EraserTool());
|
|
55
|
-
viewport.toolManager.register(new ArrowTool({ color: '#1a1a1a', width: 2 }));
|
|
56
|
-
viewport.toolManager.register(new NoteTool());
|
|
57
|
-
|
|
58
|
-
// Activate a tool
|
|
59
|
-
viewport.setTool('select');
|
|
60
|
-
|
|
61
|
-
// Clean up when done
|
|
62
|
-
viewport.destroy();
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Your container element needs a defined size (width/height). The canvas fills its container.
|
|
66
|
-
|
|
67
|
-
## Embedding HTML Elements
|
|
68
|
-
|
|
69
|
-
The main differentiator — embed any DOM node as a fully interactive canvas element:
|
|
70
|
-
|
|
71
|
-
```typescript
|
|
72
|
-
const card = document.createElement('div');
|
|
73
|
-
card.innerHTML = '<h3>My Card</h3><button>Click me</button>';
|
|
74
|
-
|
|
75
|
-
// Buttons, inputs, links — everything works
|
|
76
|
-
card.querySelector('button').addEventListener('click', () => {
|
|
77
|
-
console.log('Clicked inside the canvas!');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const elementId = viewport.addHtmlElement(card, { x: 100, y: 200 }, { w: 250, h: 150 });
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
HTML elements pan, zoom, and resize with the canvas. They use a **two-mode interaction model**:
|
|
84
|
-
|
|
85
|
-
- **Default** — the element can be selected, dragged, and resized like any other element
|
|
86
|
-
- **Double-click** — enters interact mode, making buttons, inputs, and links work
|
|
87
|
-
- **Escape** or **click outside** — exits interact mode
|
|
88
|
-
|
|
89
|
-
You can also exit interact mode programmatically:
|
|
90
|
-
|
|
91
|
-
```typescript
|
|
92
|
-
viewport.stopInteracting();
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## Adding Images
|
|
96
|
-
|
|
97
|
-
```typescript
|
|
98
|
-
// Programmatic
|
|
99
|
-
viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
|
|
100
|
-
viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });
|
|
101
|
-
|
|
102
|
-
// Drag & drop is handled automatically — drop images onto the canvas
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
> **Important: Use URLs, not base64 data URLs.** Images are stored inline in the serialized state. A single base64-encoded photo can be 2-5MB, which will blow past the `localStorage` ~5MB quota and make JSON exports impractical. Upload images to your server or CDN and use the URL. For offline/local-first apps, store blobs in IndexedDB and reference them by URL.
|
|
106
|
-
|
|
107
|
-
## Grids
|
|
108
|
-
|
|
109
|
-
Add square or hex grid overlays — useful for D&D combat maps, alignment, or graph paper backgrounds. Grids always render on top of images and other layer elements.
|
|
110
|
-
|
|
111
|
-
```typescript
|
|
112
|
-
// Add a hex grid
|
|
113
|
-
viewport.addGrid({
|
|
114
|
-
gridType: 'hex',
|
|
115
|
-
hexOrientation: 'pointy', // 'pointy' | 'flat'
|
|
116
|
-
cellSize: 40,
|
|
117
|
-
strokeColor: '#cccccc',
|
|
118
|
-
strokeWidth: 1,
|
|
119
|
-
opacity: 0.5,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Update grid properties
|
|
123
|
-
viewport.updateGrid({ cellSize: 50, strokeColor: '#aaaaaa' });
|
|
124
|
-
|
|
125
|
-
// Remove grid
|
|
126
|
-
viewport.removeGrid();
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
## Image Export
|
|
130
|
-
|
|
131
|
-
Export the canvas as a PNG image:
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
const blob = await viewport.exportImage({
|
|
135
|
-
scale: 2, // pixel density (default 2)
|
|
136
|
-
padding: 20, // world-space padding around content (default 0)
|
|
137
|
-
background: '#fff', // fill color (default '#ffffff')
|
|
138
|
-
filter: (el) => el.type !== 'html', // optional per-element filter
|
|
139
|
-
});
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
HTML elements are excluded from image exports (DOM cannot be rasterized to canvas). Cross-origin images are handled automatically via CORS cache-busting.
|
|
143
|
-
|
|
144
|
-
## Performance Monitoring
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
// Get a snapshot of render stats
|
|
148
|
-
const stats = viewport.getRenderStats();
|
|
149
|
-
// { fps, avgFrameMs, p95FrameMs, lastGridMs, frameCount }
|
|
150
|
-
|
|
151
|
-
// Log stats to console every 2 seconds (returns stop function)
|
|
152
|
-
const stop = viewport.logPerformance(2000);
|
|
153
|
-
// [FieldNotes] fps=60 frame=1.2ms p95=2.1ms grid=0.1ms
|
|
154
|
-
stop(); // stop logging
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
## Camera Control
|
|
158
|
-
|
|
159
|
-
```typescript
|
|
160
|
-
const { camera } = viewport;
|
|
161
|
-
|
|
162
|
-
camera.pan(100, 50); // pan by offset
|
|
163
|
-
camera.moveTo(0, 0); // jump to position
|
|
164
|
-
camera.setZoom(2); // set zoom level
|
|
165
|
-
camera.zoomAt(1.5, { x: 400, y: 300 }); // zoom toward screen point
|
|
166
|
-
|
|
167
|
-
const world = camera.screenToWorld({ x: e.clientX, y: e.clientY });
|
|
168
|
-
const screen = camera.worldToScreen({ x: 0, y: 0 });
|
|
169
|
-
|
|
170
|
-
camera.onChange(() => {
|
|
171
|
-
/* camera moved */
|
|
172
|
-
});
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Element Store
|
|
176
|
-
|
|
177
|
-
Direct access to canvas elements:
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
const { store } = viewport;
|
|
181
|
-
|
|
182
|
-
const all = store.getAll(); // sorted by zIndex
|
|
183
|
-
const el = store.getById('some-id');
|
|
184
|
-
const strokes = store.getElementsByType('stroke');
|
|
185
|
-
|
|
186
|
-
store.update('some-id', { locked: true });
|
|
187
|
-
store.remove('some-id');
|
|
188
|
-
|
|
189
|
-
store.on('add', (el) => console.log('added', el));
|
|
190
|
-
store.on('remove', (el) => console.log('removed', el));
|
|
191
|
-
store.on('update', ({ previous, current }) => {
|
|
192
|
-
/* ... */
|
|
193
|
-
});
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
## Undo / Redo
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
viewport.undo();
|
|
200
|
-
viewport.redo();
|
|
201
|
-
|
|
202
|
-
viewport.history.canUndo; // boolean
|
|
203
|
-
viewport.history.canRedo; // boolean
|
|
204
|
-
viewport.history.onChange(() => {
|
|
205
|
-
/* update UI */
|
|
206
|
-
});
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
## Layers
|
|
210
|
-
|
|
211
|
-
Organize elements into named layers with visibility, lock, and ordering controls. All elements on a higher layer render above all elements on a lower layer, regardless of individual z-index.
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
const { layerManager } = viewport;
|
|
215
|
-
|
|
216
|
-
// Create layers
|
|
217
|
-
const background = layerManager.activeLayer; // "Layer 1" exists by default
|
|
218
|
-
layerManager.renameLayer(background.id, 'Map');
|
|
219
|
-
const tokens = layerManager.createLayer('Tokens');
|
|
220
|
-
const notes = layerManager.createLayer('Notes');
|
|
221
|
-
|
|
222
|
-
// Set active layer — new elements are created on the active layer
|
|
223
|
-
layerManager.setActiveLayer(tokens.id);
|
|
224
|
-
|
|
225
|
-
// Visibility and locking
|
|
226
|
-
layerManager.setLayerVisible(background.id, false); // hide
|
|
227
|
-
layerManager.setLayerLocked(background.id, true); // prevent selection/editing
|
|
228
|
-
|
|
229
|
-
// Move elements between layers
|
|
230
|
-
layerManager.moveElementToLayer(elementId, notes.id);
|
|
231
|
-
|
|
232
|
-
// Reorder layers
|
|
233
|
-
layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top
|
|
234
|
-
|
|
235
|
-
// Query
|
|
236
|
-
layerManager.getLayers(); // sorted by order
|
|
237
|
-
layerManager.isLayerVisible(id);
|
|
238
|
-
layerManager.isLayerLocked(id);
|
|
239
|
-
|
|
240
|
-
// Listen for changes
|
|
241
|
-
layerManager.on('change', () => {
|
|
242
|
-
/* update UI */
|
|
243
|
-
});
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
Locked layers prevent selection, erasing, and arrow binding on their elements. Hidden layers are invisible and non-interactive. The active layer cannot be hidden or locked — if you try, it automatically switches to the next available layer.
|
|
247
|
-
|
|
248
|
-
## State Serialization
|
|
249
|
-
|
|
250
|
-
```typescript
|
|
251
|
-
// Save
|
|
252
|
-
const json = viewport.exportJSON();
|
|
253
|
-
localStorage.setItem('canvas', json);
|
|
254
|
-
|
|
255
|
-
// Load
|
|
256
|
-
viewport.loadJSON(localStorage.getItem('canvas'));
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
> **Note:** Serialized state includes all layers and element `layerId` assignments. States saved before layers were introduced are automatically migrated — elements are placed on a default "Layer 1".
|
|
260
|
-
|
|
261
|
-
> **Two equivalent pairs:** `exportJSON()` / `loadJSON()` work with strings and are the
|
|
262
|
-
> canonical choice for persistence. `exportState()` / `loadState()` work with in-memory
|
|
263
|
-
> `CanvasState` objects, skipping the JSON round-trip — this is what `AutoSave` uses. The
|
|
264
|
-
> module-level `exportState` / `parseState` functions are no longer exported; use the
|
|
265
|
-
> `Viewport` methods.
|
|
266
|
-
|
|
267
|
-
## Tool Switching
|
|
268
|
-
|
|
269
|
-
```typescript
|
|
270
|
-
viewport.setTool('pencil');
|
|
271
|
-
viewport.setTool('hand');
|
|
272
|
-
|
|
273
|
-
viewport.toolManager.onChange((toolName) => {
|
|
274
|
-
console.log('switched to', toolName);
|
|
275
|
-
});
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
## Keyboard shortcuts
|
|
279
|
-
|
|
280
|
-
Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
|
|
281
|
-
`mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
|
|
282
|
-
`[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · `mod+=` zoom in ·
|
|
283
|
-
`mod+-` zoom out · `mod+0` reset zoom to 100% · arrows nudge
|
|
284
|
-
(`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
|
|
285
|
-
`A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
|
|
286
|
-
|
|
287
|
-
`mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
|
|
288
|
-
pass `shortcuts: { scope: 'window' }` for page-wide handling.
|
|
289
|
-
|
|
290
|
-
```ts
|
|
291
|
-
const viewport = new Viewport(el, {
|
|
292
|
-
shortcuts: {
|
|
293
|
-
bindings: {
|
|
294
|
-
duplicate: 'mod+shift+d', // remap
|
|
295
|
-
'tool:pencil': ['p', 'b'], // multiple bindings
|
|
296
|
-
copy: null, // disable
|
|
297
|
-
'tool:my-custom-tool': 'f', // any registered tool works
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
viewport.shortcuts.rebind('undo', 'mod+u');
|
|
303
|
-
viewport.shortcuts.disable('select-all');
|
|
304
|
-
viewport.shortcuts.reset(); // back to defaults
|
|
305
|
-
viewport.shortcuts.getBindings(); // current table — render a settings UI
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
## Changing Tool Options at Runtime
|
|
309
|
-
|
|
310
|
-
All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
|
|
311
|
-
|
|
312
|
-
```typescript
|
|
313
|
-
// Get a tool by name (type-safe with generics)
|
|
314
|
-
const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
|
|
315
|
-
const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
|
|
316
|
-
const note = viewport.toolManager.getTool<NoteTool>('note');
|
|
317
|
-
|
|
318
|
-
// Change colors
|
|
319
|
-
pencil?.setOptions({ color: '#ff0000' });
|
|
320
|
-
arrow?.setOptions({ color: '#ff0000' });
|
|
321
|
-
note?.setOptions({ backgroundColor: '#e8f5e9' });
|
|
322
|
-
|
|
323
|
-
// Change stroke width
|
|
324
|
-
pencil?.setOptions({ width: 5 });
|
|
325
|
-
arrow?.setOptions({ width: 3 });
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
### Stroke Smoothing
|
|
329
|
-
|
|
330
|
-
The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
|
|
331
|
-
|
|
332
|
-
```typescript
|
|
333
|
-
new PencilTool({
|
|
334
|
-
smoothing: 1.5, // default — higher = smoother, lower = more detail
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
// Or at runtime
|
|
338
|
-
pencil?.setOptions({ smoothing: 3 });
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
### Pressure-Sensitive Width
|
|
342
|
-
|
|
343
|
-
When using a stylus (Apple Pencil, Surface Pen), stroke width varies based on pressure automatically. The `width` option sets the **maximum** width at full pressure. Mouse input uses a default pressure of 0.5 for consistent-width strokes.
|
|
344
|
-
|
|
345
|
-
Stroke points include pressure data in the `StrokePoint` type:
|
|
346
|
-
|
|
347
|
-
```typescript
|
|
348
|
-
interface StrokePoint {
|
|
349
|
-
x: number;
|
|
350
|
-
y: number;
|
|
351
|
-
pressure: number; // 0-1
|
|
352
|
-
}
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
## Custom Tools
|
|
356
|
-
|
|
357
|
-
Implement the `Tool` interface to create your own tools:
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
|
|
361
|
-
|
|
362
|
-
const myTool: Tool = {
|
|
363
|
-
name: 'my-tool',
|
|
364
|
-
|
|
365
|
-
onPointerDown(state: PointerState, ctx: ToolContext) {
|
|
366
|
-
const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
|
|
367
|
-
// state.pressure is available for stylus input (0-1)
|
|
368
|
-
},
|
|
369
|
-
|
|
370
|
-
onPointerMove(state: PointerState, ctx: ToolContext) {
|
|
371
|
-
// called during drag
|
|
372
|
-
},
|
|
373
|
-
|
|
374
|
-
onPointerUp(state: PointerState, ctx: ToolContext) {
|
|
375
|
-
// finalize action
|
|
376
|
-
ctx.store.add(myElement);
|
|
377
|
-
ctx.requestRender();
|
|
378
|
-
},
|
|
379
|
-
|
|
380
|
-
// Optional
|
|
381
|
-
onActivate(ctx) {
|
|
382
|
-
ctx.setCursor?.('crosshair');
|
|
383
|
-
},
|
|
384
|
-
onDeactivate(ctx) {
|
|
385
|
-
ctx.setCursor?.('default');
|
|
386
|
-
},
|
|
387
|
-
renderOverlay(canvasCtx) {
|
|
388
|
-
/* draw preview on canvas */
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
viewport.toolManager.register(myTool);
|
|
393
|
-
viewport.setTool('my-tool');
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
## Configuration
|
|
397
|
-
|
|
398
|
-
### Viewport Options
|
|
399
|
-
|
|
400
|
-
```typescript
|
|
401
|
-
new Viewport(container, {
|
|
402
|
-
camera: {
|
|
403
|
-
minZoom: 0.1, // default: 0.1
|
|
404
|
-
maxZoom: 10, // default: 10
|
|
405
|
-
},
|
|
406
|
-
background: {
|
|
407
|
-
pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
|
|
408
|
-
spacing: 24, // grid spacing in px (default: 24)
|
|
409
|
-
color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
|
|
410
|
-
},
|
|
411
|
-
// Called for every drop; replaces the built-in image-drop handling
|
|
412
|
-
onDrop: (event, worldPosition) => {
|
|
413
|
-
/* handle drop */
|
|
414
|
-
},
|
|
415
|
-
// Called when an image element fails to load; failed images render a gray
|
|
416
|
-
// placeholder. Falls back to console.warn when unset.
|
|
417
|
-
onImageError: ({ src, elementIds }) => {
|
|
418
|
-
/* handle broken image */
|
|
419
|
-
},
|
|
420
|
-
});
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
### ViewportOptions reference
|
|
424
|
-
|
|
425
|
-
- `camera?: CameraOptions` — `minZoom` / `maxZoom` (defaults `0.1` / `10`).
|
|
426
|
-
- `background?: BackgroundOptions` — `pattern`, `spacing`, `color`.
|
|
427
|
-
- `fontSizePresets?: FontSizePreset[]` — custom font-size steps for the note toolbar.
|
|
428
|
-
- `toolbar?: boolean` — show/hide the note formatting toolbar (default `true`).
|
|
429
|
-
- `placeholder?: string` — placeholder text shown in empty notes.
|
|
430
|
-
- `shortcuts?: ShortcutOptions` — seed the keyboard shortcut table with custom bindings.
|
|
431
|
-
- `onHtmlElementMount?` — called after `loadState` for HTML elements that need content injected.
|
|
432
|
-
- `onDrop?` — called for every drop event; replaces the built-in image-drop handling.
|
|
433
|
-
- `onImageError?` — called when an image element fails to load.
|
|
434
|
-
- `panBufferMargin?: number` (default `256`) — CSS-pixel margin cached beyond the viewport so
|
|
435
|
-
small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan
|
|
436
|
-
reuse, more memory per layer. Set `0` to disable (exact-viewport caches) on memory-tight hosts.
|
|
437
|
-
|
|
438
|
-
### Tool Options
|
|
439
|
-
|
|
440
|
-
```typescript
|
|
441
|
-
new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
|
|
442
|
-
new EraserTool({ radius: 30 }); // radius is screen pixels (converted to world units per zoom); mode: 'partial' (default) splits strokes at the erased span; mode: 'stroke' deletes the whole stroke
|
|
443
|
-
new ArrowTool({ color: '#333', width: 2 });
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
`PencilTool` also accepts `opacity` (0–1), `blendMode` (`'source-over'` | `'multiply'`), and `name` — so a highlighter tool is just a named pencil variant with multiply blending:
|
|
447
|
-
|
|
448
|
-
```typescript
|
|
449
|
-
// Register a highlighter alongside the standard pencil
|
|
450
|
-
viewport.toolManager.register(
|
|
451
|
-
new PencilTool({
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
|
490
|
-
|
|
|
491
|
-
| `
|
|
492
|
-
| `
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
| `
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
viewport.alignSelection('middle'
|
|
577
|
-
viewport.distributeSelection('horizontal'
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
viewport.groupSelection()
|
|
602
|
-
viewport.ungroupSelection()
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
##
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1
|
+
# @fieldnotes/core
|
|
2
|
+
|
|
3
|
+
A lightweight, framework-agnostic infinite canvas SDK for the web — with first-class support for embedding interactive HTML elements.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Infinite canvas** — pan, zoom, pinch-to-zoom
|
|
8
|
+
- **Freehand drawing** — pencil tool with stroke smoothing and pressure-sensitive width
|
|
9
|
+
- **Sticky notes** — editable text notes with customizable colors
|
|
10
|
+
- **Arrows** — curved bezier arrows with element binding
|
|
11
|
+
- **Shapes** — rectangles, ellipses with fill and stroke
|
|
12
|
+
- **Text** — standalone text elements with font size and alignment
|
|
13
|
+
- **Images** — drag & drop or programmatic placement (canvas-rendered for proper layer ordering)
|
|
14
|
+
- **HTML embedding** — add any DOM element as a fully interactive canvas citizen
|
|
15
|
+
- **Layers** — named layers with visibility, locking, and absolute ordering
|
|
16
|
+
- **Select & multi-select** — click, drag box, move, resize (layer-aware)
|
|
17
|
+
- **Undo / redo** — full history stack with configurable depth
|
|
18
|
+
- **State serialization** — export/import JSON snapshots with automatic migration
|
|
19
|
+
- **Grids** — square and hex grid overlays for D&D maps and alignment
|
|
20
|
+
- **Export** — PNG export with scale, padding, background, and element filter options
|
|
21
|
+
- **Performance instrumentation** — `getRenderStats()` and `logPerformance()` for frame timing
|
|
22
|
+
- **Touch & tablet** — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
|
|
23
|
+
- **Zero dependencies** — vanilla TypeScript, no framework required
|
|
24
|
+
- **Tree-shakeable** — ESM + CJS output
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @fieldnotes/core
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import {
|
|
36
|
+
Viewport,
|
|
37
|
+
HandTool,
|
|
38
|
+
SelectTool,
|
|
39
|
+
PencilTool,
|
|
40
|
+
EraserTool,
|
|
41
|
+
ArrowTool,
|
|
42
|
+
NoteTool,
|
|
43
|
+
} from '@fieldnotes/core';
|
|
44
|
+
|
|
45
|
+
// Mount on any container element
|
|
46
|
+
const viewport = new Viewport(document.getElementById('canvas'), {
|
|
47
|
+
background: { pattern: 'dots', spacing: 24 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Register tools
|
|
51
|
+
viewport.toolManager.register(new HandTool());
|
|
52
|
+
viewport.toolManager.register(new SelectTool());
|
|
53
|
+
viewport.toolManager.register(new PencilTool({ color: '#1a1a1a', width: 2 }));
|
|
54
|
+
viewport.toolManager.register(new EraserTool());
|
|
55
|
+
viewport.toolManager.register(new ArrowTool({ color: '#1a1a1a', width: 2 }));
|
|
56
|
+
viewport.toolManager.register(new NoteTool());
|
|
57
|
+
|
|
58
|
+
// Activate a tool
|
|
59
|
+
viewport.setTool('select');
|
|
60
|
+
|
|
61
|
+
// Clean up when done
|
|
62
|
+
viewport.destroy();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Your container element needs a defined size (width/height). The canvas fills its container.
|
|
66
|
+
|
|
67
|
+
## Embedding HTML Elements
|
|
68
|
+
|
|
69
|
+
The main differentiator — embed any DOM node as a fully interactive canvas element:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const card = document.createElement('div');
|
|
73
|
+
card.innerHTML = '<h3>My Card</h3><button>Click me</button>';
|
|
74
|
+
|
|
75
|
+
// Buttons, inputs, links — everything works
|
|
76
|
+
card.querySelector('button').addEventListener('click', () => {
|
|
77
|
+
console.log('Clicked inside the canvas!');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const elementId = viewport.addHtmlElement(card, { x: 100, y: 200 }, { w: 250, h: 150 });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
HTML elements pan, zoom, and resize with the canvas. They use a **two-mode interaction model**:
|
|
84
|
+
|
|
85
|
+
- **Default** — the element can be selected, dragged, and resized like any other element
|
|
86
|
+
- **Double-click** — enters interact mode, making buttons, inputs, and links work
|
|
87
|
+
- **Escape** or **click outside** — exits interact mode
|
|
88
|
+
|
|
89
|
+
You can also exit interact mode programmatically:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
viewport.stopInteracting();
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Adding Images
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Programmatic
|
|
99
|
+
viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
|
|
100
|
+
viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });
|
|
101
|
+
|
|
102
|
+
// Drag & drop is handled automatically — drop images onto the canvas
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> **Important: Use URLs, not base64 data URLs.** Images are stored inline in the serialized state. A single base64-encoded photo can be 2-5MB, which will blow past the `localStorage` ~5MB quota and make JSON exports impractical. Upload images to your server or CDN and use the URL. For offline/local-first apps, store blobs in IndexedDB and reference them by URL.
|
|
106
|
+
|
|
107
|
+
## Grids
|
|
108
|
+
|
|
109
|
+
Add square or hex grid overlays — useful for D&D combat maps, alignment, or graph paper backgrounds. Grids always render on top of images and other layer elements.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// Add a hex grid
|
|
113
|
+
viewport.addGrid({
|
|
114
|
+
gridType: 'hex',
|
|
115
|
+
hexOrientation: 'pointy', // 'pointy' | 'flat'
|
|
116
|
+
cellSize: 40,
|
|
117
|
+
strokeColor: '#cccccc',
|
|
118
|
+
strokeWidth: 1,
|
|
119
|
+
opacity: 0.5,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Update grid properties
|
|
123
|
+
viewport.updateGrid({ cellSize: 50, strokeColor: '#aaaaaa' });
|
|
124
|
+
|
|
125
|
+
// Remove grid
|
|
126
|
+
viewport.removeGrid();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Image Export
|
|
130
|
+
|
|
131
|
+
Export the canvas as a PNG image:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const blob = await viewport.exportImage({
|
|
135
|
+
scale: 2, // pixel density (default 2)
|
|
136
|
+
padding: 20, // world-space padding around content (default 0)
|
|
137
|
+
background: '#fff', // fill color (default '#ffffff')
|
|
138
|
+
filter: (el) => el.type !== 'html', // optional per-element filter
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
HTML elements are excluded from image exports (DOM cannot be rasterized to canvas). Cross-origin images are handled automatically via CORS cache-busting.
|
|
143
|
+
|
|
144
|
+
## Performance Monitoring
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// Get a snapshot of render stats
|
|
148
|
+
const stats = viewport.getRenderStats();
|
|
149
|
+
// { fps, avgFrameMs, p95FrameMs, lastGridMs, frameCount }
|
|
150
|
+
|
|
151
|
+
// Log stats to console every 2 seconds (returns stop function)
|
|
152
|
+
const stop = viewport.logPerformance(2000);
|
|
153
|
+
// [FieldNotes] fps=60 frame=1.2ms p95=2.1ms grid=0.1ms
|
|
154
|
+
stop(); // stop logging
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Camera Control
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
const { camera } = viewport;
|
|
161
|
+
|
|
162
|
+
camera.pan(100, 50); // pan by offset
|
|
163
|
+
camera.moveTo(0, 0); // jump to position
|
|
164
|
+
camera.setZoom(2); // set zoom level
|
|
165
|
+
camera.zoomAt(1.5, { x: 400, y: 300 }); // zoom toward screen point
|
|
166
|
+
|
|
167
|
+
const world = camera.screenToWorld({ x: e.clientX, y: e.clientY });
|
|
168
|
+
const screen = camera.worldToScreen({ x: 0, y: 0 });
|
|
169
|
+
|
|
170
|
+
camera.onChange(() => {
|
|
171
|
+
/* camera moved */
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Element Store
|
|
176
|
+
|
|
177
|
+
Direct access to canvas elements:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const { store } = viewport;
|
|
181
|
+
|
|
182
|
+
const all = store.getAll(); // sorted by zIndex
|
|
183
|
+
const el = store.getById('some-id');
|
|
184
|
+
const strokes = store.getElementsByType('stroke');
|
|
185
|
+
|
|
186
|
+
store.update('some-id', { locked: true });
|
|
187
|
+
store.remove('some-id');
|
|
188
|
+
|
|
189
|
+
store.on('add', (el) => console.log('added', el));
|
|
190
|
+
store.on('remove', (el) => console.log('removed', el));
|
|
191
|
+
store.on('update', ({ previous, current }) => {
|
|
192
|
+
/* ... */
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Undo / Redo
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
viewport.undo();
|
|
200
|
+
viewport.redo();
|
|
201
|
+
|
|
202
|
+
viewport.history.canUndo; // boolean
|
|
203
|
+
viewport.history.canRedo; // boolean
|
|
204
|
+
viewport.history.onChange(() => {
|
|
205
|
+
/* update UI */
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Layers
|
|
210
|
+
|
|
211
|
+
Organize elements into named layers with visibility, lock, and ordering controls. All elements on a higher layer render above all elements on a lower layer, regardless of individual z-index.
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const { layerManager } = viewport;
|
|
215
|
+
|
|
216
|
+
// Create layers
|
|
217
|
+
const background = layerManager.activeLayer; // "Layer 1" exists by default
|
|
218
|
+
layerManager.renameLayer(background.id, 'Map');
|
|
219
|
+
const tokens = layerManager.createLayer('Tokens');
|
|
220
|
+
const notes = layerManager.createLayer('Notes');
|
|
221
|
+
|
|
222
|
+
// Set active layer — new elements are created on the active layer
|
|
223
|
+
layerManager.setActiveLayer(tokens.id);
|
|
224
|
+
|
|
225
|
+
// Visibility and locking
|
|
226
|
+
layerManager.setLayerVisible(background.id, false); // hide
|
|
227
|
+
layerManager.setLayerLocked(background.id, true); // prevent selection/editing
|
|
228
|
+
|
|
229
|
+
// Move elements between layers
|
|
230
|
+
layerManager.moveElementToLayer(elementId, notes.id);
|
|
231
|
+
|
|
232
|
+
// Reorder layers
|
|
233
|
+
layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top
|
|
234
|
+
|
|
235
|
+
// Query
|
|
236
|
+
layerManager.getLayers(); // sorted by order
|
|
237
|
+
layerManager.isLayerVisible(id);
|
|
238
|
+
layerManager.isLayerLocked(id);
|
|
239
|
+
|
|
240
|
+
// Listen for changes
|
|
241
|
+
layerManager.on('change', () => {
|
|
242
|
+
/* update UI */
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Locked layers prevent selection, erasing, and arrow binding on their elements. Hidden layers are invisible and non-interactive. The active layer cannot be hidden or locked — if you try, it automatically switches to the next available layer.
|
|
247
|
+
|
|
248
|
+
## State Serialization
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// Save
|
|
252
|
+
const json = viewport.exportJSON();
|
|
253
|
+
localStorage.setItem('canvas', json);
|
|
254
|
+
|
|
255
|
+
// Load
|
|
256
|
+
viewport.loadJSON(localStorage.getItem('canvas'));
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
> **Note:** Serialized state includes all layers and element `layerId` assignments. States saved before layers were introduced are automatically migrated — elements are placed on a default "Layer 1".
|
|
260
|
+
|
|
261
|
+
> **Two equivalent pairs:** `exportJSON()` / `loadJSON()` work with strings and are the
|
|
262
|
+
> canonical choice for persistence. `exportState()` / `loadState()` work with in-memory
|
|
263
|
+
> `CanvasState` objects, skipping the JSON round-trip — this is what `AutoSave` uses. The
|
|
264
|
+
> module-level `exportState` / `parseState` functions are no longer exported; use the
|
|
265
|
+
> `Viewport` methods.
|
|
266
|
+
|
|
267
|
+
## Tool Switching
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
viewport.setTool('pencil');
|
|
271
|
+
viewport.setTool('hand');
|
|
272
|
+
|
|
273
|
+
viewport.toolManager.onChange((toolName) => {
|
|
274
|
+
console.log('switched to', toolName);
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Keyboard shortcuts
|
|
279
|
+
|
|
280
|
+
Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
|
|
281
|
+
`mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
|
|
282
|
+
`[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · `mod+=` zoom in ·
|
|
283
|
+
`mod+-` zoom out · `mod+0` reset zoom to 100% · arrows nudge
|
|
284
|
+
(`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
|
|
285
|
+
`A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
|
|
286
|
+
|
|
287
|
+
`mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
|
|
288
|
+
pass `shortcuts: { scope: 'window' }` for page-wide handling.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const viewport = new Viewport(el, {
|
|
292
|
+
shortcuts: {
|
|
293
|
+
bindings: {
|
|
294
|
+
duplicate: 'mod+shift+d', // remap
|
|
295
|
+
'tool:pencil': ['p', 'b'], // multiple bindings
|
|
296
|
+
copy: null, // disable
|
|
297
|
+
'tool:my-custom-tool': 'f', // any registered tool works
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
viewport.shortcuts.rebind('undo', 'mod+u');
|
|
303
|
+
viewport.shortcuts.disable('select-all');
|
|
304
|
+
viewport.shortcuts.reset(); // back to defaults
|
|
305
|
+
viewport.shortcuts.getBindings(); // current table — render a settings UI
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Changing Tool Options at Runtime
|
|
309
|
+
|
|
310
|
+
All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// Get a tool by name (type-safe with generics)
|
|
314
|
+
const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
|
|
315
|
+
const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
|
|
316
|
+
const note = viewport.toolManager.getTool<NoteTool>('note');
|
|
317
|
+
|
|
318
|
+
// Change colors
|
|
319
|
+
pencil?.setOptions({ color: '#ff0000' });
|
|
320
|
+
arrow?.setOptions({ color: '#ff0000' });
|
|
321
|
+
note?.setOptions({ backgroundColor: '#e8f5e9' });
|
|
322
|
+
|
|
323
|
+
// Change stroke width
|
|
324
|
+
pencil?.setOptions({ width: 5 });
|
|
325
|
+
arrow?.setOptions({ width: 3 });
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Stroke Smoothing
|
|
329
|
+
|
|
330
|
+
The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
new PencilTool({
|
|
334
|
+
smoothing: 1.5, // default — higher = smoother, lower = more detail
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Or at runtime
|
|
338
|
+
pencil?.setOptions({ smoothing: 3 });
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Pressure-Sensitive Width
|
|
342
|
+
|
|
343
|
+
When using a stylus (Apple Pencil, Surface Pen), stroke width varies based on pressure automatically. The `width` option sets the **maximum** width at full pressure. Mouse input uses a default pressure of 0.5 for consistent-width strokes.
|
|
344
|
+
|
|
345
|
+
Stroke points include pressure data in the `StrokePoint` type:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
interface StrokePoint {
|
|
349
|
+
x: number;
|
|
350
|
+
y: number;
|
|
351
|
+
pressure: number; // 0-1
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Custom Tools
|
|
356
|
+
|
|
357
|
+
Implement the `Tool` interface to create your own tools:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
|
|
361
|
+
|
|
362
|
+
const myTool: Tool = {
|
|
363
|
+
name: 'my-tool',
|
|
364
|
+
|
|
365
|
+
onPointerDown(state: PointerState, ctx: ToolContext) {
|
|
366
|
+
const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
|
|
367
|
+
// state.pressure is available for stylus input (0-1)
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
onPointerMove(state: PointerState, ctx: ToolContext) {
|
|
371
|
+
// called during drag
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
onPointerUp(state: PointerState, ctx: ToolContext) {
|
|
375
|
+
// finalize action
|
|
376
|
+
ctx.store.add(myElement);
|
|
377
|
+
ctx.requestRender();
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
// Optional
|
|
381
|
+
onActivate(ctx) {
|
|
382
|
+
ctx.setCursor?.('crosshair');
|
|
383
|
+
},
|
|
384
|
+
onDeactivate(ctx) {
|
|
385
|
+
ctx.setCursor?.('default');
|
|
386
|
+
},
|
|
387
|
+
renderOverlay(canvasCtx) {
|
|
388
|
+
/* draw preview on canvas */
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
viewport.toolManager.register(myTool);
|
|
393
|
+
viewport.setTool('my-tool');
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Configuration
|
|
397
|
+
|
|
398
|
+
### Viewport Options
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
new Viewport(container, {
|
|
402
|
+
camera: {
|
|
403
|
+
minZoom: 0.1, // default: 0.1
|
|
404
|
+
maxZoom: 10, // default: 10
|
|
405
|
+
},
|
|
406
|
+
background: {
|
|
407
|
+
pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
|
|
408
|
+
spacing: 24, // grid spacing in px (default: 24)
|
|
409
|
+
color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
|
|
410
|
+
},
|
|
411
|
+
// Called for every drop; replaces the built-in image-drop handling
|
|
412
|
+
onDrop: (event, worldPosition) => {
|
|
413
|
+
/* handle drop */
|
|
414
|
+
},
|
|
415
|
+
// Called when an image element fails to load; failed images render a gray
|
|
416
|
+
// placeholder. Falls back to console.warn when unset.
|
|
417
|
+
onImageError: ({ src, elementIds }) => {
|
|
418
|
+
/* handle broken image */
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### ViewportOptions reference
|
|
424
|
+
|
|
425
|
+
- `camera?: CameraOptions` — `minZoom` / `maxZoom` (defaults `0.1` / `10`).
|
|
426
|
+
- `background?: BackgroundOptions` — `pattern`, `spacing`, `color`.
|
|
427
|
+
- `fontSizePresets?: FontSizePreset[]` — custom font-size steps for the note toolbar.
|
|
428
|
+
- `toolbar?: boolean` — show/hide the note formatting toolbar (default `true`).
|
|
429
|
+
- `placeholder?: string` — placeholder text shown in empty notes.
|
|
430
|
+
- `shortcuts?: ShortcutOptions` — seed the keyboard shortcut table with custom bindings.
|
|
431
|
+
- `onHtmlElementMount?` — called after `loadState` for HTML elements that need content injected.
|
|
432
|
+
- `onDrop?` — called for every drop event; replaces the built-in image-drop handling.
|
|
433
|
+
- `onImageError?` — called when an image element fails to load.
|
|
434
|
+
- `panBufferMargin?: number` (default `256`) — CSS-pixel margin cached beyond the viewport so
|
|
435
|
+
small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan
|
|
436
|
+
reuse, more memory per layer. Set `0` to disable (exact-viewport caches) on memory-tight hosts.
|
|
437
|
+
|
|
438
|
+
### Tool Options
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
|
|
442
|
+
new EraserTool({ radius: 30 }); // radius is screen pixels (converted to world units per zoom); mode: 'partial' (default) splits strokes at the erased span; mode: 'stroke' deletes the whole stroke
|
|
443
|
+
new ArrowTool({ color: '#333', width: 2 });
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`PencilTool` also accepts `opacity` (0–1), `blendMode` (`'source-over'` | `'multiply'`), and `name` — so a highlighter tool is just a named pencil variant with multiply blending:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// Register a highlighter alongside the standard pencil
|
|
450
|
+
viewport.toolManager.register(
|
|
451
|
+
new PencilTool({
|
|
452
|
+
name: 'highlighter',
|
|
453
|
+
color: '#facc15',
|
|
454
|
+
width: 12,
|
|
455
|
+
opacity: 0.4,
|
|
456
|
+
blendMode: 'multiply',
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
viewport.setTool('highlighter');
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`ShapeTool` supports a `'line'` shape kind that draws a straight segment between two points. Hold **Shift** while drawing to snap to 45° increments. Lines are hit-tested by proximity to the segment, and `ShapeElement.flip` records which diagonal of the bounding box the line runs along. When a line is selected, it shows two endpoint drag-handles instead of bounding-box resize handles — drag one endpoint to reshape the line while the other stays anchored.
|
|
463
|
+
|
|
464
|
+
### Arrow Labels
|
|
465
|
+
|
|
466
|
+
Arrows support an optional `label` string, rendered as a pill at the curve midpoint. Pass it at creation or double-click an arrow on the canvas to add or edit the label inline.
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
createArrow({ from: { x: 0, y: 0 }, to: { x: 200, y: 0 }, label: 'depends on' });
|
|
470
|
+
new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
|
|
471
|
+
new ImageTool({ size: { w: 400, h: 300 } });
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Element Types
|
|
475
|
+
|
|
476
|
+
All elements share a base shape:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
interface BaseElement {
|
|
480
|
+
id: string;
|
|
481
|
+
type: string;
|
|
482
|
+
position: { x: number; y: number };
|
|
483
|
+
zIndex: number;
|
|
484
|
+
locked: boolean;
|
|
485
|
+
layerId: string;
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
| Type | Key Fields |
|
|
490
|
+
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
491
|
+
| `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
|
|
492
|
+
| `note` | `size`, `text`, `backgroundColor`, `textColor` |
|
|
493
|
+
| `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
|
|
494
|
+
| `image` | `size`, `src` |
|
|
495
|
+
| `shape` | `size`, `shape` (`rectangle` \| `ellipse` \| `line`), `strokeColor`, `fillColor`, `flip` (`boolean` — which bbox diagonal a line runs along) |
|
|
496
|
+
| `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
|
|
497
|
+
| `grid` | `gridType` (`square` \| `hex`), `hexOrientation`, `cellSize`, `strokeColor`, `opacity` |
|
|
498
|
+
| `html` | `size` |
|
|
499
|
+
|
|
500
|
+
## Styling the Selection
|
|
501
|
+
|
|
502
|
+
A normalized `ElementStyle` interface lets you read and apply visual properties across all element types through a single, consistent shape. The `SelectTool` emits a selection-change event; `Viewport` exposes four methods that together cover reactive UIs.
|
|
503
|
+
|
|
504
|
+
### `ElementStyle` interface
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
interface ElementStyle {
|
|
508
|
+
color?: string; // stroke color / text color
|
|
509
|
+
fillColor?: string; // fill / background color
|
|
510
|
+
strokeWidth?: number; // line width in world-space units
|
|
511
|
+
opacity?: number; // 0–1
|
|
512
|
+
fontSize?: number; // px
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
#### Mapping across element types
|
|
517
|
+
|
|
518
|
+
| `ElementStyle` field | `stroke` | `arrow` | `shape` | `note` | `text` |
|
|
519
|
+
| -------------------- | --------- | ------- | ------------- | ----------------- | ---------- |
|
|
520
|
+
| `color` | `color` | `color` | `strokeColor` | `textColor` | `color` |
|
|
521
|
+
| `fillColor` | — | — | `fillColor` | `backgroundColor` | — |
|
|
522
|
+
| `strokeWidth` | `width` | `width` | `strokeWidth` | — | — |
|
|
523
|
+
| `opacity` | `opacity` | — | — | — | — |
|
|
524
|
+
| `fontSize` | — | — | — | (via toolbar) | `fontSize` |
|
|
525
|
+
|
|
526
|
+
### Conversion helpers
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
import { styleToPatch, getElementStyle } from '@fieldnotes/core';
|
|
530
|
+
|
|
531
|
+
// ElementStyle → element-specific patch object
|
|
532
|
+
const patch = styleToPatch(element, { color: '#e00', strokeWidth: 3 });
|
|
533
|
+
store.update(element.id, patch);
|
|
534
|
+
|
|
535
|
+
// element → normalized ElementStyle
|
|
536
|
+
const style = getElementStyle(element);
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Viewport methods
|
|
540
|
+
|
|
541
|
+
- **`viewport.getSelectedIds()`** — returns the current selection as a referentially-stable array (the same array reference is reused across calls when the selection has not changed — safe for `useSyncExternalStore` equality checks).
|
|
542
|
+
- **`viewport.onSelectionChange(listener)`** — subscribes to selection changes; returns an unsubscribe function. The listener receives the new stable id array.
|
|
543
|
+
- **`viewport.getSelectionStyle()`** — returns an `ElementStyle` containing only the properties that are identical across every selected element. Properties that differ are omitted.
|
|
544
|
+
- **`viewport.applyStyleToSelection(style)`** — applies the given `ElementStyle` to all selected elements in a single undo step.
|
|
545
|
+
|
|
546
|
+
### `SelectTool.onSelectionChange`
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
const selectTool = viewport.toolManager.getTool<SelectTool>('select');
|
|
550
|
+
selectTool?.onSelectionChange((ids) => {
|
|
551
|
+
console.log('selected:', ids);
|
|
552
|
+
});
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Example
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// Apply a red stroke to everything currently selected — one undo step
|
|
559
|
+
viewport.applyStyleToSelection({ color: '#ff0000' });
|
|
560
|
+
|
|
561
|
+
// Read back the shared style for a UI color picker
|
|
562
|
+
const style = viewport.getSelectionStyle();
|
|
563
|
+
// style.color is defined only if all selected elements share the same color
|
|
564
|
+
|
|
565
|
+
// React to selection changes
|
|
566
|
+
const unsub = viewport.onSelectionChange((ids) => {
|
|
567
|
+
setSelectedIds(ids); // ids is referentially stable — safe for deps arrays
|
|
568
|
+
});
|
|
569
|
+
// call unsub() to unsubscribe
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
## Aligning the Selection
|
|
573
|
+
|
|
574
|
+
Two methods on `Viewport` let you snap or space selected elements in one undo step.
|
|
575
|
+
|
|
576
|
+
- **`viewport.alignSelection(edge)`** — `edge`: `AlignEdge` = `'left' | 'center-x' | 'right' | 'top' | 'middle' | 'bottom'`; aligns every selected element to the corresponding edge or center of the selection's bounding box. Needs 2+ selected elements. Locked elements anchor the bounding box without moving.
|
|
577
|
+
- **`viewport.distributeSelection(axis)`** — `axis`: `DistributeAxis` = `'horizontal' | 'vertical'`; evenly spaces selected elements' centers along the axis. Needs 3+ selected elements. Locked elements anchor the span without moving.
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
viewport.alignSelection('left'); // flush left edges
|
|
581
|
+
viewport.alignSelection('center-x'); // center on vertical axis
|
|
582
|
+
viewport.alignSelection('middle'); // center on horizontal axis
|
|
583
|
+
viewport.distributeSelection('horizontal'); // equal horizontal spacing
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
Grids are ignored by both operations.
|
|
587
|
+
|
|
588
|
+
## Smart Alignment Guides
|
|
589
|
+
|
|
590
|
+
Call `viewport.setSmartGuides(true)` to enable drag-time alignment snapping. While dragging a selection, its edges and centers snap to the edges and centers of nearby visible elements (within 6 screen pixels), and guide lines are drawn at each matched alignment. Smart guides replace grid snapping for the duration of the drag; the result is still committed as a single undo step.
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
viewport.setSmartGuides(true); // enable
|
|
594
|
+
viewport.setSmartGuides(false); // disable (default)
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
## Grouping
|
|
598
|
+
|
|
599
|
+
Group elements so they select, move, delete, z-order, and align as a single unit.
|
|
600
|
+
|
|
601
|
+
- **`viewport.groupSelection()`** — groups the current selection under a new id.
|
|
602
|
+
- **`viewport.ungroupSelection()`** — dissolves any groups in the current selection.
|
|
603
|
+
|
|
604
|
+
Each is one undo step. Selecting any member selects its whole group, so to edit a single member individually, ungroup first. Pasting or duplicating a group keeps the copies grouped under a fresh id.
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
viewport.groupSelection(); // Ctrl/Cmd+G
|
|
608
|
+
viewport.ungroupSelection(); // Ctrl/Cmd+Shift+G
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
The shortcuts are rebindable as `group` and `ungroup`.
|
|
612
|
+
|
|
613
|
+
## Rotation
|
|
614
|
+
|
|
615
|
+
Select a single element and a rotate handle appears above the selection box. Drag it to rotate the element about its center; hold **Shift** to snap to 15° increments. Notes, text, images, HTML embeds, shapes, and strokes can all be rotated.
|
|
616
|
+
|
|
617
|
+
Hit-testing, marquee selection, and resize are all rotation-aware: resizing a rotated element keeps the opposite corner fixed in the element's local frame. Rotation is reflected in PNG export and round-trips through serialization (`rotation?` on elements, stored in radians).
|
|
618
|
+
|
|
619
|
+
## Context menu & lock
|
|
620
|
+
|
|
621
|
+
Right-click (desktop) or touch long-press (tablet) opens a context menu over the canvas with Cut/Copy/Paste/Duplicate/Delete, z-order (to front / forward / backward / to back), and Lock/Unlock. The menu is core-provided (plain DOM) and selects the element under the pointer if it isn't already selected. Opt out with `new Viewport(el, { contextMenu: false })`.
|
|
622
|
+
|
|
623
|
+
Lock with **`viewport.toggleLockSelection()`** or **Ctrl/Cmd+Shift+L**; a lock badge appears on the selection. Locked elements stay selectable but can't be moved, resized, or rotated. **Ctrl/Cmd+X** cuts the selection. The shortcuts are rebindable as `toggle-lock` and `cut`.
|
|
624
|
+
|
|
625
|
+
You can drive any menu action programmatically with **`viewport.runAction(name)`** (e.g. `'cut'`, `'paste'`, `'toggle-lock'`), and **`viewport.canPaste()`** reports whether the clipboard has content.
|
|
626
|
+
|
|
627
|
+
## Built-in Interactions
|
|
628
|
+
|
|
629
|
+
| Input | Action |
|
|
630
|
+
| -------------------- | ------------------- |
|
|
631
|
+
| Scroll wheel | Zoom |
|
|
632
|
+
| Middle-click drag | Pan |
|
|
633
|
+
| Space + drag | Pan |
|
|
634
|
+
| Two-finger pinch | Zoom |
|
|
635
|
+
| Two-finger drag | Pan |
|
|
636
|
+
| Delete / Backspace | Remove selected |
|
|
637
|
+
| Ctrl+Z / Cmd+Z | Undo |
|
|
638
|
+
| Ctrl+Shift+Z / Cmd+Y | Redo |
|
|
639
|
+
| Double-click note | Edit text |
|
|
640
|
+
| Double-click HTML | Enter interact mode |
|
|
641
|
+
| Escape | Exit interact mode |
|
|
642
|
+
|
|
643
|
+
## Browser Support
|
|
644
|
+
|
|
645
|
+
Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.
|
|
646
|
+
|
|
647
|
+
## Versioning
|
|
648
|
+
|
|
649
|
+
`@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
|
|
650
|
+
package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
|
|
651
|
+
versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
|
|
652
|
+
ever breaks the wrapper, a coordinated react release raises the lower bound.
|
|
653
|
+
|
|
654
|
+
## License
|
|
655
|
+
|
|
656
|
+
MIT
|