@weasel-js/labkit 1.3.0 → 1.4.0-pre.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.
Files changed (211) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/CanvasStackContext-LnCfqNBA.d.ts +42 -0
  3. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  4. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  5. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  6. package/dist/_dts/frac-C-2c72Ij.d.ts +735 -0
  7. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  8. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  9. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  10. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  11. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  12. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  13. package/dist/canvas/index.d.ts +110 -26
  14. package/dist/canvas/index.js +5 -4
  15. package/dist/chrome/index.d.ts +9 -6
  16. package/dist/chrome/index.js +6 -6
  17. package/dist/chunk-2AYGEN57.js +32 -0
  18. package/dist/chunk-2AYGEN57.js.map +1 -0
  19. package/dist/chunk-6OEYTYML.js +669 -0
  20. package/dist/chunk-6OEYTYML.js.map +1 -0
  21. package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
  22. package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
  23. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  24. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  25. package/dist/chunk-CTRKTLYZ.js +33 -0
  26. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  27. package/dist/chunk-E2UQVZ44.js +458 -0
  28. package/dist/chunk-E2UQVZ44.js.map +1 -0
  29. package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
  30. package/dist/chunk-ESIQNQ6K.js.map +1 -0
  31. package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
  32. package/dist/chunk-HHGVISVZ.js.map +1 -0
  33. package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
  34. package/dist/chunk-HMFODCOX.js.map +1 -0
  35. package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
  36. package/dist/chunk-L5QJOOLV.js.map +1 -0
  37. package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
  38. package/dist/chunk-M75ZU6ZZ.js.map +1 -0
  39. package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
  40. package/dist/chunk-QB5SM4BM.js.map +1 -0
  41. package/dist/chunk-RBGKL7NF.js +7128 -0
  42. package/dist/chunk-RBGKL7NF.js.map +1 -0
  43. package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
  44. package/dist/chunk-TO2FUOKF.js.map +1 -0
  45. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  46. package/dist/chunk-XJ6N32QP.js.map +1 -0
  47. package/dist/controls/index.d.ts +4 -28
  48. package/dist/controls/index.js +3 -3
  49. package/dist/dragdrop/index.d.ts +9 -5
  50. package/dist/dragdrop/index.js +3 -3
  51. package/dist/index.d.ts +517 -234
  52. package/dist/index.js +1769 -143
  53. package/dist/index.js.map +1 -1
  54. package/dist/layers/index.d.ts +6 -5
  55. package/dist/layers/index.js +3 -3
  56. package/dist/loupe/index.d.ts +187 -0
  57. package/dist/loupe/index.js +7 -0
  58. package/dist/loupe/index.js.map +1 -0
  59. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  60. package/dist/passthrough/weasel-canvas.js +1 -1
  61. package/dist/passthrough/weasel-ui.d.ts +138 -91
  62. package/dist/passthrough/weasel-ui.js +2 -2
  63. package/dist/primitives/index.d.ts +3 -1
  64. package/dist/primitives/index.js +5 -5
  65. package/dist/state/index.d.ts +3 -3
  66. package/dist/state/index.js +2 -2
  67. package/dist/styles.css +229 -1
  68. package/dist/surface/index.d.ts +18 -2
  69. package/dist/surface/index.js +2 -2
  70. package/dist/ui/layers/index.js +2 -2
  71. package/dist/undo/index.d.ts +5 -4
  72. package/package.json +14 -7
  73. package/src/annotations/AnnotationOverlay.tsx +205 -0
  74. package/src/annotations/AnnotationTargets.tsx +37 -0
  75. package/src/annotations/Annotations.less +129 -0
  76. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  78. package/src/annotations/AnnotationsContext.ts +49 -0
  79. package/src/annotations/ExportMenu.test.tsx +86 -0
  80. package/src/annotations/ExportMenu.tsx +182 -0
  81. package/src/annotations/MarkList.tsx +75 -0
  82. package/src/annotations/capture.test.ts +140 -0
  83. package/src/annotations/capture.ts +237 -0
  84. package/src/annotations/drawOne.test.ts +56 -0
  85. package/src/annotations/drawOne.ts +47 -0
  86. package/src/annotations/frac.test.ts +59 -0
  87. package/src/annotations/frac.ts +66 -0
  88. package/src/annotations/history.test.ts +82 -0
  89. package/src/annotations/history.ts +118 -0
  90. package/src/annotations/index.ts +37 -0
  91. package/src/annotations/paint.test.ts +125 -0
  92. package/src/annotations/paint.ts +130 -0
  93. package/src/annotations/staleness.test.ts +56 -0
  94. package/src/annotations/staleness.ts +41 -0
  95. package/src/annotations/store.test.ts +247 -0
  96. package/src/annotations/store.ts +347 -0
  97. package/src/annotations/svgNodes.test.ts +77 -0
  98. package/src/annotations/svgNodes.ts +74 -0
  99. package/src/annotations/toolMap.test.ts +36 -0
  100. package/src/annotations/toolMap.ts +54 -0
  101. package/src/annotations/types.ts +229 -0
  102. package/src/annotations/view.test.ts +37 -0
  103. package/src/annotations/view.ts +40 -0
  104. package/src/canvas/AGENTS.md +55 -5
  105. package/src/canvas/CanvasStack.test.tsx +27 -1
  106. package/src/canvas/CanvasStack.tsx +29 -4
  107. package/src/canvas/CanvasStackContext.ts +23 -4
  108. package/src/canvas/camera.test.ts +78 -0
  109. package/src/canvas/camera.ts +62 -0
  110. package/src/canvas/canvasCoords.test.ts +26 -0
  111. package/src/canvas/canvasCoords.ts +20 -7
  112. package/src/canvas/index.ts +16 -1
  113. package/src/canvas/useLayerScheduler.ts +7 -3
  114. package/src/canvas/usePanZoom.test.ts +36 -5
  115. package/src/canvas/usePanZoom.ts +20 -15
  116. package/src/canvas/worldSpec.test.ts +63 -0
  117. package/src/canvas/worldSpec.ts +48 -0
  118. package/src/chrome/builtins.test.ts +31 -1
  119. package/src/chrome/builtins.tsx +63 -19
  120. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  121. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  122. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  123. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  124. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  125. package/src/chrome/regions/regions.test.tsx +6 -1
  126. package/src/chrome/types.ts +20 -0
  127. package/src/controls/ControlPanel.test.tsx +38 -0
  128. package/src/controls/ControlPanel.tsx +50 -3
  129. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  130. package/src/dragdrop/dragDrop.test.tsx +29 -2
  131. package/src/index.test.ts +23 -0
  132. package/src/index.ts +93 -4
  133. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  134. package/src/instrument/types.ts +24 -1
  135. package/src/lab/Lab.surface.test.tsx +162 -0
  136. package/src/lab/Lab.tsx +102 -17
  137. package/src/lab/Lab.undock.test.tsx +85 -0
  138. package/src/lab/LabFullChrome.stories.tsx +20 -0
  139. package/src/lab/LabShell.less +11 -0
  140. package/src/lab/Workspace.less +73 -0
  141. package/src/lab/Workspace.surface.test.tsx +2 -0
  142. package/src/lab/Workspace.tsx +97 -8
  143. package/src/lab/index.ts +1 -1
  144. package/src/lab/panelHost.ts +37 -0
  145. package/src/loupe/AGENTS.md +49 -0
  146. package/src/loupe/CanvasLoupe.tsx +83 -0
  147. package/src/loupe/DomLoupe.tsx +62 -0
  148. package/src/loupe/Loupe.less +38 -0
  149. package/src/loupe/LoupeBubble.tsx +32 -0
  150. package/src/loupe/TrialLoupe.tsx +93 -0
  151. package/src/loupe/canvasLens.test.ts +205 -0
  152. package/src/loupe/canvasLens.ts +141 -0
  153. package/src/loupe/index.ts +19 -0
  154. package/src/loupe/types.test.ts +29 -0
  155. package/src/loupe/types.ts +90 -0
  156. package/src/loupe/useHostSize.ts +30 -0
  157. package/src/loupe/useLoupe.test.tsx +197 -0
  158. package/src/loupe/useLoupe.ts +189 -0
  159. package/src/passthrough/weasel-ui.ts +3 -0
  160. package/src/primitives/FloatingPanel.test.tsx +76 -1
  161. package/src/primitives/FloatingPanel.tsx +19 -3
  162. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  163. package/src/primitives/Sidebar.less +18 -0
  164. package/src/primitives/Toolbar.less +8 -0
  165. package/src/primitives/Toolbar.tsx +5 -0
  166. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  167. package/src/primitives/useRovingTabIndex.ts +26 -17
  168. package/src/state/document.test.ts +23 -0
  169. package/src/state/document.ts +14 -2
  170. package/src/state/index.ts +2 -0
  171. package/src/state/store.test.ts +24 -0
  172. package/src/state/store.ts +35 -1
  173. package/src/state/types.ts +11 -0
  174. package/src/state/undock.test.ts +40 -0
  175. package/src/state/undock.ts +39 -0
  176. package/src/styles.less +2 -0
  177. package/src/surface/SurfaceContext.ts +4 -0
  178. package/src/surface/index.ts +8 -3
  179. package/src/surface/useSurfaceTile.test.tsx +2 -0
  180. package/src/surface/useSurfaceTile.ts +7 -1
  181. package/src/surface/useTiledSurface.test.tsx +58 -0
  182. package/src/surface/useTiledSurface.ts +38 -3
  183. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  184. package/src/trial/Trial.annotations.test.tsx +121 -0
  185. package/src/trial/Trial.canvas.test.tsx +76 -1
  186. package/src/trial/Trial.less +19 -0
  187. package/src/trial/Trial.loupe.test.tsx +128 -0
  188. package/src/trial/Trial.stories.tsx +1 -0
  189. package/src/trial/Trial.test.tsx +12 -0
  190. package/src/trial/Trial.trialId.test.tsx +36 -0
  191. package/src/trial/Trial.tsx +215 -40
  192. package/src/trial/TrialChrome.tsx +35 -0
  193. package/src/trial/UndockedSections.tsx +65 -0
  194. package/src/trial/trialOps.test.ts +21 -0
  195. package/src/trial/trialOps.ts +5 -3
  196. package/dist/_dts/types-BttTIent.d.ts +0 -314
  197. package/dist/chunk-3XXPU73K.js.map +0 -1
  198. package/dist/chunk-4TMMVIDM.js.map +0 -1
  199. package/dist/chunk-6X5RBC6G.js +0 -366
  200. package/dist/chunk-6X5RBC6G.js.map +0 -1
  201. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  202. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  203. package/dist/chunk-JDROYQM3.js.map +0 -1
  204. package/dist/chunk-LGKRIUSW.js.map +0 -1
  205. package/dist/chunk-QCICIHEO.js.map +0 -1
  206. package/dist/chunk-QRYPXSGP.js.map +0 -1
  207. package/dist/chunk-RGT4EJIB.js +0 -6961
  208. package/dist/chunk-RGT4EJIB.js.map +0 -1
  209. package/dist/chunk-T66HU3HX.js +0 -17
  210. package/dist/chunk-T66HU3HX.js.map +0 -1
  211. package/dist/chunk-WTI26YTM.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,114 +1,1428 @@
1
- import { useDragDrop, DragOverlay, Palette } from './chunk-3XXPU73K.js';
2
- import { useSurfaceOptional } from './chunk-LGKRIUSW.js';
3
- export { SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceOptional, useSurfaceTile, useTiledSurface } from './chunk-LGKRIUSW.js';
1
+ import { createEventBus, pushSnapshot, redo, undo } from './chunk-RL2LOLNI.js';
2
+ export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './chunk-RL2LOLNI.js';
3
+ import { useDragDrop, DragOverlay, Palette } from './chunk-M75ZU6ZZ.js';
4
+ import { useSurfaceOptional, useSurfaceCanvas, useTiledSurface, SurfaceContext, SurfaceCanvasContext } from './chunk-ESIQNQ6K.js';
5
+ export { SurfaceCanvasContext, SurfaceContext, composeRects, rectsEqual, toDeviceRect, useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTiledSurface } from './chunk-ESIQNQ6K.js';
4
6
  import { useJob } from './chunk-LN6JDUGB.js';
5
7
  export { useJob } from './chunk-LN6JDUGB.js';
6
- import { suppressContributions, mergeContributions, builtinContributions, TitleBarRegion, ToolbarRegion, PaletteRegion, SidebarRegion, ViewportRegion, StatusRegion } from './chunk-6X5RBC6G.js';
7
- export { PaletteRegion, SidebarRegion, StatusRegion, TitleBarRegion, ToolbarRegion, ViewportRegion, builtinContributions, mergeContributions, suppressContributions } from './chunk-6X5RBC6G.js';
8
- import { JobProgress } from './chunk-QRYPXSGP.js';
9
- export { FloatingPanel, JobProgress, Legend, Sidebar } from './chunk-QRYPXSGP.js';
10
- export { FpsMeter, ScaleIndicator, StatusBar, Toolbar, ZoomControl } from './chunk-JDROYQM3.js';
11
- import { LabStoreContext, createLabStore, noneAdapter } from './chunk-4TMMVIDM.js';
12
- export { CURRENT_DOCUMENT_VERSION, LabStoreContext, LabStoreProvider, TrialIdContext, TrialIdProvider, createMemoryAdapter, labDocumentKey, localStorageAdapter, noneAdapter, quarantineKey, sessionStorageAdapter, urlHashAdapter, useLabStore, useTrialId, useTrialState } from './chunk-4TMMVIDM.js';
13
- import { fromConfigFields } from './chunk-WTI26YTM.js';
14
- export { ControlPanel, fromConfigFields, isLeafVisible } from './chunk-WTI26YTM.js';
15
- import { CanvasStack } from './chunk-BDJXIBRZ.js';
16
- export { CanvasStack } from './chunk-BDJXIBRZ.js';
17
- export { screenToWorld, worldToScreen } from './chunk-T66HU3HX.js';
18
- export { CanvasStackContext } from './chunk-CPUJ3QXL.js';
19
- import { LayerList } from './chunk-BMW4TDP5.js';
20
- export { LayerList } from './chunk-BMW4TDP5.js';
8
+ import { ANNOTATION_WEASEL_TOOLS, annotationToolInfo, suppressContributions, mergeContributions, builtinContributions, TitleBarRegion, ToolbarRegion, PaletteRegion, SidebarRegion, ViewportRegion, StatusRegion, AnnotationsContext } from './chunk-6OEYTYML.js';
9
+ export { ANNOTATION_TOOLS, ANNOTATION_WEASEL_TOOLS, AnnotationsContext, ExportMenu, MarkList, PaletteRegion, SidebarRegion, StatusRegion, TitleBarRegion, ToolbarRegion, ViewportRegion, annotationToolInfo, builtinContributions, mergeContributions, suppressContributions, useAnnotations, useAnnotationsOptional } from './chunk-6OEYTYML.js';
10
+ import { JobProgress } from './chunk-QB5SM4BM.js';
11
+ export { FloatingPanel, JobProgress, Legend, Sidebar } from './chunk-QB5SM4BM.js';
12
+ export { FpsMeter, ScaleIndicator, StatusBar, Toolbar, ZoomControl } from './chunk-HHGVISVZ.js';
13
+ import { LabStoreContext, TrialIdProvider, createLabStore, panelKey, noneAdapter } from './chunk-XJ6N32QP.js';
14
+ export { CURRENT_DOCUMENT_VERSION, LabStoreContext, LabStoreProvider, TrialIdContext, TrialIdProvider, createMemoryAdapter, dockPanel, labDocumentKey, localStorageAdapter, noneAdapter, panelKey, quarantineKey, sessionStorageAdapter, undockPanel, urlHashAdapter, useLabStore, useTrialId, useTrialState } from './chunk-XJ6N32QP.js';
15
+ import { fromConfigFields } from './chunk-TO2FUOKF.js';
16
+ export { ControlPanel, fromConfigFields, isLeafVisible } from './chunk-TO2FUOKF.js';
17
+ import { CanvasStack } from './chunk-HMFODCOX.js';
18
+ export { CanvasStack, PITCH_LIMIT, clampPitch, orbitAfterDrag, orbitAfterWheel, useOrbit, usePanZoom, wrapYaw } from './chunk-HMFODCOX.js';
19
+ import { LayerList } from './chunk-AE5CNVRU.js';
20
+ export { LayerList } from './chunk-AE5CNVRU.js';
21
21
  import './chunk-SBXNQW4G.js';
