@weasel-js/labkit 1.3.0 → 1.4.0-pre.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.
Files changed (210) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -4
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +6 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
  37. package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  43. package/dist/chunk-XJ6N32QP.js.map +1 -0
  44. package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
  45. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  46. package/dist/controls/index.d.ts +4 -28
  47. package/dist/controls/index.js +3 -3
  48. package/dist/dragdrop/index.d.ts +9 -5
  49. package/dist/dragdrop/index.js +3 -3
  50. package/dist/index.d.ts +518 -190
  51. package/dist/index.js +1753 -75
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +6 -5
  54. package/dist/layers/index.js +3 -3
  55. package/dist/loupe/index.d.ts +187 -0
  56. package/dist/loupe/index.js +7 -0
  57. package/dist/loupe/index.js.map +1 -0
  58. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  59. package/dist/passthrough/weasel-canvas.js +1 -1
  60. package/dist/passthrough/weasel-ui.d.ts +138 -91
  61. package/dist/passthrough/weasel-ui.js +2 -2
  62. package/dist/primitives/index.d.ts +3 -1
  63. package/dist/primitives/index.js +5 -5
  64. package/dist/state/index.d.ts +3 -3
  65. package/dist/state/index.js +2 -2
  66. package/dist/styles.css +229 -1
  67. package/dist/surface/index.d.ts +18 -2
  68. package/dist/surface/index.js +2 -2
  69. package/dist/ui/layers/index.js +2 -2
  70. package/dist/undo/index.d.ts +5 -4
  71. package/package.json +14 -7
  72. package/src/annotations/AnnotationOverlay.tsx +205 -0
  73. package/src/annotations/AnnotationTargets.tsx +37 -0
  74. package/src/annotations/Annotations.less +129 -0
  75. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  76. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  77. package/src/annotations/AnnotationsContext.ts +49 -0
  78. package/src/annotations/ExportMenu.test.tsx +86 -0
  79. package/src/annotations/ExportMenu.tsx +182 -0
  80. package/src/annotations/MarkList.tsx +75 -0
  81. package/src/annotations/capture.test.ts +140 -0
  82. package/src/annotations/capture.ts +237 -0
  83. package/src/annotations/drawOne.test.ts +56 -0
  84. package/src/annotations/drawOne.ts +47 -0
  85. package/src/annotations/frac.test.ts +59 -0
  86. package/src/annotations/frac.ts +66 -0
  87. package/src/annotations/history.test.ts +82 -0
  88. package/src/annotations/history.ts +118 -0
  89. package/src/annotations/index.ts +37 -0
  90. package/src/annotations/paint.test.ts +125 -0
  91. package/src/annotations/paint.ts +130 -0
  92. package/src/annotations/staleness.test.ts +56 -0
  93. package/src/annotations/staleness.ts +41 -0
  94. package/src/annotations/store.test.ts +175 -0
  95. package/src/annotations/store.ts +317 -0
  96. package/src/annotations/svgNodes.test.ts +77 -0
  97. package/src/annotations/svgNodes.ts +74 -0
  98. package/src/annotations/toolMap.test.ts +36 -0
  99. package/src/annotations/toolMap.ts +54 -0
  100. package/src/annotations/types.ts +222 -0
  101. package/src/annotations/view.test.ts +37 -0
  102. package/src/annotations/view.ts +40 -0
  103. package/src/canvas/AGENTS.md +55 -5
  104. package/src/canvas/CanvasStack.test.tsx +27 -1
  105. package/src/canvas/CanvasStack.tsx +29 -4
  106. package/src/canvas/CanvasStackContext.ts +23 -4
  107. package/src/canvas/camera.test.ts +78 -0
  108. package/src/canvas/camera.ts +62 -0
  109. package/src/canvas/canvasCoords.test.ts +26 -0
  110. package/src/canvas/canvasCoords.ts +20 -7
  111. package/src/canvas/index.ts +5 -1
  112. package/src/canvas/useLayerScheduler.ts +7 -3
  113. package/src/canvas/usePanZoom.test.ts +36 -5
  114. package/src/canvas/usePanZoom.ts +20 -15
  115. package/src/canvas/worldSpec.test.ts +63 -0
  116. package/src/canvas/worldSpec.ts +48 -0
  117. package/src/chrome/builtins.test.ts +31 -1
  118. package/src/chrome/builtins.tsx +63 -19
  119. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  120. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  121. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  122. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  123. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  124. package/src/chrome/regions/regions.test.tsx +6 -1
  125. package/src/chrome/types.ts +20 -0
  126. package/src/controls/ControlPanel.test.tsx +38 -0
  127. package/src/controls/ControlPanel.tsx +50 -3
  128. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  129. package/src/dragdrop/dragDrop.test.tsx +29 -2
  130. package/src/index.test.ts +22 -0
  131. package/src/index.ts +87 -2
  132. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  133. package/src/instrument/types.ts +24 -1
  134. package/src/lab/Lab.surface.test.tsx +162 -0
  135. package/src/lab/Lab.tsx +102 -17
  136. package/src/lab/Lab.undock.test.tsx +85 -0
  137. package/src/lab/LabFullChrome.stories.tsx +20 -0
  138. package/src/lab/LabShell.less +11 -0
  139. package/src/lab/Workspace.less +73 -0
  140. package/src/lab/Workspace.surface.test.tsx +2 -0
  141. package/src/lab/Workspace.tsx +97 -8
  142. package/src/lab/index.ts +1 -1
  143. package/src/lab/panelHost.ts +37 -0
  144. package/src/loupe/AGENTS.md +49 -0
  145. package/src/loupe/CanvasLoupe.tsx +83 -0
  146. package/src/loupe/DomLoupe.tsx +62 -0
  147. package/src/loupe/Loupe.less +38 -0
  148. package/src/loupe/LoupeBubble.tsx +32 -0
  149. package/src/loupe/TrialLoupe.tsx +93 -0
  150. package/src/loupe/canvasLens.test.ts +205 -0
  151. package/src/loupe/canvasLens.ts +141 -0
  152. package/src/loupe/index.ts +19 -0
  153. package/src/loupe/types.test.ts +29 -0
  154. package/src/loupe/types.ts +90 -0
  155. package/src/loupe/useHostSize.ts +30 -0
  156. package/src/loupe/useLoupe.test.tsx +197 -0
  157. package/src/loupe/useLoupe.ts +189 -0
  158. package/src/passthrough/weasel-ui.ts +3 -0
  159. package/src/primitives/FloatingPanel.test.tsx +76 -1
  160. package/src/primitives/FloatingPanel.tsx +19 -3
  161. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  162. package/src/primitives/Sidebar.less +18 -0
  163. package/src/primitives/Toolbar.less +8 -0
  164. package/src/primitives/Toolbar.tsx +5 -0
  165. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  166. package/src/primitives/useRovingTabIndex.ts +26 -17
  167. package/src/state/document.test.ts +23 -0
  168. package/src/state/document.ts +14 -2
  169. package/src/state/index.ts +2 -0
  170. package/src/state/store.test.ts +24 -0
  171. package/src/state/store.ts +35 -1
  172. package/src/state/types.ts +11 -0
  173. package/src/state/undock.test.ts +40 -0
  174. package/src/state/undock.ts +39 -0
  175. package/src/styles.less +2 -0
  176. package/src/surface/SurfaceContext.ts +4 -0
  177. package/src/surface/index.ts +8 -3
  178. package/src/surface/useSurfaceTile.test.tsx +2 -0
  179. package/src/surface/useSurfaceTile.ts +7 -1
  180. package/src/surface/useTiledSurface.test.tsx +58 -0
  181. package/src/surface/useTiledSurface.ts +38 -3
  182. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  183. package/src/trial/Trial.annotations.test.tsx +121 -0
  184. package/src/trial/Trial.canvas.test.tsx +76 -1
  185. package/src/trial/Trial.less +19 -0
  186. package/src/trial/Trial.loupe.test.tsx +128 -0
  187. package/src/trial/Trial.stories.tsx +1 -0
  188. package/src/trial/Trial.test.tsx +12 -0
  189. package/src/trial/Trial.trialId.test.tsx +36 -0
  190. package/src/trial/Trial.tsx +215 -40
  191. package/src/trial/TrialChrome.tsx +35 -0
  192. package/src/trial/UndockedSections.tsx +65 -0
  193. package/src/trial/trialOps.test.ts +21 -0
  194. package/src/trial/trialOps.ts +5 -3
  195. package/dist/_dts/types-BttTIent.d.ts +0 -314
  196. package/dist/chunk-3XXPU73K.js.map +0 -1
  197. package/dist/chunk-4TMMVIDM.js.map +0 -1
  198. package/dist/chunk-6X5RBC6G.js +0 -366
  199. package/dist/chunk-6X5RBC6G.js.map +0 -1
  200. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  201. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  202. package/dist/chunk-JDROYQM3.js.map +0 -1
  203. package/dist/chunk-LGKRIUSW.js.map +0 -1
  204. package/dist/chunk-QCICIHEO.js.map +0 -1
  205. package/dist/chunk-QRYPXSGP.js.map +0 -1
  206. package/dist/chunk-RGT4EJIB.js +0 -6961
  207. package/dist/chunk-RGT4EJIB.js.map +0 -1
  208. package/dist/chunk-T66HU3HX.js +0 -17
  209. package/dist/chunk-T66HU3HX.js.map +0 -1
  210. package/dist/chunk-WTI26YTM.js.map +0 -1
