@fieldnotes/react 0.13.0 → 0.14.1

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
@@ -8,13 +8,42 @@ React bindings for the [Field Notes](https://github.com/IrakliDevelop/fieldnotes
8
8
  npm install @fieldnotes/core @fieldnotes/react
9
9
  ```
10
10
 
11
- Requires React 18+.
11
+ Requires React 18+. Using Vue or Svelte? See [`@fieldnotes/vue`](../vue) and [`@fieldnotes/svelte`](../svelte).
12
12
 
13
13
  ## Quick Start
14
14
 
15
15
  Full runnable example: `examples/react-app` — `pnpm --filter fieldnotes-react-example dev`
16
16
 
17
- The canvas needs a container with a defined size — it fills whatever it's given:
17
+ The canvas needs a container with a defined size — it fills whatever it's given. With no `tools`
18
+ prop and no tools registered in `onReady`, it registers the default tool bundle
19
+ (`createDefaultTools()`: hand, select, pencil, eraser, arrow, note, text, shape) with `select`
20
+ active, so a persisting board is one line:
21
+
22
+ ```tsx
23
+ import { FieldNotesCanvas } from '@fieldnotes/react';
24
+ import type { CanvasState } from '@fieldnotes/core';
25
+
26
+ const saved = localStorage.getItem('board') ?? undefined;
27
+ const save = (state: CanvasState) => localStorage.setItem('board', JSON.stringify(state));
28
+
29
+ export function App() {
30
+ return (
31
+ <div style={{ width: '100vw', height: '100vh' }}>
32
+ <FieldNotesCanvas
33
+ initialData={saved}
34
+ onChange={save}
35
+ style={{ width: '100%', height: '100%' }}
36
+ />
37
+ </div>
38
+ );
39
+ }
40
+ ```
41
+
42
+ `initialData` takes a saved state object or JSON string. `onChange` receives `viewport.exportState()`,
43
+ trailing-debounced by `changeDebounceMs` (default 250); a pending call is flushed on unmount, and
44
+ the initial load is not reported as a change.
45
+
46
+ For your own toolbar, pass an explicit tool array and control the active tool:
18
47
 
19
48
  ```tsx
20
49
  import { useState } from 'react';
@@ -80,14 +109,22 @@ function Toolbar({ tool, onSelect }: { tool: string; onSelect: (name: string) =>
80
109
 
81
110
  Not all props are reactive — the canvas is stateful. Changing a mount-only prop after mount has no effect:
82
111
 
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) |
112
+ | Prop | Reactivity |
113
+ | ---------------------------------- | ---------------------------------------------------------------------------- |
114
+ | `options` | Mount-only — the canvas is stateful; construct with the right options |
115
+ | `tools` | Array: reactive, append-only. Omitted or `'default'`: mount-only (see below) |
116
+ | `initialData` | Mount-only — loaded once before `onReady` |
117
+ | `onChange` / `changeDebounceMs` | Reactive — the latest callback is used without resubscribing |
118
+ | `tool` / `onToolChange` | Reactive (controlled). Memoize the callback |
119
+ | `defaultTool` | Mount-only (uncontrolled initial tool) |
120
+ | `snapToGrid` (deprecated) | Reactive compatibility prop; prefer a domain constraint service |
121
+ | `className` / `style` / `children` | Reactive (plain React) |
122
+
123
+ `tools` as an array registers each tool once (tools cannot be unregistered); hoist it out of render.
124
+ `tools="default"` registers `createDefaultTools()` at mount, before `onReady` (so you can customise
125
+ them there). Omitted registers the same bundle after `onReady` only when neither `options.tools` is
126
+ set nor any tool was registered by then, then activates `tool`, `defaultTool` or `select`; hosts
127
+ that register tools in `onReady` get no defaults. `tools={[]}` registers nothing.
91
128
 
92
129
  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
130
 
@@ -352,11 +389,11 @@ export class StampTool implements Tool {
352
389
  Register alongside the built-in tools:
353
390
 
354
391
  ```tsx
355
- import { HandTool, SelectTool } from '@fieldnotes/core';
392
+ import { createDefaultTools } from '@fieldnotes/core';
356
393
  import { StampTool } from './StampTool';
357
394
 
358
395
  // Hoisted — stable reference across renders
359
- const TOOLS = [new HandTool(), new SelectTool(), new StampTool()];
396
+ const TOOLS = [...createDefaultTools(), new StampTool()];
360
397
 
361
398
  function App() {
362
399
  const [tool, setTool] = useState('select');
package/dist/index.cjs CHANGED
@@ -56,6 +56,9 @@ var FieldNotesCanvas = (0, import_react2.forwardRef)(
56
56
  defaultTool,
57
57
  tool,
58
58
  onToolChange,
59
+ initialData,
60
+ onChange,
61
+ changeDebounceMs = 250,
59
62
  snapToGrid,
60
63
  className,
61
64
  style,
@@ -64,23 +67,65 @@ var FieldNotesCanvas = (0, import_react2.forwardRef)(
64
67
  }, ref) {
65
68
  const containerRef = (0, import_react2.useRef)(null);
66
69
  const [viewport, setViewport] = (0, import_react2.useState)(null);
70
+ const changeRef = (0, import_react2.useRef)({ onChange, changeDebounceMs });
71
+ changeRef.current = { onChange, changeDebounceMs };
67
72
  (0, import_react2.useImperativeHandle)(ref, () => ({ viewport }), [viewport]);
68
73
  (0, import_react2.useEffect)(() => {
69
74
  const el = containerRef.current;
70
75
  if (!el) return;
71
76
  const vp = new import_core.Viewport(el, options);
72
- if (tools) {
73
- for (const t of tools) {
74
- vp.toolManager.register(t);
75
- }
77
+ const activate = (fallback) => {
78
+ const name = tool ?? defaultTool ?? fallback;
79
+ if (name) vp.setTool(name);
80
+ };
81
+ const deferDefaults = tools === void 0 && options?.tools === void 0;
82
+ for (const t of tools === "default" ? (0, import_core.createDefaultTools)() : tools ?? []) {
83
+ vp.toolManager.register(t);
76
84
  }
77
- const initialTool = tool ?? defaultTool;
78
- if (initialTool) {
79
- vp.setTool(initialTool);
85
+ if (!deferDefaults) activate(tools === "default" ? "select" : void 0);
86
+ try {
87
+ if (typeof initialData === "string") vp.loadJSON(initialData);
88
+ else if (initialData) vp.loadState(initialData);
89
+ } catch (error) {
90
+ vp.destroy();
91
+ throw error;
92
+ }
93
+ let timer;
94
+ const emit = () => {
95
+ timer = void 0;
96
+ changeRef.current.onChange?.(vp.exportState());
97
+ };
98
+ const unsubscribe = vp.onStateChange(() => {
99
+ clearTimeout(timer);
100
+ timer = setTimeout(emit, changeRef.current.changeDebounceMs);
101
+ });
102
+ try {
103
+ onReady?.(vp);
104
+ if (deferDefaults) {
105
+ const registerDefaults = vp.toolManager.toolNames.length === 0;
106
+ if (registerDefaults) {
107
+ for (const t of (0, import_core.createDefaultTools)()) vp.toolManager.register(t);
108
+ }
109
+ activate(registerDefaults ? "select" : void 0);
110
+ }
111
+ } catch (error) {
112
+ clearTimeout(timer);
113
+ unsubscribe();
114
+ vp.destroy();
115
+ throw error;
80
116
  }
81
117
  setViewport(vp);
82
- onReady?.(vp);
83
118
  return () => {
119
+ if (timer !== void 0) {
120
+ clearTimeout(timer);
121
+ try {
122
+ emit();
123
+ } catch (error) {
124
+ if (typeof reportError === "function") reportError(error);
125
+ else console.error("[fieldnotes] onChange failed during unmount", error);
126
+ }
127
+ }
128
+ unsubscribe();
84
129
  vp.destroy();
85
130
  setViewport(null);
86
131
  };
@@ -100,7 +145,7 @@ var FieldNotesCanvas = (0, import_react2.forwardRef)(
100
145
  viewport.setSnapToGrid(snapToGrid);
101
146
  }, [viewport, snapToGrid]);
102
147
  (0, import_react2.useEffect)(() => {
103
- if (!viewport || !tools) return;
148
+ if (!viewport || !Array.isArray(tools)) return;
104
149
  for (const t of tools) {
105
150
  if (!viewport.toolManager.getTool(t.name)) {
106
151
  viewport.toolManager.register(t);
@@ -148,12 +193,11 @@ function CanvasElement({ position, size, children }) {
148
193
  };
149
194
  elementIdRef.current = register();
150
195
  setPortalTarget(container);
151
- let disposed = false;
152
196
  const handleStoreReplacement = () => {
153
197
  const clearedId = elementIdRef.current;
154
198
  if (clearedId === null) return;
155
199
  queueMicrotask(() => {
156
- if (disposed || elementIdRef.current !== clearedId) return;
200
+ if (elementIdRef.current !== clearedId) return;
157
201
  if (viewport.store.getById(clearedId)?.type === "html") {
158
202
  viewport.updateHtmlElement(clearedId, container);
159
203
  } else {
@@ -165,7 +209,6 @@ function CanvasElement({ position, size, children }) {
165
209
  const unsubscribeClear = viewport.store.on("clear", handleStoreReplacement);
166
210
  const unsubscribeBatch = viewport.store.on("batch", handleStoreReplacement);
167
211
  return () => {
168
- disposed = true;
169
212
  unsubscribeClear();
170
213
  unsubscribeBatch();
171
214
  if (elementIdRef.current) {
@@ -190,65 +233,40 @@ function CanvasElement({ position, size, children }) {
190
233
 
191
234
  // src/hooks/use-active-tool.ts
192
235
  var import_react5 = require("react");
236
+ var import_core2 = require("@fieldnotes/core");
193
237
  function useActiveTool() {
194
238
  const viewport = useViewport();
195
- const subscribe = (0, import_react5.useCallback)(
196
- (onStoreChange) => viewport.toolManager.onChange(onStoreChange),
197
- [viewport]
198
- );
199
- const getSnapshot = (0, import_react5.useCallback)(() => viewport.toolManager.activeTool?.name ?? "", [viewport]);
200
- const toolName = (0, import_react5.useSyncExternalStore)(subscribe, getSnapshot);
239
+ const source = (0, import_react5.useMemo)(() => (0, import_core2.createActiveToolSource)(viewport), [viewport]);
240
+ const toolName = (0, import_react5.useSyncExternalStore)(source.subscribe, source.getSnapshot);
201
241
  const setTool = (0, import_react5.useCallback)((name) => viewport.setTool(name), [viewport]);
202
242
  return [toolName, setTool];
203
243
  }
204
244
 
205
245
  // src/hooks/use-camera.ts
206
246
  var import_react6 = require("react");
247
+ var import_core3 = require("@fieldnotes/core");
207
248
  function useCamera() {
208
249
  const viewport = useViewport();
209
- const cachedRef = (0, import_react6.useRef)({ x: 0, y: 0, zoom: 1 });
210
- const subscribe = (0, import_react6.useCallback)(
211
- (onStoreChange) => viewport.camera.onChange(onStoreChange),
212
- [viewport]
213
- );
214
- const getSnapshot = (0, import_react6.useCallback)(() => {
215
- const { position, zoom } = viewport.camera;
216
- const cached = cachedRef.current;
217
- if (cached.x === position.x && cached.y === position.y && cached.zoom === zoom) {
218
- return cached;
219
- }
220
- const next = { x: position.x, y: position.y, zoom };
221
- cachedRef.current = next;
222
- return next;
223
- }, [viewport]);
224
- return (0, import_react6.useSyncExternalStore)(subscribe, getSnapshot);
250
+ const source = (0, import_react6.useMemo)(() => (0, import_core3.createCameraSource)(viewport), [viewport]);
251
+ return (0, import_react6.useSyncExternalStore)(source.subscribe, source.getSnapshot);
225
252
  }
226
253
 
227
254
  // src/hooks/use-tool-options.ts
228
255
  var import_react7 = require("react");
229
- function noop() {
230
- }
256
+ var import_core4 = require("@fieldnotes/core");
231
257
  function useToolOptions(toolName) {
232
258
  const viewport = useViewport();
233
- const cachedRef = (0, import_react7.useRef)(null);
259
+ const nameRef = (0, import_react7.useRef)(toolName);
260
+ nameRef.current = toolName;
261
+ const source = (0, import_react7.useMemo)(
262
+ () => (0, import_core4.createToolOptionsSource)(viewport, () => nameRef.current),
263
+ [viewport]
264
+ );
234
265
  const subscribe = (0, import_react7.useCallback)(
235
- (onStoreChange) => {
236
- const tool = viewport.toolManager.getTool(toolName);
237
- if (!tool?.onOptionsChange) return noop;
238
- return tool.onOptionsChange(onStoreChange);
239
- },
240
- [viewport, toolName]
266
+ (onStoreChange) => source.subscribe(onStoreChange),
267
+ [source, toolName]
241
268
  );
242
- const getSnapshot = (0, import_react7.useCallback)(() => {
243
- const tool = viewport.toolManager.getTool(toolName);
244
- if (!tool?.getOptions) return null;
245
- const next = tool.getOptions();
246
- const cached = cachedRef.current;
247
- if (cached && shallowEqual(cached, next)) return cached;
248
- cachedRef.current = next;
249
- return next;
250
- }, [viewport, toolName]);
251
- const options = (0, import_react7.useSyncExternalStore)(subscribe, getSnapshot);
269
+ const options = (0, import_react7.useSyncExternalStore)(subscribe, source.getSnapshot);
252
270
  const setOptions = (0, import_react7.useCallback)(
253
271
  (partial) => {
254
272
  const tool = viewport.toolManager.getTool(toolName);
@@ -258,51 +276,14 @@ function useToolOptions(toolName) {
258
276
  );
259
277
  return [options, setOptions];
260
278
  }
261
- function shallowEqual(a, b) {
262
- const keysA = Object.keys(a);
263
- const keysB = Object.keys(b);
264
- if (keysA.length !== keysB.length) return false;
265
- for (const key of keysA) {
266
- const valA = a[key];
267
- const valB = b[key];
268
- if (valA === valB) continue;
269
- if (typeof valA === "object" && valA !== null && typeof valB === "object" && valB !== null && !Array.isArray(valA) && !Array.isArray(valB)) {
270
- const objA = valA;
271
- const objB = valB;
272
- const innerKeys = Object.keys(objA);
273
- if (innerKeys.length === Object.keys(objB).length && innerKeys.every((k) => objA[k] === objB[k])) {
274
- continue;
275
- }
276
- }
277
- return false;
278
- }
279
- return true;
280
- }
281
279
 
282
280
  // src/hooks/use-layers.ts
283
281
  var import_react8 = require("react");
282
+ var import_core5 = require("@fieldnotes/core");
284
283
  function useLayers() {
285
284
  const viewport = useViewport();
286
- const cachedRef = (0, import_react8.useRef)({ layers: [], activeLayerId: "" });
287
- const subscribe = (0, import_react8.useCallback)(
288
- (onStoreChange) => viewport.layerManager.on("change", onStoreChange),
289
- [viewport]
290
- );
291
- const getSnapshot = (0, import_react8.useCallback)(() => {
292
- const layers = viewport.layerManager.getLayers();
293
- const activeLayerId = viewport.layerManager.activeLayerId;
294
- const cached = cachedRef.current;
295
- if (cached.activeLayerId === activeLayerId && cached.layers.length === layers.length && cached.layers.every((l, i) => {
296
- const next2 = layers[i];
297
- return next2 !== void 0 && l.id === next2.id && l.name === next2.name && l.visible === next2.visible && l.locked === next2.locked && l.order === next2.order && l.opacity === next2.opacity;
298
- })) {
299
- return cached;
300
- }
301
- const next = { layers, activeLayerId };
302
- cachedRef.current = next;
303
- return next;
304
- }, [viewport]);
305
- const snapshot = (0, import_react8.useSyncExternalStore)(subscribe, getSnapshot);
285
+ const source = (0, import_react8.useMemo)(() => (0, import_core5.createLayersSource)(viewport), [viewport]);
286
+ const snapshot = (0, import_react8.useSyncExternalStore)(source.subscribe, source.getSnapshot);
306
287
  const createLayer = (0, import_react8.useCallback)(
307
288
  (name) => viewport.layerManager.createLayer(name),
308
289
  [viewport]
@@ -356,25 +337,11 @@ function useLayers() {
356
337
 
357
338
  // src/hooks/use-history.ts
358
339
  var import_react9 = require("react");
340
+ var import_core6 = require("@fieldnotes/core");
359
341
  function useHistory() {
360
342
  const viewport = useViewport();
361
- const cachedRef = (0, import_react9.useRef)({ canUndo: false, canRedo: false });
362
- const subscribe = (0, import_react9.useCallback)(
363
- (onStoreChange) => viewport.history.onChange(onStoreChange),
364
- [viewport]
365
- );
366
- const getSnapshot = (0, import_react9.useCallback)(() => {
367
- const canUndo = viewport.history.canUndo;
368
- const canRedo = viewport.history.canRedo;
369
- const cached = cachedRef.current;
370
- if (cached.canUndo === canUndo && cached.canRedo === canRedo) {
371
- return cached;
372
- }
373
- const next = { canUndo, canRedo };
374
- cachedRef.current = next;
375
- return next;
376
- }, [viewport]);
377
- const snapshot = (0, import_react9.useSyncExternalStore)(subscribe, getSnapshot);
343
+ const source = (0, import_react9.useMemo)(() => (0, import_core6.createHistorySource)(viewport), [viewport]);
344
+ const snapshot = (0, import_react9.useSyncExternalStore)(source.subscribe, source.getSnapshot);
378
345
  const undo = (0, import_react9.useCallback)(() => viewport.undo(), [viewport]);
379
346
  const redo = (0, import_react9.useCallback)(() => viewport.redo(), [viewport]);
380
347
  return {
@@ -387,186 +354,69 @@ function useHistory() {
387
354
 
388
355
  // src/hooks/use-elements.ts
389
356
  var import_react10 = require("react");
390
- function shallowEqual2(a, b) {
391
- if (Object.is(a, b)) return true;
392
- if (Array.isArray(a) && Array.isArray(b)) {
393
- return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
394
- }
395
- if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
396
- const ka = Object.keys(a);
397
- const kb = Object.keys(b);
398
- return ka.length === kb.length && ka.every(
399
- (k) => k in b && Object.is(a[k], b[k])
400
- );
401
- }
402
- return false;
403
- }
357
+ var import_core7 = require("@fieldnotes/core");
404
358
  function useElements(arg, isEqual) {
405
359
  const viewport = useViewport();
406
- const cachedRef = (0, import_react10.useRef)([]);
407
- const selectedRef = (0, import_react10.useRef)(null);
408
- const subscribe = (0, import_react10.useCallback)(
409
- (onStoreChange) => viewport.store.onChange(onStoreChange),
360
+ const latest = (0, import_react10.useRef)({ arg, isEqual });
361
+ latest.current = { arg, isEqual };
362
+ const elementsSource = (0, import_react10.useMemo)(
363
+ () => (0, import_core7.createElementsSource)(viewport, () => {
364
+ const a = latest.current.arg;
365
+ return typeof a === "function" ? void 0 : a;
366
+ }),
410
367
  [viewport]
411
368
  );
412
- const getSnapshot = (0, import_react10.useCallback)(() => {
413
- if (typeof arg === "function") {
414
- const next2 = arg(viewport.store.getAll());
415
- const prev = selectedRef.current;
416
- const equal = isEqual ?? shallowEqual2;
417
- if (prev !== null && equal(prev.value, next2)) {
418
- return prev.value;
419
- }
420
- selectedRef.current = { value: next2 };
421
- return next2;
422
- }
423
- const next = arg ? viewport.store.getElementsByType(arg) : viewport.store.getAll();
424
- const cached = cachedRef.current;
425
- if (cached.length === next.length && cached.every((el, i) => el === next[i])) {
426
- return cached;
427
- }
428
- cachedRef.current = next;
429
- return next;
430
- }, [viewport, arg, isEqual]);
431
- return (0, import_react10.useSyncExternalStore)(subscribe, getSnapshot);
369
+ const selectorSource = (0, import_react10.useMemo)(
370
+ () => (0, import_core7.createElementsSelectorSource)(
371
+ viewport,
372
+ // Only read while `arg` is a selector: the source is picked below by `typeof arg`.
373
+ () => latest.current.arg,
374
+ () => latest.current.isEqual
375
+ ),
376
+ [viewport]
377
+ );
378
+ const source = typeof arg === "function" ? selectorSource : elementsSource;
379
+ return (0, import_react10.useSyncExternalStore)(source.subscribe, source.getSnapshot);
432
380
  }
433
381
 
434
382
  // src/hooks/use-selection.ts
435
383
  var import_react11 = require("react");
384
+ var import_core8 = require("@fieldnotes/core");
436
385
  function useSelection() {
437
386
  const viewport = useViewport();
438
- const subscribe = (0, import_react11.useCallback)(
439
- (onChange) => viewport.onSelectionChange(onChange),
440
- [viewport]
441
- );
442
- const getSnapshot = (0, import_react11.useCallback)(() => viewport.getSelectedIds(), [viewport]);
443
- return (0, import_react11.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
387
+ const source = (0, import_react11.useMemo)(() => (0, import_core8.createSelectionSource)(viewport), [viewport]);
388
+ return (0, import_react11.useSyncExternalStore)(source.subscribe, source.getSnapshot, source.getSnapshot);
444
389
  }
445
390
 
446
391
  // src/hooks/use-selection-style.ts
447
392
  var import_react12 = require("react");
448
-
449
- // src/hooks/style-keys.ts
450
- var STYLE_KEYS = [
451
- "color",
452
- "fillColor",
453
- "strokeWidth",
454
- "opacity",
455
- "fontSize",
456
- "strokeStyle"
457
- ];
458
-
459
- // src/hooks/use-selection-style.ts
460
- function styleEqual(a, b) {
461
- if (a === b) return true;
462
- if (a === null || b === null) return false;
463
- return STYLE_KEYS.every((k) => a[k] === b[k]);
464
- }
393
+ var import_core9 = require("@fieldnotes/core");
465
394
  function useSelectionStyle() {
466
395
  const viewport = useViewport();
467
- const cacheRef = (0, import_react12.useRef)(null);
468
- const subscribe = (0, import_react12.useCallback)(
469
- (onChange) => {
470
- const offSel = viewport.onSelectionChange(onChange);
471
- const offStore = viewport.store.onChange(onChange);
472
- return () => {
473
- offSel();
474
- offStore();
475
- };
476
- },
477
- [viewport]
478
- );
479
- const getSnapshot = (0, import_react12.useCallback)(() => {
480
- const next = viewport.getSelectionStyle();
481
- if (styleEqual(cacheRef.current, next)) return cacheRef.current;
482
- cacheRef.current = next;
483
- return next;
484
- }, [viewport]);
485
- const style = (0, import_react12.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
396
+ const source = (0, import_react12.useMemo)(() => (0, import_core9.createSelectionStyleSource)(viewport), [viewport]);
397
+ const style = (0, import_react12.useSyncExternalStore)(source.subscribe, source.getSnapshot, source.getSnapshot);
486
398
  const apply = (0, import_react12.useCallback)((s) => viewport.applyStyleToSelection(s), [viewport]);
487
399
  return [style, apply];
488
400
  }
489
401
 
490
402
  // src/hooks/use-selection-style-details.ts
491
403
  var import_react13 = require("react");
492
- function keysEqual(a, b) {
493
- return a.length === b.length && a.every((k, i) => k === b[i]);
494
- }
495
- function detailsEqual(a, b) {
496
- if (a === b) return true;
497
- if (a === null || b === null) return false;
498
- return keysEqual(a.applicable, b.applicable) && keysEqual(a.mixed, b.mixed) && STYLE_KEYS.every((k) => a.common[k] === b.common[k]);
499
- }
404
+ var import_core10 = require("@fieldnotes/core");
500
405
  function useSelectionStyleDetails() {
501
406
  const viewport = useViewport();
502
- const cacheRef = (0, import_react13.useRef)(null);
503
- const subscribe = (0, import_react13.useCallback)(
504
- (onChange) => {
505
- const offSel = viewport.onSelectionChange(onChange);
506
- const offStore = viewport.store.onChange(onChange);
507
- return () => {
508
- offSel();
509
- offStore();
510
- };
511
- },
512
- [viewport]
513
- );
514
- const getSnapshot = (0, import_react13.useCallback)(() => {
515
- const next = viewport.getSelectionStyleDetails();
516
- if (detailsEqual(cacheRef.current, next)) return cacheRef.current;
517
- cacheRef.current = next;
518
- return next;
519
- }, [viewport]);
520
- const details = (0, import_react13.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
407
+ const source = (0, import_react13.useMemo)(() => (0, import_core10.createSelectionStyleDetailsSource)(viewport), [viewport]);
408
+ const details = (0, import_react13.useSyncExternalStore)(source.subscribe, source.getSnapshot, source.getSnapshot);
521
409
  const apply = (0, import_react13.useCallback)((s) => viewport.applyStyleToSelection(s), [viewport]);
522
410
  return [details, apply];
523
411
  }
524
412
 
525
413
  // src/hooks/use-selection-ops.ts
526
414
  var import_react14 = require("react");
527
- function snapshotEqual(a, b) {
528
- return a.selectedIds === b.selectedIds && a.canGroup === b.canGroup && a.canUngroup === b.canUngroup && a.canAlign === b.canAlign && a.canDistribute === b.canDistribute && a.isLocked === b.isLocked;
529
- }
415
+ var import_core11 = require("@fieldnotes/core");
530
416
  function useSelectionOps() {
531
417
  const viewport = useViewport();
532
- const cacheRef = (0, import_react14.useRef)(null);
533
- const subscribe = (0, import_react14.useCallback)(
534
- (onChange) => {
535
- const offSel = viewport.onSelectionChange(onChange);
536
- const offStore = viewport.store.onChange(onChange);
537
- return () => {
538
- offSel();
539
- offStore();
540
- };
541
- },
542
- [viewport]
543
- );
544
- const getSnapshot = (0, import_react14.useCallback)(() => {
545
- const ids = viewport.getSelectedIds();
546
- const n = ids.length;
547
- let canUngroup = false;
548
- let lockedCount = 0;
549
- for (const id of ids) {
550
- const el = viewport.store.getById(id);
551
- if (!el) continue;
552
- if (el.groupId !== void 0) canUngroup = true;
553
- if (el.locked) lockedCount++;
554
- }
555
- const isLocked = n === 0 ? null : lockedCount === n ? true : lockedCount === 0 ? false : null;
556
- const next = {
557
- selectedIds: ids,
558
- canGroup: n >= 2,
559
- canUngroup,
560
- canAlign: n >= 2,
561
- canDistribute: n >= 3,
562
- isLocked
563
- };
564
- const prev = cacheRef.current;
565
- if (prev && snapshotEqual(prev, next)) return prev;
566
- cacheRef.current = next;
567
- return next;
568
- }, [viewport]);
569
- const snap = (0, import_react14.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
418
+ const source = (0, import_react14.useMemo)(() => (0, import_core11.createSelectionOpsSource)(viewport), [viewport]);
419
+ const snap = (0, import_react14.useSyncExternalStore)(source.subscribe, source.getSnapshot, source.getSnapshot);
570
420
  const group = (0, import_react14.useCallback)(() => viewport.groupSelection(), [viewport]);
571
421
  const ungroup = (0, import_react14.useCallback)(() => viewport.ungroupSelection(), [viewport]);
572
422
  const toggleLock = (0, import_react14.useCallback)(() => viewport.toggleLockSelection(), [viewport]);
@@ -597,7 +447,7 @@ function useSelectionOps() {
597
447
 
598
448
  // src/hooks/use-element-rects.ts
599
449
  var import_react15 = require("react");
600
- var import_core2 = require("@fieldnotes/core");
450
+ var import_core12 = require("@fieldnotes/core");
601
451
  function useElementRects(match) {
602
452
  const viewport = useViewport();
603
453
  const matchRef = (0, import_react15.useRef)(match);
@@ -607,11 +457,11 @@ function useElementRects(match) {
607
457
  const trackerRef = (0, import_react15.useRef)(null);
608
458
  const subscribe = (0, import_react15.useCallback)(
609
459
  (onStoreChange) => {
610
- const tracker = new import_core2.ElementRectTracker(viewport, { match: stableMatch });
460
+ const tracker = new import_core12.ElementRectTracker(viewport, { match: stableMatch });
611
461
  trackerRef.current = tracker;
612
462
  const next = tracker.getRects();
613
463
  const previous = cacheRef.current;
614
- if (previous === null || !(0, import_core2.elementRectsEqual)(previous, next)) {
464
+ if (previous === null || !(0, import_core12.elementRectsEqual)(previous, next)) {
615
465
  cacheRef.current = next;
616
466
  onStoreChange();
617
467
  }
@@ -629,7 +479,7 @@ function useElementRects(match) {
629
479
  );
630
480
  const getSnapshot = (0, import_react15.useCallback)(() => {
631
481
  if (cacheRef.current === null) {
632
- cacheRef.current = (0, import_core2.computeElementRects)(viewport.store, stableMatch);
482
+ cacheRef.current = (0, import_core12.computeElementRects)(viewport.store, stableMatch);
633
483
  }
634
484
  return cacheRef.current;
635
485
  }, [viewport, stableMatch]);
@@ -642,7 +492,7 @@ function useElementRects(match) {
642
492
 
643
493
  // src/minimap.tsx
644
494
  var import_react16 = require("react");
645
- var import_core3 = require("@fieldnotes/core");
495
+ var import_core13 = require("@fieldnotes/core");
646
496
  var import_jsx_runtime2 = require("react/jsx-runtime");
647
497
  var toggleStyle = {
648
498
  position: "absolute",
@@ -674,7 +524,7 @@ function Minimap({
674
524
  if (collapsed) return;
675
525
  const canvas = canvasRef.current;
676
526
  if (!canvas) return;
677
- const controller = new import_core3.MinimapController(viewport, canvas, {
527
+ const controller = new import_core13.MinimapController(viewport, canvas, {
678
528
  width,
679
529
  height,
680
530
  background,