22
- import { Rt, p, r } from './chunk-RGT4EJIB.js';
23
- export { k as CheckboxRow, O as ColorRow, m2 as CurveField, _2 as EffectCard, y as EffectCardList, c3 as LayerStack, j as NumberRow, b2 as PropertyGroup, S2 as PropertyList, x2 as PropertyPanel, w as PropertyRow, C as PropertySpan, M as SelectRow, E as SliderRow, g as Subpanel, A as TextRow, N as ToggleRow } from './chunk-RGT4EJIB.js';
24
- import './chunk-QCICIHEO.js';
25
- import { createEventBus, pushSnapshot, redo, undo } from './chunk-RL2LOLNI.js';
26
- export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './chunk-RL2LOLNI.js';
27
- import { createContext, useRef, useCallback, useContext, useMemo, useState, Children, useLayoutEffect, useEffect } from 'react';
28
- import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
22
+ import { C, p, r2 } from './chunk-RBGKL7NF.js';
23
+ export { k2 as CheckboxRow, O2 as ColorRow, m2 as CurveField, _3 as EffectCard, y2 as EffectCardList, c3 as LayerStack, j2 as NumberRow, b3 as PropertyGroup, S2 as PropertyList, x3 as PropertyPanel, w as PropertyRow, C2 as PropertySpan, M as SelectRow, E as SliderRow, g as Subpanel, A2 as TextRow, N as ToggleRow } from './chunk-RBGKL7NF.js';
24
+ import { resolveLoupe, TrialLoupe } from './chunk-E2UQVZ44.js';
25
+ export { CanvasLoupe, DomLoupe, LOUPE_DEFAULTS, LoupeBubble, TrialLoupe, drawCanvasLens, lensCamera, lensSourceRect, resolveLoupe, sampleStack, useLoupe } from './chunk-E2UQVZ44.js';
26
+ export { centerOn, zoomAt } from './chunk-CTRKTLYZ.js';
27
+ import { applyCamera } from './chunk-2AYGEN57.js';
28
+ export { DEFAULT_FRAME, applyCamera, resolveFrame, screenToWorld, worldToScreen } from './chunk-2AYGEN57.js';
29
+ export { CanvasStackContext } from './chunk-CQLQPQ4P.js';
30
+ import { textCommand, linePath, markerDrawCommands, ellipsePath, rectPath, createScene, asNodeId as asNodeId$1, WeaselProvider, SceneCanvas, sceneFromJSON, PathBuilder, renderSceneToPixels, useActiveToolContext, resolveStrokeWidth, getMarker, getPaintKind, boundsOfPath, tileGeometry, PATH_M, PATH_L, PATH_C, PATH_Q, PATH_Z, SCRIPT_METRICS } from './chunk-L5QJOOLV.js';
31
+ import { createContext, useState, useRef, useEffect, useContext, useMemo, Children, useLayoutEffect, useCallback, useSyncExternalStore } from 'react';
32
+ import { createPortal } from 'react-dom';
33
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
29
34
  import { useStore } from 'zustand/react';
30
35
  import { Provider, StrategyRegistryProvider, DragProvider, Container, useDragHandle } from 'windease/react';
31
- import { asNodeId, Store, createNode, gridStrategy } from 'windease';
36
+ import { asNodeId, Store, createNode, floatingStrategy, gridStrategy } from 'windease';
32
37
 