@@ -1,20 +1,27 @@
1
1
  import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';
2
2
 
3
- /** Where a key takes focus within `count` items, or null if it does not move it. */
4
- export function nextIndex(current: number, key: string, count: number): number | null {
3
+ /** Which axis a toolbar's arrow keys walk. Matches `aria-orientation`. */
4
+ export type RovingOrientation = 'horizontal' | 'vertical';
5
+
6
+ /** Where a key takes focus within `count` items, or null if it does not move it.
7
+ *
8
+ * Only the arrows on the toolbar's own axis move focus — APG leaves the
9
+ * cross-axis arrows to the page, so a vertical strip does not swallow
10
+ * ArrowLeft. `Home` / `End` work on both. */
11
+ export function nextIndex(
12
+ current: number,
13
+ key: string,
14
+ count: number,
15
+ orientation: RovingOrientation = 'horizontal',
16
+ ): number | null {
5
17
  if (count === 0) return null;
6
- switch (key) {
7
- case 'ArrowRight':
8
- return (current + 1) % count;
9
- case 'ArrowLeft':
10
- return (current - 1 + count) % count;
11
- case 'Home':
12
- return 0;
13
- case 'End':
14
- return count - 1;
15
- default:
16
- return null;
17
- }
18
+ const forward = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
19
+ const back = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
20
+ if (key === forward) return (current + 1) % count;
21
+ if (key === back) return (current - 1 + count) % count;
22
+ if (key === 'Home') return 0;
23
+ if (key === 'End') return count - 1;
24
+ return null;
18
25
  }
19
26
 
20
27
  /**
@@ -23,7 +30,9 @@ export function nextIndex(current: number, key: string, count: number): number |
23
30
  * threaded through props, because the items are arbitrary children — the
24
31
  * container never sees them as a list it could index.
25
32
  */
26
- export function useRovingTabIndex<T extends HTMLElement>() {
33
+ export function useRovingTabIndex<T extends HTMLElement>(
34
+ orientation: RovingOrientation = 'horizontal',
35
+ ) {
27
36
  const ref = useRef<T | null>(null);
28
37
  const stop = useRef(0);
29
38
 
@@ -66,13 +75,13 @@ export function useRovingTabIndex<T extends HTMLElement>() {
66
75
  (e: KeyboardEvent<T>) => {
67
76
  const found = items();
68
77
  const current = found.indexOf(document.activeElement as HTMLElement);
69
- const next = nextIndex(current === -1 ? 0 : current, e.key, found.length);
78
+ const next = nextIndex(current === -1 ? 0 : current, e.key, found.length, orientation);
70
79
  if (next === null) return;
71
80
  e.preventDefault();
72
81
  found[next]?.focus();
73
82
  setTabStop(next);
74
83
  },
75
- [items, setTabStop],
84
+ [items, setTabStop, orientation],
76
85
  );
77
86
 
78
87
  return { ref, onKeyDown };
@@ -8,6 +8,7 @@ import {
8
8
  MIGRATIONS,
9
9
  migrateV0toV1,
10
10
  migrateV1toV2,
11
+ migrateV2toV3,
11
12
  normalizeDocument,
12
13
  quarantineDocument,
13
14
  quarantineKey,
@@ -324,6 +325,7 @@ describe('normalizeDocument', () => {
324
325
  trials: [],
325
326
  saves: [],
326
327
  layout: {},
328
+ undockedPanels: {},
327
329
  mode: 'auto',
328
330
  });
329
331
  });
@@ -367,3 +369,24 @@ describe('normalizeDocument', () => {
367
369
  expect(out.layout).toEqual({});
368
370
  });
369
371
  });
