@dxos/react-ui-canvas 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +9044 -1135
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/types/src/components/About/About.d.ts +13 -0
  5. package/dist/types/src/components/About/About.d.ts.map +1 -0
  6. package/dist/types/src/components/About/About.stories.d.ts +7 -0
  7. package/dist/types/src/components/About/About.stories.d.ts.map +1 -0
  8. package/dist/types/src/components/About/index.d.ts +2 -0
  9. package/dist/types/src/components/About/index.d.ts.map +1 -0
  10. package/dist/types/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -1
  11. package/dist/types/src/components/ControlFrame/ControlFrame.d.ts +8 -2
  12. package/dist/types/src/components/ControlFrame/ControlFrame.d.ts.map +1 -1
  13. package/dist/types/src/components/Layers/Layers.d.ts +30 -0
  14. package/dist/types/src/components/Layers/Layers.d.ts.map +1 -0
  15. package/dist/types/src/components/Layers/Layers.stories.d.ts +11 -0
  16. package/dist/types/src/components/Layers/Layers.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Layers/index.d.ts +2 -0
  18. package/dist/types/src/components/Layers/index.d.ts.map +1 -0
  19. package/dist/types/src/components/Palette/Palette.d.ts +20 -4
  20. package/dist/types/src/components/Palette/Palette.d.ts.map +1 -1
  21. package/dist/types/src/components/Palette/Palette.stories.d.ts +22 -0
  22. package/dist/types/src/components/Palette/Palette.stories.d.ts.map +1 -0
  23. package/dist/types/src/components/PartEditor/PartEditor.d.ts.map +1 -1
  24. package/dist/types/src/components/Properties/AlignField.d.ts +23 -0
  25. package/dist/types/src/components/Properties/AlignField.d.ts.map +1 -0
  26. package/dist/types/src/components/Properties/ClassField.d.ts +23 -0
  27. package/dist/types/src/components/Properties/ClassField.d.ts.map +1 -0
  28. package/dist/types/src/components/Properties/FontField.d.ts +7 -0
  29. package/dist/types/src/components/Properties/FontField.d.ts.map +1 -0
  30. package/dist/types/src/components/Properties/LayerField.d.ts +17 -0
  31. package/dist/types/src/components/Properties/LayerField.d.ts.map +1 -0
  32. package/dist/types/src/components/Properties/Properties.d.ts +27 -3
  33. package/dist/types/src/components/Properties/Properties.d.ts.map +1 -1
  34. package/dist/types/src/components/Properties/Properties.stories.d.ts +20 -0
  35. package/dist/types/src/components/Properties/Properties.stories.d.ts.map +1 -0
  36. package/dist/types/src/components/Properties/SceneField.d.ts +8 -0
  37. package/dist/types/src/components/Properties/SceneField.d.ts.map +1 -0
  38. package/dist/types/src/components/Properties/StyleGrid.d.ts +15 -5
  39. package/dist/types/src/components/Properties/StyleGrid.d.ts.map +1 -1
  40. package/dist/types/src/components/Properties/StyleGrid.stories.d.ts +7 -0
  41. package/dist/types/src/components/Properties/StyleGrid.stories.d.ts.map +1 -0
  42. package/dist/types/src/components/SceneLayer/SceneLayer.d.ts +31 -7
  43. package/dist/types/src/components/SceneLayer/SceneLayer.d.ts.map +1 -1
  44. package/dist/types/src/components/SceneView/Dock.d.ts +22 -0
  45. package/dist/types/src/components/SceneView/Dock.d.ts.map +1 -0
  46. package/dist/types/src/components/SceneView/Dock.stories.d.ts +15 -0
  47. package/dist/types/src/components/SceneView/Dock.stories.d.ts.map +1 -0
  48. package/dist/types/src/components/SceneView/SceneView.d.ts +33 -7
  49. package/dist/types/src/components/SceneView/SceneView.d.ts.map +1 -1
  50. package/dist/types/src/components/SceneView/SceneView.stories.d.ts +16 -6
  51. package/dist/types/src/components/SceneView/SceneView.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/SceneView/SceneViewContext.d.ts +36 -4
  53. package/dist/types/src/components/SceneView/SceneViewContext.d.ts.map +1 -1
  54. package/dist/types/src/components/SceneView/Scored.stories.d.ts +4 -4
  55. package/dist/types/src/components/SceneView/Scored.stories.d.ts.map +1 -1
  56. package/dist/types/src/components/SceneView/usePointerMachine.d.ts +3 -1
  57. package/dist/types/src/components/SceneView/usePointerMachine.d.ts.map +1 -1
  58. package/dist/types/src/components/SceneView/useSceneCamera.d.ts +2 -2
  59. package/dist/types/src/components/SceneView/useSceneCamera.d.ts.map +1 -1
  60. package/dist/types/src/components/SceneView/useSceneClipboard.d.ts.map +1 -1
  61. package/dist/types/src/components/SceneView/useSceneKeys.d.ts.map +1 -1
  62. package/dist/types/src/components/SceneView/useSceneNavigation.d.ts +7 -4
  63. package/dist/types/src/components/SceneView/useSceneNavigation.d.ts.map +1 -1
  64. package/dist/types/src/components/Toolbar/Toolbar.d.ts +2 -0
  65. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  66. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +2 -0
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/hooks/useWheel.d.ts +5 -0
  70. package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
  71. package/dist/types/src/index.d.ts +4 -1
  72. package/dist/types/src/index.d.ts.map +1 -1
  73. package/dist/types/src/model/atoms.d.ts +21 -3
  74. package/dist/types/src/model/atoms.d.ts.map +1 -1
  75. package/dist/types/src/model/projection.d.ts.map +1 -1
  76. package/dist/types/src/model/projections/constrained.d.ts +2 -1
  77. package/dist/types/src/model/projections/constrained.d.ts.map +1 -1
  78. package/dist/types/src/model/projections/dynamic.d.ts.map +1 -1
  79. package/dist/types/src/model/projections/lattice.d.ts.map +1 -1
  80. package/dist/types/src/model/registry.d.ts +21 -3
  81. package/dist/types/src/model/registry.d.ts.map +1 -1
  82. package/dist/types/src/model/store.d.ts +10 -2
  83. package/dist/types/src/model/store.d.ts.map +1 -1
  84. package/dist/types/src/model/types.d.ts +699 -338
  85. package/dist/types/src/model/types.d.ts.map +1 -1
  86. package/dist/types/src/playwright/SceneManager.d.ts +1 -1
  87. package/dist/types/src/playwright/SceneManager.d.ts.map +1 -1
  88. package/dist/types/src/utils/builder.d.ts +22 -41
  89. package/dist/types/src/utils/builder.d.ts.map +1 -1
  90. package/dist/types/src/utils/camera.d.ts +19 -10
  91. package/dist/types/src/utils/camera.d.ts.map +1 -1
  92. package/dist/types/src/utils/clipboard.d.ts +4 -2
  93. package/dist/types/src/utils/clipboard.d.ts.map +1 -1
  94. package/dist/types/src/utils/diagram.d.ts.map +1 -1
  95. package/dist/types/src/utils/group.d.ts +14 -0
  96. package/dist/types/src/utils/group.d.ts.map +1 -0
  97. package/dist/types/src/utils/group.test.d.ts +2 -0
  98. package/dist/types/src/utils/group.test.d.ts.map +1 -0
  99. package/dist/types/src/utils/gutter-route.d.ts +7 -0
  100. package/dist/types/src/utils/gutter-route.d.ts.map +1 -1
  101. package/dist/types/src/utils/hit.d.ts +3 -20
  102. package/dist/types/src/utils/hit.d.ts.map +1 -1
  103. package/dist/types/src/utils/index.d.ts +3 -0
  104. package/dist/types/src/utils/index.d.ts.map +1 -1
  105. package/dist/types/src/utils/lattice.d.ts +5 -0
  106. package/dist/types/src/utils/lattice.d.ts.map +1 -1
  107. package/dist/types/src/utils/layers.d.ts +35 -0
  108. package/dist/types/src/utils/layers.d.ts.map +1 -0
  109. package/dist/types/src/utils/layers.test.d.ts +2 -0
  110. package/dist/types/src/utils/layers.test.d.ts.map +1 -0
  111. package/dist/types/src/utils/parts.d.ts +25 -9
  112. package/dist/types/src/utils/parts.d.ts.map +1 -1
  113. package/dist/types/src/utils/ports.d.ts +2 -0
  114. package/dist/types/src/utils/ports.d.ts.map +1 -1
  115. package/dist/types/src/utils/properties.d.ts +4 -3
  116. package/dist/types/src/utils/properties.d.ts.map +1 -1
  117. package/dist/types/src/utils/route.d.ts +5 -0
  118. package/dist/types/src/utils/route.d.ts.map +1 -1
  119. package/dist/types/src/utils/scenes.d.ts +14 -0
  120. package/dist/types/src/utils/scenes.d.ts.map +1 -0
  121. package/dist/types/src/utils/scenes.test.d.ts +2 -0
  122. package/dist/types/src/utils/scenes.test.d.ts.map +1 -0
  123. package/dist/types/src/utils/shapes.d.ts +23 -9
  124. package/dist/types/src/utils/shapes.d.ts.map +1 -1
  125. package/dist/types/src/utils/style.d.ts +29 -6
  126. package/dist/types/src/utils/style.d.ts.map +1 -1
  127. package/dist/types/src/utils/style.test.d.ts +2 -0
  128. package/dist/types/src/utils/style.test.d.ts.map +1 -0
  129. package/dist/types/src/utils/testing.d.ts +2 -2
  130. package/dist/types/src/utils/testing.d.ts.map +1 -1
  131. package/dist/types/src/utils/undo.d.ts +15 -4
  132. package/dist/types/src/utils/undo.d.ts.map +1 -1
  133. package/dist/types/tsconfig.tsbuildinfo +1 -1
  134. package/package.json +20 -26
  135. package/src/components/About/About.stories.tsx +32 -0
  136. package/src/components/About/About.tsx +38 -0
  137. package/src/components/About/index.ts +5 -0
  138. package/src/components/Breadcrumbs/Breadcrumbs.tsx +17 -3
  139. package/src/components/ControlFrame/ControlFrame.tsx +138 -82
  140. package/src/components/Grid/Grid.stories.tsx +1 -1
  141. package/src/components/Layers/Layers.stories.tsx +131 -0
  142. package/src/components/Layers/Layers.tsx +216 -0
  143. package/src/components/Layers/index.ts +5 -0
  144. package/src/components/Palette/Palette.stories.tsx +135 -0
  145. package/src/components/Palette/Palette.tsx +230 -39
  146. package/src/components/PartEditor/PartEditor.tsx +4 -3
  147. package/src/components/Properties/AlignField.tsx +117 -0
  148. package/src/components/Properties/ClassField.tsx +44 -0
  149. package/src/components/Properties/FontField.tsx +73 -0
  150. package/src/components/Properties/LayerField.tsx +61 -0
  151. package/src/components/Properties/Properties.stories.tsx +113 -0
  152. package/src/components/Properties/Properties.tsx +240 -60
  153. package/src/components/Properties/SceneField.tsx +22 -0
  154. package/src/components/Properties/StyleGrid.stories.tsx +50 -0
  155. package/src/components/Properties/StyleGrid.tsx +54 -26
  156. package/src/components/SceneLayer/SceneLayer.tsx +497 -188
  157. package/src/components/SceneView/Constrained.stories.tsx +1 -1
  158. package/src/components/SceneView/Dock.stories.tsx +95 -0
  159. package/src/components/SceneView/Dock.tsx +146 -0
  160. package/src/components/SceneView/Dynamic.stories.tsx +1 -1
  161. package/src/components/SceneView/SceneView.stories.tsx +170 -18
  162. package/src/components/SceneView/SceneView.tsx +565 -151
  163. package/src/components/SceneView/SceneViewContext.ts +23 -2
  164. package/src/components/SceneView/Scored.stories.tsx +11 -11
  165. package/src/components/SceneView/usePointerMachine.ts +80 -20
  166. package/src/components/SceneView/useSceneCamera.ts +15 -8
  167. package/src/components/SceneView/useSceneClipboard.ts +4 -0
  168. package/src/components/SceneView/useSceneKeys.ts +6 -4
  169. package/src/components/SceneView/useSceneNavigation.ts +44 -33
  170. package/src/components/Toolbar/Toolbar.stories.tsx +14 -15
  171. package/src/components/Toolbar/Toolbar.tsx +15 -4
  172. package/src/components/index.ts +2 -0
  173. package/src/hooks/useWheel.ts +35 -0
  174. package/src/index.ts +4 -3
  175. package/src/model/atoms.ts +27 -3
  176. package/src/model/projection.ts +5 -0
  177. package/src/model/projections/constrained.test.ts +18 -10
  178. package/src/model/projections/constrained.ts +19 -8
  179. package/src/model/projections/dynamic.ts +5 -2
  180. package/src/model/projections/lattice.ts +15 -3
  181. package/src/model/registry.test.ts +14 -9
  182. package/src/model/registry.ts +39 -34
  183. package/src/model/store.ts +12 -2
  184. package/src/model/types.ts +124 -36
  185. package/src/playwright/SceneManager.ts +2 -2
  186. package/src/playwright/create.spec.ts +5 -3
  187. package/src/playwright/demo.spec.ts +2 -2
  188. package/src/playwright/lattice.spec.ts +4 -5
  189. package/src/playwright/scene.spec.ts +27 -25
  190. package/src/utils/builder.test.ts +46 -9
  191. package/src/utils/builder.ts +42 -18
  192. package/src/utils/camera.test.ts +22 -51
  193. package/src/utils/camera.ts +39 -32
  194. package/src/utils/clipboard.test.ts +23 -2
  195. package/src/utils/clipboard.ts +8 -4
  196. package/src/utils/diagram.test.ts +3 -1
  197. package/src/utils/diagram.ts +3 -25
  198. package/src/utils/group.test.ts +69 -0
  199. package/src/utils/group.ts +94 -0
  200. package/src/utils/gutter-route.test.ts +49 -0
  201. package/src/utils/gutter-route.ts +142 -16
  202. package/src/utils/hit.test.ts +4 -38
  203. package/src/utils/hit.ts +4 -26
  204. package/src/utils/index.ts +3 -0
  205. package/src/utils/lattice.ts +7 -1
  206. package/src/utils/layers.test.ts +139 -0
  207. package/src/utils/layers.ts +175 -0
  208. package/src/utils/parts.test.ts +66 -32
  209. package/src/utils/parts.ts +63 -41
  210. package/src/utils/ports.ts +7 -4
  211. package/src/utils/properties.test.ts +23 -6
  212. package/src/utils/properties.ts +66 -16
  213. package/src/utils/route.test.ts +51 -2
  214. package/src/utils/route.ts +133 -23
  215. package/src/utils/scenes.test.ts +38 -0
  216. package/src/utils/scenes.ts +54 -0
  217. package/src/utils/shapes.test.ts +33 -8
  218. package/src/utils/shapes.ts +51 -35
  219. package/src/utils/style.test.ts +54 -0
  220. package/src/utils/style.ts +153 -40
  221. package/src/utils/testing.test.ts +3 -3
  222. package/src/utils/testing.ts +21 -30
  223. package/src/utils/undo.test.ts +25 -2
  224. package/src/utils/undo.ts +50 -13
  225. package/dist/lib/chunk-Grid.mjs +0 -99
  226. package/dist/lib/chunk-Grid.mjs.map +0 -1
  227. package/dist/lib/scene.mjs +0 -6835
  228. package/dist/lib/scene.mjs.map +0 -1
  229. package/dist/lib/types.mjs +0 -16
  230. package/dist/lib/types.mjs.map +0 -1
  231. package/dist/types/src/archive/components/Canvas/Canvas.d.ts +0 -15
  232. package/dist/types/src/archive/components/Canvas/Canvas.d.ts.map +0 -1
  233. package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts +0 -18
  234. package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts.map +0 -1
  235. package/dist/types/src/archive/components/Canvas/index.d.ts +0 -2
  236. package/dist/types/src/archive/components/Canvas/index.d.ts.map +0 -1
  237. package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts +0 -21
  238. package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts.map +0 -1
  239. package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts +0 -21
  240. package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts.map +0 -1
  241. package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts +0 -15
  242. package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts.map +0 -1
  243. package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts +0 -19
  244. package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts.map +0 -1
  245. package/dist/types/src/archive/components/CellGrid/headers/index.d.ts +0 -3
  246. package/dist/types/src/archive/components/CellGrid/headers/index.d.ts.map +0 -1
  247. package/dist/types/src/archive/components/CellGrid/index.d.ts +0 -6
  248. package/dist/types/src/archive/components/CellGrid/index.d.ts.map +0 -1
  249. package/dist/types/src/archive/components/CellGrid/input/index.d.ts +0 -3
  250. package/dist/types/src/archive/components/CellGrid/input/index.d.ts.map +0 -1
  251. package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts +0 -33
  252. package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts.map +0 -1
  253. package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts +0 -14
  254. package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts.map +0 -1
  255. package/dist/types/src/archive/components/CellGrid/render/index.d.ts +0 -3
  256. package/dist/types/src/archive/components/CellGrid/render/index.d.ts.map +0 -1
  257. package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts +0 -21
  258. package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts.map +0 -1
  259. package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts +0 -36
  260. package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts.map +0 -1
  261. package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts +0 -23
  262. package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts.map +0 -1
  263. package/dist/types/src/archive/components/CellGrid/state/index.d.ts +0 -4
  264. package/dist/types/src/archive/components/CellGrid/state/index.d.ts.map +0 -1
  265. package/dist/types/src/archive/components/CellGrid/state/types.d.ts +0 -39
  266. package/dist/types/src/archive/components/CellGrid/state/types.d.ts.map +0 -1
  267. package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts +0 -52
  268. package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts.map +0 -1
  269. package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts +0 -2
  270. package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts.map +0 -1
  271. package/dist/types/src/archive/components/FPS.d.ts +0 -9
  272. package/dist/types/src/archive/components/FPS.d.ts.map +0 -1
  273. package/dist/types/src/archive/components/Grid/Grid.d.ts +0 -6
  274. package/dist/types/src/archive/components/Grid/Grid.d.ts.map +0 -1
  275. package/dist/types/src/archive/components/Grid/index.d.ts +0 -2
  276. package/dist/types/src/archive/components/Grid/index.d.ts.map +0 -1
  277. package/dist/types/src/archive/components/index.d.ts +0 -5
  278. package/dist/types/src/archive/components/index.d.ts.map +0 -1
  279. package/dist/types/src/archive/hooks/index.d.ts +0 -5
  280. package/dist/types/src/archive/hooks/index.d.ts.map +0 -1
  281. package/dist/types/src/archive/hooks/projection.d.ts +0 -60
  282. package/dist/types/src/archive/hooks/projection.d.ts.map +0 -1
  283. package/dist/types/src/archive/hooks/useCanvasContext.d.ts +0 -13
  284. package/dist/types/src/archive/hooks/useCanvasContext.d.ts.map +0 -1
  285. package/dist/types/src/archive/hooks/useDrag.d.ts +0 -6
  286. package/dist/types/src/archive/hooks/useDrag.d.ts.map +0 -1
  287. package/dist/types/src/archive/hooks/useWheel.d.ts +0 -8
  288. package/dist/types/src/archive/hooks/useWheel.d.ts.map +0 -1
  289. package/dist/types/src/archive/index.d.ts +0 -5
  290. package/dist/types/src/archive/index.d.ts.map +0 -1
  291. package/dist/types/src/archive/types.d.ts +0 -19
  292. package/dist/types/src/archive/types.d.ts.map +0 -1
  293. package/dist/types/src/archive/util/index.d.ts +0 -4
  294. package/dist/types/src/archive/util/index.d.ts.map +0 -1
  295. package/dist/types/src/archive/util/svg-path.d.ts +0 -4
  296. package/dist/types/src/archive/util/svg-path.d.ts.map +0 -1
  297. package/dist/types/src/archive/util/svg.d.ts +0 -32
  298. package/dist/types/src/archive/util/svg.d.ts.map +0 -1
  299. package/dist/types/src/archive/util/svg.stories.d.ts +0 -15
  300. package/dist/types/src/archive/util/svg.stories.d.ts.map +0 -1
  301. package/dist/types/src/archive/util/util.d.ts +0 -17
  302. package/dist/types/src/archive/util/util.d.ts.map +0 -1
  303. package/dist/types/src/scene.d.ts +0 -5
  304. package/dist/types/src/scene.d.ts.map +0 -1
  305. package/src/archive/components/Canvas/Canvas.stories.tsx +0 -111
  306. package/src/archive/components/Canvas/Canvas.tsx +0 -87
  307. package/src/archive/components/Canvas/index.ts +0 -5
  308. package/src/archive/components/CellGrid/CellGrid.stories.tsx +0 -239
  309. package/src/archive/components/CellGrid/CellGrid.tsx +0 -270
  310. package/src/archive/components/CellGrid/headers/Ruler.tsx +0 -71
  311. package/src/archive/components/CellGrid/headers/TrackHeader.tsx +0 -58
  312. package/src/archive/components/CellGrid/headers/index.ts +0 -6
  313. package/src/archive/components/CellGrid/index.ts +0 -9
  314. package/src/archive/components/CellGrid/input/index.ts +0 -6
  315. package/src/archive/components/CellGrid/input/pointer.ts +0 -236
  316. package/src/archive/components/CellGrid/input/wheel.ts +0 -68
  317. package/src/archive/components/CellGrid/render/index.ts +0 -6
  318. package/src/archive/components/CellGrid/render/overlay-layer.ts +0 -66
  319. package/src/archive/components/CellGrid/render/static-layer.ts +0 -112
  320. package/src/archive/components/CellGrid/state/atoms.ts +0 -43
  321. package/src/archive/components/CellGrid/state/index.ts +0 -7
  322. package/src/archive/components/CellGrid/state/types.ts +0 -40
  323. package/src/archive/components/CellGrid/state/viewport.test.ts +0 -50
  324. package/src/archive/components/CellGrid/state/viewport.ts +0 -94
  325. package/src/archive/components/FPS.tsx +0 -98
  326. package/src/archive/components/Grid/Grid.tsx +0 -17
  327. package/src/archive/components/Grid/index.ts +0 -5
  328. package/src/archive/components/index.ts +0 -8
  329. package/src/archive/hooks/index.ts +0 -8
  330. package/src/archive/hooks/projection.tsx +0 -156
  331. package/src/archive/hooks/useCanvasContext.ts +0 -32
  332. package/src/archive/hooks/useDrag.tsx +0 -96
  333. package/src/archive/hooks/useWheel.tsx +0 -80
  334. package/src/archive/index.ts +0 -8
  335. package/src/archive/types.ts +0 -14
  336. package/src/archive/util/index.ts +0 -7
  337. package/src/archive/util/svg-path.ts +0 -13
  338. package/src/archive/util/svg.stories.tsx +0 -46
  339. package/src/archive/util/svg.tsx +0 -128
  340. package/src/archive/util/util.ts +0 -50
  341. package/src/scene.ts +0 -9
