@fieldnotes/react 0.10.0 → 0.12.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,446 +1,462 @@
1
- # @fieldnotes/react
2
-
3
- React bindings for the [Field Notes](https://github.com/IrakliDevelop/fieldnotes) infinite canvas SDK. Embed React components directly onto an infinite, pannable, zoomable canvas.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @fieldnotes/core @fieldnotes/react
9
- ```
10
-
11
- Requires React 18+.
12
-
13
- ## Quick Start
14
-
15
- Full runnable example: `examples/react-app` — `pnpm --filter fieldnotes-react-example dev`
16
-
17
- The canvas needs a container with a defined size — it fills whatever it's given:
18
-
19
- ```tsx
20
- import { useState } from 'react';
21
- import { FieldNotesCanvas } from '@fieldnotes/react';
22
- import {
23
- HandTool,
24
- SelectTool,
25
- PencilTool,
26
- EraserTool,
27
- NoteTool,
28
- ShapeTool,
29
- } from '@fieldnotes/core';
30
-
31
- const TOOLS = [
32
- new HandTool(),
33
- new SelectTool(),
34
- new PencilTool(),
35
- new EraserTool(),
36
- new NoteTool(),
37
- new ShapeTool(),
38
- ];
39
-
40
- export function App() {
41
- const [tool, setTool] = useState('select');
42
-
43
- return (
44
- <div style={{ width: '100vw', height: '100vh' }}>
45
- <FieldNotesCanvas
46
- tools={TOOLS}
47
- tool={tool}
48
- onToolChange={setTool}
49
- style={{ width: '100%', height: '100%' }}
50
- >
51
- <Toolbar tool={tool} onSelect={setTool} />
52
- </FieldNotesCanvas>
53
- </div>
54
- );
55
- }
56
-
57
- function Toolbar({ tool, onSelect }: { tool: string; onSelect: (name: string) => void }) {
58
- const TOOL_LABELS: readonly (readonly [string, string])[] = [
59
- ['select', 'Select'],
60
- ['hand', 'Hand'],
61
- ['pencil', 'Pencil'],
62
- ['eraser', 'Eraser'],
63
- ['note', 'Note'],
64
- ['shape', 'Shape'],
65
- ];
66
-
67
- return (
68
- <div className="toolbar">
69
- {TOOL_LABELS.map(([name, label]) => (
70
- <button key={name} className={tool === name ? 'active' : ''} onClick={() => onSelect(name)}>
71
- {label}
72
- </button>
73
- ))}
74
- </div>
75
- );
76
- }
77
- ```
78
-
79
- ## Prop Reactivity
80
-
81
- Not all props are reactive — the canvas is stateful. Changing a mount-only prop after mount has no effect:
82
-
83
- | Prop | Reactivity |
84
- | ---------------------------------- | ----------------------------------------------------------------------------------- |
85
- | `options` | Mount-only — the canvas is stateful; construct with the right options |
86
- | `tools` | Reactive, append-only (tools cannot be unregistered). Hoist the array out of render |
87
- | `tool` / `onToolChange` | Reactive (controlled). Memoize the callback |
88
- | `defaultTool` | Mount-only (uncontrolled initial tool) |
89
- | `snapToGrid` | Reactive |
90
- | `className` / `style` / `children` | Reactive (plain React) |
91
-
92
- Runtime changes beyond these go through the viewport — access it via `useViewport()` or the ref: `viewport.setSnapToGrid(...)`, `viewport.shortcuts.rebind(...)`, `viewport.fitToContent()`. The background pattern is constructor-only (`options.background`).
93
-
94
- ## Embedding React Components
95
-
96
- The headline feature — render any React subtree as a canvas element that pans, zooms, and resizes with the canvas:
97
-
98
- ```tsx
99
- import { FieldNotesCanvas, CanvasElement } from '@fieldnotes/react';
100
- import { SelectTool } from '@fieldnotes/core';
101
-
102
- function App() {
103
- return (
104
- <FieldNotesCanvas
105
- tools={[new SelectTool()]}
106
- defaultTool="select"
107
- style={{ width: '100vw', height: '100vh' }}
108
- >
109
- <CanvasElement position={{ x: 100, y: 200 }} size={{ w: 300, h: 200 }}>
110
- <MyCard />
111
- </CanvasElement>
112
-
113
- <CanvasElement position={{ x: 500, y: 100 }}>
114
- <button onClick={() => console.log('clicked!')}>Interactive button on the canvas</button>
115
- </CanvasElement>
116
- </FieldNotesCanvas>
117
- );
118
- }
119
- ```
120
-
121
- Embedded components use a **two-mode interaction model**: by default they can be selected, dragged, and resized. **Double-click** to enter interact mode (clicks, inputs, forms work). **Escape** or click outside to exit.
122
-
123
- `position` is required; `size` is optional — omit it to let the element size to its content. Both props are reactive: updating them moves or resizes the element on the canvas.
124
-
125
- ## Undo / Redo
126
-
127
- `useHistory` returns reactive undo/redo state and action callbacks:
128
-
129
- ```tsx
130
- import { useHistory } from '@fieldnotes/react';
131
-
132
- function UndoRedo() {
133
- const { canUndo, canRedo, undo, redo } = useHistory();
134
-
135
- return (
136
- <div>
137
- <button onClick={undo} disabled={!canUndo}>
138
- Undo
139
- </button>
140
- <button onClick={redo} disabled={!canRedo}>
141
- Redo
142
- </button>
143
- </div>
144
- );
145
- }
146
- ```
147
-
148
- ## Why Is My Sidebar Re-rendering 60×/s?
149
-
150
- `useElements()` with no arguments re-renders on every store mutation. For derived values, pass a selector — the hook only re-renders when the selected value changes:
151
-
152
- ```tsx
153
- import { useCallback } from 'react';
154
- import { useElements } from '@fieldnotes/react';
155
- import type { CanvasElement } from '@fieldnotes/core';
156
-
157
- export function Sidebar() {
158
- // Stable selector: useCallback with [] dependency or module-scope function
159
- const count = useElements(useCallback((els: CanvasElement[]) => els.length, []));
160
- const notes = useElements('note');
161
-
162
- return (
163
- <div className="sidebar">
164
- <p>{count} elements</p>
165
- <ul>
166
- {notes.map((n) => (
167
- <li key={n.id}>{n.text ? n.text.replace(/<[^>]+>/g, '').slice(0, 30) : '(empty)'}</li>
168
- ))}
169
- </ul>
170
- </div>
171
- );
172
- }
173
- ```
174
-
175
- Alternatively, hoist expensive rendering into a memoized child and pass elements as props — React's `memo` then does the comparison.
176
-
177
- **Default comparator is one-level shallow**: arrays are compared by index, plain objects by key, primitives by `Object.is`. Selectors returning nested fresh objects (e.g. `els => els.map(e => ({ ...e.position }))`) still cause re-renders with the default comparator — pass a custom `isEqual` as the second argument:
178
-
179
- ```tsx
180
- const positions = useElements(
181
- useCallback((els: CanvasElement[]) => els.map((e) => e.position), []),
182
- (a, b) => a.length === b.length && a.every((p, i) => p.x === b[i]?.x && p.y === b[i]?.y),
183
- );
184
- ```
185
-
186
- ## Selection & Styling
187
-
188
- ### `useSelection`
189
-
190
- Returns the current selection as a reactive, referentially-stable `string[]` of element IDs. Re-renders only when the selection changes.
191
-
192
- ```tsx
193
- import { useSelection } from '@fieldnotes/react';
194
-
195
- function SelectionBadge() {
196
- const ids = useSelection();
197
- return <span>{ids.length} selected</span>;
198
- }
199
- ```
200
-
201
- ### `useSelectionStyle`
202
-
203
- Returns `[style, applyStyle]` — the shared style of the current selection and a stable callback to apply a style patch to it.
204
-
205
- - `style` is an `ElementStyle` (`{ color?, fillColor?, strokeWidth?, opacity?, fontSize? }`) containing only properties that are identical across all selected elements. Properties that differ are omitted. `style` is `null` when nothing is selected.
206
- - `applyStyle(patch)` applies the patch to the current selection in a single undo step.
207
-
208
- ```tsx
209
- import { useSelectionStyle } from '@fieldnotes/react';
210
-
211
- function StyleToolbar() {
212
- const [style, applyStyle] = useSelectionStyle();
213
-
214
- return (
215
- <div>
216
- <input
217
- type="color"
218
- value={style?.color ?? '#000000'}
219
- disabled={!style}
220
- onChange={(e) => applyStyle({ color: e.target.value })}
221
- />
222
- <input
223
- type="range"
224
- min={1}
225
- max={20}
226
- value={style?.strokeWidth ?? 2}
227
- disabled={!style}
228
- onChange={(e) => applyStyle({ strokeWidth: Number(e.target.value) })}
229
- />
230
- </div>
231
- );
232
- }
233
- ```
234
-
235
- ### `useSelectionOps`
236
-
237
- Returns reactive selection state plus group/ungroup/lock/align/distribute actions for the core selection operations. Re-renders only when the selection (or its derived predicates) changes.
238
-
239
- - State: `selectedIds`, `selectedCount`, `canGroup`, `canUngroup`, `canAlign`, `canDistribute`, and `isLocked` (`true`/`false` when the selection is uniformly locked/unlocked, `null` when empty or mixed).
240
- - Actions: `group()`, `ungroup()`, `toggleLock()`, `align(edge)`, `distribute(axis)` — each runs in a single undo step. Requires core `>=0.36.0`.
241
-
242
- ```tsx
243
- import { useSelectionOps } from '@fieldnotes/react';
244
-
245
- function SelectionToolbar() {
246
- const { selectedCount, canGroup, canUngroup, isLocked, group, ungroup, toggleLock, align, distribute } = useSelectionOps();
247
-
248
- return (
249
- <div>
250
- <button disabled={!canGroup} onClick={group}>Group</button>
251
- <button disabled={!canUngroup} onClick={ungroup}>Ungroup</button>
252
- <button disabled={selectedCount === 0} onClick={toggleLock}>{isLocked ? 'Unlock' : 'Lock'}</button>
253
- <button onClick={() => align('left')}>Align left</button>
254
- <button onClick={() => distribute('horizontal')}>Distribute</button>
255
- </div>
256
- );
257
- }
258
- ```
259
-
260
- See [core README](../core/README.md#styling-the-selection) for the full `ElementStyle` mapping table and the underlying `Viewport` methods.
261
-
262
- ## Save / Load
263
-
264
- Access the viewport imperatively for export and import:
265
-
266
- ```tsx
267
- import { useViewport } from '@fieldnotes/react';
268
-
269
- export function SaveControls() {
270
- const viewport = useViewport();
271
-
272
- const save = () => {
273
- localStorage.setItem('fieldnotes-example', viewport.exportJSON());
274
- };
275
- const load = () => {
276
- const json = localStorage.getItem('fieldnotes-example');
277
- if (json) viewport.loadJSON(json);
278
- };
279
-
280
- return (
281
- <div>
282
- <button onClick={save}>Save</button>
283
- <button onClick={load}>Load</button>
284
- </div>
285
- );
286
- }
287
- ```
288
-
289
- For periodic auto-saving, use `AutoSave` from `@fieldnotes/core` — it debounces writes to `localStorage` and subscribes to the store and camera automatically:
290
-
291
- ```tsx
292
- import { AutoSave } from '@fieldnotes/core';
293
-
294
- // in onReady or a useEffect after useViewport():
295
- const autoSave = new AutoSave(viewport.store, viewport.camera, {
296
- key: 'my-board',
297
- layerManager: viewport.layerManager,
298
- });
299
- autoSave.start();
300
- // call autoSave.stop() on cleanup
301
- ```
302
-
303
- Pass `layerManager` — without it, saved boards lose their layer structure.
304
-
305
- ## Custom Tools
306
-
307
- Implement the `Tool` interface from `@fieldnotes/core` and register it via the `tools` prop. Hoist the array so instances are not recreated on every render:
308
-
309
- ```tsx
310
- import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
311
- import { createNote } from '@fieldnotes/core';
312
-
313
- export class StampTool implements Tool {
314
- readonly name = 'stamp';
315
-
316
- onPointerDown(state: PointerState, ctx: ToolContext): void {
317
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
318
- ctx.store.add(
319
- createNote({
320
- position: { x: world.x - 50, y: world.y - 25 },
321
- size: { w: 100, h: 50 },
322
- text: 'stamp',
323
- layerId: ctx.activeLayerId ?? '',
324
- }),
325
- );
326
- ctx.requestRender();
327
- }
328
-
329
- onPointerMove(_state: PointerState, _ctx: ToolContext): void {}
330
- onPointerUp(_state: PointerState, _ctx: ToolContext): void {}
331
- }
332
- ```
333
-
334
- Register alongside the built-in tools:
335
-
336
- ```tsx
337
- import { HandTool, SelectTool } from '@fieldnotes/core';
338
- import { StampTool } from './StampTool';
339
-
340
- // Hoisted — stable reference across renders
341
- const TOOLS = [new HandTool(), new SelectTool(), new StampTool()];
342
-
343
- function App() {
344
- const [tool, setTool] = useState('select');
345
- return (
346
- <FieldNotesCanvas tools={TOOLS} tool={tool} onToolChange={setTool} style={...}>
347
- <Toolbar tool={tool} onSelect={setTool} />
348
- </FieldNotesCanvas>
349
- );
350
- }
351
- ```
352
-
353
- ## Events
354
-
355
- ### `onReady`
356
-
357
- Fires once after the `Viewport` is created and tools are registered. Use it for imperative setup that can't wait for a child hook:
358
-
359
- ```tsx
360
- <FieldNotesCanvas
361
- tools={TOOLS}
362
- onReady={(viewport) => {
363
- const saved = localStorage.getItem('board');
364
- if (saved) viewport.loadJSON(saved);
365
- }}
366
- style={{ width: '100%', height: '100%' }}
367
- />
368
- ```
369
-
370
- ### `onToolChange`
371
-
372
- Fires whenever the active tool changes — from the keyboard, the API, or the controlled `tool` prop. Pair with `useState` and memoize with `useCallback` when the function body is non-trivial:
373
-
374
- ```tsx
375
- const [tool, setTool] = useState('select');
376
-
377
- <FieldNotesCanvas tool={tool} onToolChange={setTool} tools={TOOLS} style={...} />
378
- ```
379
-
380
- ### `options.onImageError`
381
-
382
- Called when an image element fails to load. Receives `{ src: string; elementIds: string[] }` — the source URL and all element IDs that reference it:
383
-
384
- ```tsx
385
- <FieldNotesCanvas
386
- tools={TOOLS}
387
- options={{
388
- onImageError: ({ src, elementIds }) => {
389
- console.warn(`Image failed: ${src}`, elementIds);
390
- },
391
- }}
392
- style={{ width: '100%', height: '100%' }}
393
- />
394
- ```
395
-
396
- ### `options.onDrop`
397
-
398
- Fires for **every** drop event on the canvas surface. Providing this callback replaces the built-in image-drop handling entirely if you want images to work, handle them yourself. Receives the original `DragEvent` and the world-space drop position:
399
-
400
- ```tsx
401
- <FieldNotesCanvas
402
- tools={TOOLS}
403
- options={{
404
- onDrop: (event, worldPosition) => {
405
- const url = event.dataTransfer?.getData('text/uri-list');
406
- if (url) {
407
- // add your own image element at worldPosition
408
- }
409
- },
410
- }}
411
- style={{ width: '100%', height: '100%' }}
412
- />
413
- ```
414
-
415
- ## Escape Hatch
416
-
417
- `useViewport()` returns the raw `Viewport` instance for anything not covered by the hooks above. A `ref` on `<FieldNotesCanvas>` gives the same access outside of child components:
418
-
419
- ```tsx
420
- import { useRef } from 'react';
421
- import { FieldNotesCanvas, type FieldNotesCanvasRef } from '@fieldnotes/react';
422
-
423
- function App() {
424
- const canvasRef = useRef<FieldNotesCanvasRef>(null);
425
-
426
- return (
427
- <>
428
- <FieldNotesCanvas ref={canvasRef} style={{ width: '100vw', height: '100vh' }} />
429
- <button onClick={() => canvasRef.current?.viewport?.fitToContent()}>Fit to content</button>
430
- </>
431
- );
432
- }
433
- ```
434
-
435
- See the [core README](../core/README.md) for the full `Viewport` API — `exportJSON`/`loadJSON`, `fitToContent`, `shortcuts.rebind`, `setSnapToGrid`, and more.
436
-
437
- ## Versioning
438
-
439
- `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
440
- package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
441
- versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
442
- ever breaks the wrapper, a coordinated react release raises the lower bound.
443
-
444
- ## License
445
-
446
- MIT
1
+ # @fieldnotes/react
2
+
3
+ React bindings for the [Field Notes](https://github.com/IrakliDevelop/fieldnotes) infinite canvas SDK. Embed React components directly onto an infinite, pannable, zoomable canvas.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @fieldnotes/core @fieldnotes/react
9
+ ```
10
+
11
+ Requires React 18+.
12
+
13
+ ## Quick Start
14
+
15
+ Full runnable example: `examples/react-app` — `pnpm --filter fieldnotes-react-example dev`
16
+
17
+ The canvas needs a container with a defined size — it fills whatever it's given:
18
+
19
+ ```tsx
20
+ import { useState } from 'react';
21
+ import { FieldNotesCanvas } from '@fieldnotes/react';
22
+ import {
23
+ HandTool,
24
+ SelectTool,
25
+ PencilTool,
26
+ EraserTool,
27
+ NoteTool,
28
+ ShapeTool,
29
+ } from '@fieldnotes/core';
30
+
31
+ const TOOLS = [
32
+ new HandTool(),
33
+ new SelectTool(),
34
+ new PencilTool(),
35
+ new EraserTool(),
36
+ new NoteTool(),
37
+ new ShapeTool(),
38
+ ];
39
+
40
+ export function App() {
41
+ const [tool, setTool] = useState('select');
42
+
43
+ return (
44
+ <div style={{ width: '100vw', height: '100vh' }}>
45
+ <FieldNotesCanvas
46
+ tools={TOOLS}
47
+ tool={tool}
48
+ onToolChange={setTool}
49
+ style={{ width: '100%', height: '100%' }}
50
+ >
51
+ <Toolbar tool={tool} onSelect={setTool} />
52
+ </FieldNotesCanvas>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function Toolbar({ tool, onSelect }: { tool: string; onSelect: (name: string) => void }) {
58
+ const TOOL_LABELS: readonly (readonly [string, string])[] = [
59
+ ['select', 'Select'],
60
+ ['hand', 'Hand'],
61
+ ['pencil', 'Pencil'],
62
+ ['eraser', 'Eraser'],
63
+ ['note', 'Note'],
64
+ ['shape', 'Shape'],
65
+ ];
66
+
67
+ return (
68
+ <div className="toolbar">
69
+ {TOOL_LABELS.map(([name, label]) => (
70
+ <button key={name} className={tool === name ? 'active' : ''} onClick={() => onSelect(name)}>
71
+ {label}
72
+ </button>
73
+ ))}
74
+ </div>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ## Prop Reactivity
80
+
81
+ Not all props are reactive — the canvas is stateful. Changing a mount-only prop after mount has no effect:
82
+
83
+ | Prop | Reactivity |
84
+ | ---------------------------------- | ----------------------------------------------------------------------------------- |
85
+ | `options` | Mount-only — the canvas is stateful; construct with the right options |
86
+ | `tools` | Reactive, append-only (tools cannot be unregistered). Hoist the array out of render |
87
+ | `tool` / `onToolChange` | Reactive (controlled). Memoize the callback |
88
+ | `defaultTool` | Mount-only (uncontrolled initial tool) |
89
+ | `snapToGrid` (deprecated) | Reactive compatibility prop; prefer a domain constraint service |
90
+ | `className` / `style` / `children` | Reactive (plain React) |
91
+
92
+ Runtime changes beyond these go through the viewport — access it via `useViewport()` or the ref. Grid snapping is domain behavior; VTT consumers should configure `GridController` and its constraint service rather than adopting the deprecated React prop. The background pattern is constructor-only (`options.background`).
93
+
94
+ ## Embedding React Components
95
+
96
+ The headline feature — render any React subtree as a canvas element that pans, zooms, and resizes with the canvas:
97
+
98
+ ```tsx
99
+ import { FieldNotesCanvas, CanvasElement } from '@fieldnotes/react';
100
+ import { SelectTool } from '@fieldnotes/core';
101
+
102
+ function App() {
103
+ return (
104
+ <FieldNotesCanvas
105
+ tools={[new SelectTool()]}
106
+ defaultTool="select"
107
+ style={{ width: '100vw', height: '100vh' }}
108
+ >
109
+ <CanvasElement position={{ x: 100, y: 200 }} size={{ w: 300, h: 200 }}>
110
+ <MyCard />
111
+ </CanvasElement>
112
+
113
+ <CanvasElement position={{ x: 500, y: 100 }}>
114
+ <button onClick={() => console.log('clicked!')}>Interactive button on the canvas</button>
115
+ </CanvasElement>
116
+ </FieldNotesCanvas>
117
+ );
118
+ }
119
+ ```
120
+
121
+ Embedded components use a **two-mode interaction model**: by default they can be selected, dragged, and resized. **Double-click** to enter interact mode (clicks, inputs, forms work). **Escape** or click outside to exit.
122
+
123
+ `position` is required; `size` is optional — omit it to let the element size to its content. Both props are reactive: updating them moves or resizes the element on the canvas.
124
+
125
+ ## Undo / Redo
126
+
127
+ `useHistory` returns reactive undo/redo state and action callbacks:
128
+
129
+ ```tsx
130
+ import { useHistory } from '@fieldnotes/react';
131
+
132
+ function UndoRedo() {
133
+ const { canUndo, canRedo, undo, redo } = useHistory();
134
+
135
+ return (
136
+ <div>
137
+ <button onClick={undo} disabled={!canUndo}>
138
+ Undo
139
+ </button>
140
+ <button onClick={redo} disabled={!canRedo}>
141
+ Redo
142
+ </button>
143
+ </div>
144
+ );
145
+ }
146
+ ```
147
+
148
+ ## Why Is My Sidebar Re-rendering 60×/s?
149
+
150
+ `useElements()` with no arguments re-renders on every store mutation. For derived values, pass a selector — the hook only re-renders when the selected value changes:
151
+
152
+ ```tsx
153
+ import { useCallback } from 'react';
154
+ import { useElements } from '@fieldnotes/react';
155
+ import type { CanvasElement } from '@fieldnotes/core';
156
+
157
+ export function Sidebar() {
158
+ // Stable selector: useCallback with [] dependency or module-scope function
159
+ const count = useElements(useCallback((els: CanvasElement[]) => els.length, []));
160
+ const notes = useElements('note');
161
+
162
+ return (
163
+ <div className="sidebar">
164
+ <p>{count} elements</p>
165
+ <ul>
166
+ {notes.map((n) => (
167
+ <li key={n.id}>{n.text ? n.text.replace(/<[^>]+>/g, '').slice(0, 30) : '(empty)'}</li>
168
+ ))}
169
+ </ul>
170
+ </div>
171
+ );
172
+ }
173
+ ```
174
+
175
+ Alternatively, hoist expensive rendering into a memoized child and pass elements as props — React's `memo` then does the comparison.
176
+
177
+ **Default comparator is one-level shallow**: arrays are compared by index, plain objects by key, primitives by `Object.is`. Selectors returning nested fresh objects (e.g. `els => els.map(e => ({ ...e.position }))`) still cause re-renders with the default comparator — pass a custom `isEqual` as the second argument:
178
+
179
+ ```tsx
180
+ const positions = useElements(
181
+ useCallback((els: CanvasElement[]) => els.map((e) => e.position), []),
182
+ (a, b) => a.length === b.length && a.every((p, i) => p.x === b[i]?.x && p.y === b[i]?.y),
183
+ );
184
+ ```
185
+
186
+ ## Selection & Styling
187
+
188
+ ### `useSelection`
189
+
190
+ Returns the current selection as a reactive, referentially-stable `string[]` of element IDs. Re-renders only when the selection changes.
191
+
192
+ ```tsx
193
+ import { useSelection } from '@fieldnotes/react';
194
+
195
+ function SelectionBadge() {
196
+ const ids = useSelection();
197
+ return <span>{ids.length} selected</span>;
198
+ }
199
+ ```
200
+
201
+ ### `useSelectionStyle`
202
+
203
+ Returns `[style, applyStyle]` — the shared style of the current selection and a stable callback to apply a style patch to it.
204
+
205
+ - `style` is an `ElementStyle` (`{ color?, fillColor?, strokeWidth?, opacity?, fontSize? }`) containing only properties that are identical across all selected elements. Properties that differ are omitted. `style` is `null` when nothing is selected.
206
+ - `applyStyle(patch)` applies the patch to the current selection in a single undo step.
207
+
208
+ ```tsx
209
+ import { useSelectionStyle } from '@fieldnotes/react';
210
+
211
+ function StyleToolbar() {
212
+ const [style, applyStyle] = useSelectionStyle();
213
+
214
+ return (
215
+ <div>
216
+ <input
217
+ type="color"
218
+ value={style?.color ?? '#000000'}
219
+ disabled={!style}
220
+ onChange={(e) => applyStyle({ color: e.target.value })}
221
+ />
222
+ <input
223
+ type="range"
224
+ min={1}
225
+ max={20}
226
+ value={style?.strokeWidth ?? 2}
227
+ disabled={!style}
228
+ onChange={(e) => applyStyle({ strokeWidth: Number(e.target.value) })}
229
+ />
230
+ </div>
231
+ );
232
+ }
233
+ ```
234
+
235
+ ### `useSelectionOps`
236
+
237
+ Returns reactive selection state plus group/ungroup/lock/align/distribute actions for the core selection operations. Re-renders only when the selection (or its derived predicates) changes.
238
+
239
+ - State: `selectedIds`, `selectedCount`, `canGroup`, `canUngroup`, `canAlign`, `canDistribute`, and `isLocked` (`true`/`false` when the selection is uniformly locked/unlocked, `null` when empty or mixed).
240
+ - Actions: `group()`, `ungroup()`, `toggleLock()`, `align(edge)`, `distribute(axis)` — each runs in a single undo step. Requires core `>=0.36.0`.
241
+
242
+ ```tsx
243
+ import { useSelectionOps } from '@fieldnotes/react';
244
+
245
+ function SelectionToolbar() {
246
+ const {
247
+ selectedCount,
248
+ canGroup,
249
+ canUngroup,
250
+ isLocked,
251
+ group,
252
+ ungroup,
253
+ toggleLock,
254
+ align,
255
+ distribute,
256
+ } = useSelectionOps();
257
+
258
+ return (
259
+ <div>
260
+ <button disabled={!canGroup} onClick={group}>
261
+ Group
262
+ </button>
263
+ <button disabled={!canUngroup} onClick={ungroup}>
264
+ Ungroup
265
+ </button>
266
+ <button disabled={selectedCount === 0} onClick={toggleLock}>
267
+ {isLocked ? 'Unlock' : 'Lock'}
268
+ </button>
269
+ <button onClick={() => align('left')}>Align left</button>
270
+ <button onClick={() => distribute('horizontal')}>Distribute</button>
271
+ </div>
272
+ );
273
+ }
274
+ ```
275
+
276
+ See [core README](../core/README.md#styling-the-selection) for the full `ElementStyle` mapping table and the underlying `Viewport` methods.
277
+
278
+ ## Save / Load
279
+
280
+ Access the viewport imperatively for export and import:
281
+
282
+ ```tsx
283
+ import { useViewport } from '@fieldnotes/react';
284
+
285
+ export function SaveControls() {
286
+ const viewport = useViewport();
287
+
288
+ const save = () => {
289
+ localStorage.setItem('fieldnotes-example', viewport.exportJSON());
290
+ };
291
+ const load = () => {
292
+ const json = localStorage.getItem('fieldnotes-example');
293
+ if (json) viewport.loadJSON(json);
294
+ };
295
+
296
+ return (
297
+ <div>
298
+ <button onClick={save}>Save</button>
299
+ <button onClick={load}>Load</button>
300
+ </div>
301
+ );
302
+ }
303
+ ```
304
+
305
+ For periodic auto-saving, use `AutoSave` from `@fieldnotes/core` — it debounces writes to `localStorage` and subscribes to the store and camera automatically:
306
+
307
+ ```tsx
308
+ import { AutoSave } from '@fieldnotes/core';
309
+
310
+ // in onReady or a useEffect after useViewport():
311
+ const autoSave = new AutoSave(viewport.store, viewport.camera, {
312
+ key: 'my-board',
313
+ layerManager: viewport.layerManager,
314
+ });
315
+ autoSave.start();
316
+ // call autoSave.stop() on cleanup
317
+ ```
318
+
319
+ Pass `layerManager` — without it, saved boards lose their layer structure.
320
+
321
+ ## Custom Tools
322
+
323
+ Implement the `Tool` interface from `@fieldnotes/core` and register it via the `tools` prop. Hoist the array so instances are not recreated on every render:
324
+
325
+ ```tsx
326
+ import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
327
+ import { createNote } from '@fieldnotes/core';
328
+
329
+ export class StampTool implements Tool {
330
+ readonly name = 'stamp';
331
+
332
+ onPointerDown(state: PointerState, ctx: ToolContext): void {
333
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
334
+ ctx.store.add(
335
+ createNote({
336
+ position: { x: world.x - 50, y: world.y - 25 },
337
+ size: { w: 100, h: 50 },
338
+ text: 'stamp',
339
+ layerId: ctx.activeLayerId ?? '',
340
+ }),
341
+ );
342
+ ctx.requestRender();
343
+ }
344
+
345
+ onPointerMove(_state: PointerState, _ctx: ToolContext): void {}
346
+ onPointerUp(_state: PointerState, _ctx: ToolContext): void {}
347
+ }
348
+ ```
349
+
350
+ Register alongside the built-in tools:
351
+
352
+ ```tsx
353
+ import { HandTool, SelectTool } from '@fieldnotes/core';
354
+ import { StampTool } from './StampTool';
355
+
356
+ // Hoisted — stable reference across renders
357
+ const TOOLS = [new HandTool(), new SelectTool(), new StampTool()];
358
+
359
+ function App() {
360
+ const [tool, setTool] = useState('select');
361
+ return (
362
+ <FieldNotesCanvas tools={TOOLS} tool={tool} onToolChange={setTool} style={...}>
363
+ <Toolbar tool={tool} onSelect={setTool} />
364
+ </FieldNotesCanvas>
365
+ );
366
+ }
367
+ ```
368
+
369
+ ## Events
370
+
371
+ ### `onReady`
372
+
373
+ Fires once after the `Viewport` is created and tools are registered. Use it for imperative setup that can't wait for a child hook:
374
+
375
+ ```tsx
376
+ <FieldNotesCanvas
377
+ tools={TOOLS}
378
+ onReady={(viewport) => {
379
+ const saved = localStorage.getItem('board');
380
+ if (saved) viewport.loadJSON(saved);
381
+ }}
382
+ style={{ width: '100%', height: '100%' }}
383
+ />
384
+ ```
385
+
386
+ ### `onToolChange`
387
+
388
+ Fires whenever the active tool changes — from the keyboard, the API, or the controlled `tool` prop. Pair with `useState` and memoize with `useCallback` when the function body is non-trivial:
389
+
390
+ ```tsx
391
+ const [tool, setTool] = useState('select');
392
+
393
+ <FieldNotesCanvas tool={tool} onToolChange={setTool} tools={TOOLS} style={...} />
394
+ ```
395
+
396
+ ### `options.onImageError`
397
+
398
+ Called when an image element fails to load. Receives `{ src: string; elementIds: string[] }`the source URL and all element IDs that reference it:
399
+
400
+ ```tsx
401
+ <FieldNotesCanvas
402
+ tools={TOOLS}
403
+ options={{
404
+ onImageError: ({ src, elementIds }) => {
405
+ console.warn(`Image failed: ${src}`, elementIds);
406
+ },
407
+ }}
408
+ style={{ width: '100%', height: '100%' }}
409
+ />
410
+ ```
411
+
412
+ ### `options.onDrop`
413
+
414
+ Fires for **every** drop event on the canvas surface. Providing this callback replaces the built-in image-drop handling entirely — if you want images to work, handle them yourself. Receives the original `DragEvent` and the world-space drop position:
415
+
416
+ ```tsx
417
+ <FieldNotesCanvas
418
+ tools={TOOLS}
419
+ options={{
420
+ onDrop: (event, worldPosition) => {
421
+ const url = event.dataTransfer?.getData('text/uri-list');
422
+ if (url) {
423
+ // add your own image element at worldPosition
424
+ }
425
+ },
426
+ }}
427
+ style={{ width: '100%', height: '100%' }}
428
+ />
429
+ ```
430
+
431
+ ## Escape Hatch
432
+
433
+ `useViewport()` returns the raw `Viewport` instance for anything not covered by the hooks above. A `ref` on `<FieldNotesCanvas>` gives the same access outside of child components:
434
+
435
+ ```tsx
436
+ import { useRef } from 'react';
437
+ import { FieldNotesCanvas, type FieldNotesCanvasRef } from '@fieldnotes/react';
438
+
439
+ function App() {
440
+ const canvasRef = useRef<FieldNotesCanvasRef>(null);
441
+
442
+ return (
443
+ <>
444
+ <FieldNotesCanvas ref={canvasRef} style={{ width: '100vw', height: '100vh' }} />
445
+ <button onClick={() => canvasRef.current?.viewport?.fitToContent()}>Fit to content</button>
446
+ </>
447
+ );
448
+ }
449
+ ```
450
+
451
+ See the [core README](../core/README.md) for the full `Viewport` API — `exportJSON`/`loadJSON`, `fitToContent`, `shortcuts.rebind`, `setSnapToGrid`, and more.
452
+
453
+ ## Versioning
454
+
455
+ `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react
456
+ package's `peerDependencies` declare the compatible core range. Pre-1.0, minor
457
+ versions may contain breaking changes. The core peer range is bounded at the next major rather than per-minor; if a core minor
458
+ ever breaks the wrapper, a coordinated react release raises the lower bound.
459
+
460
+ ## License
461
+
462
+ MIT