@fieldnotes/react 0.6.0 → 0.7.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 +446 -421
- package/dist/index.cjs +71 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -3
- package/dist/index.d.ts +25 -3
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,421 +1,446 @@
|
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
```tsx
|
|
267
|
-
import {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
### `
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
```tsx
|
|
360
|
-
<FieldNotesCanvas
|
|
361
|
-
tools={TOOLS}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|