@@ -0,0 +1,216 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useMemo, useState } from 'react';
6
+
7
+ import { OrderedList } from '@dxos/react-ui-list';
8
+ import * as Button from '@dxos/react-ui/Button';
9
+ import * as Editable from '@dxos/react-ui/Editable';
10
+ import * as Menu from '@dxos/react-ui/Menu';
11
+ import * as Panel from '@dxos/react-ui/Panel';
12
+ import * as Toolbar from '@dxos/react-ui/Toolbar';
13
+ import type * as Util from '@dxos/react-ui/Util';
14
+
15
+ import { type Layer, type LayerId } from '../../model/types.ts';
16
+
17
+ export type LayersPanelProps = Util.ThemedClassName<{
18
+ /** The scene's layers, bottom first (`sceneLayers`); the panel lists them top first, as they stack. */
19
+ layers: readonly Layer[];
20
+ /** The selected layers; the top-most of them is the one new shapes go on. */
21
+ selected?: readonly LayerId[];
22
+ readonly?: boolean;
23
+ onSelectedChange?: (ids: LayerId[]) => void;
24
+ /** Shows or hides a layer. */
25
+ onToggle?: (id: LayerId) => void;
26
+ onRename?: (id: LayerId, name: string) => void;
27
+ /** Moves a layer to `index` in the bottom-first order. */
28
+ onMove?: (id: LayerId, index: number) => void;
29
+ /** Adds a layer; the panel opens the name of the one whose id it returns. */
30
+ onCreate?: () => LayerId | void;
31
+ /** Removes the selected layers and their shapes; offered while at least one layer would remain. */
32
+ onDelete?: (ids: LayerId[]) => void;
33
+ /** Merges the selected layers into the top-most of them; offered while two or more are selected. */
34
+ onMerge?: (ids: LayerId[], into: LayerId) => void;
35
+ /** Whether the panel is docked beside the canvas. */
36
+ docked?: boolean;
37
+ }>;
38
+
39
+ type LayerNameProps = {
40
+ layer: Layer;
41
+ readonly?: boolean;
42
+ editing: boolean;
43
+ onEditingChange: (editing: boolean) => void;
44
+ onRename?: (name: string) => void;
45
+ };
46
+
47
+ /**
48
+ * A layer's name, edited in place on a double-click (`Editable`: the input takes the preview's cell, so the row does
49
+ * not move). Enter or leaving it commits, Escape keeps the old name. It is the row's text part, so it carries that
50
+ * part's class for the row's layout.
51
+ */
52
+ const LayerName = ({ layer, readonly, editing, onEditingChange, onRename }: LayerNameProps) => (
53
+ <Editable.Root
54
+ classNames='dx-listbox-item-text'
55
+ value={layer.name}
56
+ activation='dblclick'
57
+ editing={editing}
58
+ onEditingChange={onEditingChange}
59
+ disabled={readonly || !onRename}
60
+ onValueChange={(next) => {
61
+ const name = next.trim();
62
+ if (name && name !== layer.name) {
63
+ onRename?.(name);
64
+ }
65
+ }}
66
+ >
67
+ <Editable.Preview data-testid={`layer-name-${layer.id}`} />
68
+ <Editable.Input
69
+ aria-label='Layer name'
70
+ data-testid={`layer-input-${layer.id}`}
71
+ // While a name is typed, the list's keys (typeahead, arrows, selection) must not take its keys; otherwise the
72
+ // list keeps them, so the arrows move between rows.
73
+ onKeyDown={(event) => event.stopPropagation()}
74
+ />
75
+ </Editable.Root>
76
+ );
77
+
78
+ /**
79
+ * A scene's layers, top first as they stack: the active one is selected, each shows or hides from its eye, renames on
80
+ * a double-click and reorders by its handle. The toolbar adds a layer, deletes the active one, and merges it down.
81
+ */
82
+ export const LayersPanel = ({
83
+ classNames,
84
+ layers,
85
+ selected: selectedProp,
86
+ readonly,
87
+ onSelectedChange,
88
+ onToggle,
89
+ onRename,
90
+ onMove,
91
+ onCreate,
92
+ onDelete,
93
+ onMerge,
94
+ docked = false,
95
+ }: LayersPanelProps) => {
96
+ const items = useMemo(() => [...layers].reverse(), [layers]);
97
+ // The row whose name is open; a new layer opens its own, so it is named as it is made.
98
+ const [editingId, setEditingId] = useState<LayerId>();
99
+ // Bottom first, as `layers`, so the last is the top-most selected.
100
+ const selected = useMemo(
101
+ () => layers.filter((layer) => selectedProp?.includes(layer.id)).map((layer) => layer.id),
102
+ [layers, selectedProp],
103
+ );
104
+ const top = selected[selected.length - 1];
105
+ return (
106
+ <Panel.Root classNames={classNames} data-testid='layers'>
107
+ <Panel.Header asChild>
108
+ <Toolbar.Root data-testid='layers-toolbar'>
109
+ <Button.Root
110
+ variant='ghost'
111
+ iconOnly
112
+ icon='ph--plus--regular'
113
+ label='Add layer'
114
+ disabled={readonly || !onCreate}
115
+ data-testid='layers-create'
116
+ onClick={() => {
117
+ const id = onCreate?.();
118
+ if (id) {
119
+ setEditingId(id);
120
+ }
121
+ }}
122
+ />
123
+ {/* The rarer, destructive edits wait in a menu at the toolbar's end. */}
124
+ <Toolbar.Separator variant='gap' />
125
+ <Menu.Root positioning={{ placement: 'bottom-end', gutter: 4 }}>
126
+ <Menu.Trigger asChild>
127
+ <Button.Root
128
+ variant='ghost'
129
+ iconOnly
130
+ icon='ph--dots-three-vertical--regular'
131
+ label='Layer actions'
132
+ disabled={readonly}
133
+ data-testid='layers-menu'
134
+ />
135
+ </Menu.Trigger>
136
+ <Menu.Content>
137
+ <Menu.Item
138
+ item={{ value: 'merge', label: 'Merge layers', icon: 'ph--arrow-line-down--regular' }}
139
+ disabled={!onMerge || selected.length < 2}
140
+ data-testid='layers-merge'
141
+ onSelect={() => top && onMerge?.(selected, top)}
142
+ />
143
+ <Menu.Item
144
+ item={{ value: 'delete', label: 'Delete layers', icon: 'ph--trash--regular' }}
145
+ disabled={!onDelete || selected.length === 0 || selected.length >= layers.length}
146
+ data-testid='layers-delete'
147
+ onSelect={() => onDelete?.(selected)}
148
+ />
149
+ </Menu.Content>
150
+ </Menu.Root>
151
+ </Toolbar.Root>
152
+ </Panel.Header>
153
+ <Panel.Body>
154
+ <OrderedList.Root
155
+ items={items}
156
+ getLabel={(layer) => layer.name}
157
+ readonly={readonly || !onMove}
158
+ multiple
159
+ value={selected}
160
+ onValueChange={(ids) => onSelectedChange?.(ids)}
161
+ // The list is top first; the order the model keeps is bottom first.
162
+ onMove={(from, to) => onMove?.(items[from].id, items.length - 1 - to)}
163
+ >
164
+ {({ items }) => (
165
+ <OrderedList.Content
166
+ // Docked, the dock scrolls the panels together, so the list keeps no scroll of its own (it would hold the wheel).
167
+ scroll={!docked}
168
+ // Rows run edge to edge, with no gutter around them.
169
+ gutter='none'
170
+ aria-label='Layers'
171
+ onKeyDown={(event) => {
172
+ // Enter on the list (as well as selecting the highlighted row) opens that row's name.
173
+ if (event.key !== 'Enter' || event.target !== event.currentTarget || readonly || !onRename) {
174
+ return;
175
+ }
176
+ const active = event.currentTarget.getAttribute('aria-activedescendant');
177
+ const id = active ? event.currentTarget.ownerDocument.getElementById(active)?.dataset.value : undefined;
178
+ if (id) {
179
+ setEditingId(id);
180
+ }
181
+ }}
182
+ >
183
+ {items.map((layer) => (
184
+ <OrderedList.Item key={layer.id} id={layer.id} data-testid={`layer-${layer.id}`}>
185
+ <OrderedList.DragHandle />
186
+ <LayerName
187
+ layer={layer}
188
+ readonly={readonly}
189
+ editing={editingId === layer.id}
190
+ onEditingChange={(editing) =>
191
+ setEditingId((current) => (editing ? layer.id : current === layer.id ? undefined : current))
192
+ }
193
+ onRename={(name) => onRename?.(layer.id, name)}
194
+ />
195
+ <Button.Root
196
+ variant='ghost'
197
+ iconOnly
198
+ icon={layer.hidden ? 'ph--eye-slash--regular' : 'ph--eye--regular'}
199
+ label={layer.hidden ? 'Show layer' : 'Hide layer'}
200
+ disabled={readonly || !onToggle}
201
+ data-testid={`layer-toggle-${layer.id}`}
202
+ onClick={(event) => {
203
+ // The row's own click selects it; showing or hiding a layer leaves the selection alone.
204
+ event.stopPropagation();
205
+ onToggle?.(layer.id);
206
+ }}
207
+ />
208
+ </OrderedList.Item>
209
+ ))}
210
+ </OrderedList.Content>
211
+ )}
212
+ </OrderedList.Root>
213
+ </Panel.Body>
214
+ </Panel.Root>
215
+ );
216
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Layers.tsx';
@@ -0,0 +1,135 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useState } from 'react';
7
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
8
+
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { freehandCapabilities } from '../../model/projection.ts';
12
+ import { type NodeRegistry, defaultLinkRegistry, defaultNodeRegistry } from '../../model/registry.ts';
13
+ import { type Tool } from '../../model/types.ts';
14
+ import { Palette, type PaletteProps } from './Palette.tsx';
15
+
16
+ const ICONS = [
17
+ 'ph--brain--regular',
18
+ 'ph--function--regular',
19
+ 'ph--database--regular',
20
+ 'ph--image--regular',
21
+ 'ph--git-fork--regular',
22
+ 'ph--intersect--regular',
23
+ 'ph--shuffle--regular',
24
+ 'ph--code--regular',
25
+ ];
26
+
27
+ /** Many node types in four groups, as a host with a large registry (the compute shapes) has. */
28
+ const MANY: NodeRegistry = Object.fromEntries(
29
+ ['Inputs', 'Transform', 'Operations', 'Outputs'].flatMap((group, groupIndex) =>
30
+ Array.from({ length: 6 }, (_, index) => {
31
+ const type = `${group.toLowerCase()}-${index}`;
32
+ return [
33
+ type,
34
+ {
35
+ ...defaultNodeRegistry.rect,
36
+ type,
37
+ key: undefined,
38
+ name: `${group} ${index + 1}`,
39
+ icon: ICONS[(groupIndex * 6 + index) % ICONS.length],
40
+ group,
41
+ },
42
+ ];
43
+ }),
44
+ ),
45
+ );
46
+
47
+ type StoryArgs = Pick<PaletteProps, 'collapse' | 'flyout'> & {
48
+ /** Many types in groups, or the built-in few. */
49
+ many: boolean;
50
+ /** The height of the view the rail floats in. */
51
+ height: number;
52
+ };
53
+
54
+ /** The rail floating in a view of `height`, as `SceneView.Palette` places it. */
55
+ const DefaultStory = ({ collapse, flyout, many, height }: StoryArgs) => {
56
+ const [tool, setTool] = useState<Tool>({ kind: 'select' });
57
+ return (
58
+ <div className='relative w-64 border border-separator rounded-sm' style={{ height }}>
59
+ <div className='absolute top-2 bottom-2 left-2 pointer-events-none'>
60
+ <Palette
61
+ tool={tool}
62
+ nodes={many ? MANY : defaultNodeRegistry}
63
+ links={defaultLinkRegistry}
64
+ capabilities={freehandCapabilities}
65
+ collapse={collapse}
66
+ flyout={flyout}
67
+ onToolChange={setTool}
68
+ />
69
+ </div>
70
+ <div className='absolute bottom-2 right-2 text-xs text-fg-muted' data-testid='palette-tool'>
71
+ {'type' in tool ? tool.type : tool.kind}
72
+ </div>
73
+ </div>
74
+ );
75
+ };
76
+
77
+ const meta: Meta<StoryArgs> = {
78
+ title: 'ui/react-ui-canvas/Palette',
79
+ render: DefaultStory,
80
+ decorators: [withTheme(), withLayout({ layout: 'centered' })],
81
+ args: { collapse: 'auto', flyout: 'list', many: false, height: 640 },
82
+ argTypes: {
83
+ collapse: { control: 'select', options: ['auto', true, false] },
84
+ flyout: { control: 'select', options: ['list', 'grid'] },
85
+ },
86
+ };
87
+
88
+ export default meta;
89
+
90
+ type Story = StoryObj<StoryArgs>;
91
+
92
+ /** The built-in types: few enough that the rail stays flat. */
93
+ export const Default: Story = {};
94
+
95
+ /** Many types in a short view: the node groups fold into flyouts. */
96
+ export const Many: Story = { args: { many: true } };
97
+
98
+ /** Many types folded, each group's tools as a grid of icons rather than a list. */
99
+ export const ManyGrid: Story = { args: { many: true, flyout: 'grid' } };
100
+
101
+ /** Many types with room to spare: still flat, every tool one click away. */
102
+ export const ManyTall: Story = { args: { many: true, height: 1400 } };
103
+
104
+ /** A folded group picks its face, opens its grid, and shows the tool picked from it. */
105
+ export const Test: Story = {
106
+ args: { many: true },
107
+ play: async ({ canvasElement }) => {
108
+ const canvas = within(canvasElement);
109
+ // 1. The rail folds: one button per node group.
110
+ await waitFor(() => expect(canvas.getByTestId('palette')).toHaveAttribute('data-collapsed'));
111
+ // 2. The group's face picks its first tool.
112
+ await userEvent.click(within(canvas.getByTestId('palette-group-Transform')).getAllByRole('button')[0]);
113
+ await expect(canvas.getByTestId('palette-tool')).toHaveTextContent('transform-0');
114
+ // 3. The chevron opens the group's grid; a pick there selects it and becomes the face.
115
+ await userEvent.click(canvas.getByTestId('palette-group-Transform-open'));
116
+ const tools = await within(canvasElement.ownerDocument.body).findByTestId('palette-group-Transform-tools');
117
+ await userEvent.click(within(tools).getByTestId('palette-transform-3'));
118
+ await expect(canvas.getByTestId('palette-tool')).toHaveTextContent('transform-3');
119
+ await waitFor(() =>
120
+ expect(
121
+ within(canvas.getByTestId('palette-group-Transform')).getByTestId('palette-transform-3'),
122
+ ).toBeInTheDocument(),
123
+ );
124
+
125
+ // 4. Resting the pointer on a folded group opens its grid.
126
+ await userEvent.hover(canvas.getByTestId('palette-group-Outputs'));
127
+ await expect(
128
+ await within(canvasElement.ownerDocument.body).findByTestId(
129
+ 'palette-group-Outputs-tools',
130
+ {},
131
+ { timeout: 2_000 },
132
+ ),
133
+ ).toBeInTheDocument();
134
+ },
135
+ };
@@ -4,9 +4,11 @@
4
4
 