372
+
373
+ describe('version 2 to version 3', () => {
374
+ it('gives a document with no undocked panels an empty set', () => {
375
+ const out = migrateV2toV3({ version: 2, trials: [], saves: [], layout: {}, mode: 'auto' });
376
+ expect(out.version).toBe(3);
377
+ expect(out.undockedPanels).toEqual({});
378
+ });
379
+
380
+ it('leaves the rest of the document alone', () => {
381
+ const out = migrateV2toV3({
382
+ version: 2,
383
+ trials: [{ id: 'a' }],
384
+ saves: [],
385
+ layout: { a: 1 },
386
+ mode: 'dark',
387
+ });
388
+ expect(out.trials).toEqual([{ id: 'a' }]);
389
+ expect(out.layout).toEqual({ a: 1 });
390
+ expect(out.mode).toBe('dark');
391
+ });
392
+ });
@@ -7,9 +7,10 @@ import type {
7
7
  SerializedTrial,
8
8
  StorageAdapter,
9
9
  } from './types';
10
+ import type { UndockedPanels } from './undock';
10
11
 
11
12
  /** Bumped whenever the persisted shape changes; every bump needs a migration. */
12
- export const CURRENT_DOCUMENT_VERSION = 2;
13
+ export const CURRENT_DOCUMENT_VERSION = 3;
13
14
 
