@fieldnotes/react 0.14.0 → 0.14.2

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/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  forwardRef,
7
7
  useImperativeHandle
8
8
  } from "react";
9
- import { Viewport, createDefaultTools } from "@fieldnotes/core";
9
+ import { mountViewport } from "@fieldnotes/core";
10
10
 
11
11
  // src/context.ts
12
12
  import { createContext } from "react";
@@ -32,74 +32,37 @@ var FieldNotesCanvas = forwardRef(
32
32
  }, ref) {
33
33
  const containerRef = useRef(null);
34
34
  const [viewport, setViewport] = useState(null);
35
+ const mountedRef = useRef(null);
35
36
  const changeRef = useRef({ onChange, changeDebounceMs });
36
37
  changeRef.current = { onChange, changeDebounceMs };
37
38
  useImperativeHandle(ref, () => ({ viewport }), [viewport]);
38
39
  useEffect(() => {
39
40
  const el = containerRef.current;
40
41
  if (!el) return;
41
- const vp = new Viewport(el, options);
42
- const activate = (fallback) => {
43
- const name = tool ?? defaultTool ?? fallback;
44
- if (name) vp.setTool(name);
45
- };
46
- const deferDefaults = tools === void 0 && options?.tools === void 0;
47
- for (const t of tools === "default" ? createDefaultTools() : tools ?? []) {
48
- vp.toolManager.register(t);
49
- }
50
- if (!deferDefaults) activate(tools === "default" ? "select" : void 0);
51
- try {
52
- if (typeof initialData === "string") vp.loadJSON(initialData);
53
- else if (initialData) vp.loadState(initialData);
54
- } catch (error) {
55
- vp.destroy();
56
- throw error;
57
- }
58
- let timer;
59
- const emit = () => {
60
- timer = void 0;
61
- changeRef.current.onChange?.(vp.exportState());
62
- };
63
- const unsubscribe = vp.onStateChange(() => {
64
- clearTimeout(timer);
65
- timer = setTimeout(emit, changeRef.current.changeDebounceMs);
66
- });
67
- try {
68
- onReady?.(vp);
69
- if (deferDefaults) {
70
- const registerDefaults = vp.toolManager.toolNames.length === 0;
71
- if (registerDefaults) {
72
- for (const t of createDefaultTools()) vp.toolManager.register(t);
73
- }
74
- activate(registerDefaults ? "select" : void 0);
42
+ const mounted = mountViewport(el, {
43
+ options,
44
+ tools,
45
+ initialData,
46
+ tool,
47
+ defaultTool,
48
+ onReady,
49
+ get onChange() {
50
+ return changeRef.current.onChange;
51
+ },
52
+ get changeDebounceMs() {
53
+ return changeRef.current.changeDebounceMs;
75
54
  }
76
- } catch (error) {
77
- clearTimeout(timer);
78
- unsubscribe();
79
- vp.destroy();
80
- throw error;
81
- }
82
- setViewport(vp);
55
+ });
56
+ mountedRef.current = mounted;
57
+ setViewport(mounted.viewport);
83
58
  return () => {
84
- if (timer !== void 0) {
85
- clearTimeout(timer);
86
- try {
87
- emit();
88
- } catch (error) {
89
- if (typeof reportError === "function") reportError(error);
90
- else console.error("[fieldnotes] onChange failed during unmount", error);
91
- }
92
- }
93
- unsubscribe();
94
- vp.destroy();
59
+ mounted.destroy();
95
60
  setViewport(null);
96
61
  };
97
62
  }, []);
98
63
  useEffect(() => {
99
64
  if (!viewport || tool === void 0) return;
100
- if (viewport.toolManager.activeTool?.name !== tool) {
101
- viewport.setTool(tool);
102
- }
65
+ mountedRef.current?.setTool(tool);
103
66
  }, [viewport, tool]);
104
67
  useEffect(() => {
105
68
  if (!viewport || !onToolChange) return;
@@ -111,11 +74,7 @@ var FieldNotesCanvas = forwardRef(
111
74
  }, [viewport, snapToGrid]);
112
75
  useEffect(() => {
113
76
  if (!viewport || !Array.isArray(tools)) return;
114
- for (const t of tools) {
115
- if (!viewport.toolManager.getTool(t.name)) {
116
- viewport.toolManager.register(t);
117
- }
118
- }
77
+ mountedRef.current?.addTools(tools);
119
78
  }, [viewport, tools]);
120
79
  return /* @__PURE__ */ jsx("div", { ref: containerRef, className, style, children: viewport && /* @__PURE__ */ jsx(ViewportContext.Provider, { value: viewport, children }) });
121
80
  }
@@ -124,6 +83,7 @@ var FieldNotesCanvas = forwardRef(
124
83
  // src/canvas-element.tsx
125
84
  import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
126
85
  import { createPortal } from "react-dom";
86
+ import { attachHostElement } from "@fieldnotes/core";
127
87
 
128
88
  // src/hooks/use-viewport.ts
129
89
  import { useContext } from "react";
@@ -136,11 +96,10 @@ function useViewport() {
136
96
  }
137
97
 
138
98
  // src/canvas-element.tsx
139
- var HOST_ORIGIN = "host";
140
99
  function CanvasElement({ position, size, children }) {
141
100
  const viewport = useViewport();
142
101
  const [portalTarget, setPortalTarget] = useState2(null);
143
- const elementIdRef = useRef2(null);
102
+ const hostRef = useRef2(null);
144
103
  const geometryRef = useRef2({ position, size });
145
104
  useEffect2(() => {
146
105
  const container = document.createElement("div");
@@ -148,118 +107,59 @@ function CanvasElement({ position, size, children }) {
148
107
  width: "100%",
149
108
  height: "100%"
150
109
  });
151
- const register = () => {
152
- viewport.domLayer.appendChild(container);
153
- const { position: current, size: currentSize } = geometryRef.current;
154
- return viewport.addHtmlElement(container, current, currentSize, {
155
- transient: true,
156
- origin: HOST_ORIGIN
157
- });
158
- };
159
- elementIdRef.current = register();
110
+ const host = attachHostElement(viewport, container, geometryRef.current);
111
+ hostRef.current = host;
160
112
  setPortalTarget(container);
161
- let disposed = false;
162
- const handleStoreReplacement = () => {
163
- const clearedId = elementIdRef.current;
164
- if (clearedId === null) return;
165
- queueMicrotask(() => {
166
- if (disposed || elementIdRef.current !== clearedId) return;
167
- if (viewport.store.getById(clearedId)?.type === "html") {
168
- viewport.updateHtmlElement(clearedId, container);
169
- } else {
170
- elementIdRef.current = register();
171
- }
172
- viewport.requestRender();
173
- });
174
- };
175
- const unsubscribeClear = viewport.store.on("clear", handleStoreReplacement);
176
- const unsubscribeBatch = viewport.store.on("batch", handleStoreReplacement);
177
113
  return () => {
178
- disposed = true;
179
- unsubscribeClear();
180
- unsubscribeBatch();
181
- if (elementIdRef.current) {
182
- viewport.store.remove(elementIdRef.current, { origin: HOST_ORIGIN });
183
- viewport.requestRender();
184
- elementIdRef.current = null;
185
- }
186
- if (container.parentNode === viewport.domLayer) container.remove();
114
+ host.destroy();
187
115
  setPortalTarget(null);
188
116
  };
189
117
  }, [viewport]);
190
118
  useEffect2(() => {
191
119
  geometryRef.current = { position, size };
192
- const id = elementIdRef.current;
193
- if (!id) return;
194
- viewport.store.update(id, { position, ...size ? { size } : {} }, { origin: HOST_ORIGIN });
195
- viewport.requestRender();
120
+ hostRef.current?.setGeometry({ position, size });
196
121
  }, [viewport, position.x, position.y, size?.w, size?.h]);
197
122
  if (!portalTarget) return null;
198
123
  return createPortal(children, portalTarget);
199
124
  }
200
125
 
201
126
  // src/hooks/use-active-tool.ts
202
- import { useCallback, useSyncExternalStore } from "react";
127
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
128
+ import { createActiveToolSource } from "@fieldnotes/core";
203
129
  function useActiveTool() {
204
130
  const viewport = useViewport();
205
- const subscribe = useCallback(
206
- (onStoreChange) => viewport.toolManager.onChange(onStoreChange),
207
- [viewport]
208
- );
209
- const getSnapshot = useCallback(() => viewport.toolManager.activeTool?.name ?? "", [viewport]);
210
- const toolName = useSyncExternalStore(subscribe, getSnapshot);
131
+ const source = useMemo(() => createActiveToolSource(viewport), [viewport]);
132
+ const toolName = useSyncExternalStore(source.subscribe, source.getSnapshot);
211
133
  const setTool = useCallback((name) => viewport.setTool(name), [viewport]);
212
134
  return [toolName, setTool];
213
135
  }
214
136
 
215
137
  // src/hooks/use-camera.ts
216
- import { useCallback as useCallback2, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
138
+ import { useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore2 } from "react";
139
+ import { createCameraSource } from "@fieldnotes/core";
217
140
  function useCamera() {
218
141
  const viewport = useViewport();
219
- const cachedRef = useRef3({ x: 0, y: 0, zoom: 1 });
220
- const subscribe = useCallback2(
221
- (onStoreChange) => viewport.camera.onChange(onStoreChange),
222
- [viewport]
223
- );
224
- const getSnapshot = useCallback2(() => {
225
- const { position, zoom } = viewport.camera;
226
- const cached = cachedRef.current;
227
- if (cached.x === position.x && cached.y === position.y && cached.zoom === zoom) {
228
- return cached;
229
- }
230
- const next = { x: position.x, y: position.y, zoom };
231
- cachedRef.current = next;
232
- return next;
233
- }, [viewport]);
234
- return useSyncExternalStore2(subscribe, getSnapshot);
142
+ const source = useMemo2(() => createCameraSource(viewport), [viewport]);
143
+ return useSyncExternalStore2(source.subscribe, source.getSnapshot);
235
144
  }
236
145
 
237
146
  // src/hooks/use-tool-options.ts
238
- import { useCallback as useCallback3, useRef as useRef4, useSyncExternalStore as useSyncExternalStore3 } from "react";
239
- function noop() {
240
- }
147
+ import { useCallback as useCallback2, useMemo as useMemo3, useRef as useRef3, useSyncExternalStore as useSyncExternalStore3 } from "react";
148
+ import { createToolOptionsSource } from "@fieldnotes/core";
241
149
  function useToolOptions(toolName) {
242
150
  const viewport = useViewport();
243
- const cachedRef = useRef4(null);
244
- const subscribe = useCallback3(
245
- (onStoreChange) => {
246
- const tool = viewport.toolManager.getTool(toolName);
247
- if (!tool?.onOptionsChange) return noop;
248
- return tool.onOptionsChange(onStoreChange);
249
- },
250
- [viewport, toolName]
151
+ const nameRef = useRef3(toolName);
152
+ nameRef.current = toolName;
153
+ const source = useMemo3(
154
+ () => createToolOptionsSource(viewport, () => nameRef.current),
155
+ [viewport]
156
+ );
157
+ const subscribe = useCallback2(
158
+ (onStoreChange) => source.subscribe(onStoreChange),
159
+ [source, toolName]
251
160
  );
252
- const getSnapshot = useCallback3(() => {
253
- const tool = viewport.toolManager.getTool(toolName);
254
- if (!tool?.getOptions) return null;
255
- const next = tool.getOptions();
256
- const cached = cachedRef.current;
257
- if (cached && shallowEqual(cached, next)) return cached;
258
- cachedRef.current = next;
259
- return next;
260
- }, [viewport, toolName]);
261
- const options = useSyncExternalStore3(subscribe, getSnapshot);
262
- const setOptions = useCallback3(
161
+ const options = useSyncExternalStore3(subscribe, source.getSnapshot);
162
+ const setOptions = useCallback2(
263
163
  (partial) => {
264
164
  const tool = viewport.toolManager.getTool(toolName);
265
165
  tool?.setOptions?.(partial);
@@ -268,84 +168,47 @@ function useToolOptions(toolName) {
268
168
  );
269
169
  return [options, setOptions];
270
170
  }
271
- function shallowEqual(a, b) {
272
- const keysA = Object.keys(a);
273
- const keysB = Object.keys(b);
274
- if (keysA.length !== keysB.length) return false;
275
- for (const key of keysA) {
276
- const valA = a[key];
277
- const valB = b[key];
278
- if (valA === valB) continue;
279
- if (typeof valA === "object" && valA !== null && typeof valB === "object" && valB !== null && !Array.isArray(valA) && !Array.isArray(valB)) {
280
- const objA = valA;
281
- const objB = valB;
282
- const innerKeys = Object.keys(objA);
283
- if (innerKeys.length === Object.keys(objB).length && innerKeys.every((k) => objA[k] === objB[k])) {
284
- continue;
285
- }
286
- }
287
- return false;
288
- }
289
- return true;
290
- }
291
171
 
292
172
  // src/hooks/use-layers.ts
293
- import { useCallback as useCallback4, useRef as useRef5, useSyncExternalStore as useSyncExternalStore4 } from "react";
173
+ import { useCallback as useCallback3, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore4 } from "react";
174
+ import { createLayersSource } from "@fieldnotes/core";
294
175
  function useLayers() {
295
176
  const viewport = useViewport();
296
- const cachedRef = useRef5({ layers: [], activeLayerId: "" });
297
- const subscribe = useCallback4(
298
- (onStoreChange) => viewport.layerManager.on("change", onStoreChange),
299
- [viewport]
300
- );
301
- const getSnapshot = useCallback4(() => {
302
- const layers = viewport.layerManager.getLayers();
303
- const activeLayerId = viewport.layerManager.activeLayerId;
304
- const cached = cachedRef.current;
305
- if (cached.activeLayerId === activeLayerId && cached.layers.length === layers.length && cached.layers.every((l, i) => {
306
- const next2 = layers[i];
307
- 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;
308
- })) {
309
- return cached;
310
- }
311
- const next = { layers, activeLayerId };
312
- cachedRef.current = next;
313
- return next;
314
- }, [viewport]);
315
- const snapshot = useSyncExternalStore4(subscribe, getSnapshot);
316
- const createLayer = useCallback4(
177
+ const source = useMemo4(() => createLayersSource(viewport), [viewport]);
178
+ const snapshot = useSyncExternalStore4(source.subscribe, source.getSnapshot);
179
+ const createLayer = useCallback3(
317
180
  (name) => viewport.layerManager.createLayer(name),
318
181
  [viewport]
319
182
  );
320
- const removeLayer = useCallback4(
183
+ const removeLayer = useCallback3(
321
184
  (id) => viewport.layerManager.removeLayer(id),
322
185
  [viewport]
323
186
  );
324
- const renameLayer = useCallback4(
187
+ const renameLayer = useCallback3(
325
188
  (id, name) => viewport.layerManager.renameLayer(id, name),
326
189
  [viewport]
327
190
  );
328
- const reorderLayer = useCallback4(
191
+ const reorderLayer = useCallback3(
329
192
  (id, newOrder) => viewport.layerManager.reorderLayer(id, newOrder),
330
193
  [viewport]
331
194
  );
332
- const setVisible = useCallback4(
195
+ const setVisible = useCallback3(
333
196
  (id, visible) => viewport.layerManager.setLayerVisible(id, visible),
334
197
  [viewport]
335
198
  );
336
- const setLocked = useCallback4(
199
+ const setLocked = useCallback3(
337
200
  (id, locked) => viewport.layerManager.setLayerLocked(id, locked),
338
201
  [viewport]
339
202
  );
340
- const setOpacity = useCallback4(
203
+ const setOpacity = useCallback3(
341
204
  (id, opacity) => viewport.layerManager.setLayerOpacity(id, opacity),
342
205
  [viewport]
343
206
  );
344
- const setActiveLayer = useCallback4(
207
+ const setActiveLayer = useCallback3(
345
208
  (id) => viewport.layerManager.setActiveLayer(id),
346
209
  [viewport]
347
210
  );
348
- const moveElement = useCallback4(
211
+ const moveElement = useCallback3(
349
212
  (elementId, layerId) => viewport.layerManager.moveElementToLayer(elementId, layerId),
350
213
  [viewport]
351
214
  );
@@ -365,28 +228,14 @@ function useLayers() {
365
228
  }
366
229
 
367
230
  // src/hooks/use-history.ts
368
- import { useCallback as useCallback5, useRef as useRef6, useSyncExternalStore as useSyncExternalStore5 } from "react";
231
+ import { useCallback as useCallback4, useMemo as useMemo5, useSyncExternalStore as useSyncExternalStore5 } from "react";
232
+ import { createHistorySource } from "@fieldnotes/core";
369
233
  function useHistory() {
370
234
  const viewport = useViewport();
371
- const cachedRef = useRef6({ canUndo: false, canRedo: false });
372
- const subscribe = useCallback5(
373
- (onStoreChange) => viewport.history.onChange(onStoreChange),
374
- [viewport]
375
- );
376
- const getSnapshot = useCallback5(() => {
377
- const canUndo = viewport.history.canUndo;
378
- const canRedo = viewport.history.canRedo;
379
- const cached = cachedRef.current;
380
- if (cached.canUndo === canUndo && cached.canRedo === canRedo) {
381
- return cached;
382
- }
383
- const next = { canUndo, canRedo };
384
- cachedRef.current = next;
385
- return next;
386
- }, [viewport]);
387
- const snapshot = useSyncExternalStore5(subscribe, getSnapshot);
388
- const undo = useCallback5(() => viewport.undo(), [viewport]);
389
- const redo = useCallback5(() => viewport.redo(), [viewport]);
235
+ const source = useMemo5(() => createHistorySource(viewport), [viewport]);
236
+ const snapshot = useSyncExternalStore5(source.subscribe, source.getSnapshot);
237
+ const undo = useCallback4(() => viewport.undo(), [viewport]);
238
+ const redo = useCallback4(() => viewport.redo(), [viewport]);
390
239
  return {
391
240
  canUndo: snapshot.canUndo,
392
241
  canRedo: snapshot.canRedo,
@@ -396,197 +245,80 @@ function useHistory() {
396
245
  }
397
246
 
398
247
  // src/hooks/use-elements.ts
399
- import { useCallback as useCallback6, useRef as useRef7, useSyncExternalStore as useSyncExternalStore6 } from "react";
400
- function shallowEqual2(a, b) {
401
- if (Object.is(a, b)) return true;
402
- if (Array.isArray(a) && Array.isArray(b)) {
403
- return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
404
- }
405
- if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
406
- const ka = Object.keys(a);
407
- const kb = Object.keys(b);
408
- return ka.length === kb.length && ka.every(
409
- (k) => k in b && Object.is(a[k], b[k])
410
- );
411
- }
412
- return false;
413
- }
248
+ import { useMemo as useMemo6, useRef as useRef4, useSyncExternalStore as useSyncExternalStore6 } from "react";
249
+ import { createElementsSelectorSource, createElementsSource } from "@fieldnotes/core";
414
250
  function useElements(arg, isEqual) {
415
251
  const viewport = useViewport();
416
- const cachedRef = useRef7([]);
417
- const selectedRef = useRef7(null);
418
- const subscribe = useCallback6(
419
- (onStoreChange) => viewport.store.onChange(onStoreChange),
252
+ const latest = useRef4({ arg, isEqual });
253
+ latest.current = { arg, isEqual };
254
+ const elementsSource = useMemo6(
255
+ () => createElementsSource(viewport, () => {
256
+ const a = latest.current.arg;
257
+ return typeof a === "function" ? void 0 : a;
258
+ }),
420
259
  [viewport]
421
260
  );
422
- const getSnapshot = useCallback6(() => {
423
- if (typeof arg === "function") {
424
- const next2 = arg(viewport.store.getAll());
425
- const prev = selectedRef.current;
426
- const equal = isEqual ?? shallowEqual2;
427
- if (prev !== null && equal(prev.value, next2)) {
428
- return prev.value;
429
- }
430
- selectedRef.current = { value: next2 };
431
- return next2;
432
- }
433
- const next = arg ? viewport.store.getElementsByType(arg) : viewport.store.getAll();
434
- const cached = cachedRef.current;
435
- if (cached.length === next.length && cached.every((el, i) => el === next[i])) {
436
- return cached;
437
- }
438
- cachedRef.current = next;
439
- return next;
440
- }, [viewport, arg, isEqual]);
441
- return useSyncExternalStore6(subscribe, getSnapshot);
261
+ const selectorSource = useMemo6(
262
+ () => createElementsSelectorSource(
263
+ viewport,
264
+ // Only read while `arg` is a selector: the source is picked below by `typeof arg`.
265
+ () => latest.current.arg,
266
+ () => latest.current.isEqual
267
+ ),
268
+ [viewport]
269
+ );
270
+ const source = typeof arg === "function" ? selectorSource : elementsSource;
271
+ return useSyncExternalStore6(source.subscribe, source.getSnapshot);
442
272
  }
443
273
 
444
274
  // src/hooks/use-selection.ts
445
- import { useCallback as useCallback7, useSyncExternalStore as useSyncExternalStore7 } from "react";
275
+ import { useMemo as useMemo7, useSyncExternalStore as useSyncExternalStore7 } from "react";
276
+ import { createSelectionSource } from "@fieldnotes/core";
446
277
  function useSelection() {
447
278
  const viewport = useViewport();
448
- const subscribe = useCallback7(
449
- (onChange) => viewport.onSelectionChange(onChange),
450
- [viewport]
451
- );
452
- const getSnapshot = useCallback7(() => viewport.getSelectedIds(), [viewport]);
453
- return useSyncExternalStore7(subscribe, getSnapshot, getSnapshot);
279
+ const source = useMemo7(() => createSelectionSource(viewport), [viewport]);
280
+ return useSyncExternalStore7(source.subscribe, source.getSnapshot, source.getSnapshot);
454
281
  }
455
282
 
456
283
  // src/hooks/use-selection-style.ts
457
- import { useCallback as useCallback8, useRef as useRef8, useSyncExternalStore as useSyncExternalStore8 } from "react";
458
-
459
- // src/hooks/style-keys.ts
460
- var STYLE_KEYS = [
461
- "color",
462
- "fillColor",
463
- "strokeWidth",
464
- "opacity",
465
- "fontSize",
466
- "strokeStyle"
467
- ];
468
-
469
- // src/hooks/use-selection-style.ts
470
- function styleEqual(a, b) {
471
- if (a === b) return true;
472
- if (a === null || b === null) return false;
473
- return STYLE_KEYS.every((k) => a[k] === b[k]);
474
- }
284
+ import { useCallback as useCallback5, useMemo as useMemo8, useSyncExternalStore as useSyncExternalStore8 } from "react";
285
+ import { createSelectionStyleSource } from "@fieldnotes/core";
475
286
  function useSelectionStyle() {
476
287
  const viewport = useViewport();
477
- const cacheRef = useRef8(null);
478
- const subscribe = useCallback8(
479
- (onChange) => {
480
- const offSel = viewport.onSelectionChange(onChange);
481
- const offStore = viewport.store.onChange(onChange);
482
- return () => {
483
- offSel();
484
- offStore();
485
- };
486
- },
487
- [viewport]
488
- );
489
- const getSnapshot = useCallback8(() => {
490
- const next = viewport.getSelectionStyle();
491
- if (styleEqual(cacheRef.current, next)) return cacheRef.current;
492
- cacheRef.current = next;
493
- return next;
494
- }, [viewport]);
495
- const style = useSyncExternalStore8(subscribe, getSnapshot, getSnapshot);
496
- const apply = useCallback8((s) => viewport.applyStyleToSelection(s), [viewport]);
288
+ const source = useMemo8(() => createSelectionStyleSource(viewport), [viewport]);
289
+ const style = useSyncExternalStore8(source.subscribe, source.getSnapshot, source.getSnapshot);
290
+ const apply = useCallback5((s) => viewport.applyStyleToSelection(s), [viewport]);
497
291
  return [style, apply];
498
292
  }
499
293
 
500
294
  // src/hooks/use-selection-style-details.ts
501
- import { useCallback as useCallback9, useRef as useRef9, useSyncExternalStore as useSyncExternalStore9 } from "react";
502
- function keysEqual(a, b) {
503
- return a.length === b.length && a.every((k, i) => k === b[i]);
504
- }
505
- function detailsEqual(a, b) {
506
- if (a === b) return true;
507
- if (a === null || b === null) return false;
508
- return keysEqual(a.applicable, b.applicable) && keysEqual(a.mixed, b.mixed) && STYLE_KEYS.every((k) => a.common[k] === b.common[k]);
509
- }
295
+ import { useCallback as useCallback6, useMemo as useMemo9, useSyncExternalStore as useSyncExternalStore9 } from "react";
296
+ import { createSelectionStyleDetailsSource } from "@fieldnotes/core";
510
297
  function useSelectionStyleDetails() {
511
298
  const viewport = useViewport();
512
- const cacheRef = useRef9(null);
513
- const subscribe = useCallback9(
514
- (onChange) => {
515
- const offSel = viewport.onSelectionChange(onChange);
516
- const offStore = viewport.store.onChange(onChange);
517
- return () => {
518
- offSel();
519
- offStore();
520
- };
521
- },
522
- [viewport]
523
- );
524
- const getSnapshot = useCallback9(() => {
525
- const next = viewport.getSelectionStyleDetails();
526
- if (detailsEqual(cacheRef.current, next)) return cacheRef.current;
527
- cacheRef.current = next;
528
- return next;
529
- }, [viewport]);
530
- const details = useSyncExternalStore9(subscribe, getSnapshot, getSnapshot);
531
- const apply = useCallback9((s) => viewport.applyStyleToSelection(s), [viewport]);
299
+ const source = useMemo9(() => createSelectionStyleDetailsSource(viewport), [viewport]);
300
+ const details = useSyncExternalStore9(source.subscribe, source.getSnapshot, source.getSnapshot);
301
+ const apply = useCallback6((s) => viewport.applyStyleToSelection(s), [viewport]);
532
302
  return [details, apply];
533
303
  }
534
304
 
535
305
  // src/hooks/use-selection-ops.ts
536
- import { useCallback as useCallback10, useRef as useRef10, useSyncExternalStore as useSyncExternalStore10 } from "react";
537
- function snapshotEqual(a, b) {
538
- 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;
539
- }
306
+ import { useCallback as useCallback7, useMemo as useMemo10, useSyncExternalStore as useSyncExternalStore10 } from "react";
307
+ import { createSelectionOpsSource } from "@fieldnotes/core";
540
308
  function useSelectionOps() {
541
309
  const viewport = useViewport();
542
- const cacheRef = useRef10(null);
543
- const subscribe = useCallback10(
544
- (onChange) => {
545
- const offSel = viewport.onSelectionChange(onChange);
546
- const offStore = viewport.store.onChange(onChange);
547
- return () => {
548
- offSel();
549
- offStore();
550
- };
551
- },
552
- [viewport]
553
- );
554
- const getSnapshot = useCallback10(() => {
555
- const ids = viewport.getSelectedIds();
556
- const n = ids.length;
557
- let canUngroup = false;
558
- let lockedCount = 0;
559
- for (const id of ids) {
560
- const el = viewport.store.getById(id);
561
- if (!el) continue;
562
- if (el.groupId !== void 0) canUngroup = true;
563
- if (el.locked) lockedCount++;
564
- }
565
- const isLocked = n === 0 ? null : lockedCount === n ? true : lockedCount === 0 ? false : null;
566
- const next = {
567
- selectedIds: ids,
568
- canGroup: n >= 2,
569
- canUngroup,
570
- canAlign: n >= 2,
571
- canDistribute: n >= 3,
572
- isLocked
573
- };
574
- const prev = cacheRef.current;
575
- if (prev && snapshotEqual(prev, next)) return prev;
576
- cacheRef.current = next;
577
- return next;
578
- }, [viewport]);
579
- const snap = useSyncExternalStore10(subscribe, getSnapshot, getSnapshot);
580
- const group = useCallback10(() => viewport.groupSelection(), [viewport]);
581
- const ungroup = useCallback10(() => viewport.ungroupSelection(), [viewport]);
582
- const toggleLock = useCallback10(() => viewport.toggleLockSelection(), [viewport]);
583
- const align = useCallback10((edge) => viewport.alignSelection(edge), [viewport]);
584
- const distribute = useCallback10(
310
+ const source = useMemo10(() => createSelectionOpsSource(viewport), [viewport]);
311
+ const snap = useSyncExternalStore10(source.subscribe, source.getSnapshot, source.getSnapshot);
312
+ const group = useCallback7(() => viewport.groupSelection(), [viewport]);
313
+ const ungroup = useCallback7(() => viewport.ungroupSelection(), [viewport]);
314
+ const toggleLock = useCallback7(() => viewport.toggleLockSelection(), [viewport]);
315
+ const align = useCallback7((edge) => viewport.alignSelection(edge), [viewport]);
316
+ const distribute = useCallback7(
585
317
  (axis) => viewport.distributeSelection(axis),
586
318
  [viewport]
587
319
  );
588
- const rotateCW = useCallback10(() => viewport.rotateSelection("cw"), [viewport]);
589
- const rotateCCW = useCallback10(() => viewport.rotateSelection("ccw"), [viewport]);
320
+ const rotateCW = useCallback7(() => viewport.rotateSelection("cw"), [viewport]);
321
+ const rotateCCW = useCallback7(() => viewport.rotateSelection("ccw"), [viewport]);
590
322
  return {
591
323
  selectedIds: snap.selectedIds,
592
324
  selectedCount: snap.selectedIds.length,
@@ -606,16 +338,16 @@ function useSelectionOps() {
606
338
  }
607
339
 
608
340
  // src/hooks/use-element-rects.ts
609
- import { useCallback as useCallback11, useEffect as useEffect3, useRef as useRef11, useSyncExternalStore as useSyncExternalStore11 } from "react";
341
+ import { useCallback as useCallback8, useEffect as useEffect3, useRef as useRef5, useSyncExternalStore as useSyncExternalStore11 } from "react";
610
342
  import { ElementRectTracker, computeElementRects, elementRectsEqual } from "@fieldnotes/core";
611
343
  function useElementRects(match) {
612
344
  const viewport = useViewport();
613
- const matchRef = useRef11(match);
345
+ const matchRef = useRef5(match);
614
346
  matchRef.current = match;
615
- const stableMatch = useCallback11((el) => matchRef.current(el), []);
616
- const cacheRef = useRef11(null);
617
- const trackerRef = useRef11(null);
618
- const subscribe = useCallback11(
347
+ const stableMatch = useCallback8((el) => matchRef.current(el), []);
348
+ const cacheRef = useRef5(null);
349
+ const trackerRef = useRef5(null);
350
+ const subscribe = useCallback8(
619
351
  (onStoreChange) => {
620
352
  const tracker = new ElementRectTracker(viewport, { match: stableMatch });
621
353
  trackerRef.current = tracker;
@@ -637,7 +369,7 @@ function useElementRects(match) {
637
369
  },
638
370
  [viewport, stableMatch]
639
371
  );
640
- const getSnapshot = useCallback11(() => {
372
+ const getSnapshot = useCallback8(() => {
641
373
  if (cacheRef.current === null) {
642
374
  cacheRef.current = computeElementRects(viewport.store, stableMatch);
643
375
  }
@@ -651,7 +383,7 @@ function useElementRects(match) {
651
383
  }
652
384
 
653
385
  // src/minimap.tsx
654
- import { useEffect as useEffect4, useRef as useRef12, useState as useState3 } from "react";
386
+ import { useEffect as useEffect4, useRef as useRef6, useState as useState3 } from "react";
655
387
  import { MinimapController } from "@fieldnotes/core";
656
388
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
657
389
  var toggleStyle = {
@@ -679,7 +411,7 @@ function Minimap({
679
411
  }) {
680
412
  const viewport = useViewport();
681
413
  const [collapsed, setCollapsed] = useState3(defaultCollapsed);
682
- const canvasRef = useRef12(null);
414
+ const canvasRef = useRef6(null);
683
415
  useEffect4(() => {
684
416
  if (collapsed) return;
685
417
  const canvas = canvasRef.current;