5
5
  import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
6
  import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
7
- import React, { useEffect, useRef } from 'react';
7
+ import React, { type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
9
  import * as Button from '@dxos/react-ui/Button';
10
+ import * as Icon from '@dxos/react-ui/Icon';
11
+ import * as Popover from '@dxos/react-ui/Popover';
10
12
  import { mx } from '@dxos/ui-theme';
11
13
 
12
14
  import { type LinkRegistry, type NodeRegistry } from '../../model/registry.ts';
@@ -20,8 +22,11 @@ const BASE: Entry[] = [
20
22
  { tool: { kind: 'hand' }, icon: 'ph--hand--regular', label: 'Pan', key: 'H' },
21
23
  ];
22
24
 
25
+ /** A run of palette entries; a group of node types may fold into one flyout button when the rail runs out of room. */
26
+ export type PaletteGroup = { name: string; entries: Entry[]; collapsible: boolean };
27
+
23
28
  /** Node types by their `group`, in registry order; types without one share the first, unnamed group. */
24
- const nodeGroups = (nodes: NodeRegistry): Entry[][] => {
29
+ const nodeGroups = (nodes: NodeRegistry): PaletteGroup[] => {
25
30
  const groups = new Map<string, Entry[]>();
26
31
  for (const def of Object.values(nodes)) {
27
32
  const name = def.group ?? '';
@@ -30,26 +35,34 @@ const nodeGroups = (nodes: NodeRegistry): Entry[][] => {
30
35
  { tool: { kind: 'node' as const, type: def.type }, icon: def.icon, label: def.name, key: def.key },
31
36
  ]);
32
37
  }
33
- return [...groups.values()];
38
+ return [...groups.entries()].map(([name, entries]) => ({ name, entries, collapsible: entries.length > 1 }));
34
39
  };
35
40
 
36
41
  /**
37
- * Palette entries: the fixed tools, then the node types by group, then a link per link type; a group the
38
- * projection cannot apply (`create`, `link`) is left out rather than offered and silently dropped.
42
+ * Palette groups: the fixed tools, then the node types by group, then a link per link type; a group the projection
43
+ * cannot apply (`create`, `link`) is left out rather than offered and silently dropped. Only node groups fold.
39
44
  */
40
- export const paletteEntries = (nodes: NodeRegistry, links: LinkRegistry, capabilities: Capabilities): Entry[][] =>
45
+ export const paletteGroups = (nodes: NodeRegistry, links: LinkRegistry, capabilities: Capabilities): PaletteGroup[] =>
41
46
  [
42
- BASE,
47
+ { name: 'Tools', entries: BASE, collapsible: false },
43
48
  ...(capabilities.create ? nodeGroups(nodes) : []),
44
- capabilities.link
45
- ? Object.values(links).map((def) => ({
46
- tool: { kind: 'link' as const, type: def.type },
47
- icon: def.icon,
48
- label: def.name,
49
- key: def.key,
50
- }))
51
- : [],
52
- ].filter((group) => group.length > 0);
49
+ {
50
+ name: 'Links',
51
+ entries: capabilities.link
52
+ ? Object.values(links).map((def) => ({
53
+ tool: { kind: 'link' as const, type: def.type },
54
+ icon: def.icon,
55
+ label: def.name,
56
+ key: def.key,
57
+ }))
58
+ : [],
59
+ collapsible: false,
60
+ },
61
+ ].filter((group) => group.entries.length > 0);
62
+
63
+ /** Palette entries by group, flat within each. */
64
+ export const paletteEntries = (nodes: NodeRegistry, links: LinkRegistry, capabilities: Capabilities): Entry[][] =>
65
+ paletteGroups(nodes, links, capabilities).map((group) => group.entries);
53
66
 