14
15
  /** The one key a lab persists under. The `:doc` suffix keeps it out of the
15
16
  * legacy bucket namespace, where a lab named `a:saves` would otherwise write
@@ -28,6 +29,7 @@ export function quarantineKey(storageKey: string): string {
28
29
  export function emptyDocument(mode: LabMode): LabDocument {
29
30
  return {
30
31
  version: CURRENT_DOCUMENT_VERSION,
32
+ undockedPanels: {},
31
33
  trials: [],
32
34
  saves: [],
33
35
  layout: {},
@@ -161,6 +163,10 @@ export function normalizeDocument(
161
163
  saves: Array.isArray(doc.saves) ? (doc.saves as SavedSnapshot[]) : [],
162
164
  layout:
163
165
  doc.layout && typeof doc.layout === 'object' ? (doc.layout as Record<string, unknown>) : {},
166
+ undockedPanels:
167
+ doc.undockedPanels && typeof doc.undockedPanels === 'object'
168
+ ? (doc.undockedPanels as UndockedPanels)
169
+ : {},
164
170
  mode,
165
171
  };
166
172
  }
@@ -190,5 +196,11 @@ export function migrateV1toV2(doc: Record<string, unknown>): Record<string, unkn
190
196
  return { ...rest, trials: Array.isArray(workspaces) ? workspaces : [], version: 2 };
191
197
  }
192
198
 
199
+ /** Version 2 to version 3: undocked sidebar panels became persisted state.
200
+ * Nothing existing moves; the field simply starts empty. */
201
+ export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unknown> {
202
+ return { ...doc, undockedPanels: {}, version: 3 };
203
+ }
204
+
193
205
  /** Index `i` migrates a version-`i` document to version `i + 1`. */
194
- export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2];
206
+ export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
@@ -45,4 +45,6 @@ export type {
45
45
  TrialStateHandle,
46
46
  UndoStack,
47
47
  } from './types';
48
+ export type { UndockedPanel, UndockedPanels } from './undock';
49
+ export { dockPanel, panelKey, undockPanel } from './undock';
48
50
  export { useTrialState } from './useTrialState';
@@ -649,3 +649,27 @@ describe('createLabStore — folding without a mutation', () => {
649
649
  warn.mockRestore();
650
650
  });
651
651
  });
652
+
653
+ describe('undocking a sidebar panel', () => {
654
+ it('starts with nothing undocked', () => {
655
+ expect(makeStore().getState().undockedPanels).toEqual({});
656
+ });
657
+
658
+ it('records an undock and docks it back', () => {
659
+ const s = makeStore();
660
+ s.getState().undockPanel('t1', 'settings', 'floating');
661
+ expect(Object.values(s.getState().undockedPanels)).toEqual([
662
+ { trialId: 't1', sectionId: 'settings', as: 'floating' },
663
+ ]);
664
+ s.getState().dockPanel('t1', 'settings');
665
+ expect(s.getState().undockedPanels).toEqual({});
666
+ });
667
+
668
+ it('closing a trial takes its undocked panels with it', () => {
669
+ const s = makeStore();
670
+ s.getState().addTrial({ id: 't1', instrumentName: 'i', config: {}, state: {}, view: null });
671
+ s.getState().undockPanel('t1', 'settings');
672
+ s.getState().removeTrial('t1');
673
+ expect(s.getState().undockedPanels).toEqual({});
674
+ });
675
+ });
@@ -20,6 +20,11 @@ import type {
20
20
  SavedSnapshot,
21
21
  TrialRecord,
22
22
  } from './types';