33
- var PITCH_LIMIT = Math.PI / 2 - 0.01;
34
- var YAW_PER_PX = 8e-3;
35
- var PITCH_PER_PX = 8e-3;
36
- var DISTANCE_PER_NOTCH = 15e-4;
37
- var DRAG_THRESHOLD = 3;
38
- function clampPitch(pitch) {
39
- return Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, pitch));
40
- }
41
- function wrapYaw(yaw) {
42
- const wrapped = ((yaw + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
43
- return wrapped === -Math.PI ? Math.PI : wrapped;
44
- }
45
- function orbitAfterDrag(start, dx, dy) {
38
+ // ../svg/dist/index.js
39
+ var IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
40
+ function multiply(a, b2) {
41
+ return [
42
+ a[0] * b2[0] + a[2] * b2[1],
43
+ a[1] * b2[0] + a[3] * b2[1],
44
+ a[0] * b2[2] + a[2] * b2[3],
45
+ a[1] * b2[2] + a[3] * b2[3],
46
+ a[0] * b2[4] + a[2] * b2[5] + a[4],
47
+ a[1] * b2[4] + a[3] * b2[5] + a[5]
48
+ ];
49
+ }
50
+ function isIdentity(m2, eps = 1e-9) {
51
+ return Math.abs(m2[0] - 1) < eps && Math.abs(m2[1]) < eps && Math.abs(m2[2]) < eps && Math.abs(m2[3] - 1) < eps && Math.abs(m2[4]) < eps && Math.abs(m2[5]) < eps;
52
+ }
53
+ function formatMatrix(m2) {
54
+ if (isIdentity(m2)) return null;
55
+ return `matrix(${m2.map((n) => trimNumber(n)).join(" ")})`;
56
+ }
57
+ function trimNumber(n) {
58
+ if (!Number.isFinite(n)) return "0";
59
+ const r2 = Math.round(n * 1e6) / 1e6;
60
+ if (Number.isInteger(r2)) return String(r2);
61
+ return String(r2);
62
+ }
63
+ function patternSpecOf(paint) {
64
+ return "tile" in paint.pattern ? paint.pattern : null;
65
+ }
66
+ function patternXml(id, paint, onWarn) {
67
+ const spec = patternSpecOf(paint);
68
+ if (!spec) {
69
+ onWarn?.("pattern fill carries a TextureHandle, which has no vector form \u2014 omitted from <defs>");
70
+ return "";
71
+ }
72
+ const geometry = tileGeometry(spec);
73
+ const body = geometry.shapes.map(shapeXml).join("");
74
+ const origin = paint.origin ?? { x: 0, y: 0 };
75
+ const attrs = [
76
+ `id="${id}"`,
77
+ 'patternUnits="userSpaceOnUse"',
78
+ `width="${trimNumber(geometry.size)}"`,
79
+ `height="${trimNumber(geometry.size)}"`
80
+ ];
81
+ if (origin.x !== 0 || origin.y !== 0) {
82
+ attrs.push(`x="${trimNumber(origin.x)}"`, `y="${trimNumber(origin.y)}"`);
83
+ }
84
+ attrs.push(`data-weasel-tile="${escapeAttr(JSON.stringify(spec))}"`);
85
+ return `<pattern ${attrs.join(" ")}>${body}</pattern>`;
86
+ }
87
+ function shapeXml(shape) {
88
+ switch (shape.kind) {
89
+ case "line":
90
+ return `<line x1="${trimNumber(shape.x1)}" y1="${trimNumber(shape.y1)}" x2="${trimNumber(shape.x2)}" y2="${trimNumber(shape.y2)}" stroke="${shape.color}" stroke-width="${trimNumber(shape.width)}"/>`;
91
+ case "circle":
92
+ return `<circle cx="${trimNumber(shape.cx)}" cy="${trimNumber(shape.cy)}" r="${trimNumber(shape.r)}" fill="${shape.color}"/>`;
93
+ case "ellipse": {
94
+ const deg = shape.rotation * 180 / Math.PI;
95
+ const transform = deg === 0 ? "" : ` transform="rotate(${trimNumber(deg)} ${trimNumber(shape.cx)} ${trimNumber(shape.cy)})"`;
96
+ return `<ellipse cx="${trimNumber(shape.cx)}" cy="${trimNumber(shape.cy)}" rx="${trimNumber(shape.rx)}" ry="${trimNumber(shape.ry)}" fill="${shape.color}"${transform}/>`;
97
+ }
98
+ case "rect":
99
+ return `<rect x="${trimNumber(shape.x)}" y="${trimNumber(shape.y)}" width="${trimNumber(shape.width)}" height="${trimNumber(shape.height)}" fill="${shape.color}"/>`;
100
+ default:
101
+ return "";
102
+ }
103
+ }
104
+ function escapeAttr(s) {
105
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
106
+ }
107
+ function serializePathD(path) {
108
+ if (path.kind === "rect") {
109
+ const { x: x2, y: y2, width, height } = path;
110
+ return `M${trimNumber(x2)} ${trimNumber(y2)}h${trimNumber(width)}v${trimNumber(height)}h${trimNumber(-width)}Z`;
111
+ }
112
+ const cmds = path.commands;
113
+ const xs = path.coords;
114
+ const out = [];
115
+ let ci = 0;
116
+ for (let i = 0; i < cmds.length; i++) {
117
+ const c2 = cmds[i];
118
+ if (c2 === PATH_M) {
119
+ out.push(`M${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])}`);
120
+ ci += 2;
121
+ } else if (c2 === PATH_L) {
122
+ out.push(`L${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])}`);
123
+ ci += 2;
124
+ } else if (c2 === PATH_C) {
125
+ out.push(
126
+ `C${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])} ${trimNumber(xs[ci + 2])} ${trimNumber(xs[ci + 3])} ${trimNumber(xs[ci + 4])} ${trimNumber(xs[ci + 5])}`
127
+ );
128
+ ci += 6;
129
+ } else if (c2 === PATH_Q) {
130
+ out.push(
131
+ `Q${trimNumber(xs[ci])} ${trimNumber(xs[ci + 1])} ${trimNumber(xs[ci + 2])} ${trimNumber(xs[ci + 3])}`
132
+ );
133
+ ci += 4;
134
+ } else if (c2 === PATH_Z) {
135
+ out.push("Z");
136
+ }
137
+ }
138
+ return out.join("");
139
+ }
140
+ var PaintServerRegistry = class {
141
+ byPaint = /* @__PURE__ */ new Map();
142
+ order = [];
143
+ counter = 0;
144
+ // Marker keys referenced by any stroke, in first-use order. The `<defs>`
145
+ // id is the marker key itself, not a minted counter id: `parseSvg` reads a
146
+ // `marker-end="url(#id)"` fragment back as the marker key directly (it
147
+ // never inspects the `<marker>` element), so a synthetic id would come
148
+ // back unresolvable and warn on round-trip.
149
+ markerKeys = [];
150
+ markerKeySet = /* @__PURE__ */ new Set();
151
+ register(paint) {
152
+ const existing = this.byPaint.get(paint);
153
+ if (existing) return existing;
154
+ const id = paint.fill === "pattern" ? `pat${this.counter++}` : `grad${this.counter++}`;
155
+ this.byPaint.set(paint, id);
156
+ this.order.push(paint);
157
+ return id;
158
+ }
159
+ /** The `<defs>` id for a marker key — the key itself, minting nothing.
160
+ * `undefined` when nothing is registered under `key`, so a caller emits no
161
+ * attribute at all rather than a `url(#…)` pointing at a def that will
162
+ * never be written. */
163
+ markerId(key) {
164
+ if (getMarker(key) === void 0) return void 0;
165
+ if (!this.markerKeySet.has(key)) {
166
+ this.markerKeySet.add(key);
167
+ this.markerKeys.push(key);
168
+ }
169
+ return key;
170
+ }
171
+ /** Emit `<defs>...</defs>` XML for every registered paint server and marker. */
172
+ toDefsXml(onWarn) {
173
+ if (this.order.length === 0 && this.markerKeys.length === 0) return "";
174
+ const parts = ["<defs>"];
175
+ for (const paint of this.order) {
176
+ parts.push(paintServerXml(this.byPaint.get(paint), paint, onWarn));
177
+ }
178
+ for (const key of this.markerKeys) {
179
+ const entry = getMarker(key);
180
+ if (!entry) continue;
181
+ parts.push(entry.toSvg ? entry.toSvg(key, entry) : defaultMarkerXml(key, entry));
182
+ }
183
+ parts.push("</defs>");
184
+ return parts.join("");
185
+ }
186
+ };
187
+ function defaultMarkerXml(id, entry) {
188
+ const path = entry.path({ size: 1, stroke: { paint: { fill: "solid", color: "#000" } } });
189
+ const d = serializePathD(path);
190
+ const fill = entry.fill === "none" ? "none" : "context-stroke";
191
+ const outline = entry.outline ? ` stroke="context-stroke" stroke-width="${entry.outline.width}" stroke-linecap="round" stroke-linejoin="round"` : "";
192
+ return `<marker id="${id}" markerUnits="strokeWidth" markerWidth="8" markerHeight="8" refX="0" refY="0" orient="auto" overflow="visible"><path d="${d}" fill="${fill}"${outline}/></marker>`;
193
+ }
194
+ function paintServerXml(id, paint, onWarn) {
195
+ if (paint.fill === "pattern") return patternXml(id, paint, onWarn);
196
+ const builtin = gradientXml(id, paint);
197
+ if (builtin) return builtin;
198
+ const custom = getPaintKind(paint.fill)?.toSvg?.(id, paint);
199
+ if (custom) return custom;
200
+ onWarn?.(`${paint.fill} fill has no vector form \u2014 omitted from <defs>`);
201
+ return "";
202
+ }
203
+ function gradientUnitsAttr(units) {
204
+ return units === "bounds" ? "objectBoundingBox" : "userSpaceOnUse";
205
+ }
206
+ function gradientXml(id, paint) {
207
+ if (paint.fill === "linear-gradient") {
208
+ const stops = paint.stops.map(stopXml).join("");
209
+ return `<linearGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" x1="${trimNumber(paint.from.x)}" y1="${trimNumber(paint.from.y)}" x2="${trimNumber(paint.to.x)}" y2="${trimNumber(paint.to.y)}">${stops}</linearGradient>`;
210
+ }
211
+ if (paint.fill === "radial-gradient") {
212
+ const stops = paint.stops.map(stopXml).join("");
213
+ return `<radialGradient id="${id}" gradientUnits="${gradientUnitsAttr(paint.units)}" cx="${trimNumber(paint.center.x)}" cy="${trimNumber(paint.center.y)}" r="${trimNumber(paint.radius)}">${stops}</radialGradient>`;
214
+ }
215
+ return "";
216
+ }
217
+ function stopXml(s) {
218
+ const m2 = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(s.color);
219
+ if (m2) {
220
+ const alpha = parseInt(m2[2], 16) / 255;
221
+ return `<stop offset="${trimNumber(s.offset)}" stop-color="#${m2[1].toLowerCase()}" stop-opacity="${trimNumber(alpha)}"/>`;
222
+ }
223
+ return `<stop offset="${trimNumber(s.offset)}" stop-color="${s.color}"/>`;
224
+ }
225
+ function serializeSvg(nodes, opts = {}) {
226
+ const registry = new PaintServerRegistry();
227
+ registerPaintServers(nodes, registry);
228
+ const bounds = opts.viewBox ?? computeBounds(nodes);
229
+ const vb = `${trimNumber(bounds.x)} ${trimNumber(bounds.y)} ${trimNumber(bounds.width)} ${trimNumber(bounds.height)}`;
230
+ const namespaces = opts.namespaces ?? {};
231
+ const rootAttrs = [`xmlns="http://www.w3.org/2000/svg"`];
232
+ for (const [prefix, uri] of Object.entries(namespaces)) {
233
+ rootAttrs.push(`xmlns:${prefix}="${escapeAttr2(uri)}"`);
234
+ }
235
+ rootAttrs.push(`viewBox="${vb}"`);
236
+ if (opts.width != null) rootAttrs.push(`width="${trimNumber(opts.width)}"`);
237
+ if (opts.height != null) rootAttrs.push(`height="${trimNumber(opts.height)}"`);
238
+ if (opts.documentMeta) {
239
+ for (const prefix of Object.keys(namespaces)) {
240
+ const bucket = opts.documentMeta[prefix];
241
+ if (!bucket?.attrs) continue;
242
+ for (const [name, value] of Object.entries(bucket.attrs)) {
243
+ rootAttrs.push(`${prefix}:${name}="${escapeAttr2(value)}"`);
244
+ }
245
+ }
246
+ }
247
+ let docMetaXml = "";
248
+ if (opts.documentMeta) {
249
+ for (const prefix of Object.keys(namespaces)) {
250
+ const bucket = opts.documentMeta[prefix];
251
+ if (!bucket?.elements) continue;
252
+ for (const [localName, list] of Object.entries(bucket.elements)) {
253
+ for (const el of list) docMetaXml += namespacedElementXml(prefix, localName, el);
254
+ }
255
+ }
256
+ }
257
+ const defsXml = registry.toDefsXml(opts.onWarn);
258
+ const bodyXml = nodes.map((n) => nodeXml(n, registry, namespaces)).join("");
259
+ const titleXml = opts.title && opts.title.length > 0 ? `<title>${escapeText(opts.title)}</title>` : "";
260
+ return `<svg ${rootAttrs.join(" ")}>${titleXml}${defsXml}${docMetaXml}${bodyXml}</svg>`;
261
+ }
262
+ function namespacedElementXml(prefix, localName, el) {
263
+ const attrs = [];
264
+ for (const [name, value] of Object.entries(el.attrs)) {
265
+ attrs.push(`${name}="${escapeAttr2(value)}"`);
266
+ }
267
+ const head = attrs.length > 0 ? `<${prefix}:${localName} ${attrs.join(" ")}>` : `<${prefix}:${localName}>`;
268
+ let body = "";
269
+ if (el.children) {
270
+ for (const [childName, list] of Object.entries(el.children)) {
271
+ for (const child of list) body += namespacedElementXml(prefix, childName, child);
272
+ }
273
+ } else if (el.text != null) {
274
+ body = escapeText(el.text);
275
+ }
276
+ return `${head}${body}</${prefix}:${localName}>`;
277
+ }
278
+ function metaAttrsXml(meta, namespaces) {
279
+ if (!meta) return "";
280
+ const parts = [];
281
+ for (const prefix of Object.keys(namespaces)) {
282
+ const bucket = meta[prefix];
283
+ if (!bucket?.attrs) continue;
284
+ for (const [name, value] of Object.entries(bucket.attrs)) {
285
+ parts.push(`${prefix}:${name}="${escapeAttr2(value)}"`);
286
+ }
287
+ }
288
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
289
+ }
290
+ function metaElementsXml(meta, namespaces) {
291
+ if (!meta) return "";
292
+ let out = "";
293
+ for (const prefix of Object.keys(namespaces)) {
294
+ const bucket = meta[prefix];
295
+ if (!bucket?.elements) continue;
296
+ for (const [localName, list] of Object.entries(bucket.elements)) {
297
+ for (const el of list) out += namespacedElementXml(prefix, localName, el);
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+ function registerPaintServers(nodes, registry) {
303
+ for (const n of nodes) {
304
+ if (n.kind === "group") {
305
+ registerPaintServers(n.children, registry);
306
+ } else if (n.kind === "path") {
307
+ if (n.fill.kind === "gradient") registry.register(n.fill.paint);
308
+ if (n.stroke && n.stroke.paint.kind === "gradient") registry.register(n.stroke.paint.paint);
309
+ registerMarkers(n.stroke, registry);
310
+ } else if (n.kind === "text") {
311
+ registerTextPaint(n.fill, registry);
312
+ registerTextPaint(n.stroke?.paint, registry);
313
+ registerMarkers(n.stroke, registry);
314
+ for (const run of n.runs ?? []) {
315
+ registerTextPaint(run.fill, registry);
316
+ registerTextPaint(run.stroke?.paint, registry);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ function registerTextPaint(paint, registry) {
322
+ if (paint && !("color" in paint)) registry.register(paint);
323
+ }
324
+ function markerKeyOfRef(ref) {
325
+ if (typeof ref === "string") return ref;
326
+ if (ref && typeof ref === "object" && "key" in ref) return String(ref.key);
327
+ return void 0;
328
+ }
329
+ function registerMarkers(stroke, registry) {
330
+ if (!stroke) return;
331
+ for (const ref of [stroke.markerStart, stroke.markerMid, stroke.markerEnd]) {
332
+ const key = markerKeyOfRef(ref);
333
+ if (key) registry.markerId(key);
334
+ }
335
+ }
336
+ function nodeXml(node, registry, namespaces) {
337
+ if (node.kind === "group") return groupXml(node, registry, namespaces);
338
+ if (node.kind === "text") return textXml(node, registry, namespaces);
339
+ if (node.kind === "image") return imageXml(node, namespaces);
340
+ return pathXml(node, registry, namespaces);
341
+ }
342
+ function imageXml(node, namespaces) {
343
+ const attrs = [
344
+ `href="${escapeAttr2(node.href)}"`,
345
+ `x="${trimNumber(node.x)}"`,
346
+ `y="${trimNumber(node.y)}"`,
347
+ `width="${trimNumber(node.width)}"`,
348
+ `height="${trimNumber(node.height)}"`,
349
+ `preserveAspectRatio="none"`
350
+ ];
351
+ if (node.opacity != null && node.opacity !== 1) {
352
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
353
+ }
354
+ if (node.rotation != null && node.rotation !== 0) {
355
+ const cx = node.x + node.width / 2;
356
+ const cy = node.y + node.height / 2;
357
+ const deg = node.rotation * 180 / Math.PI;
358
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
359
+ }
360
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
361
+ const metaEls = metaElementsXml(node.meta, namespaces);
362
+ if (metaEls) return `<image ${attrs.join(" ")}${metaAttrs}>${metaEls}</image>`;
363
+ return `<image ${attrs.join(" ")}${metaAttrs}/>`;
364
+ }
365
+ function groupXml(node, registry, namespaces) {
366
+ const attrs = [];
367
+ if (node.transform) {
368
+ const m2 = formatMatrix(node.transform);
369
+ if (m2) attrs.push(`transform="${m2}"`);
370
+ }
371
+ if (node.opacity != null && node.opacity !== 1) {
372
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
373
+ }
374
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
375
+ const head = attrs.length > 0 ? `<g ${attrs.join(" ")}${metaAttrs}>` : `<g${metaAttrs}>`;
376
+ const body = node.children.map((c2) => nodeXml(c2, registry, namespaces)).join("");
377
+ return `${head}${body}${metaElementsXml(node.meta, namespaces)}</g>`;
378
+ }
379
+ function pathXml(node, registry, namespaces) {
380
+ const attrs = [`d="${serializePathD(node.path)}"`];
381
+ const fillAttrs = paintAttrs(node.fill, "fill", registry);
382
+ for (const a of fillAttrs) attrs.push(a);
383
+ if (node.path.kind === "polygon" && node.path.fillRule === "evenodd") {
384
+ attrs.push(`fill-rule="evenodd"`);
385
+ }
386
+ if (node.stroke) {
387
+ const strokeAttrs = strokeAttrsFor(node.stroke, registry);
388
+ for (const a of strokeAttrs) attrs.push(a);
389
+ } else {
390
+ attrs.push('stroke="none"');
391
+ }
392
+ if (node.opacity != null && node.opacity !== 1) {
393
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
394
+ }
395
+ if (node.rotation != null && node.rotation !== 0) {
396
+ const b2 = pathBounds(node.path);
397
+ const cx = b2.minX + (b2.maxX - b2.minX) / 2;
398
+ const cy = b2.minY + (b2.maxY - b2.minY) / 2;
399
+ const deg = node.rotation * 180 / Math.PI;
400
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
401
+ }
402
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
403
+ const metaEls = metaElementsXml(node.meta, namespaces);
404
+ if (metaEls) {
405
+ return `<path ${attrs.join(" ")}${metaAttrs}>${metaEls}</path>`;
406
+ }
407
+ return `<path ${attrs.join(" ")}${metaAttrs}/>`;
408
+ }
409
+ function paintAttrs(paint, name, registry, includeOpacity = true) {
410
+ if (paint.kind === "none") return [`${name}="none"`];
411
+ if (paint.kind === "solid") {
412
+ const out = [`${name}="${paint.color}"`];
413
+ if (includeOpacity && paint.opacity != null && paint.opacity !== 1) {
414
+ out.push(`${name}-opacity="${trimNumber(paint.opacity)}"`);
415
+ }
416
+ return out;
417
+ }
418
+ const id = registry.register(paint.paint);
419
+ return [`${name}="url(#${id})"`];
420
+ }
421
+ function coreStrokeAttrs(stroke, registry) {
422
+ if (!stroke) return [];
423
+ const width = resolveStrokeWidth(stroke.width ?? 1, 1);
424
+ if (!(width > 0)) return [];
425
+ const attrs = [];
426
+ if ("color" in stroke.paint) {
427
+ attrs.push(`stroke="${stroke.paint.color}"`);
428
+ if (stroke.paint.opacity != null && stroke.paint.opacity !== 1) {
429
+ attrs.push(`stroke-opacity="${trimNumber(stroke.paint.opacity)}"`);
430
+ }
431
+ } else {
432
+ attrs.push(`stroke="url(#${registry.register(stroke.paint)})"`);
433
+ }
434
+ attrs.push(`stroke-width="${trimNumber(width)}"`);
435
+ if (stroke.cap) attrs.push(`stroke-linecap="${stroke.cap}"`);
436
+ if (stroke.join) attrs.push(`stroke-linejoin="${stroke.join}"`);
437
+ if (stroke.dash && stroke.dash.length > 0) {
438
+ attrs.push(`stroke-dasharray="${stroke.dash.map(trimNumber).join(" ")}"`);
439
+ }
440
+ if (stroke.miterLimit != null) attrs.push(`stroke-miterlimit="${trimNumber(stroke.miterLimit)}"`);
441
+ for (const [field, attr] of [
442
+ ["markerStart", "marker-start"],
443
+ ["markerMid", "marker-mid"],
444
+ ["markerEnd", "marker-end"]
445
+ ]) {
446
+ const ref = stroke[field];
447
+ if (ref === void 0) continue;
448
+ const key = typeof ref === "string" ? ref : ref.key;
449
+ const id = registry.markerId(key);
450
+ if (id === void 0) continue;
451
+ attrs.push(`${attr}="url(#${id})"`);
452
+ }
453
+ return attrs;
454
+ }
455
+ function strokeAttrsFor(stroke, registry) {
456
+ const attrs = paintAttrs(stroke.paint, "stroke", registry, false);
457
+ attrs.push(`stroke-width="${trimNumber(stroke.width)}"`);
458
+ const opacity = stroke.opacity ?? (stroke.paint.kind === "solid" ? stroke.paint.opacity : void 0);
459
+ if (opacity != null && opacity !== 1) {
460
+ attrs.push(`stroke-opacity="${trimNumber(opacity)}"`);
461
+ }
462
+ if (stroke.cap) {
463
+ attrs.push(`stroke-linecap="${stroke.cap}"`);
464
+ }
465
+ if (stroke.join) {
466
+ attrs.push(`stroke-linejoin="${stroke.join}"`);
467
+ }
468
+ if (stroke.dash && stroke.dash.length > 0) {
469
+ attrs.push(`stroke-dasharray="${stroke.dash.map(trimNumber).join(" ")}"`);
470
+ }
471
+ if (stroke.miterLimit != null) {
472
+ attrs.push(`stroke-miterlimit="${trimNumber(stroke.miterLimit)}"`);
473
+ }
474
+ for (const [field, attr] of [
475
+ ["markerStart", "marker-start"],
476
+ ["markerMid", "marker-mid"],
477
+ ["markerEnd", "marker-end"]
478
+ ]) {
479
+ const ref = stroke[field];
480
+ if (ref === void 0) continue;
481
+ const id = registry.markerId(ref);
482
+ if (id === void 0) continue;
483
+ attrs.push(`${attr}="url(#${id})"`);
484
+ }
485
+ return attrs;
486
+ }
487
+ function computeBounds(nodes) {
488
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
489
+ const visit = (n, ctm) => {
490
+ if (n.kind === "group") {
491
+ const childCtm = n.transform ? multiply(ctm, n.transform) : ctm;
492
+ n.children.forEach((c2) => visit(c2, childCtm));
493
+ return;
494
+ }
495
+ const b2 = n.kind === "text" || n.kind === "image" ? { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height } : pathBounds(n.path);
496
+ const local = n.rotation ? rotateAboutCenter(ctm, b2, n.rotation) : ctm;
497
+ for (const [x2, y2] of corners(b2)) {
498
+ const px = local[0] * x2 + local[2] * y2 + local[4];
499
+ const py = local[1] * x2 + local[3] * y2 + local[5];
500
+ if (px < minX) minX = px;
501
+ if (py < minY) minY = py;
502
+ if (px > maxX) maxX = px;
503
+ if (py > maxY) maxY = py;
504
+ }
505
+ };
506
+ nodes.forEach((n) => visit(n, IDENTITY_MATRIX));
507
+ if (!Number.isFinite(minX)) {
508
+ return { x: 0, y: 0, width: 0, height: 0 };
509
+ }
510
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
511
+ }
512
+ function corners(b2) {
513
+ return [[b2.minX, b2.minY], [b2.maxX, b2.minY], [b2.maxX, b2.maxY], [b2.minX, b2.maxY]];
514
+ }
515
+ function rotateAboutCenter(ctm, b2, angle) {
516
+ const cx = (b2.minX + b2.maxX) / 2;
517
+ const cy = (b2.minY + b2.maxY) / 2;
518
+ const c2 = Math.cos(angle);
519
+ const s = Math.sin(angle);
520
+ return multiply(ctm, [c2, s, -s, c2, cx - cx * c2 + cy * s, cy - cx * s - cy * c2]);
521
+ }
522
+ function textXml(node, registry, namespaces) {
523
+ const attrs = [
524
+ `x="${trimNumber(node.x)}"`,
525
+ `y="${trimNumber(node.y)}"`,
526
+ `dominant-baseline="text-before-edge"`,
527
+ // The runs model stores real line breaks; SVG's default whitespace
528
+ // handling would collapse them (and any indentation) away on re-import.
529
+ `xml:space="preserve"`,
530
+ `data-weasel-width="${trimNumber(node.width)}"`,
531
+ `data-weasel-height="${trimNumber(node.height)}"`
532
+ ];
533
+ const style = node.style;
534
+ if (style?.fontSize != null) attrs.push(`font-size="${trimNumber(style.fontSize)}"`);
535
+ if (style?.fontFamily) attrs.push(`font-family="${escapeAttr2(style.fontFamily)}"`);
536
+ if (style?.fontWeight != null) attrs.push(`font-weight="${String(style.fontWeight)}"`);
537
+ if (style?.fontStyle && style.fontStyle !== "normal") attrs.push(`font-style="${style.fontStyle}"`);
538
+ const direction = style?.direction ?? "ltr";
539
+ if (direction === "rtl") attrs.push('direction="rtl"');
540
+ if (style?.align != null || direction === "rtl") {
541
+ const align = style?.align ?? "left";
542
+ const anchor = align === "center" ? "middle" : align === "start" ? "start" : align === "end" ? "end" : align === "left" === (direction === "ltr") ? "start" : "end";
543
+ if (anchor !== "start") attrs.push(`text-anchor="${anchor}"`);
544
+ }
545
+ if (style?.letterSpacing != null && style.letterSpacing !== 0) {
546
+ attrs.push(`letter-spacing="${trimNumber(style.letterSpacing)}"`);
547
+ }
548
+ const decoration = textDecorationValue(style?.underline, style?.strikethrough, style?.overline);
549
+ if (decoration) attrs.push(`text-decoration="${decoration}"`);
550
+ if (node.fill) {
551
+ if ("color" in node.fill) {
552
+ attrs.push(`fill="${node.fill.color}"`);
553
+ if (node.fill.opacity != null && node.fill.opacity !== 1) {
554
+ attrs.push(`fill-opacity="${trimNumber(node.fill.opacity)}"`);
555
+ }
556
+ } else {
557
+ const id = registry.register(node.fill);
558
+ attrs.push(`fill="url(#${id})"`);
559
+ }
560
+ }
561
+ for (const a of coreStrokeAttrs(node.stroke, registry)) attrs.push(a);
562
+ if (node.opacity != null && node.opacity !== 1) {
563
+ attrs.push(`opacity="${trimNumber(node.opacity)}"`);
564
+ }
565
+ if (node.rotation != null && node.rotation !== 0) {
566
+ const cx = node.x + node.width / 2;
567
+ const cy = node.y + node.height / 2;
568
+ const deg = node.rotation * 180 / Math.PI;
569
+ attrs.push(`transform="rotate(${trimNumber(deg)} ${trimNumber(cx)} ${trimNumber(cy)})"`);
570
+ }
571
+ const body = node.runs && node.runs.length > 0 ? node.runs.map((r2) => runXml(r2, registry)).join("") : escapeText(node.text);
572
+ const metaAttrs = metaAttrsXml(node.meta, namespaces);
573
+ const metaEls = metaElementsXml(node.meta, namespaces);
574
+ return `<text ${attrs.join(" ")}${metaAttrs}>${body}${metaEls}</text>`;
575
+ }
576
+ function runXml(run, registry) {
577
+ const attrs = [];
578
+ if (run.bold) attrs.push(`font-weight="700"`);
579
+ if (run.italic) attrs.push(`font-style="italic"`);
580
+ if (run.fontFamily) attrs.push(`font-family="${escapeAttr2(run.fontFamily)}"`);
581
+ const presetScale = run.script != null && run.baselineShift != null ? SCRIPT_METRICS[run.script].size : void 0;
582
+ const scale = run.fontScale ?? presetScale;
583
+ if (run.fontSize != null) attrs.push(`font-size="${trimNumber(run.fontSize)}"`);
584
+ else if (scale != null) attrs.push(`font-size="${trimNumber(scale * 100)}%"`);
585
+ if (run.baselineShift != null) {
586
+ attrs.push(`baseline-shift="${trimNumber(run.baselineShift * 100)}%"`);
587
+ } else if (run.script) {
588
+ attrs.push(`baseline-shift="${run.script}"`);
589
+ }
590
+ if (run.letterSpacing != null) {
591
+ attrs.push(`letter-spacing="${trimNumber(run.letterSpacing)}"`);
592
+ }
593
+ const runDecoration = textDecorationValue(run.underline, run.strikethrough, run.overline);
594
+ if (runDecoration) attrs.push(`text-decoration="${runDecoration}"`);
595
+ if (run.fill) {
596
+ if ("color" in run.fill) {
597
+ attrs.push(`fill="${run.fill.color}"`);
598
+ } else {
599
+ const id = registry.register(run.fill);
600
+ attrs.push(`fill="url(#${id})"`);
601
+ }
602
+ }
603
+ for (const a of coreStrokeAttrs(run.stroke, registry)) attrs.push(a);
604
+ const head = attrs.length > 0 ? `<tspan ${attrs.join(" ")}>` : "<tspan>";
605
+ return `${head}${escapeText(run.text)}</tspan>`;
606
+ }
607
+ function textDecorationValue(underline, strikethrough, overline) {
608
+ const tokens = [];
609
+ if (underline) tokens.push("underline");
610
+ if (strikethrough) tokens.push("line-through");
611
+ if (overline) tokens.push("overline");
612
+ return tokens.length > 0 ? tokens.join(" ") : null;
613
+ }
614
+ function escapeAttr2(s) {
615
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
616
+ }
617
+ function escapeText(s) {
618
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
619
+ }
620
+ function pathBounds(path) {
621
+ if (path.kind === "rect") {
622
+ return {
623
+ minX: path.x,
624
+ minY: path.y,
625
+ maxX: path.x + path.width,
626
+ maxY: path.y + path.height
627
+ };
628
+ }
629
+ const b2 = boundsOfPath(path);
630
+ return { minX: b2.x, minY: b2.y, maxX: b2.x + b2.width, maxY: b2.y + b2.height };
631
+ }
632
+
633
+ // src/annotations/paint.ts
634
+ var MARK_COLOR = "#e5484d";
635
+ var MARK_WIDTH = 2;
636
+ var TEXT_SIZE = 14;
637
+ var toWorld = (p2, content) => ({
638
+ x: p2.x * content.w,
639
+ y: p2.y * content.h
640
+ });
641
+ function vertices(m2, content) {
642
+ const stored = m2.data.points;
643
+ if (stored && stored.length >= 2) return stored.map((p2) => toWorld(p2, content));
644
+ const { x: x2, y: y2, width, height } = m2.pose;
645
+ return [
646
+ { x: x2, y: y2 },
647
+ { x: x2 + width, y: y2 + height }
648
+ ];
649
+ }
650
+ function polyline(points) {
651
+ const b2 = new PathBuilder();
652
+ const first = points[0];
653
+ if (!first) return rectPath(0, 0, 0, 0);
654
+ b2.moveTo(first.x, first.y);
655
+ for (const p2 of points.slice(1)) b2.lineTo(p2.x, p2.y);
656
+ return b2.build();
657
+ }
658
+ function markCommands(m2, content, style = {}) {
659
+ const color = style.color ?? MARK_COLOR;
660
+ const stroke = {
661
+ paint: { color },
662
+ width: MARK_WIDTH,
663
+ cap: "round",
664
+ join: "round",
665
+ ...style.stale ? { dash: [6, 4] } : {}
666
+ };
667
+ switch (m2.data.kind) {
668
+ case "rect":
669
+ return [
670
+ { kind: "path", path: rectPath(m2.pose.x, m2.pose.y, m2.pose.width, m2.pose.height), stroke }
671
+ ];
672
+ case "ellipse":
673
+ return [{ kind: "path", path: ellipsePath(m2.pose), stroke }];
674
+ case "line": {
675
+ const [a, b2] = vertices(m2, content);
676
+ return [{ kind: "path", path: linePath(a, b2), stroke }];
677
+ }
678
+ case "arrow": {
679
+ const [a, b2] = vertices(m2, content);
680
+ const marked = { ...stroke, markerEnd: "arrow" };
681
+ const path = linePath(a, b2);
682
+ return [
683
+ { kind: "path", path, stroke: marked },
684
+ ...markerDrawCommands(path, marked, MARK_WIDTH, void 0)
685
+ ];
686
+ }
687
+ case "stroke":
688
+ return [{ kind: "path", path: polyline(vertices(m2, content)), stroke }];
689
+ case "text": {
690
+ const text = m2.data.title;
691
+ if (!text) return [];
692
+ return [
693
+ textCommand(
694
+ m2.pose.x,
695
+ m2.pose.y,
696
+ text,
697
+ { fontSize: TEXT_SIZE },
698
+ void 0,
699
+ void 0,
700
+ void 0,
701
+ {
702
+ fill: { color }
703
+ }
704
+ )
705
+ ];
706
+ }
707
+ }
708
+ }
709
+
710
+ // src/annotations/staleness.ts
711
+ function seenFrom(config, keys) {
712
+ const out = {};
713
+ if (config === null || typeof config !== "object") return out;
714
+ const src = config;
715
+ for (const k2 of keys) {
716
+ if (k2 in src) out[k2] = src[k2];
717
+ }
718
+ return out;
719
+ }
720
+ function isStale(seen, config, keys) {
721
+ if (!seen || keys.length === 0) return false;
722
+ if (config === null || typeof config !== "object") return false;
723
+ const src = config;
724
+ for (const k2 of keys) {
725
+ if (!(k2 in seen)) continue;
726
+ if (!Object.is(seen[k2], src[k2])) return true;
727
+ }
728
+ return false;
729
+ }
730
+
731
+ // src/annotations/drawOne.ts
732
+ function createMarkDrawOne(opts) {
733
+ return (node, pose) => markCommands({ pose, data: node.data }, opts.content, resolveMarkStyle(node.data, opts));
734
+ }
735
+ function resolveMarkStyle(data, opts) {
46
736
  return {
47
- ...start,
48
- yaw: wrapYaw(start.yaw + dx * YAW_PER_PX),
49
- pitch: clampPitch(start.pitch + dy * PITCH_PER_PX)
737
+ color: opts.meaning?.statuses?.find((s) => s.id === data.status)?.color,
738
+ // Read off the node rather than through the store's `isStale`, which
739
+ // wants a projected Annotation this path does not have.
740
+ stale: isStale(data.seen, opts.config, opts.positionDependsOn ?? [])
50
741
  };
51
742
  }
52
- function orbitAfterWheel(view, deltaY, minDistance, maxDistance) {
53
- const factor = Math.exp(deltaY * DISTANCE_PER_NOTCH);
743
+
744
+ // src/annotations/svgNodes.ts
745
+ var NO_PAINT = { kind: "none" };
746
+ function toSvgPaint(paint) {
747
+ if (!paint) return NO_PAINT;
748
+ if ("color" in paint && typeof paint.color === "string") {
749
+ return {
750
+ kind: "solid",
751
+ color: paint.color,
752
+ ...paint.opacity != null ? { opacity: paint.opacity } : {}
753
+ };
754
+ }
755
+ return { kind: "gradient", paint };
756
+ }
757
+ function toSvgStroke(stroke) {
54
758
  return {
55
- ...view,
56
- distance: Math.min(maxDistance, Math.max(minDistance, view.distance * factor))
759
+ paint: toSvgPaint(stroke.paint),
760
+ width: resolveStrokeWidth(stroke.width ?? 1, 1),
761
+ ...stroke.cap ? { cap: stroke.cap } : {},
762
+ ...stroke.join ? { join: stroke.join } : {},
763
+ ...stroke.dash ? { dash: [...stroke.dash] } : {},
764
+ ...stroke.miterLimit != null ? { miterLimit: stroke.miterLimit } : {}
57
765
  };
58
766
  }
59
- function useOrbit({
60
- view,
61
- onViewChange,
62
- home,
63
- minDistance = 0.1,
64
- maxDistance = 1e3
767
+ function toSvgNode(cmd, m2) {
768
+ if (cmd.kind === "path") {
769
+ return {
770
+ kind: "path",
771
+ path: cmd.path,
772
+ fill: toSvgPaint(cmd.fill),
773
+ ...cmd.stroke ? { stroke: toSvgStroke(cmd.stroke) } : {}
774
+ };
775
+ }
776
+ if (cmd.kind === "text") {
777
+ return {
778
+ kind: "text",
779
+ x: cmd.x,
780
+ y: cmd.y,
781
+ width: m2.pose.width,
782
+ height: m2.pose.height,
783
+ text: cmd.runs.map((run) => run.text).join(""),
784
+ style: cmd.style,
785
+ ...cmd.runs[0]?.fill ? { fill: cmd.runs[0].fill } : {}
786
+ };
787
+ }
788
+ throw new Error(`[labkit] a mark drew a '${cmd.kind}' command, which has no vector form`);
789
+ }
790
+ function markSvgNodes(m2, content, style = {}) {
791
+ return markCommands(m2, content, style).map((cmd) => toSvgNode(cmd, m2));
792
+ }
793
+
794
+ // src/annotations/capture.ts
795
+ var SVG_NS = "http://www.w3.org/2000/svg";
796
+ function capturePlan(base, format) {
797
+ if (format === "svg") return "svg-document";
798
+ return base?.kind === "svg" ? "svg-document" : "raster-stack";
799
+ }
800
+ function nestBaseSvg(markup, w2, h) {
801
+ const doc = new DOMParser().parseFromString(markup, "image/svg+xml");
802
+ const root = doc.documentElement;
803
+ if (doc.getElementsByTagName("parsererror").length > 0 || root.localName !== "svg") {
804
+ throw new Error("[labkit] a target's `base()` returned markup that is not an <svg> document");
805
+ }
806
+ root.setAttribute("x", "0");
807
+ root.setAttribute("y", "0");
808
+ root.setAttribute("width", String(w2));
809
+ root.setAttribute("height", String(h));
810
+ return new XMLSerializer().serializeToString(root);
811
+ }
812
+ function embedRasterBase(base, w2, h, onWarn) {
813
+ const href = base.kind === "canvas" ? base.canvas.toDataURL() : base.kind === "image" ? base.src : "";
814
+ if (base.kind === "image" && !href.startsWith("data:")) {
815
+ onWarn?.(`base image '${href}' is a reference, so this SVG is not self-contained`);
816
+ }
817
+ return serializeSvg([{ kind: "image", href, x: 0, y: 0, width: w2, height: h }], {
818
+ viewBox: { x: 0, y: 0, width: w2, height: h }
819
+ });
820
+ }
821
+ function markNodesOf(scene, draw) {
822
+ return [...scene.renderOrder()].flatMap((id) => {
823
+ const node = scene.get(asNodeId$1(String(id)));
824
+ if (!node) return [];
825
+ return markSvgNodes(
826
+ { pose: node.pose, data: node.data },
827
+ draw.content,
828
+ resolveMarkStyle(node.data, draw)
829
+ );
830
+ });
831
+ }
832
+ function composeCaptureSvg(args) {
833
+ const { base, scene, draw, scale, onWarn } = args;
834
+ const { w: w2, h } = draw.content;
835
+ const baseXml = base === void 0 ? "" : base.kind === "svg" ? nestBaseSvg(base.markup, w2, h) : embedRasterBase(base, w2, h, onWarn);
836
+ const marksXml = serializeSvg(markNodesOf(scene, draw), {
837
+ viewBox: { x: 0, y: 0, width: w2, height: h },
838
+ onWarn
839
+ });
840
+ const outW = Math.max(1, Math.round(w2 * scale));
841
+ const outH = Math.max(1, Math.round(h * scale));
842
+ return `<svg xmlns="${SVG_NS}" width="${outW}" height="${outH}" viewBox="0 0 ${w2} ${h}">${baseXml}${marksXml}</svg>`;
843
+ }
844
+ function context2d(width, height) {
845
+ const canvas = document.createElement("canvas");
846
+ canvas.width = width;
847
+ canvas.height = height;
848
+ const ctx = canvas.getContext("2d");
849
+ if (!ctx) throw new Error("[labkit] capture needs a 2D canvas context, and this one has none");
850
+ return ctx;
851
+ }
852
+ function toPngBlob(canvas) {
853
+ return new Promise((resolve, reject) => {
854
+ let done;
855
+ try {
856
+ done = (blob) => blob ? resolve(blob) : reject(new Error("[labkit] the browser produced no PNG"));
857
+ canvas.toBlob(done, "image/png");
858
+ } catch (err) {
859
+ reject(
860
+ new Error(
861
+ "[labkit] the capture canvas is tainted, so it cannot be read back \u2014 a base referencing a cross-origin image does this. Embed it as a data: URI.",
862
+ { cause: err }
863
+ )
864
+ );
865
+ }
866
+ });
867
+ }
868
+ function loadImage(src) {
869
+ return new Promise((resolve, reject) => {
870
+ const img = new Image();
871
+ img.onload = () => resolve(img);
872
+ img.onerror = () => reject(new Error(`[labkit] a capture base failed to load: ${src}`));
873
+ img.src = src;
874
+ });
875
+ }
876
+ async function rasterizeSvg(svg, width, height) {
877
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
878
+ try {
879
+ const img = await loadImage(url);
880
+ const ctx = context2d(width, height);
881
+ ctx.drawImage(img, 0, 0, width, height);
882
+ return await toPngBlob(ctx.canvas);
883
+ } finally {
884
+ URL.revokeObjectURL(url);
885
+ }
886
+ }
887
+ async function stackRaster(args) {
888
+ const { base, scene, draw, scale, width, height } = args;
889
+ const ctx = context2d(width, height);
890
+ if (base?.kind === "image") ctx.drawImage(await loadImage(base.src), 0, 0, width, height);
891
+ else if (base?.kind === "canvas") ctx.drawImage(base.canvas, 0, 0, width, height);
892
+ const raster = renderSceneToPixels({
893
+ scene,
894
+ sourceRect: { x: 0, y: 0, width: draw.content.w, height: draw.content.h },
895
+ scale: { x: scale, y: scale },
896
+ drawOne: createMarkDrawOne(draw)
897
+ });
898
+ const marks = context2d(raster.width, raster.height);
899
+ marks.putImageData(
900
+ new ImageData(Uint8ClampedArray.from(raster.data), raster.width, raster.height),
901
+ 0,
902
+ 0
903
+ );
904
+ ctx.drawImage(marks.canvas, 0, 0, width, height);
905
+ return toPngBlob(ctx.canvas);
906
+ }
907
+ async function captureTarget(args, opts = {}) {
908
+ const format = opts.format ?? "png";
909
+ const scale = opts.scale ?? 2;
910
+ const { content } = args.draw;
911
+ const width = Math.max(1, Math.round(content.w * scale));
912
+ const height = Math.max(1, Math.round(content.h * scale));
913
+ const base = await args.base?.();
914
+ const compose = {
915
+ base,
916
+ scene: args.scene,
917
+ draw: args.draw,
918
+ scale,
919
+ onWarn: args.onWarn
920
+ };
921
+ if (capturePlan(base, format) === "raster-stack") {
922
+ return {
923
+ target: args.target,
924
+ blob: await stackRaster({ ...compose, width, height }),
925
+ format,
926
+ width,
927
+ height
928
+ };
929
+ }
930
+ const svg = composeCaptureSvg(compose);
931
+ const blob = format === "svg" ? new Blob([svg], { type: "image/svg+xml;charset=utf-8" }) : await rasterizeSvg(svg, width, height);
932
+ return { target: args.target, blob, format, width, height };
933
+ }
934
+
935
+ // src/annotations/frac.ts
936
+ function fracToWorld(f2, content) {
937
+ return {
938
+ x: f2.x * content.w,
939
+ y: f2.y * content.h,
940
+ width: f2.w * content.w,
941
+ height: f2.h * content.h
942
+ };
943
+ }
944
+ function worldToFrac(r2, content) {
945
+ const sx = content.w === 0 ? 0 : 1 / content.w;
946
+ const sy = content.h === 0 ? 0 : 1 / content.h;
947
+ return { x: r2.x * sx, y: r2.y * sy, w: r2.width * sx, h: r2.height * sy };
948
+ }
949
+ var DP = 1e4;
950
+ function roundFrac(f2) {
951
+ return {
952
+ x: Math.round(f2.x * DP) / DP,
953
+ y: Math.round(f2.y * DP) / DP,
954
+ w: Math.round(f2.w * DP) / DP,
955
+ h: Math.round(f2.h * DP) / DP
956
+ };
957
+ }
958
+ function fracContains(box, pt, tol = 0) {
959
+ return pt.x >= box.x - tol && pt.x <= box.x + box.w + tol && pt.y >= box.y - tol && pt.y <= box.y + box.h + tol;
960
+ }
961
+ function fracIntersects(outer, inner) {
962
+ return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.w <= outer.x + outer.w && inner.y + inner.h <= outer.y + outer.h;
963
+ }
964
+
965
+ // src/annotations/history.ts
966
+ var MarkHistory = class {
967
+ depth = /* @__PURE__ */ new Map();
968
+ undoable = [];
969
+ redoable = [];
970
+ /** Set while this class is driving, so a redo is not read as a new change. */
971
+ replaying = null;
972
+ /** Start following `scene` under `target`. Idempotent per target. */
973
+ track(target, scene) {
974
+ if (this.depth.has(target)) return;
975
+ this.depth.set(target, scene.historyIndex());
976
+ }
977
+ /** Call on every notification from `target`'s scene. */
978
+ observe(target, scene) {
979
+ const now = scene.historyIndex();
980
+ const before = this.depth.get(target) ?? 0;
981
+ this.depth.set(target, now);
982
+ if (now > before) {
983
+ if (this.replaying === "redo") {
984
+ drop(this.redoable, target);
985
+ } else {
986
+ this.redoable = [];
987
+ }
988
+ this.undoable.push(target);
989
+ } else if (now < before) {
990
+ drop(this.undoable, target);
991
+ this.redoable.push(target);
992
+ }
993
+ }
994
+ /** The target `undo` would act on, skipping any whose scene has nothing
995
+ * left — a scene can drop entries this ordering still lists. */
996
+ next(stack, can, at) {
997
+ for (let i = stack.length - 1; i >= 0; i--) {
998
+ const target = stack[i];
999
+ if (target === void 0) continue;
1000
+ const scene = at(target);
1001
+ if (scene && can(scene)) return target;
1002
+ }
1003
+ return null;
1004
+ }
1005
+ canUndo(at) {
1006
+ return this.next(this.undoable, (s) => s.canUndo(), at) !== null;
1007
+ }
1008
+ canRedo(at) {
1009
+ return this.next(this.redoable, (s) => s.canRedo(), at) !== null;
1010
+ }
1011
+ undo(at) {
1012
+ const target = this.next(this.undoable, (s) => s.canUndo(), at);
1013
+ const scene = target === null ? void 0 : at(target);
1014
+ if (!scene) return false;
1015
+ this.replaying = "undo";
1016
+ try {
1017
+ return scene.undo();
1018
+ } finally {
1019
+ this.replaying = null;
1020
+ }
1021
+ }
1022
+ redo(at) {
1023
+ const target = this.next(this.redoable, (s) => s.canRedo(), at);
1024
+ const scene = target === null ? void 0 : at(target);
1025
+ if (!scene) return false;
1026
+ this.replaying = "redo";
1027
+ try {
1028
+ return scene.redo();
1029
+ } finally {
1030
+ this.replaying = null;
1031
+ }
1032
+ }
1033
+ };
1034
+ function drop(stack, value) {
1035
+ for (let i = stack.length - 1; i >= 0; i--) {
1036
+ if (stack[i] === value) {
1037
+ stack.splice(i, 1);
1038
+ return;
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ // src/annotations/store.ts
1044
+ function createAnnotationScene() {
1045
+ return createScene({ systemLayers: [{ id: "marks" }] });
1046
+ }
1047
+ var NO_CONTENT = { w: 0, h: 0 };
1048
+ function splitId(id) {
1049
+ const at = id.lastIndexOf("/");
1050
+ return at <= 0 ? void 0 : { target: id.slice(0, at), node: id.slice(at + 1) };
1051
+ }
1052
+ function createAnnotationStore(opts) {
1053
+ const { targets, restore } = opts;
1054
+ const scenes = /* @__PURE__ */ new Map();
1055
+ const subs = /* @__PURE__ */ new Set();
1056
+ const history = new MarkHistory();
1057
+ const notify = () => {
1058
+ for (const fn of subs) fn();
1059
+ };
1060
+ const sceneFor = (target) => {
1061
+ const known = scenes.get(target);
1062
+ if (known) return known;
1063
+ const raw = restore?.[target];
1064
+ const scene = raw ? sceneFromJSON(
1065
+ raw,
1066
+ {}
1067
+ ) : createAnnotationScene();
1068
+ scenes.set(target, scene);
1069
+ history.track(target, scene);
1070
+ scene.subscribe(() => {
1071
+ history.observe(target, scene);
1072
+ notify();
1073
+ });
1074
+ return scene;
1075
+ };
1076
+ const historySceneAt = (target) => scenes.get(target);
1077
+ for (const target of Object.keys(restore ?? {})) sceneFor(target);
1078
+ const targetOf = (id) => targets().find((t) => t.id === id);
1079
+ const contentOf = (id) => targetOf(id)?.content ?? NO_CONTENT;
1080
+ const project = (target, node) => {
1081
+ const found = scenes.get(target)?.get(asNodeId$1(node));
1082
+ if (!found) return void 0;
1083
+ return {
1084
+ ...found.data,
1085
+ target,
1086
+ id: `${target}/${node}`,
1087
+ frac: roundFrac(worldToFrac(found.pose, contentOf(target)))
1088
+ };
1089
+ };
1090
+ const liveTargets = () => {
1091
+ const declared = targets().map((t) => t.id);
1092
+ const rest = [...scenes.keys()].filter((id) => !declared.includes(id));
1093
+ return [...declared, ...rest];
1094
+ };
1095
+ const marksOn = (target) => {
1096
+ const scene = scenes.get(target);
1097
+ if (!scene) return [];
1098
+ const out = [];
1099
+ for (const id of scene.renderOrder()) {
1100
+ const a = project(target, String(id));
1101
+ if (a) out.push(a);
1102
+ }
1103
+ return out;
1104
+ };
1105
+ const all = () => liveTargets().flatMap(marksOn);
1106
+ const matches = (a, q) => {
1107
+ if (q.target !== void 0 && a.target !== q.target) return false;
1108
+ if (q.kind !== void 0 && a.kind !== q.kind) return false;
1109
+ if (q.status !== void 0 && a.status !== q.status) return false;
1110
+ if (q.tags !== void 0) {
1111
+ const have = a.tags ?? [];
1112
+ for (const t of q.tags) if (!have.includes(t)) return false;
1113
+ }
1114
+ if (q.where && !q.where(a)) return false;
1115
+ return true;
1116
+ };
1117
+ const nodeOf = (id) => {
1118
+ const parts = splitId(id);
1119
+ if (!parts) return void 0;
1120
+ const scene = scenes.get(parts.target);
1121
+ const node = scene?.get(asNodeId$1(parts.node));
1122
+ return node && scene ? { ...parts, scene, node } : void 0;
1123
+ };
1124
+ return {
1125
+ sceneFor,
1126
+ targets,
1127
+ get(id) {
1128
+ const parts = splitId(id);
1129
+ return parts ? project(parts.target, parts.node) : void 0;
1130
+ },
1131
+ query(q) {
1132
+ return q ? all().filter((a) => matches(a, q)) : all();
1133
+ },
1134
+ hitTest(target, pt, tol = 0) {
1135
+ return marksOn(target).filter((a) => fracContains(a.frac, pt, tol)).reverse();
1136
+ },
1137
+ within(target, box) {
1138
+ return marksOn(target).filter((a) => fracIntersects(box, a.frac));
1139
+ },
1140
+ isStale(a, config) {
1141
+ const keys = targetOf(a.target)?.positionDependsOn ?? [];
1142
+ return isStale(a.seen, config, keys);
1143
+ },
1144
+ canUndo() {
1145
+ return history.canUndo(historySceneAt);
1146
+ },
1147
+ canRedo() {
1148
+ return history.canRedo(historySceneAt);
1149
+ },
1150
+ undo() {
1151
+ return history.undo(historySceneAt);
1152
+ },
1153
+ redo() {
1154
+ return history.redo(historySceneAt);
1155
+ },
1156
+ selection() {
1157
+ const out = [];
1158
+ for (const target of liveTargets()) {
1159
+ const scene = scenes.get(target);
1160
+ if (!scene) continue;
1161
+ for (const node of scene.getSelection()) {
1162
+ if (scene.get(node)) out.push(`${target}/${String(node)}`);
1163
+ }
1164
+ }
1165
+ return out;
1166
+ },
1167
+ setSelection(ids) {
1168
+ const wanted = /* @__PURE__ */ new Map();
1169
+ for (const id of ids) {
1170
+ const found = nodeOf(id);
1171
+ if (!found) continue;
1172
+ const node = String(found.node.id);
1173
+ const on = wanted.get(found.target);
1174
+ if (!on) wanted.set(found.target, [node]);
1175
+ else if (!on.includes(node)) on.push(node);
1176
+ }
1177
+ for (const [target, scene] of scenes) {
1178
+ const next = wanted.get(target) ?? [];
1179
+ const now = scene.getSelection();
1180
+ if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
1181
+ scene.setSelection(next.map(asNodeId$1));
1182
+ }
1183
+ },
1184
+ subscribe(fn) {
1185
+ subs.add(fn);
1186
+ return () => {
1187
+ subs.delete(fn);
1188
+ };
1189
+ },
1190
+ add(init, config) {
1191
+ const target = targetOf(init.target);
1192
+ const data = {
1193
+ target: init.target,
1194
+ kind: init.kind,
1195
+ ...init.points ? { points: init.points } : {},
1196
+ ...init.title !== void 0 ? { title: init.title } : {},
1197
+ ...init.status !== void 0 ? { status: init.status } : {},
1198
+ ...init.tags !== void 0 ? { tags: init.tags } : {},
1199
+ ...init.meta !== void 0 ? { meta: init.meta } : {},
1200
+ seen: seenFrom(config, target?.positionDependsOn ?? [])
1201
+ };
1202
+ const node = sceneFor(init.target).add({
1203
+ kind: "leaf",
1204
+ layer: "marks",
1205
+ pose: fracToWorld(roundFrac(init.frac), target?.content ?? NO_CONTENT),
1206
+ data
1207
+ });
1208
+ return `${init.target}/${String(node)}`;
1209
+ },
1210
+ update(id, patch) {
1211
+ const found = nodeOf(id);
1212
+ if (!found) return;
1213
+ const { frac, ...meaning } = patch;
1214
+ const nid = asNodeId$1(found.node.id);
1215
+ if (Object.keys(meaning).length > 0) {
1216
+ found.scene.update(nid, { data: { ...found.node.data, ...meaning } });
1217
+ }
1218
+ if (frac) {
1219
+ found.scene.setPose(nid, fracToWorld(roundFrac(frac), contentOf(found.target)));
1220
+ }
1221
+ },
1222
+ setMeta(id, meta) {
1223
+ const found = nodeOf(id);
1224
+ if (!found) return;
1225
+ found.scene.update(asNodeId$1(found.node.id), { data: { ...found.node.data, meta } });
1226
+ },
1227
+ remove(id) {
1228
+ const found = nodeOf(id);
1229
+ if (found) found.scene.remove(asNodeId$1(found.node.id));
1230
+ },
1231
+ async capture(target, captureOpts) {
1232
+ const info = targetOf(target);
1233
+ if (!info) throw new Error(`[labkit] no annotation target called '${target}'`);
1234
+ const result = await captureTarget(
1235
+ {
1236
+ target,
1237
+ scene: sceneFor(target),
1238
+ draw: {
1239
+ content: info.content,
1240
+ positionDependsOn: info.positionDependsOn,
1241
+ config: opts.config?.(),
1242
+ meaning: opts.meaning
1243
+ },
1244
+ base: info.base,
1245
+ onWarn: (message) => console.warn(`[labkit] capture: ${message}`)
1246
+ },
1247
+ captureOpts
1248
+ );
1249
+ opts.onCapture?.(result);
1250
+ return result;
1251
+ },
1252
+ toJSON() {
1253
+ const out = {};
1254
+ for (const [target, scene] of scenes) out[target] = scene.toJSON();
1255
+ return { version: 1, scenes: out };
1256
+ }
1257
+ };
1258
+ }
1259
+ function annotationsFromJSON(raw, targets, rest = {}) {
1260
+ const doc = raw;
1261
+ if (doc?.version !== 1 || !doc.scenes) return createAnnotationStore({ targets, ...rest });
1262
+ return createAnnotationStore({ targets, restore: doc.scenes, ...rest });
1263
+ }
1264
+
1265
+ // src/annotations/view.ts
1266
+ function toWeaselView(v) {
1267
+ return { x: v.pan.x, y: v.pan.y, scale: { x: v.zoom, y: v.zoom } };
1268
+ }
1269
+ function fromWeaselView(v) {
1270
+ return { zoom: v.scale.x, pan: { x: v.x, y: v.y } };
1271
+ }
1272
+ function fitView(content, pane) {
1273
+ const zoom = content.w > 0 && content.h > 0 ? Math.min(pane.w / content.w, pane.h / content.h) : 1;
1274
+ return { zoom: Number.isFinite(zoom) && zoom > 0 ? zoom : 1, pan: { x: 0, y: 0 } };
1275
+ }
1276
+ var sameRect = (a, b2) => a !== null && a.x === b2.x && a.y === b2.y && a.w === b2.w && a.h === b2.h;
1277
+ function ToolBridge({ toolId }) {
1278
+ const tools = useActiveToolContext();
1279
+ const setActive = tools.setActive;
1280
+ useEffect(() => {
1281
+ setActive(toolId);
1282
+ }, [toolId, setActive]);
1283
+ return null;
1284
+ }
1285
+ function pointsOf(extras, content) {
1286
+ const toFrac = (p2) => ({
1287
+ x: content.w === 0 ? 0 : p2.x / content.w,
1288
+ y: content.h === 0 ? 0 : p2.y / content.h
1289
+ });
1290
+ if (extras.kind === "line") {
1291
+ return [toFrac(extras.a), toFrac(extras.b)];
1292
+ }
1293
+ if (extras.kind === "pencil") {
1294
+ return extras.samples.map(toFrac);
1295
+ }
1296
+ return void 0;
1297
+ }
1298
+ function AnnotationOverlay({
1299
+ target,
1300
+ scene,
1301
+ config,
1302
+ activeToolId,
1303
+ meaning
65
1304
  }) {
66
- const dragRef = useRef(null);
67
- const viewRef = useRef(view);
68
- viewRef.current = view;
69
- const homeRef = useRef(home ?? view);
70
- if (home) homeRef.current = home;
71
- const onWheel = useCallback(
72
- (e) => {
73
- e.preventDefault();
74
- onViewChange(orbitAfterWheel(viewRef.current, e.deltaY, minDistance, maxDistance));
1305
+ const surface = useSurfaceOptional();
1306
+ const canvas = useSurfaceCanvas();
1307
+ const [rect, setRect] = useState(null);
1308
+ const [input, setInput] = useState(null);
1309
+ const sceneCanvas = useRef(null);
1310
+ const toolRef = useRef(activeToolId);
1311
+ toolRef.current = activeToolId;
1312
+ const configRef = useRef(config);
1313
+ configRef.current = config;
1314
+ const id = target.id;
1315
+ const lastTile = useRef(null);
1316
+ useEffect(() => {
1317
+ const el = target.ref.current ?? null;
1318
+ if (el === lastTile.current) return;
1319
+ lastTile.current = el;
1320
+ surface?.registerTile(id, el);
1321
+ });
1322
+ useEffect(
1323
+ () => () => {
1324
+ lastTile.current = null;
1325
+ surface?.registerTile(id, null);
75
1326
  },
76
- [onViewChange, minDistance, maxDistance]
1327
+ [surface, id]
77
1328
  );
78
- const onPointerDown = useCallback((e) => {
79
- if (e.button !== 0) return;
80
- e.currentTarget.setPointerCapture(e.pointerId);
81
- dragRef.current = {
82
- pointerId: e.pointerId,
83
- startX: e.clientX,
84
- startY: e.clientY,
85
- startView: viewRef.current,
86
- moved: false
1329
+ useEffect(
1330
+ () => surface?.registerPainter(id, (next) => {
1331
+ setRect((prev) => sameRect(prev, next) ? prev : next);
1332
+ sceneCanvas.current?.requestRedraw();
1333
+ }),
1334
+ [surface, id]
1335
+ );
1336
+ const container = surface?.getContainer() ?? null;
1337
+ if (!container || !rect) return null;
1338
+ const view = toWeaselView(target.view ?? fitView(target.content, { w: rect.w, h: rect.h }));
1339
+ const drawOne = createMarkDrawOne({
1340
+ content: target.content,
1341
+ positionDependsOn: target.positionDependsOn,
1342
+ config,
1343
+ meaning
1344
+ });
1345
+ const factories = {};
1346
+ for (const weaselTool of ANNOTATION_WEASEL_TOOLS) {
1347
+ factories[weaselTool] = (_bounds, extras) => {
1348
+ const info = annotationToolInfo(toolRef.current);
1349
+ const kind = info?.kind;
1350
+ if (!kind) return null;
1351
+ const points = pointsOf(extras, target.content);
1352
+ const data = {
1353
+ target: id,
1354
+ kind,
1355
+ ...points ? { points } : {},
1356
+ seen: seenFrom(configRef.current, target.positionDependsOn ?? [])
1357
+ };
1358
+ return { data };
87
1359
  };
88
- }, []);
89
- const onPointerMove = useCallback(
90
- (e) => {
91
- const drag = dragRef.current;
92
- if (!drag || drag.pointerId !== e.pointerId) return;
93
- const dx = e.clientX - drag.startX;
94
- const dy = e.clientY - drag.startY;
95
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
96
- drag.moved = true;
97
- onViewChange(orbitAfterDrag(drag.startView, dx, dy));
98
- },
99
- [onViewChange]
1360
+ }
1361
+ const box = {
1362
+ ["--lk-anno-x"]: `${rect.x}px`,
1363
+ ["--lk-anno-y"]: `${rect.y}px`,
1364
+ ["--lk-anno-w"]: `${rect.w}px`,
1365
+ ["--lk-anno-h"]: `${rect.h}px`
1366
+ };
1367
+ return createPortal(
1368
+ /* @__PURE__ */ jsxs(Fragment, { children: [
1369
+ /* @__PURE__ */ jsx(
1370
+ "div",
1371
+ {
1372
+ ref: setInput,
1373
+ className: "lk-annotate__input",
1374
+ "data-annotation-target": id,
1375
+ role: "application",
1376
+ "aria-label": `Annotations on ${id}`,
1377
+ tabIndex: 0,
1378
+ style: box
1379
+ }
1380
+ ),
1381
+ canvas && input ? (
1382
+ // One scope per pane: a shared <ActionsProvider> lets only the newest
1383
+ // canvas under it respond to input, and the rest go silently dead.
1384
+ /* @__PURE__ */ jsxs(WeaselProvider, { isolate: true, children: [
1385
+ /* @__PURE__ */ jsx(ToolBridge, { toolId: annotationToolInfo(activeToolId)?.weaselTool ?? "select" }),
1386
+ /* @__PURE__ */ jsx(
1387
+ SceneCanvas,
1388
+ {
1389
+ ref: sceneCanvas,
1390
+ scene,
1391
+ width: rect.w,
1392
+ height: rect.h,
1393
+ view,
1394
+ paintInto: { canvas, x: rect.x, y: rect.y },
1395
+ inputElement: input,
1396
+ defaultTools: ANNOTATION_WEASEL_TOOLS,
1397
+ insertNodeFactories: factories,
1398
+ layers: { scene: { drawOne } }
1399
+ }
1400
+ )
1401
+ ] })
1402
+ ) : null
1403
+ ] }),
1404
+ container
100
1405
  );
101
- const onPointerUp = useCallback((e) => {
102
- const drag = dragRef.current;
103
- if (!drag || drag.pointerId !== e.pointerId) return;
104
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
105
- e.currentTarget.releasePointerCapture(e.pointerId);
106
- }
107
- dragRef.current = null;
108
- }, []);
109
- const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
110
- const isDragging = useCallback(() => dragRef.current?.moved === true, []);
111
- return { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, isDragging };
1406
+ }
1407
+ function AnnotationTargets({
1408
+ capability,
1409
+ state,
1410
+ config,
1411
+ annotations,
1412
+ activeToolId
1413
+ }) {
1414
+ const targets = capability.targets(state, config);
1415
+ return /* @__PURE__ */ jsx(Fragment, { children: targets.map((t) => /* @__PURE__ */ jsx(
1416
+ AnnotationOverlay,
1417
+ {
1418
+ target: t,
1419
+ scene: annotations.sceneFor(t.id),
1420
+ meaning: capability.meaning,
1421
+ config,
1422
+ activeToolId
1423
+ },
1424
+ t.id
1425
+ )) });
112
1426
  }
113
1427
 
114
1428
  // src/config/builder.ts
@@ -1242,6 +2556,69 @@ function TrialTitleBar({ title, children }) {
1242
2556
  children
1243
2557
  ] });
1244
2558
  }
2559
+ function createPanelHostRegistry() {
2560
+ const hosts = /* @__PURE__ */ new Map();
2561
+ const listeners = /* @__PURE__ */ new Set();
2562
+ const notify = () => {
2563
+ for (const fn of listeners) fn();
2564
+ };
2565
+ return {
2566
+ set: (key, el) => {
2567
+ if (el) hosts.set(key, el);
2568
+ else hosts.delete(key);
2569
+ notify();
2570
+ },
2571
+ get: (key) => hosts.get(key) ?? null,
2572
+ subscribe: (fn) => {
2573
+ listeners.add(fn);
2574
+ return () => listeners.delete(fn);
2575
+ }
2576
+ };
2577
+ }
2578
+ var PanelHostContext = createContext(null);
2579
+ function usePanelHosts() {
2580
+ return useContext(PanelHostContext);
2581
+ }
2582
+ function Portalled({
2583
+ trialId,
2584
+ contribution,
2585
+ onDock
2586
+ }) {
2587
+ const hosts = usePanelHosts();
2588
+ const key = panelKey(trialId, contribution.id);
2589
+ const host = useSyncExternalStore(
2590
+ (fn) => hosts?.subscribe(fn) ?? (() => {
2591
+ }),
2592
+ () => hosts?.get(key) ?? null,
2593
+ () => null
2594
+ );
2595
+ if (!host || !contribution.item) return null;
2596
+ const item = contribution.item;
2597
+ return createPortal(
2598
+ /* @__PURE__ */ jsxs(Fragment, { children: [
2599
+ /* @__PURE__ */ jsxs("div", { className: "lk-panel-tile__titlebar", children: [
2600
+ /* @__PURE__ */ jsx("span", { className: "lk-panel-tile__title", children: item.title }),
2601
+ /* @__PURE__ */ jsx(
2602
+ "button",
2603
+ {
2604
+ type: "button",
2605
+ className: "lk-panel-tile__dock",
2606
+ "aria-label": `Dock ${item.title}`,
2607
+ title: `Dock ${item.title}`,
2608
+ onClick: () => onDock(contribution.id),
2609
+ children: "\u21E4"
2610
+ }
2611
+ )
2612
+ ] }),
2613
+ item.body
2614
+ ] }),
2615
+ host
2616
+ );
2617
+ }
2618
+ function UndockedSections({ trialId, sections, onDock }) {
2619
+ if (sections.length === 0) return null;
2620
+ return /* @__PURE__ */ jsx(Fragment, { children: sections.map((c2) => /* @__PURE__ */ jsx(Portalled, { trialId, contribution: c2, onDock }, c2.id)) });
2621
+ }
1245
2622
  var NO_OP = () => {
1246
2623
  };
1247
2624
  function TrialChrome({
@@ -1251,6 +2628,7 @@ function TrialChrome({
1251
2628
  isLastTrial,
1252
2629
  undoBindings,
1253
2630
  job,
2631
+ loupe,
1254
2632
  trialChrome,
1255
2633
  chrome,
1256
2634
  suppress,
@@ -1264,6 +2642,13 @@ function TrialChrome({
1264
2642
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
1265
2643
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
1266
2644
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
2645
+ const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
2646
+ const dockPanelAction = useStore(storeCtx.store, (s) => s.dockPanel);
2647
+ const undockedPanels = useStore(storeCtx.store, (s) => s.undockedPanels);
2648
+ const undockedIds = useMemo(
2649
+ () => Object.values(undockedPanels).filter((p2) => p2.trialId === trialId).map((p2) => p2.sectionId),
2650
+ [undockedPanels, trialId]
2651
+ );
1267
2652
  const view2d = as2DView(record.view);
1268
2653
  const configSchema = useConfigSchema(instrument);
1269
2654
  const ctx = useMemo(() => {
@@ -1281,6 +2666,8 @@ function TrialChrome({
1281
2666
  canRedo: undoBindings?.canRedo ?? false,
1282
2667
  undo: undoBindings?.undo ?? NO_OP,
1283
2668
  redo: undoBindings?.redo ?? NO_OP,
2669
+ loupeOn: loupe?.on ?? false,
2670
+ toggleLoupe: loupe?.toggle ?? NO_OP,
1284
2671
  configSchema,
1285
2672
  configFields: instrument.config ? [] : instrument.configSchema?.() ?? [],
1286
2673
  config: record.config,
@@ -1298,6 +2685,9 @@ function TrialChrome({
1298
2685
  updateTrialState(trialId, nextState);
1299
2686
  }
1300
2687
  },
2688
+ undockedPanels: undockedIds,
2689
+ undockPanel: (sectionId, as) => undockPanelAction(trialId, sectionId, as),
2690
+ dockPanel: (sectionId) => dockPanelAction(trialId, sectionId),
1301
2691
  savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
1302
2692
  saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
1303
2693
  loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
@@ -1317,10 +2707,14 @@ function TrialChrome({
1317
2707
  updateTrialConfig,
1318
2708
  updateTrialState,
1319
2709
  undoBindings,
2710
+ loupe,
1320
2711
  view2d,
1321
2712
  activeToolId,
1322
2713
  setActiveTool,
1323
- configSchema
2714
+ configSchema,
2715
+ undockedIds,
2716
+ undockPanelAction,
2717
+ dockPanelAction
1324
2718
  ]);
1325
2719
  const contributions = useMemo(
1326
2720
  () => suppressContributions(
@@ -1359,7 +2753,17 @@ function TrialChrome({
1359
2753
  /* @__PURE__ */ jsx("div", { className: "lk-trial__toolbar", children: /* @__PURE__ */ jsx(ToolbarRegion, { contributions: inRegion("toolbar"), ctx }) }),
1360
2754
  /* @__PURE__ */ jsxs("div", { className: "lk-trial__body", children: [
1361
2755
  /* @__PURE__ */ jsx(PaletteRegion, { contributions: inRegion("palette"), ctx }),
1362
- /* @__PURE__ */ jsx("div", { className: "lk-trial__sidebar", children: /* @__PURE__ */ jsx(SidebarRegion, { contributions: inRegion("sidebar"), ctx }) }),
2756
+ /* @__PURE__ */ jsxs("div", { className: "lk-trial__sidebar", children: [
2757
+ /* @__PURE__ */ jsx(SidebarRegion, { contributions: inRegion("sidebar"), ctx }),
2758
+ /* @__PURE__ */ jsx(
2759
+ UndockedSections,
2760
+ {
2761
+ trialId,
2762
+ sections: inRegion("sidebar").filter((c2) => undockedIds.includes(c2.id)),
2763
+ onDock: ctx.dockPanel
2764
+ }
2765
+ )
2766
+ ] }),
1363
2767
  /* @__PURE__ */ jsxs("div", { className: `lk-trial__content${instrument.canvas ? " lk-trial__content--flush" : ""}`, children: [
1364
2768
  children,
1365
2769
  /* @__PURE__ */ jsx(ViewportRegion, { contributions: inRegion("viewport"), ctx })
@@ -1405,10 +2809,12 @@ function Trial({ id, chrome, suppress }) {
1405
2809
  }
1406
2810
  function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1407
2811
  const canvasContainerRef = useRef(null);
2812
+ const loupeHostRef = useRef(null);
1408
2813
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
1409
2814
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
1410
2815
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
1411
2816
  const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
2817
+ const updateTrialAnnotations = useStore(store, (s) => s.updateTrialAnnotations);
1412
2818
  const labToolId = useStore(store, (s) => s.activeToolId);
1413
2819
  const setLabTool = useStore(store, (s) => s.setLabTool);
1414
2820
  const setTrialTool = useStore(store, (s) => s.setTrialTool);
@@ -1417,6 +2823,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1417
2823
  const bus = busRef.current;
1418
2824
  const [layerVisibility, setLayerVisibility] = useState({});
1419
2825
  const [layerOrder, setLayerOrder] = useState(null);
2826
+ const [loupeOn, setLoupeOn] = useState(false);
2827
+ const visibleLayers = useMemo(
2828
+ () => (instrument.canvas?.layers ?? []).filter((l) => layerVisibility[l.id] !== false).map((l) => l.id),
2829
+ [instrument.canvas, layerVisibility]
2830
+ );
1420
2831
  const undoCap = instrument.undo;
1421
2832
  const undoEvents = useMemo(() => new Set(undoCap?.snapshotOn ?? ["state.change"]), [undoCap]);
1422
2833
  const maxDepth = undoCap?.maxDepth ?? 50;
@@ -1428,8 +2839,69 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1428
2839
  updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
1429
2840
  };
1430
2841
  const setView = (v) => updateTrialView(record.id, v);
1431
- const declaresTools = instrument.tools != null;
1432
- const resolvedToolId = declaresTools ? record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null : labToolId;
2842
+ const placeView = (size) => {
2843
+ const declared = instrument.canvas?.initialView;
2844
+ if (typeof declared !== "function") return;
2845
+ if (record.view != null) return;
2846
+ setView(declared(size));
2847
+ };
2848
+ const annotationsCap = instrument.annotations;
2849
+ const targetsRef = useRef(() => []);
2850
+ targetsRef.current = () => annotationsCap ? annotationsCap.targets(record.state, record.config) : [];
2851
+ const annotationsRef = useRef(null);
2852
+ const configRef = useRef(record.config);
2853
+ configRef.current = record.config;
2854
+ if (annotationsRef.current === null) {
2855
+ const kept = annotationsCap?.storage ? annotationsCap.storage.load() : record.annotations;
2856
+ annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
2857
+ meaning: annotationsCap?.meaning,
2858
+ config: () => configRef.current,
2859
+ onCapture: (result) => annotationsCap?.onCapture?.(result)
2860
+ });
2861
+ }
2862
+ const annotations = annotationsRef.current;
2863
+ const saveCap = annotationsCap?.storage;
2864
+ const saveRef = useRef(() => {
2865
+ });
2866
+ saveRef.current = (doc) => {
2867
+ if (saveCap) saveCap.save(doc);
2868
+ else updateTrialAnnotations(record.id, doc);
2869
+ };
2870
+ const [markMoves, setMarkMoves] = useState({ undo: false, redo: false });
2871
+ useEffect(() => {
2872
+ if (!annotationsCap) return;
2873
+ const read = () => {
2874
+ const next = { undo: annotations.canUndo(), redo: annotations.canRedo() };
2875
+ setMarkMoves((prev) => prev.undo === next.undo && prev.redo === next.redo ? prev : next);
2876
+ };
2877
+ read();
2878
+ return annotations.subscribe(read);
2879
+ }, [annotations, annotationsCap]);
2880
+ useEffect(() => {
2881
+ if (!annotationsCap) return;
2882
+ let timer = null;
2883
+ let dirty = false;
2884
+ const flush = () => {
2885
+ if (!dirty) return;
2886
+ dirty = false;
2887
+ saveRef.current(annotations.toJSON());
2888
+ };
2889
+ const off = annotations.subscribe(() => {
2890
+ dirty = true;
2891
+ if (timer) clearTimeout(timer);
2892
+ timer = setTimeout(() => {
2893
+ timer = null;
2894
+ flush();
2895
+ }, 250);
2896
+ });
2897
+ return () => {
2898
+ off();
2899
+ if (timer) clearTimeout(timer);
2900
+ flush();
2901
+ };
2902
+ }, [annotations, annotationsCap]);
2903
+ const declaresTools = instrument.tools != null || instrument.annotations != null;
2904
+ const resolvedToolId = declaresTools ? record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? (instrument.annotations ? "select" : null) : labToolId;
1433
2905
  const setActiveTool = (id) => {
1434
2906
  if (declaresTools) setTrialTool(record.id, id);
1435
2907
  else setLabTool(id);
@@ -1468,7 +2940,8 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1468
2940
  if (!view2d) return;
1469
2941
  updateTrialView(record.id, { ...view2d, zoom: z });
1470
2942
  },
1471
- activeToolId: resolvedToolId
2943
+ activeToolId: resolvedToolId,
2944
+ visibleLayers
1472
2945
  },
1473
2946
  emit: (event) => {
1474
2947
  snapshotIfNeeded(event);
@@ -1476,22 +2949,36 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1476
2949
  },
1477
2950
  job: jobCap ? job : void 0
1478
2951
  };
1479
- const undoBindings = undoCap ? {
1480
- canUndo: record.undoStack.past.length > 0,
1481
- canRedo: record.undoStack.future.length > 0,
2952
+ const undoState = () => {
2953
+ const result = undo(record.undoStack, structuredClone(record.state));
2954
+ if (!result) return;
2955
+ updateTrialState(record.id, result.snapshot);
2956
+ updateTrialUndoStack(record.id, result.stack);
2957
+ };
2958
+ const redoState = () => {
2959
+ const result = redo(record.undoStack, structuredClone(record.state));
2960
+ if (!result) return;
2961
+ updateTrialState(record.id, result.snapshot);
2962
+ updateTrialUndoStack(record.id, result.stack);
2963
+ };
2964
+ const undoBindings = undoCap || annotationsCap ? {
2965
+ canUndo: markMoves.undo || (undoCap ? record.undoStack.past.length > 0 : false),
2966
+ canRedo: markMoves.redo || (undoCap ? record.undoStack.future.length > 0 : false),
1482
2967
  undo: () => {
1483
- const result = undo(record.undoStack, structuredClone(record.state));
1484
- if (!result) return;
1485
- updateTrialState(record.id, result.snapshot);
1486
- updateTrialUndoStack(record.id, result.stack);
2968
+ if (annotationsCap && annotations.undo()) return;
2969
+ if (undoCap) undoState();
1487
2970
  },
1488
2971
  redo: () => {
1489
- const result = redo(record.undoStack, structuredClone(record.state));
1490
- if (!result) return;
1491
- updateTrialState(record.id, result.snapshot);
1492
- updateTrialUndoStack(record.id, result.stack);
2972
+ if (annotationsCap && annotations.redo()) return;
2973
+ if (undoCap) redoState();
1493
2974
  }
1494
2975
  } : void 0;
2976
+ const loupeCap = useMemo(() => {
2977
+ const declared = instrument.loupe;
2978
+ if (declared == null) return null;
2979
+ return resolveLoupe(typeof declared === "function" ? declared(record.config) : declared);
2980
+ }, [instrument.loupe, record.config]);
2981
+ const loupeBindings = loupeCap ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) } : void 0;
1495
2982
  const canvasLayers = useMemo(() => {
1496
2983
  if (!instrument.canvas) return [];
1497
2984
  const baseLayers = instrument.canvas.layers;
@@ -1499,9 +2986,8 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1499
2986
  return ordered.map((layer) => ({
1500
2987
  id: layer.id,
1501
2988
  visible: layerVisibility[layer.id] !== false,
1502
- render: (ctx, view) => {
1503
- ctx.translate(view.pan.x, view.pan.y);
1504
- ctx.scale(view.zoom, view.zoom);
2989
+ render: (ctx, view, frame) => {
2990
+ applyCamera(ctx, view, frame);
1505
2991
  layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
1506
2992
  }
1507
2993
  }));
@@ -1514,6 +3000,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1514
3000
  capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
1515
3001
  canvasContainerRef,
1516
3002
  view: view2d ?? DEFAULT_VIEW,
3003
+ worldSpec: instrument.canvas?.worldSpec,
1517
3004
  state: record.state,
1518
3005
  config: record.config,
1519
3006
  setState: (next) => {
@@ -1554,22 +3041,44 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1554
3041
  }
1555
3042
  ];
1556
3043
  }, [canvasLayers, dragDropResult.drag]);
3044
+ const lens = loupeCap ? /* @__PURE__ */ jsx(
3045
+ TrialLoupe,
3046
+ {
3047
+ capability: loupeCap,
3048
+ enabled: loupeOn,
3049
+ state: record.state,
3050
+ config: record.config,
3051
+ view: view2d ?? DEFAULT_VIEW,
3052
+ worldSpec: instrument.canvas?.worldSpec,
3053
+ hostRef: loupeHostRef
3054
+ }
3055
+ ) : null;
1557
3056
  let body;
1558
3057
  if (instrument.canvas) {
1559
3058
  body = /* @__PURE__ */ jsxs("div", { ref: canvasContainerRef, className: "lk-trial__canvas-host", children: [
1560
- /* @__PURE__ */ jsx(
3059
+ /* @__PURE__ */ jsxs(
1561
3060
  CanvasStack,
1562
3061
  {
1563
3062
  layers: layersWithFeedback,
1564
3063
  view: view2d ?? DEFAULT_VIEW,
1565
3064
  onViewChange: setView,
3065
+ worldSpec: instrument.canvas.worldSpec,
3066
+ onResize: placeView,
1566
3067
  minZoom: instrument.canvas.minZoom,
1567
3068
  maxZoom: instrument.canvas.maxZoom,
1568
- children: instrument.render(renderCtx)
3069
+ children: [
3070
+ instrument.render(renderCtx),
3071
+ lens
3072
+ ]
1569
3073
  }
1570
3074
  ),
1571
3075
  /* @__PURE__ */ jsx(DragOverlay, { drag: dragDropResult.drag })
1572
3076
  ] });
3077
+ } else if (lens) {
3078
+ body = /* @__PURE__ */ jsxs("div", { ref: loupeHostRef, className: "lk-trial__loupe-host", children: [
3079
+ instrument.render(renderCtx),
3080
+ lens
3081
+ ] });
1573
3082
  } else {
1574
3083
  body = instrument.render(renderCtx);
1575
3084
  }
@@ -1614,10 +3123,21 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1614
3123
  }
1615
3124
  return out;
1616
3125
  }, [paletteNode, layerListNode]);
1617
- return /* @__PURE__ */ jsx(
3126
+ const annotationOverlays = annotationsCap ? /* @__PURE__ */ jsx(
3127
+ AnnotationTargets,
3128
+ {
3129
+ capability: annotationsCap,
3130
+ state: record.state,
3131
+ config: record.config,
3132
+ annotations,
3133
+ activeToolId: resolvedToolId
3134
+ }
3135
+ ) : null;
3136
+ return /* @__PURE__ */ jsx(TrialIdProvider, { trialId: record.id, children: /* @__PURE__ */ jsx(AnnotationsContext.Provider, { value: annotationsCap ? annotations : null, children: /* @__PURE__ */ jsxs(
1618
3137
  TrialChrome,
1619
3138
  {
1620
3139
  job: jobCap ? job : void 0,
3140
+ loupe: loupeBindings,
1621
3141
  trialId: record.id,
1622
3142
  record,
1623
3143
  instrument,
@@ -1628,9 +3148,12 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }) {
1628
3148
  suppress,
1629
3149
  activeToolId: resolvedToolId,
1630
3150
  setActiveTool,
1631
- children: body
3151
+ children: [
3152
+ body,
3153
+ annotationOverlays
3154
+ ]
1632
3155
  }
1633
- );
3156
+ ) }) });
1634
3157
  }
1635
3158
 
1636
3159
  // src/trial/trialOps.ts
@@ -1643,7 +3166,9 @@ function findInstrument(instruments, name) {
1643
3166
  return found;
1644
3167
  }
1645
3168
  function initialView(instrument) {
1646
- return instrument.canvas?.initialView ? structuredClone(instrument.canvas.initialView) : { ...DEFAULT_VIEW2, pan: { ...DEFAULT_VIEW2.pan } };
3169
+ const declared = instrument.canvas?.initialView;
3170
+ if (typeof declared === "function") return null;
3171
+ return declared ? structuredClone(declared) : { ...DEFAULT_VIEW2, pan: { ...DEFAULT_VIEW2.pan } };
1647
3172
  }
1648
3173
  function addTrial(trials, instruments, instrumentName) {
1649
3174
  const instrument = findInstrument(instruments, instrumentName);
@@ -1716,7 +3241,7 @@ function LabHeader() {
1716
3241
  className: "lk-lab-header__add",
1717
3242
  onClick: () => lab.addTrial(only.name),
1718
3243
  children: [
1719
- /* @__PURE__ */ jsx(Rt, { size: 16 }),
3244
+ /* @__PURE__ */ jsx(C, { size: 16 }),
1720
3245
  /* @__PURE__ */ jsx("span", { children: "Add trial" })
1721
3246
  ]
1722
3247
  }
@@ -1738,7 +3263,7 @@ function LabHeader() {
1738
3263
  )
1739
3264
  ),
1740
3265
  /* @__PURE__ */ jsx(
1741
- r,
3266
+ r2,
1742
3267
  {
1743
3268
  className: "lk-lab-header__mode",
1744
3269
  ariaLabel: "Color mode",
@@ -1800,7 +3325,9 @@ function LabShell({ title, children, header, footer, mode = "auto" }) {
1800
3325
  return outer ? shell : /* @__PURE__ */ jsx(ThemeProvider, { theme: interstellarTheme, mode: resolved, children: shell });
1801
3326
  }
1802
3327
  var ZONE_ID = asNodeId("lk-workspace");
1803
- var STRATEGIES = { grid: gridStrategy };
3328
+ var STRATEGIES = { grid: gridStrategy, floating: floatingStrategy };
3329
+ var FLOAT_ZONE_ID = asNodeId("lk-workspace-floating");
3330
+ var PANEL_KIND = "panel";
1804
3331
  var KIND = "trial";
1805
3332
  function extentOf(store, id) {
1806
3333
  const p2 = store.getNode(id)?.membership?.placement;
@@ -1810,9 +3337,14 @@ function extentOf(store, id) {
1810
3337
  if (p2.span) out.span = p2.span;
1811
3338
  return Object.keys(out).length > 0 ? out : null;
1812
3339
  }
3340
+ function PanelFrame({ panel, floating = false }) {
3341
+ const hosts = usePanelHosts();
3342
+ return /* @__PURE__ */ jsx("div", { className: floating ? "lk-panel-tile lk-panel-tile--floating" : "lk-panel-tile", children: /* @__PURE__ */ jsx("div", { className: "lk-panel-tile__body", ref: (el) => hosts?.set(panel.key, el) }) });
3343
+ }
1813
3344
  function Workspace({
1814
3345
  children,
1815
3346
  ids,
3347
+ panels,
1816
3348
  resizable = false,
1817
3349
  reorderable = false,
1818
3350
  onReorder,
@@ -1823,8 +3355,20 @@ function Workspace({
1823
3355
  viewport
1824
3356
  }) {
1825
3357
  const items = Children.toArray(children);
1826
- const idKey = ids ? ids.join(",") : `#${items.length}`;
1827
- const nodeIds = useMemo(() => items.map((_2, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
3358
+ const tilePanels = useMemo(() => (panels ?? []).filter((p2) => p2.as === "tile"), [panels]);
3359
+ const floatPanels = useMemo(() => (panels ?? []).filter((p2) => p2.as === "floating"), [panels]);
3360
+ const wantedIds = [
3361
+ ...items.map((_2, i) => ids?.[i] ?? `lk-ws-${i}`),
3362
+ ...tilePanels.map((p2) => `lk-panel-${p2.key}`)
3363
+ ];
3364
+ const idKey = wantedIds.join(",");
3365
+ const idKeyRef = useRef(null);
3366
+ const nodeIdsRef = useRef([]);
3367
+ if (idKeyRef.current !== idKey) {
3368
+ idKeyRef.current = idKey;
3369
+ nodeIdsRef.current = wantedIds.map(asNodeId);
3370
+ }
3371
+ const nodeIds = nodeIdsRef.current;
1828
3372
  const layoutRef = useRef(layout);
1829
3373
  layoutRef.current = layout;
1830
3374
  const onLayoutChangeRef = useRef(onLayoutChange);
@@ -1839,6 +3383,13 @@ function Workspace({
1839
3383
  container: { strategyId: "grid", config: { resizable, gap, padding } }
1840
3384
  })
1841
3385
  );
3386
+ store2.registerNode(
3387
+ createNode({
3388
+ kind: "zone",
3389
+ id: FLOAT_ZONE_ID,
3390
+ container: { strategyId: "floating", config: {} }
3391
+ })
3392
+ );
1842
3393
  storeRef.current = store2;
1843
3394
  }
1844
3395
  const store = storeRef.current;
@@ -1854,7 +3405,8 @@ function Workspace({
1854
3405
  }
1855
3406
  for (const id of nodeIds) {
1856
3407
  if (present.has(id)) continue;
1857
- store.registerNode(createNode({ kind: KIND, id, parentId: ZONE_ID, focus: true }));
3408
+ const kind = String(id).startsWith("lk-panel-") ? PANEL_KIND : KIND;
3409
+ store.registerNode(createNode({ kind, id, parentId: ZONE_ID, focus: true }));
1858
3410
  store.showNode(id);
1859
3411
  const saved = layoutRef.current?.[id];
1860
3412
  if (saved) store.patchPlacement(id, saved);
@@ -1883,7 +3435,15 @@ function Workspace({
1883
3435
  );
1884
3436
  const chrome = useMemo(() => {
1885
3437
  const byId = new Map(nodeIds.map((id, i) => [id, items[i]]));
3438
+ const panelByNode = new Map(
3439
+ tilePanels.map((p2) => [`lk-panel-${p2.key}`, p2])
3440
+ );
1886
3441
  return {
3442
+ [PANEL_KIND]: ({ node }) => {
3443
+ const panel = panelByNode.get(String(node.id));
3444
+ if (!panel) return null;
3445
+ return /* @__PURE__ */ jsx(PanelFrame, { panel });
3446
+ },
1887
3447
  [KIND]: ({ node }) => {
1888
3448
  const content = byId.get(node.id) ?? null;
1889
3449
  if (!reorderable) return content;
@@ -1893,7 +3453,8 @@ function Workspace({
1893
3453
  );
1894
3454
  }
1895
3455
  };
1896
- }, [nodeIds, items, reorderable]);
3456
+ }, [nodeIds, items, reorderable, tilePanels]);
3457
+ const floatingLayer = floatPanels.length === 0 ? null : /* @__PURE__ */ jsx("div", { className: "lk-workspace__floating", children: floatPanels.map((p2) => /* @__PURE__ */ jsx(PanelFrame, { panel: p2, floating: true }, p2.key)) });
1897
3458
  const grid = /* @__PURE__ */ jsx(
1898
3459
  Container,
1899
3460
  {
@@ -1905,7 +3466,15 @@ function Workspace({
1905
3466
  onChildOrderChange: reorderable ? commitOrder : void 0
1906
3467
  }
1907
3468
  );
1908
- return /* @__PURE__ */ jsx(Provider, { store, children: /* @__PURE__ */ jsx(StrategyRegistryProvider, { strategies: STRATEGIES, children: reorderable ? /* @__PURE__ */ jsx(DragProvider, { children: grid }) : grid }) });
3469
+ return /* @__PURE__ */ jsx(Provider, { store, children: /* @__PURE__ */ jsx(StrategyRegistryProvider, { strategies: STRATEGIES, children: floatingLayer ? (
3470
+ // The floating layer insets against the workspace, so it needs a
3471
+ // positioned box around both. Without one it insets against the page
3472
+ // and lands over the lab header.
3473
+ /* @__PURE__ */ jsxs("div", { className: "lk-workspace-host", children: [
3474
+ reorderable ? /* @__PURE__ */ jsx(DragProvider, { children: grid }) : grid,
3475
+ floatingLayer
3476
+ ] })
3477
+ ) : reorderable ? /* @__PURE__ */ jsx(DragProvider, { children: grid }) : grid }) });
1909
3478
  }
1910
3479
  function buildStore(instruments, defaultInstrument, storage, storageKey, initialMode) {
1911
3480
  const store = createLabStore({ storageKey, storage, initialMode });
@@ -1968,6 +3537,40 @@ function Lab({
1968
3537
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
1969
3538
  const modeValue = useStore(store, (s) => s.mode);
1970
3539
  const layout = useStore(store, (s) => s.layout);
3540
+ const undockedPanels = useStore(store, (s) => s.undockedPanels);
3541
+ const panelHostsRef = useRef(null);
3542
+ if (panelHostsRef.current === null) panelHostsRef.current = createPanelHostRegistry();
3543
+ const outerSurface = useSurfaceOptional();
3544
+ const outerCanvas = useSurfaceCanvas();
3545
+ const [ownCanvas, setOwnCanvas] = useState(null);
3546
+ const ownCanvasRef = useRef(null);
3547
+ const bufferRef = useRef({ w: 0, h: 0 });
3548
+ const surfaceRef = useRef(null);
3549
+ const onFrame = useCallback((frame) => {
3550
+ const c2 = ownCanvasRef.current;
3551
+ if (!c2) return;
3552
+ const w2 = Math.round(frame.size.width * frame.dpr);
3553
+ const h = Math.round(frame.size.height * frame.dpr);
3554
+ if (bufferRef.current.w === w2 && bufferRef.current.h === h) return;
3555
+ bufferRef.current = { w: w2, h };
3556
+ c2.width = w2;
3557
+ c2.height = h;
3558
+ c2.style.width = `${frame.size.width}px`;
3559
+ c2.style.height = `${frame.size.height}px`;
3560
+ surfaceRef.current?.invalidateAll();
3561
+ }, []);
3562
+ const ownSurface = useTiledSurface({ onFrame });
3563
+ surfaceRef.current = ownSurface;
3564
+ const surface = outerSurface ?? ownSurface;
3565
+ const surfaceCanvas = outerSurface ? outerCanvas : ownCanvas;
3566
+ const workspacePanels = useMemo(
3567
+ () => Object.entries(undockedPanels).map(([key, panel]) => ({
3568
+ key,
3569
+ title: panel.sectionId,
3570
+ as: panel.as
3571
+ })),
3572
+ [undockedPanels]
3573
+ );
1971
3574
  const resolvedMode = useResolvedMode(modeValue);
1972
3575
  useEffect(() => {
1973
3576
  if (mode && mode !== store.getState().mode) {
@@ -2047,27 +3650,50 @@ function Lab({
2047
3650
  /* @__PURE__ */ jsx(LabHeader, {}),
2048
3651
  children
2049
3652
  ] }),
2050
- children: /* @__PURE__ */ jsxs("div", { className: "lk-lab__body", children: [
2051
- tools ? /* @__PURE__ */ jsx(LabPalette, { tools }) : null,
2052
- /* @__PURE__ */ jsx(
2053
- Workspace,
2054
- {
2055
- ids: trials.map((w2) => w2.id),
2056
- resizable: true,
2057
- reorderable: true,
2058
- onReorder: (ids) => contextValue.reorderTrials(ids),
2059
- layout,
2060
- onLayoutChange: (next) => store.getState().setLayout(next),
2061
- children: trials.map((w2) => /* @__PURE__ */ jsx(Trial, { id: w2.id, chrome, suppress }, w2.id))
2062
- }
2063
- )
2064
- ] })
3653
+ children: /* @__PURE__ */ jsx(PanelHostContext.Provider, { value: panelHostsRef.current, children: /* @__PURE__ */ jsx(SurfaceContext.Provider, { value: surface, children: /* @__PURE__ */ jsx(SurfaceCanvasContext.Provider, { value: surfaceCanvas, children: /* @__PURE__ */ jsxs(
3654
+ "div",
3655
+ {
3656
+ className: "lk-lab__body",
3657
+ ref: outerSurface ? void 0 : ownSurface.containerRef,
3658
+ children: [
3659
+ outerSurface ? null : (
3660
+ // Above the trials and inert: the marks a tile paints have
3661
+ // to sit over the instrument's own DOM, and nothing on this
3662
+ // buffer takes input — each tile has its own input box.
3663
+ /* @__PURE__ */ jsx(
3664
+ "canvas",
3665
+ {
3666
+ className: "lk-lab__surface",
3667
+ ref: (el) => {
3668
+ ownCanvasRef.current = el;
3669
+ setOwnCanvas(el);
3670
+ }
3671
+ }
3672
+ )
3673
+ ),
3674
+ tools ? /* @__PURE__ */ jsx(LabPalette, { tools }) : null,
3675
+ /* @__PURE__ */ jsx(
3676
+ Workspace,
3677
+ {
3678
+ panels: workspacePanels,
3679
+ ids: trials.map((w2) => w2.id),
3680
+ resizable: true,
3681
+ reorderable: true,
3682
+ onReorder: (ids) => contextValue.reorderTrials(ids),
3683
+ layout,
3684
+ onLayoutChange: (next) => store.getState().setLayout(next),
3685
+ children: trials.map((w2) => /* @__PURE__ */ jsx(Trial, { id: w2.id, chrome, suppress }, w2.id))
3686
+ }
3687
+ )
3688
+ ]
3689
+ }
3690
+ ) }) }) })
2065
3691
  }
2066
3692
  )
2067
3693
  }
2068
3694
  ) }) });
2069
3695
  }
2070
3696
 
2071
- export { DEFAULT_VIEW, Lab, LabContext, LabPalette, LabShell, PITCH_LIMIT, Trial, TrialChrome, Workspace, addTrial, applyRules, as2DView, builtinRules, clampPitch, cloneTrial, closeTrial, defineInstrument, f, interstellarTheme, orbitAfterDrag, orbitAfterWheel, reorderTrials, resetTrial, resolveConfigSchema, titleCase, useConfigSchema, useLabContext, useOrbit, validateConfigSchema, wrapYaw };
3697
+ export { AnnotationOverlay, AnnotationTargets, DEFAULT_VIEW, Lab, LabContext, LabPalette, LabShell, Trial, TrialChrome, Workspace, addTrial, annotationsFromJSON, applyRules, as2DView, builtinRules, capturePlan, captureTarget, cloneTrial, closeTrial, composeCaptureSvg, createAnnotationScene, createAnnotationStore, createMarkDrawOne, defineInstrument, f, fitView, fracContains, fracIntersects, fracToWorld, fromWeaselView, interstellarTheme, isStale, markCommands, markSvgNodes, reorderTrials, resetTrial, resolveConfigSchema, resolveMarkStyle, roundFrac, seenFrom, titleCase, toWeaselView, useConfigSchema, useLabContext, validateConfigSchema, worldToFrac };
2072
3698
  //# sourceMappingURL=index.js.map
2073
3699
  //# sourceMappingURL=index.js.map