54
67
  export const sameTool = (left: Tool, right: Tool): boolean =>
55
68
  left.kind === right.kind && ('type' in left ? left.type === ('type' in right ? right.type : undefined) : true);
@@ -69,40 +82,218 @@ export type PaletteProps = {
69
82
  nodes: NodeRegistry;
70
83
  links: LinkRegistry;
71
84
  capabilities: Capabilities;
85
+ /**
86
+ * Folds each group of node types into one flyout button: `auto` (the default) does so only when the flat rail would
87
+ * not fit the space below it, so a small registry keeps every tool one click away.
88
+ */
89
+ collapse?: 'auto' | boolean;
90
+ /** How a folded group lists its tools: one per row with its name (the default), or a grid of icons. */
91
+ flyout?: 'list' | 'grid';
72
92
  onToolChange: (tool: Tool) => void;
73
93
  };
74
94
 
95
+ /** The height the rail would take flat: each button, the gaps between them, and each group's padding and divider. */
96
+ const flatHeight = (groups: readonly PaletteGroup[], button: number): number =>
97
+ groups.reduce((height, group) => height + group.entries.length * (button + 4) - 4 + 9, 0);
98
+
99
+ /**
100
+ * Whether the rail folds its node groups: always, never, or (`auto`) when the flat rail would overflow its parent,
101
+ * which the host sizes to the room the rail may take (`SceneView.Palette` spans from below the navigation to above
102
+ * the bottom bars); re-measured as that resizes.
103
+ */
104
+ const useCollapsed = (
105
+ ref: RefObject<HTMLDivElement | null>,
106
+ groups: readonly PaletteGroup[],
107
+ collapse: PaletteProps['collapse'],
108
+ ) => {
109
+ const [overflows, setOverflows] = useState(false);
110
+ useLayoutEffect(() => {
111
+ const element = ref.current;
112
+ const host = element?.parentElement;
113
+ if (collapse !== 'auto' || !element || !host) {
114
+ return;
115
+ }
116
+ const measure = () => {
117
+ const button = element.querySelector('button')?.getBoundingClientRect().height ?? 32;
118
+ setOverflows(flatHeight(groups, button) > host.clientHeight);
119
+ };
120
+ measure();
121
+ const observer = new ResizeObserver(measure);
122
+ observer.observe(host);
123
+ return () => observer.disconnect();
124
+ }, [ref, groups, collapse]);
125
+ return collapse === 'auto' ? overflows : collapse;
126
+ };
127
+
75
128
  /**
76
129
  * Tool palette: a click picks the tool; a node type can also be dragged onto the canvas, which creates
77
- * it where it drops (the canvas is a pragmatic-dnd drop target for `nodeDragData`).
130
+ * it where it drops (the canvas is a pragmatic-dnd drop target for `nodeDragData`). With many types the node
131
+ * groups fold into flyouts (`collapse`), each showing its active or last-used tool.
132
+ */
133
+ export const Palette = ({
134
+ tool,
135
+ nodes,
136
+ links,
137
+ capabilities,
138
+ collapse = 'auto',
139
+ flyout = 'list',
140
+ onToolChange,
141
+ }: PaletteProps) => {
142
+ const ref = useRef<HTMLDivElement>(null);
143
+ const groups = useMemo(() => paletteGroups(nodes, links, capabilities), [nodes, links, capabilities]);
144
+ const collapsed = useCollapsed(ref, groups, collapse);
145
+ return (
146
+ <div
147
+ ref={ref}
148
+ // The host's frame spans the room the rail may take and lets the canvas below it take the pointer.
149
+ className='flex flex-col w-fit rounded-sm bg-modal-surface border border-separator divide-y divide-separator pointer-events-auto'
150
+ data-testid='palette'
151
+ data-collapsed={collapsed || undefined}
152
+ >
153
+ {groups.map((group) =>
154
+ collapsed && group.collapsible ? (
155
+ <div key={group.name} className='flex flex-col p-1'>
156
+ <PaletteFlyout group={group} tool={tool} layout={flyout} onToolChange={onToolChange} />
157
+ </div>
158
+ ) : (
159
+ <div key={group.name} className='flex flex-col gap-1 p-1'>
160
+ {group.entries.map((entry) => (
161
+ <PaletteButton
162
+ key={entry.label}
163
+ entry={entry}
164
+ active={sameTool(tool, entry.tool)}
165
+ onToolChange={onToolChange}
166
+ />
167
+ ))}
168
+ </div>
169
+ ),
170
+ )}
171
+ </div>
172
+ );
173
+ };
174
+
175
+ /** How long the pointer rests on a folded group before its tools open. */
176
+ const HOVER_OPEN_MS = 500;
177
+ /** How long the tools stay open after the pointer leaves, so it can cross the gap between the rail and the grid. */
178
+ const HOVER_CLOSE_MS = 250;
179
+
180
+ /**
181
+ * Opens after the pointer rests on the trigger, and closes once it has left both the trigger and the content. `hovered`
182
+ * is how it opened, so the content can leave focus where it is.
78
183
  */