23
+ import {
24
+ dockPanel as dockPanelIn,
25
+ type UndockedPanel,
26
+ undockPanel as undockPanelIn,
27
+ } from './undock';
23
28
 
24
29
  /** Every mutation a lab store supports: managing trials, saving and
25
30
  * restoring snapshots, and setting the color mode. */
@@ -29,6 +34,7 @@ export interface LabStoreActions {
29
34
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
30
35
  updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
31
36
  updateTrialView: (id: string, view: unknown) => void;
37
+ updateTrialAnnotations: (id: string, doc: unknown) => void;
32
38
  updateTrialUndoStack: (
33
39
  id: string,
34
40
  next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
@@ -42,6 +48,10 @@ export interface LabStoreActions {
42
48
  setLabTool: (id: string | null) => void;
43
49
  setTrialTool: (trialId: string, id: string | null) => void;
44
50
  setLayout: (layout: Record<string, unknown>) => void;
51
+ /** Tear a sidebar section out of its trial. Default target is a workspace tile. */
52
+ undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
53
+ /** Put one section back, or — with no `sectionId` — every panel the trial owns. */
54
+ dockPanel: (trialId: string, sectionId?: string) => void;
45
55
  }
46
56
 
47
57
  /** A lab's store: its state and actions, plus the hook instruments use to
@@ -72,6 +82,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
72
82
  savedSnapshots: hydratedSnapshots,
73
83
  mode: hydratedMode,
74
84
  layout: hydratedLayout,
85
+ undockedPanels: hydrated.undockedPanels,
75
86
  activeToolId: null,
76
87
 
77
88
  addTrial: (record) => {
@@ -82,7 +93,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
82
93
  },
83
94
 
84
95
  removeTrial: (id) => {
85
- set((s) => ({ trials: s.trials.filter((w) => w.id !== id) }));
96
+ set((s) => ({
97
+ trials: s.trials.filter((w) => w.id !== id),
98
+ undockedPanels: dockPanelIn(s.undockedPanels, id),
99
+ }));
86
100
  scheduleFlush();
87
101
  },
88
102
 
@@ -122,6 +136,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
122
136
  scheduleFlush();
123
137
  },
124
138
 
139
+ updateTrialAnnotations: (id, doc) => {
140
+ set((s) => ({
141
+ trials: s.trials.map((w) =>
142
+ w.id === id && !Object.is(doc, w.annotations) ? { ...w, annotations: doc } : w,
143
+ ),
144
+ }));
145
+ scheduleFlush();
146
+ },
147
+
125
148
  updateTrialUndoStack: (id, next) => {
126
149
  set((s) => ({
127
150
  trials: s.trials.map((w) => {
@@ -211,6 +234,16 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
211
234
  trials: s.trials.map((t) => (t.id === trialId ? { ...t, activeToolId: id } : t)),
212
235
  })),
213
236
 
237
+ undockPanel: (trialId, sectionId, as) => {
238
+ set((s) => ({ undockedPanels: undockPanelIn(s.undockedPanels, trialId, sectionId, as) }));
239
+ scheduleFlush();
240
+ },
241
+
242
+ dockPanel: (trialId, sectionId) => {
243
+ set((s) => ({ undockedPanels: dockPanelIn(s.undockedPanels, trialId, sectionId) }));
244
+ scheduleFlush();
245
+ },
246
+
214
247
  setLayout: (layout) => {
215
248
  set({ layout });
216
249
  scheduleFlush();
@@ -227,6 +260,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
227
260
  trials: serializeTrials(s.trials, serializers),
228
261
  saves: s.savedSnapshots,
229
262
  layout: s.layout,
263
+ undockedPanels: s.undockedPanels,
230
264
  mode: s.mode,
231
265
  };
232
266
  const serialized = JSON.stringify(document);
@@ -1,3 +1,4 @@
1
+ import type { UndockedPanels } from './undock';
1
2
  /** A trial's undo history, as snapshots of its state either side of the
2
3
  * present. */
3
4
  export interface UndoStack {
@@ -17,6 +18,11 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
17
18
  view: TV;
18
19
  /** This trial's own tool slot. Undefined means it reads the lab's. */
19
20
  activeToolId?: string | null;
21
+ /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
22
+ * wrote them. Opaque here, and absent for a trial whose instrument declares
23
+ * no `annotations` — or declares its own `storage`. Not in `state`, which
24
+ * belongs to the instrument and is typed as such. */
25
+ annotations?: unknown;
20
26
  undoStack: UndoStack;
21
27
  }
22
28
 
@@ -46,6 +52,9 @@ export interface LabStoreState {
46
52
  /** Per-trial tile extents, keyed by trial id. Opaque here — the
47
53
  * shape belongs to whatever lays the trials out. */
48
54
  layout: Record<string, unknown>;
55
+ /** Sidebar sections torn out of their trial. A panel here is not rendered in
56
+ * its trial's sidebar; the workspace renders it instead. */
57
+ undockedPanels: UndockedPanels;
49
58
  }
50
59
 
51
60
  /** Where a lab persists itself. Implementations are keyed string storage and
@@ -91,6 +100,8 @@ export interface LabDocument {
91
100
  trials: SerializedTrial[];
92
101
  saves: SavedSnapshot[];
93
102
  layout: Record<string, unknown>;
103
+ /** Sidebar sections torn out of their trial, keyed by trial and section. */
104
+ undockedPanels: UndockedPanels;
94
105
  mode: LabMode;
95
106
  }
96
107
 
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dockPanel, panelKey, undockPanel } from './undock';
3
+
4
+ describe('undocked panel bookkeeping', () => {
5
+ it('keys a panel by its trial and section, so two trials can undock the same section', () => {
6
+ expect(panelKey('t1', 'settings')).not.toBe(panelKey('t2', 'settings'));
7
+ });
8
+
9
+ it('records an undock as a tile by default', () => {
10
+ const next = undockPanel({}, 't1', 'settings');
11
+ expect(next[panelKey('t1', 'settings')]).toEqual({
12
+ trialId: 't1',
13
+ sectionId: 'settings',
14
+ as: 'tile',
15
+ });
16
+ });
17
+
18
+ it('records the requested target', () => {
19
+ const next = undockPanel({}, 't1', 'settings', 'floating');
20
+ expect(next[panelKey('t1', 'settings')]?.as).toBe('floating');
21
+ });
22
+
23
+ it('re-undocking to a different target moves it rather than adding a second', () => {
24
+ const once = undockPanel({}, 't1', 'settings', 'tile');
25
+ const twice = undockPanel(once, 't1', 'settings', 'floating');
26
+ expect(Object.keys(twice)).toHaveLength(1);
27
+ expect(twice[panelKey('t1', 'settings')]?.as).toBe('floating');
28
+ });
29
+
30
+ it('docking removes it and leaves other panels alone', () => {
31
+ const both = undockPanel(undockPanel({}, 't1', 'a'), 't1', 'b');
32
+ const next = dockPanel(both, 't1', 'a');
33
+ expect(Object.keys(next)).toEqual([panelKey('t1', 'b')]);
34
+ });
35
+
36
+ it('closing a trial takes its undocked panels with it', () => {
37
+ const panels = undockPanel(undockPanel({}, 't1', 'a'), 't2', 'a');
38
+ expect(Object.keys(dockPanel(panels, 't1'))).toEqual([panelKey('t2', 'a')]);
39
+ });
40
+ });
@@ -0,0 +1,39 @@
1
+ /** A sidebar section torn out of its trial, and where it went. */
2
+ export interface UndockedPanel {
3
+ trialId: string;
4
+ sectionId: string;
5
+ as: 'tile' | 'floating';
6
+ }
7
+
8
+ /** Undocked panels, keyed by {@link panelKey}. */
9
+ export type UndockedPanels = Record<string, UndockedPanel>;
10
+
11
+ export function panelKey(trialId: string, sectionId: string): string {
12
+ return `${trialId}::${sectionId}`;
13
+ }
14
+
15
+ export function undockPanel(
16
+ panels: UndockedPanels,
17
+ trialId: string,
18
+ sectionId: string,
19
+ as: UndockedPanel['as'] = 'tile',
20
+ ): UndockedPanels {
21
+ return { ...panels, [panelKey(trialId, sectionId)]: { trialId, sectionId, as } };
22
+ }
23
+
24
+ /** Dock one section back, or — with no `sectionId` — every panel the trial
25
+ * owns, which is what closing a trial wants. */
26
+ export function dockPanel(
27
+ panels: UndockedPanels,
28
+ trialId: string,
29
+ sectionId?: string,
30
+ ): UndockedPanels {
31
+ const out: UndockedPanels = {};
32
+ for (const [key, panel] of Object.entries(panels)) {
33
+ if (panel.trialId === trialId && (sectionId === undefined || panel.sectionId === sectionId)) {
34
+ continue;
35
+ }
36
+ out[key] = panel;
37
+ }
38
+ return out;
39
+ }
package/src/styles.less CHANGED
@@ -17,6 +17,8 @@
17
17
  @import './chrome/regions/PaletteRegion.less';
