@fieldnotes/core 0.21.0 → 0.22.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 CHANGED
@@ -1,481 +1,481 @@
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
- ## Tool Switching
262
-
263
- ```typescript
264
- viewport.setTool('pencil');
265
- viewport.setTool('hand');
266
-
267
- viewport.toolManager.onChange((toolName) => {
268
- console.log('switched to', toolName);
269
- });
270
- ```
271
-
272
- ## Keyboard shortcuts
273
-
274
- Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
275
- `mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
276
- `[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · arrows nudge
277
- (`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
278
- `A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
279
-
280
- `mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
281
- pass `shortcuts: { scope: 'window' }` for page-wide handling.
282
-
283
- ```ts
284
- const viewport = new Viewport(el, {
285
- shortcuts: {
286
- bindings: {
287
- duplicate: 'mod+shift+d', // remap
288
- 'tool:pencil': ['p', 'b'], // multiple bindings
289
- copy: null, // disable
290
- 'tool:my-custom-tool': 'f', // any registered tool works
291
- },
292
- },
293
- });
294
-
295
- viewport.shortcuts.rebind('undo', 'mod+u');
296
- viewport.shortcuts.disable('select-all');
297
- viewport.shortcuts.reset(); // back to defaults
298
- viewport.shortcuts.getBindings(); // current table — render a settings UI
299
- ```
300
-
301
- ## Changing Tool Options at Runtime
302
-
303
- All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
304
-
305
- ```typescript
306
- // Get a tool by name (type-safe with generics)
307
- const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
308
- const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
309
- const note = viewport.toolManager.getTool<NoteTool>('note');
310
-
311
- // Change colors
312
- pencil?.setOptions({ color: '#ff0000' });
313
- arrow?.setOptions({ color: '#ff0000' });
314
- note?.setOptions({ backgroundColor: '#e8f5e9' });
315
-
316
- // Change stroke width
317
- pencil?.setOptions({ width: 5 });
318
- arrow?.setOptions({ width: 3 });
319
- ```
320
-
321
- ### Stroke Smoothing
322
-
323
- The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
324
-
325
- ```typescript
326
- new PencilTool({
327
- smoothing: 1.5, // default — higher = smoother, lower = more detail
328
- });
329
-
330
- // Or at runtime
331
- pencil?.setOptions({ smoothing: 3 });
332
- ```
333
-
334
- ### Pressure-Sensitive Width
335
-
336
- 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.
337
-
338
- Stroke points include pressure data in the `StrokePoint` type:
339
-
340
- ```typescript
341
- interface StrokePoint {
342
- x: number;
343
- y: number;
344
- pressure: number; // 0-1
345
- }
346
- ```
347
-
348
- ## Custom Tools
349
-
350
- Implement the `Tool` interface to create your own tools:
351
-
352
- ```typescript
353
- import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
354
-
355
- const myTool: Tool = {
356
- name: 'my-tool',
357
-
358
- onPointerDown(state: PointerState, ctx: ToolContext) {
359
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
360
- // state.pressure is available for stylus input (0-1)
361
- },
362
-
363
- onPointerMove(state: PointerState, ctx: ToolContext) {
364
- // called during drag
365
- },
366
-
367
- onPointerUp(state: PointerState, ctx: ToolContext) {
368
- // finalize action
369
- ctx.store.add(myElement);
370
- ctx.requestRender();
371
- },
372
-
373
- // Optional
374
- onActivate(ctx) {
375
- ctx.setCursor?.('crosshair');
376
- },
377
- onDeactivate(ctx) {
378
- ctx.setCursor?.('default');
379
- },
380
- renderOverlay(canvasCtx) {
381
- /* draw preview on canvas */
382
- },
383
- };
384
-
385
- viewport.toolManager.register(myTool);
386
- viewport.setTool('my-tool');
387
- ```
388
-
389
- ## Configuration
390
-
391
- ### Viewport Options
392
-
393
- ```typescript
394
- new Viewport(container, {
395
- camera: {
396
- minZoom: 0.1, // default: 0.1
397
- maxZoom: 10, // default: 10
398
- },
399
- background: {
400
- pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
401
- spacing: 24, // grid spacing in px (default: 24)
402
- color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
403
- },
404
- // Called for every drop; replaces the built-in image-drop handling
405
- onDrop: (event, worldPosition) => {
406
- /* handle drop */
407
- },
408
- // Called when an image element fails to load; failed images render a gray
409
- // placeholder. Falls back to console.warn when unset.
410
- onImageError: ({ src, elementIds }) => {
411
- /* handle broken image */
412
- },
413
- });
414
- ```
415
-
416
- ### Tool Options
417
-
418
- ```typescript
419
- new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
420
- new EraserTool({ radius: 30 });
421
- new ArrowTool({ color: '#333', width: 2 });
422
- new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
423
- new ImageTool({ size: { w: 400, h: 300 } });
424
- ```
425
-
426
- ## Element Types
427
-
428
- All elements share a base shape:
429
-
430
- ```typescript
431
- interface BaseElement {
432
- id: string;
433
- type: string;
434
- position: { x: number; y: number };
435
- zIndex: number;
436
- locked: boolean;
437
- layerId: string;
438
- }
439
- ```
440
-
441
- | Type | Key Fields |
442
- | -------- | -------------------------------------------------------------------------------------- |
443
- | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
444
- | `note` | `size`, `text`, `backgroundColor`, `textColor` |
445
- | `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
446
- | `image` | `size`, `src` |
447
- | `shape` | `size`, `shape` (`rectangle` \| `ellipse`), `strokeColor`, `fillColor` |
448
- | `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
449
- | `grid` | `gridType` (`square` \| `hex`), `hexOrientation`, `cellSize`, `strokeColor`, `opacity` |
450
- | `html` | `size` |
451
-
452
- ## Built-in Interactions
453
-
454
- | Input | Action |
455
- | -------------------- | ------------------- |
456
- | Scroll wheel | Zoom |
457
- | Middle-click drag | Pan |
458
- | Space + drag | Pan |
459
- | Two-finger pinch | Zoom |
460
- | Two-finger drag | Pan |
461
- | Delete / Backspace | Remove selected |
462
- | Ctrl+Z / Cmd+Z | Undo |
463
- | Ctrl+Shift+Z / Cmd+Y | Redo |
464
- | Double-click note | Edit text |
465
- | Double-click HTML | Enter interact mode |
466
- | Escape | Exit interact mode |
467
-
468
- ## Browser Support
469
-
470
- Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.
471
-
472
- ## Versioning
473
-
474
- `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
475
- package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
476
- versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
477
- ever breaks the wrapper, a coordinated react release raises the lower bound.
478
-
479
- ## License
480
-
481
- MIT
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
+ ## Tool Switching
262
+
263
+ ```typescript
264
+ viewport.setTool('pencil');
265
+ viewport.setTool('hand');
266
+
267
+ viewport.toolManager.onChange((toolName) => {
268
+ console.log('switched to', toolName);
269
+ });
270
+ ```
271
+
272
+ ## Keyboard shortcuts
273
+
274
+ Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
275
+ `mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
276
+ `[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · arrows nudge
277
+ (`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
278
+ `A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
279
+
280
+ `mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
281
+ pass `shortcuts: { scope: 'window' }` for page-wide handling.
282
+
283
+ ```ts
284
+ const viewport = new Viewport(el, {
285
+ shortcuts: {
286
+ bindings: {
287
+ duplicate: 'mod+shift+d', // remap
288
+ 'tool:pencil': ['p', 'b'], // multiple bindings
289
+ copy: null, // disable
290
+ 'tool:my-custom-tool': 'f', // any registered tool works
291
+ },
292
+ },
293
+ });
294
+
295
+ viewport.shortcuts.rebind('undo', 'mod+u');
296
+ viewport.shortcuts.disable('select-all');
297
+ viewport.shortcuts.reset(); // back to defaults
298
+ viewport.shortcuts.getBindings(); // current table — render a settings UI
299
+ ```
300
+
301
+ ## Changing Tool Options at Runtime
302
+
303
+ All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
304
+
305
+ ```typescript
306
+ // Get a tool by name (type-safe with generics)
307
+ const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
308
+ const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
309
+ const note = viewport.toolManager.getTool<NoteTool>('note');
310
+
311
+ // Change colors
312
+ pencil?.setOptions({ color: '#ff0000' });
313
+ arrow?.setOptions({ color: '#ff0000' });
314
+ note?.setOptions({ backgroundColor: '#e8f5e9' });
315
+
316
+ // Change stroke width
317
+ pencil?.setOptions({ width: 5 });
318
+ arrow?.setOptions({ width: 3 });
319
+ ```
320
+
321
+ ### Stroke Smoothing
322
+
323
+ The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
324
+
325
+ ```typescript
326
+ new PencilTool({
327
+ smoothing: 1.5, // default — higher = smoother, lower = more detail
328
+ });
329
+
330
+ // Or at runtime
331
+ pencil?.setOptions({ smoothing: 3 });
332
+ ```
333
+
334
+ ### Pressure-Sensitive Width
335
+
336
+ 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.
337
+
338
+ Stroke points include pressure data in the `StrokePoint` type:
339
+
340
+ ```typescript
341
+ interface StrokePoint {
342
+ x: number;
343
+ y: number;
344
+ pressure: number; // 0-1
345
+ }
346
+ ```
347
+
348
+ ## Custom Tools
349
+
350
+ Implement the `Tool` interface to create your own tools:
351
+
352
+ ```typescript
353
+ import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
354
+
355
+ const myTool: Tool = {
356
+ name: 'my-tool',
357
+
358
+ onPointerDown(state: PointerState, ctx: ToolContext) {
359
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
360
+ // state.pressure is available for stylus input (0-1)
361
+ },
362
+
363
+ onPointerMove(state: PointerState, ctx: ToolContext) {
364
+ // called during drag
365
+ },
366
+
367
+ onPointerUp(state: PointerState, ctx: ToolContext) {
368
+ // finalize action
369
+ ctx.store.add(myElement);
370
+ ctx.requestRender();
371
+ },
372
+
373
+ // Optional
374
+ onActivate(ctx) {
375
+ ctx.setCursor?.('crosshair');
376
+ },
377
+ onDeactivate(ctx) {
378
+ ctx.setCursor?.('default');
379
+ },
380
+ renderOverlay(canvasCtx) {
381
+ /* draw preview on canvas */
382
+ },
383
+ };
384
+
385
+ viewport.toolManager.register(myTool);
386
+ viewport.setTool('my-tool');
387
+ ```
388
+
389
+ ## Configuration
390
+
391
+ ### Viewport Options
392
+
393
+ ```typescript
394
+ new Viewport(container, {
395
+ camera: {
396
+ minZoom: 0.1, // default: 0.1
397
+ maxZoom: 10, // default: 10
398
+ },
399
+ background: {
400
+ pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
401
+ spacing: 24, // grid spacing in px (default: 24)
402
+ color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
403
+ },
404
+ // Called for every drop; replaces the built-in image-drop handling
405
+ onDrop: (event, worldPosition) => {
406
+ /* handle drop */
407
+ },
408
+ // Called when an image element fails to load; failed images render a gray
409
+ // placeholder. Falls back to console.warn when unset.
410
+ onImageError: ({ src, elementIds }) => {
411
+ /* handle broken image */
412
+ },
413
+ });
414
+ ```
415
+
416
+ ### Tool Options
417
+
418
+ ```typescript
419
+ new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
420
+ new EraserTool({ radius: 30 });
421
+ new ArrowTool({ color: '#333', width: 2 });
422
+ new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
423
+ new ImageTool({ size: { w: 400, h: 300 } });
424
+ ```
425
+
426
+ ## Element Types
427
+
428
+ All elements share a base shape:
429
+
430
+ ```typescript
431
+ interface BaseElement {
432
+ id: string;
433
+ type: string;
434
+ position: { x: number; y: number };
435
+ zIndex: number;
436
+ locked: boolean;
437
+ layerId: string;
438
+ }
439
+ ```
440
+
441
+ | Type | Key Fields |
442
+ | -------- | -------------------------------------------------------------------------------------- |
443
+ | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
444
+ | `note` | `size`, `text`, `backgroundColor`, `textColor` |
445
+ | `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
446
+ | `image` | `size`, `src` |
447
+ | `shape` | `size`, `shape` (`rectangle` \| `ellipse`), `strokeColor`, `fillColor` |
448
+ | `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
449
+ | `grid` | `gridType` (`square` \| `hex`), `hexOrientation`, `cellSize`, `strokeColor`, `opacity` |
450
+ | `html` | `size` |
451
+
452
+ ## Built-in Interactions
453
+
454
+ | Input | Action |
455
+ | -------------------- | ------------------- |
456
+ | Scroll wheel | Zoom |
457
+ | Middle-click drag | Pan |
458
+ | Space + drag | Pan |
459
+ | Two-finger pinch | Zoom |
460
+ | Two-finger drag | Pan |
461
+ | Delete / Backspace | Remove selected |
462
+ | Ctrl+Z / Cmd+Z | Undo |
463
+ | Ctrl+Shift+Z / Cmd+Y | Redo |
464
+ | Double-click note | Edit text |
465
+ | Double-click HTML | Enter interact mode |
466
+ | Escape | Exit interact mode |
467
+
468
+ ## Browser Support
469
+
470
+ Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.
471
+
472
+ ## Versioning
473
+
474
+ `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
475
+ package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
476
+ versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
477
+ ever breaks the wrapper, a coordinated react release raises the lower bound.
478
+
479
+ ## License
480
+
481
+ MIT