79
- export const Palette = ({ tool, nodes, links, capabilities, onToolChange }: PaletteProps) => (
80
- <div
81
- className='flex flex-col rounded-sm bg-modal-surface border border-separator divide-y divide-separator'
82
- data-testid='palette'
83
- >
84
- {paletteEntries(nodes, links, capabilities).map((group, index) => (
85
- <div key={index} className='flex flex-col gap-1 p-1'>
86
- {group.map((entry) => (
87
- <PaletteButton
88
- key={entry.label}
89
- entry={entry}
90
- active={sameTool(tool, entry.tool)}
91
- onToolChange={onToolChange}
92
- />
93
- ))}
94
- </div>
95
- ))}
96
- </div>
97
- );
184
+ const useHoverOpen = (setOpen: (open: boolean, hovered?: boolean) => void) => {
185
+ const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
186
+ useEffect(() => () => clearTimeout(timer.current), []);
187
+ return useMemo(
188
+ () => ({
189
+ enter: () => {
190
+ clearTimeout(timer.current);
191
+ timer.current = setTimeout(() => setOpen(true, true), HOVER_OPEN_MS);
192
+ },
193
+ keep: () => clearTimeout(timer.current),
194
+ leave: () => {
195
+ clearTimeout(timer.current);
196
+ timer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_MS);
197
+ },
198
+ }),
199
+ [setOpen],
200
+ );
201
+ };
202
+
203
+ type PaletteFlyoutProps = {
204
+ group: PaletteGroup;
205
+ tool: Tool;
206
+ layout: NonNullable<PaletteProps['flyout']>;
207
+ onToolChange: (tool: Tool) => void;
208
+ };
209
+
210
+ /**
211
+ * A folded group: its active (else last-used, else first) tool as one button, picked with a click, and a corner
212
+ * chevron (or a hover) opening the group's tools beside the rail, as a list or a grid (`layout`).
213
+ */
214
+ const PaletteFlyout = ({ group, tool, layout, onToolChange }: PaletteFlyoutProps) => {
215
+ const [open, setOpenState] = useState(false);
216
+ // Opened by a hover, the list takes no focus (a pointer user would see a focus ring on the first row); opened from
217
+ // the chevron (a click or the keyboard), it does, so arrows move through it.
218
+ const [hovered, setHovered] = useState(false);
219
+ const setOpen = useCallback((next: boolean, byHover = false) => {
220
+ setHovered(next && byHover);
221
+ setOpenState(next);
222
+ }, []);
223
+ const hover = useHoverOpen(setOpen);
224
+ const [lastUsed, setLastUsed] = useState<Entry>(group.entries[0]);
225
+ const active = group.entries.find((entry) => sameTool(tool, entry.tool));
226
+ // A tool picked from elsewhere (its key, the grid) becomes the group's face, so the rail shows what is in use.
227
+ useEffect(() => {
228
+ if (active) {
229
+ setLastUsed(active);
230
+ }
231
+ }, [active]);
232
+ const shown = active ?? lastUsed;
233
+ return (
234
+ <Popover.Root
235
+ open={open}
236
+ autoFocus={!hovered}
237
+ onOpenChange={({ open }) => setOpen(open)}
238
+ positioning={{ placement: 'right-start' }}
239
+ >
240
+ <Popover.Anchor asChild>
241
+ <div
242
+ className='relative'
243
+ data-testid={`palette-group-${group.name}`}
244
+ onPointerEnter={hover.enter}
245
+ onPointerLeave={hover.leave}
246
+ >
247
+ <PaletteButton entry={shown} active={active !== undefined} onToolChange={onToolChange} />
248
+ <Popover.Trigger asChild>
249
+ <button
250
+ type='button'
251
+ aria-label={`${group.name} tools`}
252
+ className='absolute bottom-0 right-0 flex size-3 items-end justify-end text-fg-subtle hover:text-fg'
253
+ data-testid={`palette-group-${group.name}-open`}
254
+ >
255
+ <Icon.Icon icon='ph--caret-right--fill' size='xs' />
256
+ </button>
257
+ </Popover.Trigger>
258
+ </div>
259
+ </Popover.Anchor>
260
+ <Popover.Content onPointerEnter={hover.keep} onPointerLeave={hover.leave}>
261
+ <div
262
+ className={mx(
263
+ layout === 'list'
264
+ ? 'flex flex-col items-stretch gap-1 p-1'
265
+ : 'grid grid-cols-[repeat(3,min-content)] gap-1 p-1',
266
+ )}
267
+ data-testid={`palette-group-${group.name}-tools`}
268
+ >
269
+ {group.entries.map((entry) => (
270
+ <PaletteButton
271
+ key={entry.label}
272
+ entry={entry}
273
+ labelled={layout === 'list'}
274
+ active={sameTool(tool, entry.tool)}
275
+ onToolChange={(next) => {
276
+ onToolChange(next);
277
+ setOpen(false);
278
+ }}
279
+ />
280
+ ))}
281
+ </div>
282
+ </Popover.Content>
283
+ </Popover.Root>
284
+ );
285
+ };
98
286
 