18
18
  @import './controls/ControlPanel.less';
19
19
  @import './canvas/CanvasStack.less';
20
+ @import './loupe/Loupe.less';
20
21
  @import './layers/LayerList.less';
22
+ @import './annotations/Annotations.less';
21
23
  @import './dragdrop/Palette.less';
22
24
  @import './dragdrop/DragGhost.less';
@@ -3,3 +3,7 @@ import type { SurfaceHandle } from './useTiledSurface';
3
3
 
4
4
  /** Null when no surface owner is above — a lab with no shared surface at all. */
5
5
  export const SurfaceContext = createContext<SurfaceHandle | null>(null);
6
+
7
+ /** The buffer the surface's tiles paint into. Null until the owner's canvas is
8
+ * mounted, and for an owner that keeps its surface unpainted. */
9
+ export const SurfaceCanvasContext = createContext<HTMLCanvasElement | null>(null);
@@ -1,7 +1,12 @@
1
1
  export { composeRects, rectsEqual } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
- export { SurfaceContext } from './SurfaceContext';
5
- export { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
6
- export type { SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions } from './useTiledSurface';
4
+ export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
5
+ export { useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
6
+ export type {
7
+ SurfaceFrame,
8
+ SurfaceHandle,
9
+ TilePainter,
10
+ UseTiledSurfaceOptions,
11
+ } from './useTiledSurface';
7
12
  export { useTiledSurface } from './useTiledSurface';
@@ -10,7 +10,9 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
+ registerPainter: vi.fn(() => () => {}),
13
14
  containerRef: vi.fn(),
15
+ getContainer: vi.fn(() => null),
14
16
  };
15
17
  }
