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