99
287
  const PaletteButton = ({
100
288
  entry,
101
289
  active,
290
+ labelled,
102
291
  onToolChange,
103
292
  }: {
104
293
  entry: Entry;
105
294
  active: boolean;
295
+ /** Shows the tool's name beside its icon (a flyout's list), rather than the icon alone. */
296
+ labelled?: boolean;
106
297
  onToolChange: (tool: Tool) => void;
107
298
  }) => {
108
299
  const ref = useRef<HTMLButtonElement>(null);
@@ -122,10 +313,10 @@ const PaletteButton = ({
122
313
  <Button.Root
123
314
  ref={ref}
124
315
  variant='ghost'
125
- iconOnly
316
+ iconOnly={!labelled}
126
317
  icon={entry.icon}
127
- label={entry.key ? `${entry.label} (${entry.key})` : entry.label}
128
- classNames={mx(active && 'bg-primary-500/20')}
318
+ label={labelled ? entry.label : entry.key ? `${entry.label} (${entry.key})` : entry.label}
319
+ classNames={mx(labelled && 'w-full justify-start', active && 'bg-primary-500/20')}
129
320
  data-testid={`palette-${entry.key ?? ('type' in entry.tool ? entry.tool.type : entry.tool.kind)}`}
130
321
  onClick={() => onToolChange(entry.tool)}
131
322
  />