16
18
 
@@ -1,7 +1,13 @@
1
1
  import { useCallback, useContext } from 'react';
2
- import { SurfaceContext } from './SurfaceContext';
2
+ import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
3
3
  import type { SurfaceHandle } from './useTiledSurface';
4
4
 
5
+ /** The surface's shared buffer, or null where nothing paints one. What a tile
6
+ * hands to `paintInto`. */
7
+ export function useSurfaceCanvas(): HTMLCanvasElement | null {
8
+ return useContext(SurfaceCanvasContext);
9
+ }
10
+
5
11
  /** The surface above, or null. Use this where a surface is genuinely optional. */
6
12
  export function useSurfaceOptional(): SurfaceHandle | null {
7
13
  return useContext(SurfaceContext);
@@ -228,4 +228,62 @@ describe('useTiledSurface', () => {
228
228
 
229
229
  expect(frames[0]?.rects.has('b')).toBe(false);
230
230
  });
231
+
232
+ it("calls a dirty tile's painter with that tile's rect", () => {
233
+ const frames: SurfaceFrame[] = [];
234
+ let handle: SurfaceHandle | null = null;
235
+ render(
236
+ <Harness
237
+ frames={frames}
238
+ onHandle={(h) => {
239
+ handle = h;
240
+ }}
241
+ />,
242
+ );
243
+ flushFrames();
244
+
245
+ const paintA = vi.fn();
246
+ const paintB = vi.fn();
247
+ act(() => {
248
+ handle?.registerPainter('a', paintA);
249
+ handle?.registerPainter('b', paintB);
250
+ handle?.invalidate('a');
251
+ });
252
+ flushFrames();
253
+
254
+ // The rect, not just the call: a painter handed the wrong origin paints
255
+ // the right picture in the wrong place.
256
+ expect(paintA).toHaveBeenCalledWith({ x: 0, y: 0, w: 400, h: 600 }, expect.anything());
257
+ expect(paintB).not.toHaveBeenCalled();
258
+ });
259
+
260
+ it('stops calling a painter that unregisters', () => {
261
+ const frames: SurfaceFrame[] = [];
262
+ let handle: SurfaceHandle | null = null;
263
+ render(
264
+ <Harness
265
+ frames={frames}
266
+ onHandle={(h) => {
267
+ handle = h;
268
+ }}
269
+ />,
270
+ );
271
+ flushFrames();
272
+
273
+ const paint = vi.fn();
274
+ let off: (() => void) | undefined;
275
+ act(() => {
276
+ off = handle?.registerPainter('a', paint);
277
+ handle?.invalidate('a');
278
+ });
279
+ flushFrames();
280
+ expect(paint).toHaveBeenCalledTimes(1);
281
+
282
+ act(() => {
283
+ off?.();
284
+ handle?.invalidate('a');
285
+ });
286
+ flushFrames();
287
+ expect(paint).toHaveBeenCalledTimes(1);
288
+ });
231
289
  });
