@fieldnotes/core 0.66.0 → 0.67.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,706 +1,742 @@
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
- - **Canvas-routed HTML painters** — register a painter to draw `html` elements on the canvas (screen, minimap, and export) instead of the DOM, with typed element activation events
16
- - **Layers** — named layers with visibility, locking, and absolute ordering
17
- - **Select & multi-select** — click, drag box, move, resize (layer-aware)
18
- - **Undo / redo** — full history stack with configurable depth
19
- - **State serialization** — export/import JSON snapshots with automatic migration
20
- - **Grids** — square and hex grid overlays for D&D maps and alignment
21
- - **Export** — PNG export with scale, padding, background, and element filter options
22
- - **Performance instrumentation** — `getRenderStats()` and `logPerformance()` for frame timing
23
- - **Touch & tablet** — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
24
- - **Zero dependencies** — vanilla TypeScript, no framework required
25
- - **Tree-shakeable** — ESM + CJS output
26
-
27
- ## Install
28
-
29
- ```bash
30
- npm install @fieldnotes/core
31
- ```
32
-
33
- ## Quick Start
34
-
35
- ```typescript
36
- import {
37
- Viewport,
38
- HandTool,
39
- SelectTool,
40
- PencilTool,
41
- EraserTool,
42
- ArrowTool,
43
- NoteTool,
44
- } from '@fieldnotes/core';
45
-
46
- // Mount on any container element
47
- const viewport = new Viewport(document.getElementById('canvas'), {
48
- background: { pattern: 'dots', spacing: 24 },
49
- });
50
-
51
- // Register tools
52
- viewport.toolManager.register(new HandTool());
53
- viewport.toolManager.register(new SelectTool());
54
- viewport.toolManager.register(new PencilTool({ color: '#1a1a1a', width: 2 }));
55
- viewport.toolManager.register(new EraserTool());
56
- viewport.toolManager.register(new ArrowTool({ color: '#1a1a1a', width: 2 }));
57
- viewport.toolManager.register(new NoteTool());
58
-
59
- // Activate a tool
60
- viewport.setTool('select');
61
-
62
- // Clean up when done
63
- viewport.destroy();
64
- ```
65
-
66
- Your container element needs a defined size (width/height). The canvas fills its container.
67
-
68
- ## Embedding HTML Elements
69
-
70
- The main differentiator — embed any DOM node as a fully interactive canvas element:
71
-
72
- ```typescript
73
- const card = document.createElement('div');
74
- card.innerHTML = '<h3>My Card</h3><button>Click me</button>';
75
-
76
- // Buttons, inputs, links — everything works
77
- card.querySelector('button').addEventListener('click', () => {
78
- console.log('Clicked inside the canvas!');
79
- });
80
-
81
- const elementId = viewport.addHtmlElement(card, { x: 100, y: 200 }, { w: 250, h: 150 });
82
- ```
83
-
84
- HTML elements pan, zoom, resize, and interleave by layer/`zIndex` with canvas-rendered content. They
85
- use a **two-mode interaction model**:
86
-
87
- - **Default** — the element can be selected, dragged, and resized like any other element
88
- - **Double-click** — enters interact mode, making buttons, inputs, and links work
89
- - **Escape** or **click outside** — exits interact mode
90
-
91
- You can also exit interact mode programmatically:
92
-
93
- ```typescript
94
- viewport.stopInteracting();
95
- ```
96
-
97
- ## Adding Images
98
-
99
- ```typescript
100
- // Programmatic
101
- viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
102
- viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });
103
-
104
- // Drag & drop is handled automatically — drop images onto the canvas
105
- ```
106
-
107
- > **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.
108
-
109
- ## Adding Shapes
110
-
111
- ```typescript
112
- // Default: a centered 100×100 rectangle
113
- const id = viewport.addShape();
114
-
115
- // Override shape, size, position, and colors
116
- viewport.addShape({
117
- shape: 'ellipse',
118
- size: { w: 200, h: 120 },
119
- position: { x: 0, y: 0 },
120
- strokeColor: '#1d4ed8',
121
- fillColor: '#dbeafe',
122
- strokeWidth: 2,
123
- });
124
- ```
125
-
126
- `addShape(opts?): string` creates a shape in a single undo step, selects the new shape, and returns its id. With no options it places a 100×100 rectangle centered in the current viewport — a keyboard-friendly path to shape creation.
127
-
128
- ## Grids
129
-
130
- 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.
131
-
132
- ```typescript
133
- // Add a hex grid
134
- viewport.addGrid({
135
- gridType: 'hex',
136
- hexOrientation: 'pointy', // 'pointy' | 'flat'
137
- cellSize: 40,
138
- strokeColor: '#cccccc',
139
- strokeWidth: 1,
140
- opacity: 0.5,
141
- });
142
-
143
- // Update grid properties
144
- viewport.updateGrid({ cellSize: 50, strokeColor: '#aaaaaa' });
145
-
146
- // Remove grid
147
- viewport.removeGrid();
148
- ```
149
-
150
- ## Image Export
151
-
152
- Export the canvas as a PNG image:
153
-
154
- ```typescript
155
- const blob = await viewport.exportImage({
156
- scale: 2, // pixel density (default 2)
157
- padding: 20, // world-space padding around content (default 0)
158
- background: '#fff', // fill color (default '#ffffff')
159
- filter: (el) => el.type !== 'html', // optional per-element filter
160
- imageTimeoutMs: 10_000, // maximum wait per image
161
- maxDimension: 16_384, // maximum output width or height
162
- maxPixels: 67_108_864, // maximum output pixel count
163
- onAssetError: ({ elementId, src, reason }) => {
164
- console.warn(`Could not export ${elementId} (${reason}): ${src}`);
165
- },
166
- });
167
- ```
168
-
169
- Remote images are requested with anonymous CORS using their original URLs; failures are omitted from
170
- the result and reported through `onAssetError` when supplied.
171
-
172
- Application-owned HTML embeds require an explicit rasterization hook. Return a ready canvas-compatible
173
- image source; Field Notes applies the element's size, rotation, paint order, and layer opacity in both
174
- PNG and SVG exports:
175
-
176
- ```typescript
177
- const options = {
178
- htmlTimeoutMs: 10_000,
179
- renderHtml: async (element) => {
180
- const node = document.querySelector(`[data-element-id="${element.id}"]`);
181
- return node ? rasterizeToCanvas(node) : null; // application or library implementation
182
- },
183
- onHtmlError: ({ elementId, reason }) => {
184
- console.warn(`Could not export HTML element ${elementId}: ${reason}`);
185
- },
186
- };
187
-
188
- const png = await viewport.exportImage(options);
189
- const svg = await viewport.exportSVG(options);
190
- ```
191
-
192
- Without `renderHtml`, embeds remain omitted and can be observed through `onHtmlError`.
193
-
194
- ## Performance Monitoring
195
-
196
- ```typescript
197
- // Get a snapshot of render stats
198
- const stats = viewport.getRenderStats();
199
- // { fps, avgFrameMs, p95FrameMs, lastGridMs, frameCount }
200
-
201
- // Log stats to console every 2 seconds (returns stop function)
202
- const stop = viewport.logPerformance(2000);
203
- // [FieldNotes] fps=60 frame=1.2ms p95=2.1ms grid=0.1ms
204
- stop(); // stop logging
205
- ```
206
-
207
- ## Camera Control
208
-
209
- ```typescript
210
- const { camera } = viewport;
211
-
212
- camera.pan(100, 50); // pan by offset
213
- camera.moveTo(0, 0); // jump to position
214
- camera.setZoom(2); // set zoom level
215
- camera.zoomAt(1.5, { x: 400, y: 300 }); // zoom toward screen point
216
-
217
- const world = camera.screenToWorld({ x: e.clientX, y: e.clientY });
218
- const screen = camera.worldToScreen({ x: 0, y: 0 });
219
-
220
- camera.onChange(() => {
221
- /* camera moved */
222
- });
223
- ```
224
-
225
- ## Element Store
226
-
227
- Direct access to canvas elements:
228
-
229
- ```typescript
230
- const { store } = viewport;
231
-
232
- const all = store.getAll(); // sorted by zIndex
233
- const el = store.getById('some-id');
234
- const strokes = store.getElementsByType('stroke');
235
-
236
- store.update('some-id', { locked: true });
237
- store.remove('some-id');
238
-
239
- store.on('add', (el) => console.log('added', el));
240
- store.on('remove', (el) => console.log('removed', el));
241
- store.on('update', ({ previous, current }) => {
242
- /* ... */
243
- });
244
- ```
245
-
246
- ## Undo / Redo
247
-
248
- ```typescript
249
- viewport.undo();
250
- viewport.redo();
251
-
252
- viewport.history.canUndo; // boolean
253
- viewport.history.canRedo; // boolean
254
- viewport.history.onChange(() => {
255
- /* update UI */
256
- });
257
- ```
258
-
259
- ## Layers
260
-
261
- 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.
262
-
263
- ```typescript
264
- const { layerManager } = viewport;
265
-
266
- // Create layers
267
- const background = layerManager.activeLayer; // "Layer 1" exists by default
268
- layerManager.renameLayer(background.id, 'Map');
269
- const tokens = layerManager.createLayer('Tokens');
270
- const notes = layerManager.createLayer('Notes');
271
-
272
- // Set active layer — new elements are created on the active layer
273
- layerManager.setActiveLayer(tokens.id);
274
-
275
- // Visibility and locking
276
- layerManager.setLayerVisible(background.id, false); // hide
277
- layerManager.setLayerLocked(background.id, true); // prevent selection/editing
278
-
279
- // Move elements between layers
280
- layerManager.moveElementToLayer(elementId, notes.id);
281
-
282
- // Reorder layers
283
- layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top
284
-
285
- // Query
286
- layerManager.getLayers(); // sorted by order
287
- layerManager.isLayerVisible(id);
288
- layerManager.isLayerLocked(id);
289
-
290
- // Listen for changes
291
- layerManager.on('change', () => {
292
- /* update UI */
293
- });
294
- ```
295
-
296
- 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.
297
-
298
- ## State Serialization
299
-
300
- ```typescript
301
- // Save
302
- const json = viewport.exportJSON();
303
- localStorage.setItem('canvas', json);
304
-
305
- // Load
306
- viewport.loadJSON(localStorage.getItem('canvas'));
307
- ```
308
-
309
- > **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".
310
-
311
- > **Two equivalent pairs:** `exportJSON()` / `loadJSON()` work with strings and are the
312
- > canonical choice for persistence. `exportState()` / `loadState()` work with in-memory
313
- > `CanvasState` objects, skipping the JSON round-trip — this is what `AutoSave` uses. The
314
- > module-level `exportState` / `parseState` functions are no longer exported; use the
315
- > `Viewport` methods.
316
-
317
- ## Tool Switching
318
-
319
- ```typescript
320
- viewport.setTool('pencil');
321
- viewport.setTool('hand');
322
-
323
- viewport.toolManager.onChange((toolName) => {
324
- console.log('switched to', toolName);
325
- });
326
- ```
327
-
328
- ## Keyboard shortcuts
329
-
330
- Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
331
- `mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
332
- `[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · `mod+=` zoom in ·
333
- `mod+-` zoom out · `mod+0` reset zoom to 100% · arrows nudge
334
- (`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
335
- `A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
336
-
337
- `mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
338
- pass `shortcuts: { scope: 'window' }` for page-wide handling.
339
-
340
- ```ts
341
- const viewport = new Viewport(el, {
342
- shortcuts: {
343
- bindings: {
344
- duplicate: 'mod+shift+d', // remap
345
- 'tool:pencil': ['p', 'b'], // multiple bindings
346
- copy: null, // disable
347
- 'tool:my-custom-tool': 'f', // any registered tool works
348
- },
349
- },
350
- });
351
-
352
- viewport.shortcuts.rebind('undo', 'mod+u');
353
- viewport.shortcuts.disable('select-all');
354
- viewport.shortcuts.reset(); // back to defaults
355
- viewport.shortcuts.getBindings(); // current table — render a settings UI
356
- ```
357
-
358
- ## Changing Tool Options at Runtime
359
-
360
- All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
361
-
362
- ```typescript
363
- // Get a tool by name (type-safe with generics)
364
- const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
365
- const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
366
- const note = viewport.toolManager.getTool<NoteTool>('note');
367
-
368
- // Change colors
369
- pencil?.setOptions({ color: '#ff0000' });
370
- arrow?.setOptions({ color: '#ff0000' });
371
- note?.setOptions({ backgroundColor: '#e8f5e9' });
372
-
373
- // Change stroke width
374
- pencil?.setOptions({ width: 5 });
375
- arrow?.setOptions({ width: 3 });
376
- ```
377
-
378
- ### Stroke Smoothing
379
-
380
- The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
381
-
382
- ```typescript
383
- new PencilTool({
384
- smoothing: 1.5, // default — higher = smoother, lower = more detail
385
- });
386
-
387
- // Or at runtime
388
- pencil?.setOptions({ smoothing: 3 });
389
- ```
390
-
391
- ### Pressure-Sensitive Width
392
-
393
- 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.
394
-
395
- Stroke points include pressure data in the `StrokePoint` type:
396
-
397
- ```typescript
398
- interface StrokePoint {
399
- x: number;
400
- y: number;
401
- pressure: number; // 0-1
402
- }
403
- ```
404
-
405
- ## Custom Tools
406
-
407
- Implement the `Tool` interface to create your own tools:
408
-
409
- ```typescript
410
- import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
411
-
412
- const myTool: Tool = {
413
- name: 'my-tool',
414
-
415
- onPointerDown(state: PointerState, ctx: ToolContext) {
416
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
417
- // state.pressure is available for stylus input (0-1)
418
- },
419
-
420
- onPointerMove(state: PointerState, ctx: ToolContext) {
421
- // called during drag
422
- },
423
-
424
- onPointerUp(state: PointerState, ctx: ToolContext) {
425
- // finalize action
426
- ctx.store.add(myElement);
427
- ctx.requestRender();
428
- },
429
-
430
- // Optional
431
- onActivate(ctx) {
432
- ctx.setCursor?.('crosshair');
433
- },
434
- onDeactivate(ctx) {
435
- ctx.setCursor?.('default');
436
- },
437
- renderOverlay(canvasCtx) {
438
- /* draw preview on canvas */
439
- },
440
- };
441
-
442
- viewport.toolManager.register(myTool);
443
- viewport.setTool('my-tool');
444
- ```
445
-
446
- ## Configuration
447
-
448
- ### Viewport Options
449
-
450
- ```typescript
451
- new Viewport(container, {
452
- camera: {
453
- minZoom: 0.1, // default: 0.1
454
- maxZoom: 10, // default: 10
455
- },
456
- background: {
457
- pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
458
- spacing: 24, // grid spacing in px (default: 24)
459
- color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
460
- },
461
- // Called for every drop; replaces the built-in image-drop handling
462
- onDrop: (event, worldPosition) => {
463
- /* handle drop */
464
- },
465
- // Called when an image element fails to load; failed images render a gray
466
- // placeholder. Falls back to console.warn when unset.
467
- onImageError: ({ src, elementIds }) => {
468
- /* handle broken image */
469
- },
470
- });
471
- ```
472
-
473
- ### ViewportOptions reference
474
-
475
- - `camera?: CameraOptions` — `minZoom` / `maxZoom` (defaults `0.1` / `10`).
476
- - `background?: BackgroundOptions` — `pattern`, `spacing`, `color`.
477
- - `fontSizePresets?: FontSizePreset[]` — custom font-size steps for the note toolbar.
478
- - `toolbar?: boolean` — show/hide the note formatting toolbar (default `true`).
479
- - `placeholder?: string` — placeholder text shown in empty notes.
480
- - `shortcuts?: ShortcutOptions` — seed the keyboard shortcut table with custom bindings.
481
- - `onHtmlElementMount?` — called after `loadState` for HTML elements that need content injected.
482
- - `onDrop?` — called for every drop event; replaces the built-in image-drop handling.
483
- - `onImageError?` — called when an image element fails to load.
484
- - `panBufferMargin?: number` (default `256`) — CSS-pixel margin cached beyond the viewport so
485
- small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan
486
- reuse, more memory per layer. Set `0` to disable (exact-viewport caches) on memory-tight hosts.
487
-
488
- ### Tool Options
489
-
490
- ```typescript
491
- new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
492
- 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
493
- new ArrowTool({ color: '#333', width: 2 });
494
- ```
495
-
496
- `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:
497
-
498
- ```typescript
499
- // Register a highlighter alongside the standard pencil
500
- viewport.toolManager.register(
501
- new PencilTool({
502
- name: 'highlighter',
503
- color: '#facc15',
504
- width: 12,
505
- opacity: 0.4,
506
- blendMode: 'multiply',
507
- }),
508
- );
509
- viewport.setTool('highlighter');
510
- ```
511
-
512
- `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.
513
-
514
- ### Arrow Labels
515
-
516
- 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.
517
-
518
- ```typescript
519
- createArrow({ from: { x: 0, y: 0 }, to: { x: 200, y: 0 }, label: 'depends on' });
520
- new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
521
- new ImageTool({ size: { w: 400, h: 300 } });
522
- ```
523
-
524
- ## Element Types
525
-
526
- All elements share a base shape:
527
-
528
- ```typescript
529
- interface BaseElement {
530
- id: string;
531
- type: string;
532
- position: { x: number; y: number };
533
- zIndex: number;
534
- locked: boolean;
535
- layerId: string;
536
- }
537
- ```
538
-
539
- | Type | Key Fields |
540
- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
541
- | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
542
- | `note` | `size`, `text`, `backgroundColor`, `textColor` |
543
- | `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
544
- | `image` | `size`, `src` |
545
- | `shape` | `size`, `shape` (`rectangle` \| `ellipse` \| `line`), `strokeColor`, `fillColor`, `flip` (`boolean` — which bbox diagonal a line runs along) |
546
- | `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
547
- | `grid` | `gridType` (`square` \| `hex`), `hexOrientation`, `cellSize`, `strokeColor`, `opacity` |
548
- | `html` | `size` |
549
-
550
- ## Styling the Selection
551
-
552
- 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.
553
-
554
- ### `ElementStyle` interface
555
-
556
- ```typescript
557
- interface ElementStyle {
558
- color?: string; // stroke color / text color
559
- fillColor?: string; // fill / background color
560
- strokeWidth?: number; // line width in world-space units
561
- opacity?: number; // 0–1
562
- fontSize?: number; // px
563
- }
564
- ```
565
-
566
- #### Mapping across element types
567
-
568
- | `ElementStyle` field | `stroke` | `arrow` | `shape` | `note` | `text` |
569
- | -------------------- | --------- | ------- | ------------- | ----------------- | ---------- |
570
- | `color` | `color` | `color` | `strokeColor` | `textColor` | `color` |
571
- | `fillColor` | — | — | `fillColor` | `backgroundColor` | — |
572
- | `strokeWidth` | `width` | `width` | `strokeWidth` | — | — |
573
- | `opacity` | `opacity` | — | — | — | — |
574
- | `fontSize` | — | — | — | (via toolbar) | `fontSize` |
575
-
576
- ### Conversion helpers
577
-
578
- ```typescript
579
- import { styleToPatch, getElementStyle } from '@fieldnotes/core';
580
-
581
- // ElementStyle element-specific patch object
582
- const patch = styleToPatch(element, { color: '#e00', strokeWidth: 3 });
583
- store.update(element.id, patch);
584
-
585
- // element → normalized ElementStyle
586
- const style = getElementStyle(element);
587
- ```
588
-
589
- ### Viewport methods
590
-
591
- - **`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).
592
- - **`viewport.onSelectionChange(listener)`** — subscribes to selection changes; returns an unsubscribe function. The listener receives the new stable id array.
593
- - **`viewport.getSelectionStyle()`** — returns an `ElementStyle` containing only the properties that are identical across every selected element. Properties that differ are omitted.
594
- - **`viewport.applyStyleToSelection(style)`** applies the given `ElementStyle` to all selected elements in a single undo step.
595
-
596
- ### `SelectTool.onSelectionChange`
597
-
598
- ```typescript
599
- const selectTool = viewport.toolManager.getTool<SelectTool>('select');
600
- selectTool?.onSelectionChange((ids) => {
601
- console.log('selected:', ids);
602
- });
603
- ```
604
-
605
- ### Example
606
-
607
- ```typescript
608
- // Apply a red stroke to everything currently selected one undo step
609
- viewport.applyStyleToSelection({ color: '#ff0000' });
610
-
611
- // Read back the shared style for a UI color picker
612
- const style = viewport.getSelectionStyle();
613
- // style.color is defined only if all selected elements share the same color
614
-
615
- // React to selection changes
616
- const unsub = viewport.onSelectionChange((ids) => {
617
- setSelectedIds(ids); // ids is referentially stable — safe for deps arrays
618
- });
619
- // call unsub() to unsubscribe
620
- ```
621
-
622
- ## Aligning the Selection
623
-
624
- Two methods on `Viewport` let you snap or space selected elements in one undo step.
625
-
626
- - **`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.
627
- - **`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.
628
-
629
- ```typescript
630
- viewport.alignSelection('left'); // flush left edges
631
- viewport.alignSelection('center-x'); // center on vertical axis
632
- viewport.alignSelection('middle'); // center on horizontal axis
633
- viewport.distributeSelection('horizontal'); // equal horizontal spacing
634
- ```
635
-
636
- Grids are ignored by both operations.
637
-
638
- ## Smart Alignment Guides
639
-
640
- 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.
641
-
642
- ```typescript
643
- viewport.setSmartGuides(true); // enable
644
- viewport.setSmartGuides(false); // disable (default)
645
- ```
646
-
647
- ## Grouping
648
-
649
- Group elements so they select, move, delete, z-order, and align as a single unit.
650
-
651
- - **`viewport.groupSelection()`** groups the current selection under a new id.
652
- - **`viewport.ungroupSelection()`** dissolves any groups in the current selection.
653
-
654
- 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.
655
-
656
- ```typescript
657
- viewport.groupSelection(); // Ctrl/Cmd+G
658
- viewport.ungroupSelection(); // Ctrl/Cmd+Shift+G
659
- ```
660
-
661
- The shortcuts are rebindable as `group` and `ungroup`.
662
-
663
- ## Rotation
664
-
665
- 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.
666
-
667
- 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).
668
-
669
- ## Context menu & lock
670
-
671
- 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 })`.
672
-
673
- 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`.
674
-
675
- 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.
676
-
677
- ## Built-in Interactions
678
-
679
- | Input | Action |
680
- | -------------------- | ------------------- |
681
- | Scroll wheel | Zoom |
682
- | Middle-click drag | Pan |
683
- | Space + drag | Pan |
684
- | Two-finger pinch | Zoom |
685
- | Two-finger drag | Pan |
686
- | Delete / Backspace | Remove selected |
687
- | Ctrl+Z / Cmd+Z | Undo |
688
- | Ctrl+Shift+Z / Cmd+Y | Redo |
689
- | Double-click note | Edit text |
690
- | Double-click HTML | Enter interact mode |
691
- | Escape | Exit interact mode |
692
-
693
- ## Browser Support
694
-
695
- Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.
696
-
697
- ## Versioning
698
-
699
- `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
700
- package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
701
- versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
702
- ever breaks the wrapper, a coordinated react release raises the lower bound.
703
-
704
- ## License
705
-
706
- 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
+ - **Canvas-routed HTML painters** — register a painter to draw `html` elements on the canvas (screen, minimap, and export) instead of the DOM, with typed element activation events
16
+ - **Layers** — named layers with visibility, locking, and absolute ordering
17
+ - **Select & multi-select** — click, drag box, move, resize (layer-aware)
18
+ - **Undo / redo** — full history stack with configurable depth
19
+ - **State serialization** — export/import JSON snapshots with automatic migration
20
+ - **Grids** — square and hex grid overlays for D&D maps and alignment
21
+ - **Export** — PNG export with scale, padding, background, and element filter options
22
+ - **Fog of war** — solid or deterministic procedural concealment with editor/player presentation
23
+ - **Performance instrumentation** — `getRenderStats()` and `logPerformance()` for frame timing
24
+ - **Touch & tablet** — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
25
+ - **Zero dependencies** — vanilla TypeScript, no framework required
26
+ - **Tree-shakeable** — ESM + CJS output
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install @fieldnotes/core
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```typescript
37
+ import {
38
+ Viewport,
39
+ HandTool,
40
+ SelectTool,
41
+ PencilTool,
42
+ EraserTool,
43
+ ArrowTool,
44
+ NoteTool,
45
+ } from '@fieldnotes/core';
46
+
47
+ // Mount on any container element
48
+ const viewport = new Viewport(document.getElementById('canvas'), {
49
+ background: { pattern: 'dots', spacing: 24 },
50
+ });
51
+
52
+ // Register tools
53
+ viewport.toolManager.register(new HandTool());
54
+ viewport.toolManager.register(new SelectTool());
55
+ viewport.toolManager.register(new PencilTool({ color: '#1a1a1a', width: 2 }));
56
+ viewport.toolManager.register(new EraserTool());
57
+ viewport.toolManager.register(new ArrowTool({ color: '#1a1a1a', width: 2 }));
58
+ viewport.toolManager.register(new NoteTool());
59
+
60
+ // Activate a tool
61
+ viewport.setTool('select');
62
+
63
+ // Clean up when done
64
+ viewport.destroy();
65
+ ```
66
+
67
+ Your container element needs a defined size (width/height). The canvas fills its container.
68
+
69
+ ## Embedding HTML Elements
70
+
71
+ The main differentiator — embed any DOM node as a fully interactive canvas element:
72
+
73
+ ```typescript
74
+ const card = document.createElement('div');
75
+ card.innerHTML = '<h3>My Card</h3><button>Click me</button>';
76
+
77
+ // Buttons, inputs, links — everything works
78
+ card.querySelector('button').addEventListener('click', () => {
79
+ console.log('Clicked inside the canvas!');
80
+ });
81
+
82
+ const elementId = viewport.addHtmlElement(card, { x: 100, y: 200 }, { w: 250, h: 150 });
83
+ ```
84
+
85
+ HTML elements pan, zoom, resize, and interleave by layer/`zIndex` with canvas-rendered content. They
86
+ use a **two-mode interaction model**:
87
+
88
+ - **Default** — the element can be selected, dragged, and resized like any other element
89
+ - **Double-click** — enters interact mode, making buttons, inputs, and links work
90
+ - **Escape** or **click outside** — exits interact mode
91
+
92
+ You can also exit interact mode programmatically:
93
+
94
+ ```typescript
95
+ viewport.stopInteracting();
96
+ ```
97
+
98
+ ## Adding Images
99
+
100
+ ```typescript
101
+ // Programmatic
102
+ viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
103
+ viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });
104
+
105
+ // Drag & drop is handled automatically — drop images onto the canvas
106
+ ```
107
+
108
+ > **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.
109
+
110
+ ## Adding Shapes
111
+
112
+ ```typescript
113
+ // Default: a centered 100×100 rectangle
114
+ const id = viewport.addShape();
115
+
116
+ // Override shape, size, position, and colors
117
+ viewport.addShape({
118
+ shape: 'ellipse',
119
+ size: { w: 200, h: 120 },
120
+ position: { x: 0, y: 0 },
121
+ strokeColor: '#1d4ed8',
122
+ fillColor: '#dbeafe',
123
+ strokeWidth: 2,
124
+ });
125
+ ```
126
+
127
+ `addShape(opts?): string` creates a shape in a single undo step, selects the new shape, and returns its id. With no options it places a 100×100 rectangle centered in the current viewport — a keyboard-friendly path to shape creation.
128
+
129
+ ## Grids
130
+
131
+ 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.
132
+
133
+ ```typescript
134
+ // Add a hex grid
135
+ viewport.addGrid({
136
+ gridType: 'hex',
137
+ hexOrientation: 'pointy', // 'pointy' | 'flat'
138
+ cellSize: 40,
139
+ strokeColor: '#cccccc',
140
+ strokeWidth: 1,
141
+ opacity: 0.5,
142
+ });
143
+
144
+ // Update grid properties
145
+ viewport.updateGrid({ cellSize: 50, strokeColor: '#aaaaaa' });
146
+
147
+ // Remove grid
148
+ viewport.removeGrid();
149
+ ```
150
+
151
+ ## Image Export
152
+
153
+ Export the canvas as a PNG image:
154
+
155
+ ```typescript
156
+ const blob = await viewport.exportImage({
157
+ scale: 2, // pixel density (default 2)
158
+ padding: 20, // world-space padding around content (default 0)
159
+ background: '#fff', // fill color (default '#ffffff')
160
+ filter: (el) => el.type !== 'html', // optional per-element filter
161
+ imageTimeoutMs: 10_000, // maximum wait per image
162
+ maxDimension: 16_384, // maximum output width or height
163
+ maxPixels: 67_108_864, // maximum output pixel count
164
+ onAssetError: ({ elementId, src, reason }) => {
165
+ console.warn(`Could not export ${elementId} (${reason}): ${src}`);
166
+ },
167
+ });
168
+ ```
169
+
170
+ Remote images are requested with anonymous CORS using their original URLs; failures are omitted from
171
+ the result and reported through `onAssetError` when supplied.
172
+
173
+ Application-owned HTML embeds require an explicit rasterization hook. Return a ready canvas-compatible
174
+ image source; Field Notes applies the element's size, rotation, paint order, and layer opacity in both
175
+ PNG and SVG exports:
176
+
177
+ ```typescript
178
+ const options = {
179
+ htmlTimeoutMs: 10_000,
180
+ renderHtml: async (element) => {
181
+ const node = document.querySelector(`[data-element-id="${element.id}"]`);
182
+ return node ? rasterizeToCanvas(node) : null; // application or library implementation
183
+ },
184
+ onHtmlError: ({ elementId, reason }) => {
185
+ console.warn(`Could not export HTML element ${elementId}: ${reason}`);
186
+ },
187
+ };
188
+
189
+ const png = await viewport.exportImage(options);
190
+ const svg = await viewport.exportSVG(options);
191
+ ```
192
+
193
+ Without `renderHtml`, embeds remain omitted and can be observed through `onHtmlError`.
194
+
195
+ ## Fog of War Styling
196
+
197
+ Solid fog remains the default. Hosts can opt into deterministic procedural presentation separately
198
+ for editor and player modes without changing or persisting the fog mask:
199
+
200
+ ```typescript
201
+ const viewport = new Viewport(container, {
202
+ fog: {
203
+ editorStyle: {
204
+ kind: 'procedural',
205
+ backdrop: 'rgba(30, 40, 60, 0.45)',
206
+ tint: 'rgba(150, 170, 210, 0.8)',
207
+ opacity: 0.45,
208
+ scale: 256, // world units per repeat
209
+ seed: 42,
210
+ detail: 2,
211
+ },
212
+ playerStyle: {
213
+ kind: 'procedural',
214
+ backdrop: '#0b1020',
215
+ tint: '#596683',
216
+ opacity: 0.55,
217
+ scale: 256,
218
+ seed: 42,
219
+ detail: 2,
220
+ },
221
+ },
222
+ });
223
+ ```
224
+
225
+ `backdrop` and `tint` accept Canvas-compatible CSS colors. Procedural player fog paints an opaque
226
+ safety base before its configurable backdrop and pattern. Style is local presentation only: it is
227
+ not included in `CanvasState`, history, autosave, or sync. Existing `editorColor` and `playerColor`
228
+ options remain available for solid fog.
229
+
230
+ ## Performance Monitoring
231
+
232
+ ```typescript
233
+ // Get a snapshot of render stats
234
+ const stats = viewport.getRenderStats();
235
+ // { fps, avgFrameMs, p95FrameMs, lastGridMs, frameCount }
236
+
237
+ // Log stats to console every 2 seconds (returns stop function)
238
+ const stop = viewport.logPerformance(2000);
239
+ // [FieldNotes] fps=60 frame=1.2ms p95=2.1ms grid=0.1ms
240
+ stop(); // stop logging
241
+ ```
242
+
243
+ ## Camera Control
244
+
245
+ ```typescript
246
+ const { camera } = viewport;
247
+
248
+ camera.pan(100, 50); // pan by offset
249
+ camera.moveTo(0, 0); // jump to position
250
+ camera.setZoom(2); // set zoom level
251
+ camera.zoomAt(1.5, { x: 400, y: 300 }); // zoom toward screen point
252
+
253
+ const world = camera.screenToWorld({ x: e.clientX, y: e.clientY });
254
+ const screen = camera.worldToScreen({ x: 0, y: 0 });
255
+
256
+ camera.onChange(() => {
257
+ /* camera moved */
258
+ });
259
+ ```
260
+
261
+ ## Element Store
262
+
263
+ Direct access to canvas elements:
264
+
265
+ ```typescript
266
+ const { store } = viewport;
267
+
268
+ const all = store.getAll(); // sorted by zIndex
269
+ const el = store.getById('some-id');
270
+ const strokes = store.getElementsByType('stroke');
271
+
272
+ store.update('some-id', { locked: true });
273
+ store.remove('some-id');
274
+
275
+ store.on('add', (el) => console.log('added', el));
276
+ store.on('remove', (el) => console.log('removed', el));
277
+ store.on('update', ({ previous, current }) => {
278
+ /* ... */
279
+ });
280
+ ```
281
+
282
+ ## Undo / Redo
283
+
284
+ ```typescript
285
+ viewport.undo();
286
+ viewport.redo();
287
+
288
+ viewport.history.canUndo; // boolean
289
+ viewport.history.canRedo; // boolean
290
+ viewport.history.onChange(() => {
291
+ /* update UI */
292
+ });
293
+ ```
294
+
295
+ ## Layers
296
+
297
+ 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.
298
+
299
+ ```typescript
300
+ const { layerManager } = viewport;
301
+
302
+ // Create layers
303
+ const background = layerManager.activeLayer; // "Layer 1" exists by default
304
+ layerManager.renameLayer(background.id, 'Map');
305
+ const tokens = layerManager.createLayer('Tokens');
306
+ const notes = layerManager.createLayer('Notes');
307
+
308
+ // Set active layer — new elements are created on the active layer
309
+ layerManager.setActiveLayer(tokens.id);
310
+
311
+ // Visibility and locking
312
+ layerManager.setLayerVisible(background.id, false); // hide
313
+ layerManager.setLayerLocked(background.id, true); // prevent selection/editing
314
+
315
+ // Move elements between layers
316
+ layerManager.moveElementToLayer(elementId, notes.id);
317
+
318
+ // Reorder layers
319
+ layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top
320
+
321
+ // Query
322
+ layerManager.getLayers(); // sorted by order
323
+ layerManager.isLayerVisible(id);
324
+ layerManager.isLayerLocked(id);
325
+
326
+ // Listen for changes
327
+ layerManager.on('change', () => {
328
+ /* update UI */
329
+ });
330
+ ```
331
+
332
+ 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.
333
+
334
+ ## State Serialization
335
+
336
+ ```typescript
337
+ // Save
338
+ const json = viewport.exportJSON();
339
+ localStorage.setItem('canvas', json);
340
+
341
+ // Load
342
+ viewport.loadJSON(localStorage.getItem('canvas'));
343
+ ```
344
+
345
+ > **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".
346
+
347
+ > **Two equivalent pairs:** `exportJSON()` / `loadJSON()` work with strings and are the
348
+ > canonical choice for persistence. `exportState()` / `loadState()` work with in-memory
349
+ > `CanvasState` objects, skipping the JSON round-trip — this is what `AutoSave` uses. The
350
+ > module-level `exportState` / `parseState` functions are no longer exported; use the
351
+ > `Viewport` methods.
352
+
353
+ ## Tool Switching
354
+
355
+ ```typescript
356
+ viewport.setTool('pencil');
357
+ viewport.setTool('hand');
358
+
359
+ viewport.toolManager.onChange((toolName) => {
360
+ console.log('switched to', toolName);
361
+ });
362
+ ```
363
+
364
+ ## Keyboard shortcuts
365
+
366
+ Defaults (remappable): `Delete`/`Backspace` delete · `Escape` deselect · `mod+Z` undo ·
367
+ `mod+Y`/`mod+Shift+Z` redo · `mod+A` select all · `mod+C/V/D` copy/paste/duplicate ·
368
+ `[`/`]` z-order (with `mod` = to back/front) · `Shift+1` zoom-to-fit · `mod+=` zoom in ·
369
+ `mod+-` zoom out · `mod+0` reset zoom to 100% · arrows nudge
370
+ (`Shift` = one grid cell) · tool keys `V` select, `H` hand, `P` pencil, `E` eraser,
371
+ `A` arrow, `N` note, `T` text, `S` shape, `M` measure, `G` template.
372
+
373
+ `mod` = Ctrl or Cmd. Shortcuts fire only while the canvas has focus (click it once);
374
+ pass `shortcuts: { scope: 'window' }` for page-wide handling.
375
+
376
+ ```ts
377
+ const viewport = new Viewport(el, {
378
+ shortcuts: {
379
+ bindings: {
380
+ duplicate: 'mod+shift+d', // remap
381
+ 'tool:pencil': ['p', 'b'], // multiple bindings
382
+ copy: null, // disable
383
+ 'tool:my-custom-tool': 'f', // any registered tool works
384
+ },
385
+ },
386
+ });
387
+
388
+ viewport.shortcuts.rebind('undo', 'mod+u');
389
+ viewport.shortcuts.disable('select-all');
390
+ viewport.shortcuts.reset(); // back to defaults
391
+ viewport.shortcuts.getBindings(); // current table — render a settings UI
392
+ ```
393
+
394
+ ## Changing Tool Options at Runtime
395
+
396
+ All drawing tools support `setOptions()` for changing color, width, and other settings without re-creating the tool:
397
+
398
+ ```typescript
399
+ // Get a tool by name (type-safe with generics)
400
+ const pencil = viewport.toolManager.getTool<PencilTool>('pencil');
401
+ const arrow = viewport.toolManager.getTool<ArrowTool>('arrow');
402
+ const note = viewport.toolManager.getTool<NoteTool>('note');
403
+
404
+ // Change colors
405
+ pencil?.setOptions({ color: '#ff0000' });
406
+ arrow?.setOptions({ color: '#ff0000' });
407
+ note?.setOptions({ backgroundColor: '#e8f5e9' });
408
+
409
+ // Change stroke width
410
+ pencil?.setOptions({ width: 5 });
411
+ arrow?.setOptions({ width: 3 });
412
+ ```
413
+
414
+ ### Stroke Smoothing
415
+
416
+ The pencil tool automatically smooths freehand strokes using Ramer-Douglas-Peucker point simplification and Catmull-Rom curve fitting. You can control the smoothing tolerance:
417
+
418
+ ```typescript
419
+ new PencilTool({
420
+ smoothing: 1.5, // default — higher = smoother, lower = more detail
421
+ });
422
+
423
+ // Or at runtime
424
+ pencil?.setOptions({ smoothing: 3 });
425
+ ```
426
+
427
+ ### Pressure-Sensitive Width
428
+
429
+ 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.
430
+
431
+ Stroke points include pressure data in the `StrokePoint` type:
432
+
433
+ ```typescript
434
+ interface StrokePoint {
435
+ x: number;
436
+ y: number;
437
+ pressure: number; // 0-1
438
+ }
439
+ ```
440
+
441
+ ## Custom Tools
442
+
443
+ Implement the `Tool` interface to create your own tools:
444
+
445
+ ```typescript
446
+ import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
447
+
448
+ const myTool: Tool = {
449
+ name: 'my-tool',
450
+
451
+ onPointerDown(state: PointerState, ctx: ToolContext) {
452
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
453
+ // state.pressure is available for stylus input (0-1)
454
+ },
455
+
456
+ onPointerMove(state: PointerState, ctx: ToolContext) {
457
+ // called during drag
458
+ },
459
+
460
+ onPointerUp(state: PointerState, ctx: ToolContext) {
461
+ // finalize action
462
+ ctx.store.add(myElement);
463
+ ctx.requestRender();
464
+ },
465
+
466
+ // Optional
467
+ onActivate(ctx) {
468
+ ctx.setCursor?.('crosshair');
469
+ },
470
+ onDeactivate(ctx) {
471
+ ctx.setCursor?.('default');
472
+ },
473
+ renderOverlay(canvasCtx) {
474
+ /* draw preview on canvas */
475
+ },
476
+ };
477
+
478
+ viewport.toolManager.register(myTool);
479
+ viewport.setTool('my-tool');
480
+ ```
481
+
482
+ ## Configuration
483
+
484
+ ### Viewport Options
485
+
486
+ ```typescript
487
+ new Viewport(container, {
488
+ camera: {
489
+ minZoom: 0.1, // default: 0.1
490
+ maxZoom: 10, // default: 10
491
+ },
492
+ background: {
493
+ pattern: 'dots', // 'dots' | 'grid' | 'none' (default: 'dots')
494
+ spacing: 24, // grid spacing in px (default: 24)
495
+ color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
496
+ },
497
+ // Called for every drop; replaces the built-in image-drop handling
498
+ onDrop: (event, worldPosition) => {
499
+ /* handle drop */
500
+ },
501
+ // Called when an image element fails to load; failed images render a gray
502
+ // placeholder. Falls back to console.warn when unset.
503
+ onImageError: ({ src, elementIds }) => {
504
+ /* handle broken image */
505
+ },
506
+ });
507
+ ```
508
+
509
+ ### ViewportOptions reference
510
+
511
+ - `camera?: CameraOptions` — `minZoom` / `maxZoom` (defaults `0.1` / `10`).
512
+ - `background?: BackgroundOptions` — `pattern`, `spacing`, `color`.
513
+ - `fontSizePresets?: FontSizePreset[]` — custom font-size steps for the note toolbar.
514
+ - `toolbar?: boolean` — show/hide the note formatting toolbar (default `true`).
515
+ - `placeholder?: string` — placeholder text shown in empty notes.
516
+ - `shortcuts?: ShortcutOptions` seed the keyboard shortcut table with custom bindings.
517
+ - `onHtmlElementMount?` — called after `loadState` for HTML elements that need content injected.
518
+ - `onDrop?` — called for every drop event; replaces the built-in image-drop handling.
519
+ - `onImageError?` called when an image element fails to load.
520
+ - `panBufferMargin?: number` (default `256`) CSS-pixel margin cached beyond the viewport so
521
+ small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan
522
+ reuse, more memory per layer. Set `0` to disable (exact-viewport caches) on memory-tight hosts.
523
+
524
+ ### Tool Options
525
+
526
+ ```typescript
527
+ new PencilTool({ color: '#ff0000', width: 3, smoothing: 1.5 });
528
+ 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
529
+ new ArrowTool({ color: '#333', width: 2 });
530
+ ```
531
+
532
+ `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:
533
+
534
+ ```typescript
535
+ // Register a highlighter alongside the standard pencil
536
+ viewport.toolManager.register(
537
+ new PencilTool({
538
+ name: 'highlighter',
539
+ color: '#facc15',
540
+ width: 12,
541
+ opacity: 0.4,
542
+ blendMode: 'multiply',
543
+ }),
544
+ );
545
+ viewport.setTool('highlighter');
546
+ ```
547
+
548
+ `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.
549
+
550
+ ### Arrow Labels
551
+
552
+ 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.
553
+
554
+ ```typescript
555
+ createArrow({ from: { x: 0, y: 0 }, to: { x: 200, y: 0 }, label: 'depends on' });
556
+ new NoteTool({ backgroundColor: '#fff9c4', size: { w: 200, h: 150 } });
557
+ new ImageTool({ size: { w: 400, h: 300 } });
558
+ ```
559
+
560
+ ## Element Types
561
+
562
+ All elements share a base shape:
563
+
564
+ ```typescript
565
+ interface BaseElement {
566
+ id: string;
567
+ type: string;
568
+ position: { x: number; y: number };
569
+ zIndex: number;
570
+ locked: boolean;
571
+ layerId: string;
572
+ }
573
+ ```
574
+
575
+ | Type | Key Fields |
576
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
577
+ | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
578
+ | `note` | `size`, `text`, `backgroundColor`, `textColor` |
579
+ | `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
580
+ | `image` | `size`, `src` |
581
+ | `shape` | `size`, `shape` (`rectangle` \| `ellipse` \| `line`), `strokeColor`, `fillColor`, `flip` (`boolean` — which bbox diagonal a line runs along) |
582
+ | `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
583
+ | `grid` | `gridType` (`square` \| `hex`), `hexOrientation`, `cellSize`, `strokeColor`, `opacity` |
584
+ | `html` | `size` |
585
+
586
+ ## Styling the Selection
587
+
588
+ 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.
589
+
590
+ ### `ElementStyle` interface
591
+
592
+ ```typescript
593
+ interface ElementStyle {
594
+ color?: string; // stroke color / text color
595
+ fillColor?: string; // fill / background color
596
+ strokeWidth?: number; // line width in world-space units
597
+ opacity?: number; // 0–1
598
+ fontSize?: number; // px
599
+ }
600
+ ```
601
+
602
+ #### Mapping across element types
603
+
604
+ | `ElementStyle` field | `stroke` | `arrow` | `shape` | `note` | `text` |
605
+ | -------------------- | --------- | ------- | ------------- | ----------------- | ---------- |
606
+ | `color` | `color` | `color` | `strokeColor` | `textColor` | `color` |
607
+ | `fillColor` | — | — | `fillColor` | `backgroundColor` | — |
608
+ | `strokeWidth` | `width` | `width` | `strokeWidth` | — | |
609
+ | `opacity` | `opacity` | — | — | — | — |
610
+ | `fontSize` | — | — | — | (via toolbar) | `fontSize` |
611
+
612
+ ### Conversion helpers
613
+
614
+ ```typescript
615
+ import { styleToPatch, getElementStyle } from '@fieldnotes/core';
616
+
617
+ // ElementStyle element-specific patch object
618
+ const patch = styleToPatch(element, { color: '#e00', strokeWidth: 3 });
619
+ store.update(element.id, patch);
620
+
621
+ // element → normalized ElementStyle
622
+ const style = getElementStyle(element);
623
+ ```
624
+
625
+ ### Viewport methods
626
+
627
+ - **`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).
628
+ - **`viewport.onSelectionChange(listener)`** — subscribes to selection changes; returns an unsubscribe function. The listener receives the new stable id array.
629
+ - **`viewport.getSelectionStyle()`** — returns an `ElementStyle` containing only the properties that are identical across every selected element. Properties that differ are omitted.
630
+ - **`viewport.applyStyleToSelection(style)`** applies the given `ElementStyle` to all selected elements in a single undo step.
631
+
632
+ ### `SelectTool.onSelectionChange`
633
+
634
+ ```typescript
635
+ const selectTool = viewport.toolManager.getTool<SelectTool>('select');
636
+ selectTool?.onSelectionChange((ids) => {
637
+ console.log('selected:', ids);
638
+ });
639
+ ```
640
+
641
+ ### Example
642
+
643
+ ```typescript
644
+ // Apply a red stroke to everything currently selected — one undo step
645
+ viewport.applyStyleToSelection({ color: '#ff0000' });
646
+
647
+ // Read back the shared style for a UI color picker
648
+ const style = viewport.getSelectionStyle();
649
+ // style.color is defined only if all selected elements share the same color
650
+
651
+ // React to selection changes
652
+ const unsub = viewport.onSelectionChange((ids) => {
653
+ setSelectedIds(ids); // ids is referentially stable — safe for deps arrays
654
+ });
655
+ // call unsub() to unsubscribe
656
+ ```
657
+
658
+ ## Aligning the Selection
659
+
660
+ Two methods on `Viewport` let you snap or space selected elements in one undo step.
661
+
662
+ - **`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.
663
+ - **`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.
664
+
665
+ ```typescript
666
+ viewport.alignSelection('left'); // flush left edges
667
+ viewport.alignSelection('center-x'); // center on vertical axis
668
+ viewport.alignSelection('middle'); // center on horizontal axis
669
+ viewport.distributeSelection('horizontal'); // equal horizontal spacing
670
+ ```
671
+
672
+ Grids are ignored by both operations.
673
+
674
+ ## Smart Alignment Guides
675
+
676
+ 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.
677
+
678
+ ```typescript
679
+ viewport.setSmartGuides(true); // enable
680
+ viewport.setSmartGuides(false); // disable (default)
681
+ ```
682
+
683
+ ## Grouping
684
+
685
+ Group elements so they select, move, delete, z-order, and align as a single unit.
686
+
687
+ - **`viewport.groupSelection()`** groups the current selection under a new id.
688
+ - **`viewport.ungroupSelection()`** dissolves any groups in the current selection.
689
+
690
+ 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.
691
+
692
+ ```typescript
693
+ viewport.groupSelection(); // Ctrl/Cmd+G
694
+ viewport.ungroupSelection(); // Ctrl/Cmd+Shift+G
695
+ ```
696
+
697
+ The shortcuts are rebindable as `group` and `ungroup`.
698
+
699
+ ## Rotation
700
+
701
+ 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.
702
+
703
+ 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).
704
+
705
+ ## Context menu & lock
706
+
707
+ 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 })`.
708
+
709
+ 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`.
710
+
711
+ 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.
712
+
713
+ ## Built-in Interactions
714
+
715
+ | Input | Action |
716
+ | -------------------- | ------------------- |
717
+ | Scroll wheel | Zoom |
718
+ | Middle-click drag | Pan |
719
+ | Space + drag | Pan |
720
+ | Two-finger pinch | Zoom |
721
+ | Two-finger drag | Pan |
722
+ | Delete / Backspace | Remove selected |
723
+ | Ctrl+Z / Cmd+Z | Undo |
724
+ | Ctrl+Shift+Z / Cmd+Y | Redo |
725
+ | Double-click note | Edit text |
726
+ | Double-click HTML | Enter interact mode |
727
+ | Escape | Exit interact mode |
728
+
729
+ ## Browser Support
730
+
731
+ Works in all modern browsers supporting Pointer Events API and HTML5 Canvas.
732
+
733
+ ## Versioning
734
+
735
+ `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
736
+ package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
737
+ versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
738
+ ever breaks the wrapper, a coordinated react release raises the lower bound.
739
+
740
+ ## License
741
+
742
+ MIT