@fieldnotes/react 0.4.2 → 0.6.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
@@ -12,26 +12,88 @@ Requires React 18+.
12
12
 
13
13
  ## Quick Start
14
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
+
15
19
  ```tsx
20
+ import { useState } from 'react';
16
21
  import { FieldNotesCanvas } from '@fieldnotes/react';
17
- import { HandTool, SelectTool, PencilTool } from '@fieldnotes/core';
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');
18
42
 
19
- function App() {
20
43
  return (
21
- <FieldNotesCanvas
22
- tools={[new HandTool(), new SelectTool(), new PencilTool()]}
23
- defaultTool="select"
24
- style={{ width: '100vw', height: '100vh' }}
25
- />
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>
26
75
  );
27
76
  }
28
77
  ```
29
78
 
30
- Your container needs a defined size — the canvas fills it.
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`).
31
93
 
32
94
  ## Embedding React Components
33
95
 
34
- The main feature — render any React component as a canvas element that pans, zooms, and resizes with the canvas:
96
+ The headline feature — render any React subtree as a canvas element that pans, zooms, and resizes with the canvas:
35
97
 
36
98
  ```tsx
37
99
  import { FieldNotesCanvas, CanvasElement } from '@fieldnotes/react';
@@ -58,222 +120,276 @@ function App() {
58
120
 
59
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.
60
122
 
61
- ## Hooks
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.
62
124
 
63
- All hooks must be used inside `<FieldNotesCanvas>`.
125
+ ## Undo / Redo
64
126
 
65
- ### `useActiveTool()`
66
-
67
- Reactive tool name + setter — re-renders when the active tool changes:
127
+ `useHistory` returns reactive undo/redo state and action callbacks:
68
128
 
69
129
  ```tsx
70
- import { useActiveTool } from '@fieldnotes/react';
130
+ import { useHistory } from '@fieldnotes/react';
71
131
 
72
- function Toolbar() {
73
- const [tool, setTool] = useActiveTool();
132
+ function UndoRedo() {
133
+ const { canUndo, canRedo, undo, redo } = useHistory();
74
134
 
75
135
  return (
76
136
  <div>
77
- <span>Current: {tool}</span>
78
- <button onClick={() => setTool('pencil')}>Pencil</button>
79
- <button onClick={() => setTool('select')}>Select</button>
137
+ <button onClick={undo} disabled={!canUndo}>
138
+ Undo
139
+ </button>
140
+ <button onClick={redo} disabled={!canRedo}>
141
+ Redo
142
+ </button>
80
143
  </div>
81
144
  );
82
145
  }
83
146
  ```
84
147
 
85
- ### `useToolOptions(toolName)`
148
+ ## Why Is My Sidebar Re-rendering 60×/s?
86
149
 
87
- Reactive tool options with two-way syncread and write tool configuration:
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:
88
151
 
89
152
  ```tsx
90
- import { useActiveTool, useToolOptions } from '@fieldnotes/react';
91
- import type { PencilToolOptions } from '@fieldnotes/core';
153
+ import { useCallback } from 'react';
154
+ import { useElements } from '@fieldnotes/react';
155
+ import type { CanvasElement } from '@fieldnotes/core';
92
156
 
93
- function PencilSettings() {
94
- const [tool, setTool] = useActiveTool();
95
- const [opts, setOpts] = useToolOptions<PencilToolOptions>('pencil');
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');
96
161
 
97
162
  return (
98
- <div>
99
- <button onClick={() => setTool('pencil')}>Pencil</button>
100
- {tool === 'pencil' && opts && (
101
- <>
102
- <input
103
- type="color"
104
- value={opts.color}
105
- onChange={(e) => setOpts({ color: e.target.value })}
106
- />
107
- <input
108
- type="range"
109
- min={1}
110
- max={20}
111
- value={opts.width}
112
- onChange={(e) => setOpts({ width: Number(e.target.value) })}
113
- />
114
- </>
115
- )}
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>
116
170
  </div>
117
171
  );
118
172
  }
119
173
  ```
120
174
 
121
- Returns `[null, noop]` for tools that don't support options (e.g., `HandTool`).
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:
122
178
 
123
- ### `useLayers()`
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
+ ```
124
185
 
125
- Full layer management — reactive layer list with action callbacks:
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.
126
191
 
127
192
  ```tsx
128
- import { useLayers } from '@fieldnotes/react';
129
-
130
- function LayersPanel() {
131
- const {
132
- layers,
133
- activeLayerId,
134
- createLayer,
135
- removeLayer,
136
- setVisible,
137
- setLocked,
138
- setOpacity,
139
- setActiveLayer,
140
- } = useLayers();
193
+ import { useSelection } from '@fieldnotes/react';
141
194
 
142
- return (
143
- <div>
144
- <button onClick={() => createLayer()}>Add Layer</button>
145
- {layers.map((layer) => (
146
- <div key={layer.id} onClick={() => setActiveLayer(layer.id)}>
147
- <span>
148
- {layer.name} {layer.id === activeLayerId ? '(active)' : ''}
149
- </span>
150
- <button onClick={() => setVisible(layer.id, !layer.visible)}>
151
- {layer.visible ? 'Hide' : 'Show'}
152
- </button>
153
- <button onClick={() => setLocked(layer.id, !layer.locked)}>
154
- {layer.locked ? 'Unlock' : 'Lock'}
155
- </button>
156
- <input
157
- type="range"
158
- min={0}
159
- max={1}
160
- step={0.1}
161
- value={layer.opacity}
162
- onChange={(e) => setOpacity(layer.id, Number(e.target.value))}
163
- />
164
- </div>
165
- ))}
166
- </div>
167
- );
195
+ function SelectionBadge() {
196
+ const ids = useSelection();
197
+ return <span>{ids.length} selected</span>;
168
198
  }
169
199
  ```
170
200
 
171
- Also exposes: `renameLayer`, `reorderLayer`, `moveElement`.
201
+ ### `useSelectionStyle`
172
202
 
173
- ### `useHistory()`
203
+ Returns `[style, applyStyle]` — the shared style of the current selection and a stable callback to apply a style patch to it.
174
204
 
175
- Reactive undo/redo state:
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.
176
207
 
177
208
  ```tsx
178
- import { useHistory } from '@fieldnotes/react';
209
+ import { useSelectionStyle } from '@fieldnotes/react';
179
210
 
180
- function UndoRedo() {
181
- const { canUndo, canRedo, undo, redo } = useHistory();
211
+ function StyleToolbar() {
212
+ const [style, applyStyle] = useSelectionStyle();
182
213
 
183
214
  return (
184
215
  <div>
185
- <button onClick={undo} disabled={!canUndo}>
186
- Undo
187
- </button>
188
- <button onClick={redo} disabled={!canRedo}>
189
- Redo
190
- </button>
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
+ />
191
230
  </div>
192
231
  );
193
232
  }
194
233
  ```
195
234
 
196
- ### `useElements(type?)`
235
+ See [core README](../core/README.md#styling-the-selection) for the full `ElementStyle` mapping table and the underlying `Viewport` methods.
197
236
 
198
- Reactive element list — re-renders when elements are added, removed, or updated:
237
+ ## Save / Load
238
+
239
+ Access the viewport imperatively for export and import:
199
240
 
200
241
  ```tsx
201
- import { useElements } from '@fieldnotes/react';
242
+ import { useViewport } from '@fieldnotes/react';
202
243
 
203
- function ElementCount() {
204
- const elements = useElements();
205
- const notes = useElements('note');
244
+ export function SaveControls() {
245
+ const viewport = useViewport();
246
+
247
+ const save = () => {
248
+ localStorage.setItem('fieldnotes-example', viewport.exportJSON());
249
+ };
250
+ const load = () => {
251
+ const json = localStorage.getItem('fieldnotes-example');
252
+ if (json) viewport.loadJSON(json);
253
+ };
206
254
 
207
255
  return (
208
- <span>
209
- {notes.length} notes / {elements.length} total
210
- </span>
256
+ <div>
257
+ <button onClick={save}>Save</button>
258
+ <button onClick={load}>Load</button>
259
+ </div>
211
260
  );
212
261
  }
213
262
  ```
214
263
 
215
- Pass an element type (`'note'`, `'stroke'`, `'arrow'`, etc.) to filter.
264
+ For periodic auto-saving, use `AutoSave` from `@fieldnotes/core` — it debounces writes to `localStorage` and subscribes to the store and camera automatically:
265
+
266
+ ```tsx
267
+ import { AutoSave } from '@fieldnotes/core';
268
+
269
+ // in onReady or a useEffect after useViewport():
270
+ const autoSave = new AutoSave(viewport.store, viewport.camera, {
271
+ key: 'my-board',
272
+ layerManager: viewport.layerManager,
273
+ });
274
+ autoSave.start();
275
+ // call autoSave.stop() on cleanup
276
+ ```
277
+
278
+ Pass `layerManager` — without it, saved boards lose their layer structure.
279
+
280
+ ## Custom Tools
216
281
 
217
- ### `useCamera()`
282
+ 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:
218
283
 
219
- Reactive camera state (position + zoom) — re-renders on pan/zoom:
284
+ ```tsx
285
+ import type { Tool, ToolContext, PointerState } from '@fieldnotes/core';
286
+ import { createNote } from '@fieldnotes/core';
287
+
288
+ export class StampTool implements Tool {
289
+ readonly name = 'stamp';
290
+
291
+ onPointerDown(state: PointerState, ctx: ToolContext): void {
292
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
293
+ ctx.store.add(
294
+ createNote({
295
+ position: { x: world.x - 50, y: world.y - 25 },
296
+ size: { w: 100, h: 50 },
297
+ text: 'stamp',
298
+ layerId: ctx.activeLayerId ?? '',
299
+ }),
300
+ );
301
+ ctx.requestRender();
302
+ }
303
+
304
+ onPointerMove(_state: PointerState, _ctx: ToolContext): void {}
305
+ onPointerUp(_state: PointerState, _ctx: ToolContext): void {}
306
+ }
307
+ ```
308
+
309
+ Register alongside the built-in tools:
220
310
 
221
311
  ```tsx
222
- import { useCamera } from '@fieldnotes/react';
312
+ import { HandTool, SelectTool } from '@fieldnotes/core';
313
+ import { StampTool } from './StampTool';
223
314
 
224
- function CameraInfo() {
225
- const { x, y, zoom } = useCamera();
315
+ // Hoisted — stable reference across renders
316
+ const TOOLS = [new HandTool(), new SelectTool(), new StampTool()];
226
317
 
318
+ function App() {
319
+ const [tool, setTool] = useState('select');
227
320
  return (
228
- <span>
229
- {zoom.toFixed(2)}x at ({x.toFixed(0)}, {y.toFixed(0)})
230
- </span>
321
+ <FieldNotesCanvas tools={TOOLS} tool={tool} onToolChange={setTool} style={...}>
322
+ <Toolbar tool={tool} onSelect={setTool} />
323
+ </FieldNotesCanvas>
231
324
  );
232
325
  }
233
326
  ```
234
327
 
235
- ### `useViewport()`
328
+ ## Events
236
329
 
237
- Access the core `Viewport` instance for imperative operations not covered by the hooks above:
330
+ ### `onReady`
331
+
332
+ Fires once after the `Viewport` is created and tools are registered. Use it for imperative setup that can't wait for a child hook:
238
333
 
239
334
  ```tsx
240
- import { useViewport } from '@fieldnotes/react';
335
+ <FieldNotesCanvas
336
+ tools={TOOLS}
337
+ onReady={(viewport) => {
338
+ const saved = localStorage.getItem('board');
339
+ if (saved) viewport.loadJSON(saved);
340
+ }}
341
+ style={{ width: '100%', height: '100%' }}
342
+ />
343
+ ```
241
344
 
242
- function ExportButton() {
243
- const viewport = useViewport();
345
+ ### `onToolChange`
244
346
 
245
- return <button onClick={() => viewport.exportImage()}>Export PNG</button>;
246
- }
347
+ 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:
348
+
349
+ ```tsx
350
+ const [tool, setTool] = useState('select');
351
+
352
+ <FieldNotesCanvas tool={tool} onToolChange={setTool} tools={TOOLS} style={...} />
247
353
  ```
248
354
 
249
- ## Component API
355
+ ### `options.onImageError`
250
356
 
251
- ### `<FieldNotesCanvas>`
357
+ Called when an image element fails to load. Receives `{ src: string; elementIds: string[] }` — the source URL and all element IDs that reference it:
252
358
 
253
- | Prop | Type | Description |
254
- | ------------- | ------------------------------ | --------------------------------------- |
255
- | `options` | `ViewportOptions` | Camera and background config |
256
- | `tools` | `Tool[]` | Tools to register on mount |
257
- | `defaultTool` | `string` | Tool to activate on mount |
258
- | `className` | `string` | CSS class for the container div |
259
- | `style` | `CSSProperties` | Inline styles for the container div |
260
- | `onReady` | `(viewport: Viewport) => void` | Called after Viewport is created |
261
- | `children` | `ReactNode` | Child components (have access to hooks) |
262
- | `ref` | `Ref<FieldNotesCanvasRef>` | Exposes `{ viewport }` |
359
+ ```tsx
360
+ <FieldNotesCanvas
361
+ tools={TOOLS}
362
+ options={{
363
+ onImageError: ({ src, elementIds }) => {
364
+ console.warn(`Image failed: ${src}`, elementIds);
365
+ },
366
+ }}
367
+ style={{ width: '100%', height: '100%' }}
368
+ />
369
+ ```
263
370
 
264
- ### `<CanvasElement>`
371
+ ### `options.onDrop`
265
372
 
266
- | Prop | Type | Default | Description |
267
- | ---------- | -------------------------- | -------------------- | ---------------------------------- |
268
- | `position` | `{ x: number; y: number }` | required | World-space position |
269
- | `size` | `{ w: number; h: number }` | `{ w: 200, h: 150 }` | Element size in world-space pixels |
270
- | `children` | `ReactNode` | required | React content to render on canvas |
373
+ 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:
271
374
 
272
- Position and size updates are reactive — change the props and the element moves/resizes on the canvas.
375
+ ```tsx
376
+ <FieldNotesCanvas
377
+ tools={TOOLS}
378
+ options={{
379
+ onDrop: (event, worldPosition) => {
380
+ const url = event.dataTransfer?.getData('text/uri-list');
381
+ if (url) {
382
+ // add your own image element at worldPosition
383
+ }
384
+ },
385
+ }}
386
+ style={{ width: '100%', height: '100%' }}
387
+ />
388
+ ```
273
389
 
274
- ## Accessing the Viewport Directly
390
+ ## Escape Hatch
275
391
 
276
- For advanced use cases, use a ref:
392
+ `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:
277
393
 
278
394
  ```tsx
279
395
  import { useRef } from 'react';
@@ -282,20 +398,17 @@ import { FieldNotesCanvas, type FieldNotesCanvasRef } from '@fieldnotes/react';
282
398
  function App() {
283
399
  const canvasRef = useRef<FieldNotesCanvasRef>(null);
284
400
 
285
- const exportState = () => {
286
- const json = canvasRef.current?.viewport?.exportJSON();
287
- console.log(json);
288
- };
289
-
290
401
  return (
291
402
  <>
292
403
  <FieldNotesCanvas ref={canvasRef} style={{ width: '100vw', height: '100vh' }} />
293
- <button onClick={exportState}>Export</button>
404
+ <button onClick={() => canvasRef.current?.viewport?.fitToContent()}>Fit to content</button>
294
405
  </>
295
406
  );
296
407
  }
297
408
  ```
298
409
 
410
+ See the [core README](../core/README.md) for the full `Viewport` API — `exportJSON`/`loadJSON`, `fitToContent`, `shortcuts.rebind`, `setSnapToGrid`, and more.
411
+
299
412
  ## Versioning
300
413
 
301
414
  `@fieldnotes/core` and `@fieldnotes/react` are versioned independently. The react