@@ -23,9 +23,20 @@ export interface SurfaceHandle {
23
23
  * moved something a ResizeObserver cannot see. */
24
24
  invalidateRects: () => void;
25
25
  registerTile: (id: string, el: HTMLElement | null) => void;
26
+ /** Subscribe a tile to the frames it is dirty on. A tile paints on its own
27
+ * loop, so this is how the surface wakes one: a resize of the shared buffer
28
+ * clears every tile, not only the one that moved. */
29
+ registerPainter: (id: string, paint: TilePainter) => () => void;
26
30
  containerRef: (el: HTMLElement | null) => void;
31
+ /** The element tile rects are measured against, and so the one a tile's own
32
+ * chrome positions itself inside. Null before the owner attaches it. */
33
+ getContainer: () => HTMLElement | null;
27
34
  }
28
35
 
36
+ /** What a tile's painter is handed: where it sits on the surface now, and the
37
+ * frame that dirtied it. */
38
+ export type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
39
+
29
40
  export interface UseTiledSurfaceOptions {
30
41
  onFrame: (frame: SurfaceFrame) => void;
31
42
  }
@@ -35,6 +46,7 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
35
46
  const tiles = useRef(new Map<string, HTMLElement>());
36
47
  const rects = useRef(new Map<string, Rect>());
37
48
  const dirty = useRef(new Set<string>());
49
+ const painters = useRef(new Map<string, TilePainter>());
38
50
  const needsMeasure = useRef(true);
39
51
  const observer = useRef<ResizeObserver | null>(null);
40
52
  const lastDpr = useRef(0);
@@ -76,12 +88,18 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
76
88
  }
77
89
  if (dirty.current.size === 0) return;
78
90
  const box = el.getBoundingClientRect();
79
- onFrameRef.current({
91
+ const frame: SurfaceFrame = {
80
92
  dirty: new Set(dirty.current),
81
93
  rects: new Map(rects.current),
82
94
  dpr,
83
95
  size: { width: box.width, height: box.height },
84
- });
96
+ };
97
+ onFrameRef.current(frame);
98
+ // After the owner, which is what resized the buffer this frame.
99
+ for (const id of frame.dirty) {
100
+ const rect = frame.rects.get(id);
101
+ if (rect) painters.current.get(id)?.(rect, frame);
102
+ }
85
103
  dirty.current.clear();
86
104
  },
87
105
  // The host attaches its container through `containerRef`, which may land
@@ -130,6 +148,15 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
130
148
  [schedule],
131
149
  );
132
150
 
151
+ const registerPainter = useCallback((id: string, paint: TilePainter) => {
152
+ painters.current.set(id, paint);
153
+ return () => {
154
+ if (painters.current.get(id) === paint) painters.current.delete(id);
155
+ };
156
+ }, []);
157
+
158
+ const getContainer = useCallback(() => container.current, []);
159
+
133
160
  const containerRef = useCallback(
134
161
  (el: HTMLElement | null) => {
135
162
  if (container.current === el) return;
@@ -157,5 +184,13 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
157
184
  };
158
185
  }, [schedule, frameLoop]);
159
186
 
160
- return { invalidate, invalidateAll, invalidateRects, registerTile, containerRef };
187
+ return {
188
+ invalidate,
189
+ invalidateAll,
190
+ invalidateRects,
191
+ registerTile,
192
+ registerPainter,
193
+ containerRef,
194
+ getContainer,
195
+ };
161
196
  }