@svgrid/grid 2.6.22 → 2.7.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 (331) hide show
  1. package/CHANGELOG.md +88 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/GridMenus.svelte +184 -6
  6. package/dist/SvAutoComplete.svelte +169 -165
  7. package/dist/SvAvatar.svelte +75 -75
  8. package/dist/SvCalendar.svelte +503 -503
  9. package/dist/SvCarousel.svelte +141 -141
  10. package/dist/SvCheckBox.svelte +102 -102
  11. package/dist/SvCircularProgress.svelte +109 -109
  12. package/dist/SvColorInput.svelte +181 -181
  13. package/dist/SvComboBox.svelte +279 -275
  14. package/dist/SvContextMenu.svelte +116 -116
  15. package/dist/SvCountryInput.svelte +163 -163
  16. package/dist/SvDateRangeInput.svelte +25 -3
  17. package/dist/SvDrawer.svelte +254 -254
  18. package/dist/SvDropDownList.svelte +378 -374
  19. package/dist/SvDurationInput.svelte +126 -126
  20. package/dist/SvField.svelte +293 -293
  21. package/dist/SvForm.svelte +437 -437
  22. package/dist/SvGrid.controller.svelte.d.ts +48 -1
  23. package/dist/SvGrid.controller.svelte.js +214 -52
  24. package/dist/SvGrid.css +221 -0
  25. package/dist/SvGrid.helpers.d.ts +55 -0
  26. package/dist/SvGrid.helpers.js +83 -0
  27. package/dist/SvGrid.svelte +145 -0
  28. package/dist/SvGrid.types.d.ts +105 -0
  29. package/dist/SvGridCellEditor.svelte +744 -662
  30. package/dist/SvGridChart.svelte +1716 -1716
  31. package/dist/SvGridChartPanel.svelte +485 -485
  32. package/dist/SvGridChartView.svelte +70 -70
  33. package/dist/SvGridDropdown.svelte +728 -728
  34. package/dist/SvGridSelect.svelte +270 -266
  35. package/dist/SvGridSelect.svelte.d.ts +1 -1
  36. package/dist/SvGroupCell.svelte +116 -116
  37. package/dist/SvListBox.svelte +334 -326
  38. package/dist/SvMaskedInput.svelte +122 -122
  39. package/dist/SvMenu.svelte +124 -124
  40. package/dist/SvMenuList.svelte +146 -146
  41. package/dist/SvMultiSelect.svelte +293 -289
  42. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  43. package/dist/SvNumberInput.svelte +172 -168
  44. package/dist/SvOtpInput.svelte +158 -158
  45. package/dist/SvPasswordInput.svelte +151 -151
  46. package/dist/SvPhoneInput.svelte +133 -133
  47. package/dist/SvPopover.svelte +197 -197
  48. package/dist/SvProgress.svelte +116 -116
  49. package/dist/SvRadioGroup.svelte +107 -107
  50. package/dist/SvRating.svelte +112 -112
  51. package/dist/SvResult.svelte +73 -73
  52. package/dist/SvRichCell.svelte +125 -0
  53. package/dist/SvRichCell.svelte.d.ts +15 -0
  54. package/dist/SvRichText.svelte +211 -211
  55. package/dist/SvRowGroupPanel.svelte +170 -170
  56. package/dist/SvScrollArea.svelte +61 -61
  57. package/dist/SvSlider.svelte +203 -203
  58. package/dist/SvSwitchButton.svelte +108 -108
  59. package/dist/SvTagsInput.svelte +115 -115
  60. package/dist/SvTextArea.svelte +5 -1
  61. package/dist/SvTextInput.svelte +147 -147
  62. package/dist/SvTimePicker.svelte +245 -245
  63. package/dist/SvToaster.svelte +159 -159
  64. package/dist/SvToggleButton.svelte +85 -85
  65. package/dist/SvTooltip.svelte +161 -161
  66. package/dist/SvTour.svelte +208 -208
  67. package/dist/SvTree.svelte +444 -444
  68. package/dist/SvTreeSelect.svelte +239 -235
  69. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  70. package/dist/builtin-editors.d.ts +2 -2
  71. package/dist/builtin-editors.js +19 -3
  72. package/dist/cdn/GridMenus-C4oLsJyj.js +600 -0
  73. package/dist/cdn/GridMenus-yEivjNwl.js +601 -0
  74. package/dist/cdn/SvDateRangeInput-Bh0A0JkF.js +199 -0
  75. package/dist/cdn/SvDateRangeInput-DflbiP7N.js +198 -0
  76. package/dist/cdn/SvDateTimePicker-BWwpfB_o.js +812 -0
  77. package/dist/cdn/SvDateTimePicker-Bivn8dAP.js +816 -0
  78. package/dist/cdn/SvGridCellEditor-AXL8cHGO.js +550 -0
  79. package/dist/cdn/SvGridCellEditor-Ba7rY3eu.js +549 -0
  80. package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
  81. package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
  82. package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
  83. package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
  84. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
  85. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
  86. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
  87. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
  88. package/dist/cdn/date-format-CcP1tafP.js +1395 -0
  89. package/dist/cdn/date-format-DZT7T1wf.js +1393 -0
  90. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  91. package/dist/cdn/{src-C9Hihx1W.js → src-COC0vnpg.js} +7233 -6701
  92. package/dist/cdn/{src-D1lXwq1l.js → src-pYGV2rMR.js} +5747 -5215
  93. package/dist/cdn/svgrid.js +15 -13
  94. package/dist/cdn/svgrid.svelte-external.js +15 -13
  95. package/dist/cell-values.d.ts +16 -0
  96. package/dist/cell-values.js +28 -0
  97. package/dist/chart-export.js +8 -8
  98. package/dist/clipboard.d.ts +22 -0
  99. package/dist/clipboard.js +405 -27
  100. package/dist/editing.js +12 -7
  101. package/dist/editors/cell-editors.d.ts +11 -0
  102. package/dist/editors/cell-editors.js +18 -1
  103. package/dist/grid-messages.d.ts +15 -0
  104. package/dist/grid-messages.js +15 -0
  105. package/dist/index.d.ts +4 -0
  106. package/dist/index.js +4 -0
  107. package/dist/list-option.d.ts +6 -0
  108. package/dist/menus.js +40 -7
  109. package/dist/sanitize-html.d.ts +37 -0
  110. package/dist/sanitize-html.js +234 -0
  111. package/dist/selection-bar-view.svelte.d.ts +25 -0
  112. package/dist/selection-bar-view.svelte.js +13 -0
  113. package/dist/selection.d.ts +2 -1
  114. package/dist/selection.js +129 -13
  115. package/package.json +11 -11
  116. package/src/FlexRender.svelte +96 -96
  117. package/src/GridFooter.svelte +181 -181
  118. package/src/GridMenus.svelte +184 -6
  119. package/src/SvAutoComplete.svelte +169 -165
  120. package/src/SvAvatar.svelte +75 -75
  121. package/src/SvCalendar.svelte +503 -503
  122. package/src/SvCalendar.test.ts +226 -226
  123. package/src/SvCarousel.svelte +141 -141
  124. package/src/SvCheckBox.svelte +102 -102
  125. package/src/SvCircularProgress.svelte +109 -109
  126. package/src/SvColorInput.svelte +181 -181
  127. package/src/SvComboBox.svelte +279 -275
  128. package/src/SvContextMenu.svelte +116 -116
  129. package/src/SvCountryInput.svelte +163 -163
  130. package/src/SvDateRangeInput.svelte +25 -3
  131. package/src/SvDrawer.svelte +254 -254
  132. package/src/SvDropDownList.svelte +378 -374
  133. package/src/SvDurationInput.svelte +126 -126
  134. package/src/SvField.svelte +293 -293
  135. package/src/SvForm.svelte +437 -437
  136. package/src/SvForm.test.ts +411 -411
  137. package/src/SvGrid.controller.svelte.ts +251 -61
  138. package/src/SvGrid.css +221 -0
  139. package/src/SvGrid.helpers.ts +87 -0
  140. package/src/SvGrid.svelte +145 -0
  141. package/src/SvGrid.types.ts +2092 -1980
  142. package/src/SvGridCellEditor.svelte +744 -662
  143. package/src/SvGridChart.svelte +1716 -1716
  144. package/src/SvGridChartPanel.svelte +485 -485
  145. package/src/SvGridChartView.svelte +70 -70
  146. package/src/SvGridDropdown.svelte +728 -728
  147. package/src/SvGridSelect.svelte +270 -266
  148. package/src/SvGroupCell.svelte +116 -116
  149. package/src/SvListBox.svelte +334 -326
  150. package/src/SvMaskedInput.svelte +122 -122
  151. package/src/SvMenu.svelte +124 -124
  152. package/src/SvMenu.test.ts +97 -97
  153. package/src/SvMenuList.svelte +146 -146
  154. package/src/SvMultiSelect.svelte +293 -289
  155. package/src/SvNumberInput.svelte +172 -168
  156. package/src/SvOtpInput.svelte +158 -158
  157. package/src/SvPasswordInput.svelte +151 -151
  158. package/src/SvPhoneInput.svelte +133 -133
  159. package/src/SvPopover.svelte +197 -197
  160. package/src/SvProgress.svelte +116 -116
  161. package/src/SvRadioGroup.svelte +107 -107
  162. package/src/SvRating.svelte +112 -112
  163. package/src/SvResult.svelte +73 -73
  164. package/src/SvRichCell.svelte +125 -0
  165. package/src/SvRichText.svelte +211 -211
  166. package/src/SvRowGroupPanel.svelte +170 -170
  167. package/src/SvScrollArea.svelte +61 -61
  168. package/src/SvSlider.svelte +203 -203
  169. package/src/SvSwitchButton.svelte +108 -108
  170. package/src/SvTagsInput.svelte +115 -115
  171. package/src/SvTextArea.svelte +5 -1
  172. package/src/SvTextInput.svelte +147 -147
  173. package/src/SvTimePicker.svelte +245 -245
  174. package/src/SvToaster.svelte +159 -159
  175. package/src/SvToaster.test.ts +95 -95
  176. package/src/SvToggleButton.svelte +85 -85
  177. package/src/SvTooltip.svelte +161 -161
  178. package/src/SvTour.svelte +208 -208
  179. package/src/SvTree.svelte +444 -444
  180. package/src/SvTreeSelect.svelte +239 -235
  181. package/src/a11y/dismissable.test.ts +119 -119
  182. package/src/a11y/dismissable.ts +114 -114
  183. package/src/a11y.contract.test.ts +49 -49
  184. package/src/a11y.test.ts +59 -59
  185. package/src/a11y.ts +61 -61
  186. package/src/ai.test.ts +502 -502
  187. package/src/ai.ts +1419 -1419
  188. package/src/build-api.coverage.test.ts +633 -633
  189. package/src/build-api.ts +846 -846
  190. package/src/builtin-editors.grid.test.ts +83 -83
  191. package/src/builtin-editors.test.ts +18 -1
  192. package/src/builtin-editors.ts +19 -3
  193. package/src/cell-formatting.ts +171 -171
  194. package/src/cell-render.test.ts +513 -513
  195. package/src/cell-render.ts +496 -496
  196. package/src/cell-values.ts +148 -118
  197. package/src/chart-export.ts +202 -202
  198. package/src/chart-view.svelte.ts +36 -36
  199. package/src/chart.ts +2321 -2321
  200. package/src/clipboard.test.ts +93 -20
  201. package/src/clipboard.ts +433 -33
  202. package/src/collaboration.test.ts +104 -104
  203. package/src/collaboration.ts +167 -167
  204. package/src/column-groups.ts +78 -78
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1865 -1865
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCombobox.svelte.ts +191 -191
  209. package/src/createCountryInput.svelte.ts +157 -157
  210. package/src/createDropdownList.svelte.ts +168 -168
  211. package/src/createForm.svelte.ts +386 -386
  212. package/src/createGrid.svelte.ts +42 -42
  213. package/src/createGrid.test.ts +10 -10
  214. package/src/createGridState.svelte.ts +17 -17
  215. package/src/createListbox.svelte.ts +250 -250
  216. package/src/createMenu.svelte.ts +224 -224
  217. package/src/createPopoverSelect.svelte.ts +213 -213
  218. package/src/createSlider.svelte.ts +191 -191
  219. package/src/createTooltip.svelte.ts +144 -144
  220. package/src/createTree.svelte.ts +322 -322
  221. package/src/date-string-column.test.ts +87 -0
  222. package/src/datetime/date-core.ts +206 -206
  223. package/src/datetime/date-restrict.ts +61 -61
  224. package/src/datetime/timezone.ts +135 -135
  225. package/src/dock-manager-model.ts +596 -596
  226. package/src/dock-model.ts +374 -374
  227. package/src/editing.test.ts +974 -949
  228. package/src/editing.ts +609 -606
  229. package/src/editor-block-prop.test.ts +132 -0
  230. package/src/editor-contract.ts +171 -171
  231. package/src/editor-registry.grid.test.ts +144 -144
  232. package/src/editor-registry.ts +122 -122
  233. package/src/editors/cell-editors.ts +27 -1
  234. package/src/export-data-api.test.ts +126 -126
  235. package/src/export-format.test.ts +107 -107
  236. package/src/export-format.ts +601 -601
  237. package/src/filter-operators.ts +160 -160
  238. package/src/filtering/excel-filters.ts +325 -325
  239. package/src/flex-render.ts +3 -3
  240. package/src/form-field.ts +127 -127
  241. package/src/grid-messages.ts +34 -0
  242. package/src/group-display.test.ts +167 -167
  243. package/src/group-display.ts +200 -200
  244. package/src/headless.ts +87 -87
  245. package/src/index.ts +13 -0
  246. package/src/js-scroller.svelte.ts +173 -173
  247. package/src/keyboard-handlers.ts +270 -270
  248. package/src/keyboard.test.ts +59 -59
  249. package/src/keyboard.ts +97 -97
  250. package/src/list-nav.test.ts +49 -49
  251. package/src/list-nav.ts +29 -29
  252. package/src/list-option.test.ts +56 -56
  253. package/src/list-option.ts +179 -173
  254. package/src/menus.test.ts +43 -0
  255. package/src/menus.ts +597 -567
  256. package/src/merge-objects.ts +48 -48
  257. package/src/move-cells.test.ts +850 -0
  258. package/src/number-editor-literal.test.ts +84 -0
  259. package/src/number-editor.grid.test.ts +160 -0
  260. package/src/overlays.test.ts +90 -90
  261. package/src/positioning.ts +268 -268
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag-touch.ts +211 -211
  264. package/src/row-drag.test.ts +353 -353
  265. package/src/row-drag.ts +415 -415
  266. package/src/row-resize.test.ts +524 -524
  267. package/src/row-resize.ts +228 -228
  268. package/src/sanitize-html.test.ts +182 -0
  269. package/src/sanitize-html.ts +235 -0
  270. package/src/scheduler-ical.ts +181 -181
  271. package/src/scheduler-model.test.ts +562 -562
  272. package/src/scheduler-model.ts +873 -873
  273. package/src/selection-bar-view.svelte.ts +36 -0
  274. package/src/selection.test.ts +900 -743
  275. package/src/selection.ts +131 -13
  276. package/src/server-data-source.test.ts +383 -383
  277. package/src/server-data-source.ts +469 -469
  278. package/src/sparkline.test.ts +68 -68
  279. package/src/sparkline.ts +169 -169
  280. package/src/spreadsheet.test.ts +488 -488
  281. package/src/spreadsheet.ts +312 -312
  282. package/src/static-functions.ts +11 -11
  283. package/src/subscribe.ts +38 -38
  284. package/src/summaries.ts +113 -113
  285. package/src/svgrid-wrapper.types.ts +563 -563
  286. package/src/svgrid.async-editor-options.test.ts +273 -273
  287. package/src/svgrid.auto-row-height.test.ts +204 -204
  288. package/src/svgrid.behavior.test.ts +910 -910
  289. package/src/svgrid.charting.test.ts +534 -534
  290. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  291. package/src/svgrid.context-menu.test.ts +147 -147
  292. package/src/svgrid.features.test.ts +157 -157
  293. package/src/svgrid.filter-depth.test.ts +163 -163
  294. package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -331
  295. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  296. package/src/svgrid.grand-total.test.ts +188 -188
  297. package/src/svgrid.group-display-mode.test.ts +171 -171
  298. package/src/svgrid.group-footers.test.ts +121 -121
  299. package/src/svgrid.group-pagination.test.ts +153 -153
  300. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  301. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  302. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  303. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  304. package/src/svgrid.tree-data.test.ts +186 -186
  305. package/src/svgrid.upsell-license.test.ts +117 -0
  306. package/src/svgrid.wrapper.test.ts +63 -63
  307. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  308. package/src/test-setup.ts +62 -62
  309. package/src/themes/index.ts +288 -288
  310. package/src/toast-store.svelte.ts +250 -250
  311. package/src/toast-store.test.ts +147 -147
  312. package/src/tree-row-model.test.ts +168 -168
  313. package/src/ui-buttons.test.ts +144 -144
  314. package/src/ui-inputs.test.ts +118 -118
  315. package/src/ui-localization.test.ts +113 -113
  316. package/src/ui-range.test.ts +70 -70
  317. package/src/ui-selection.test.ts +155 -155
  318. package/src/ui-tier1.test.ts +142 -142
  319. package/src/virtual.test.ts +88 -88
  320. package/src/virtualization/column-virtualizer.test.ts +27 -27
  321. package/src/virtualization/column-virtualizer.ts +30 -30
  322. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  323. package/src/virtualization/types.ts +30 -30
  324. package/src/virtualization/virtualizer.test.ts +47 -47
  325. package/src/virtualization/virtualizer.ts +322 -322
  326. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  327. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  328. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  329. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  330. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  331. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
@@ -1,596 +1,596 @@
1
- /**
2
- * dock-manager-model - the pure state behind <SvDockManager>. It wraps the base
3
- * `dock-model` tree (the docked/tiled area) with the three "pro" docking
4
- * surfaces a real IDE has:
5
- *
6
- * floating panes popped out into movable/resizable windows (a single tabs
7
- * leaf each - a floating panel).
8
- * autoHide leaves collapsed to an edge strip; a fly-out reveals them and
9
- * "pin" re-docks them.
10
- * main the tiled dock area itself (a `DockNode` tree, or null when
11
- * everything has been floated / hidden).
12
- *
13
- * As with the base model, every operation is a pure transform returning a NEW
14
- * state, so a whole workspace - tiled + floating + hidden - serializes to JSON
15
- * and restores exactly. The Svelte view owns the DOM, gestures and z-order
16
- * painting; all the surgery lives here and is unit-tested without a browser.
17
- */
18
- import {
19
- tabs,
20
- dockInto,
21
- removePane as removePaneFromTree,
22
- removeLeaf,
23
- dockLeafToEdge,
24
- reorderPane,
25
- findTabsWithPane,
26
- allPaneIds,
27
- type DockNode,
28
- type DockTabs,
29
- type DockPane,
30
- type DockZone,
31
- type IdGen,
32
- } from './dock-model'
33
-
34
- /** Which edge a pane docks against when dropped. */
35
- export type DockSide = Exclude<DockZone, 'center'>
36
-
37
- /** A floating window: one tabs leaf shown in a movable/resizable frame. */
38
- export type FloatWindow = {
39
- id: string
40
- leaf: DockTabs
41
- x: number
42
- y: number
43
- width: number
44
- height: number
45
- /** Stacking order; higher is on top. */
46
- z: number
47
- /** Collapsed to just its title bar. */
48
- minimized?: boolean
49
- /** Expanded to fill the manager (its x/y/w/h are kept for restore). */
50
- maximized?: boolean
51
- }
52
-
53
- /** A leaf collapsed to an edge; a fly-out reveals it, "pin" re-docks it. */
54
- export type AutoHideEntry = {
55
- id: string
56
- side: DockSide
57
- leaf: DockTabs
58
- /** Fly-out panel size in px along the reveal axis. */
59
- size: number
60
- }
61
-
62
- /** The whole dock manager: its layout tree plus floating and pinned panes. */
63
- export type DockManagerState = {
64
- main: DockNode | null
65
- floating: FloatWindow[]
66
- autoHide: AutoHideEntry[]
67
- /** A tiled leaf shown maximized (filling the docked area), or null. */
68
- maximizedLeaf?: string | null
69
- }
70
-
71
- // ---- queries --------------------------------------------------------------
72
-
73
- /** Where a pane currently lives. */
74
- export type PaneLocation =
75
- | { kind: 'main' }
76
- | { kind: 'floating'; windowId: string }
77
- | { kind: 'autoHide'; entryId: string }
78
-
79
- /** Find a pane in the layout tree by id, returning it with its parent for mutation. */
80
- export function locatePane(state: DockManagerState, paneId: string): PaneLocation | null {
81
- if (state.main && findTabsWithPane(state.main, paneId)) return { kind: 'main' }
82
- for (const w of state.floating) {
83
- if (w.leaf.panes.some((p) => p.id === paneId)) return { kind: 'floating', windowId: w.id }
84
- }
85
- for (const e of state.autoHide) {
86
- if (e.leaf.panes.some((p) => p.id === paneId)) return { kind: 'autoHide', entryId: e.id }
87
- }
88
- return null
89
- }
90
-
91
- /** Which surface holds a tabs leaf: 'main', a window id, or null. */
92
- export function surfaceOfTabs(state: DockManagerState, tabsId: string): 'main' | string | null {
93
- if (state.main && containsTabs(state.main, tabsId)) return 'main'
94
- const w = state.floating.find((f) => f.leaf.id === tabsId)
95
- return w ? w.id : null
96
- }
97
-
98
- function containsTabs(node: DockNode, tabsId: string): boolean {
99
- if (node.type === 'tabs') return node.id === tabsId
100
- return node.children.some((c) => containsTabs(c, tabsId))
101
- }
102
-
103
- function findPaneObject(state: DockManagerState, paneId: string): DockPane | null {
104
- const scan = (node: DockNode): DockPane | null => {
105
- if (node.type === 'tabs') return node.panes.find((p) => p.id === paneId) ?? null
106
- for (const c of node.children) { const hit = scan(c); if (hit) return hit }
107
- return null
108
- }
109
- if (state.main) { const m = scan(state.main); if (m) return m }
110
- for (const w of state.floating) { const f = w.leaf.panes.find((p) => p.id === paneId); if (f) return f }
111
- for (const e of state.autoHide) { const a = e.leaf.panes.find((p) => p.id === paneId); if (a) return a }
112
- return null
113
- }
114
-
115
- function maxZ(state: DockManagerState): number {
116
- return state.floating.reduce((m, w) => Math.max(m, w.z), 0)
117
- }
118
-
119
- // ---- internal: remove a pane from wherever it is --------------------------
120
-
121
- /** Remove a pane from every surface, dropping windows/entries it empties. */
122
- function stripPane(state: DockManagerState, paneId: string): DockManagerState {
123
- let main = state.main
124
- if (main && findTabsWithPane(main, paneId)) {
125
- main = removePaneFromTree(main, paneId).root
126
- }
127
- const floating = state.floating
128
- .map((w) => {
129
- if (!w.leaf.panes.some((p) => p.id === paneId)) return w
130
- const panes = w.leaf.panes.filter((p) => p.id !== paneId)
131
- if (panes.length === 0) return null
132
- const active = Math.max(0, Math.min(w.leaf.active, panes.length - 1))
133
- return { ...w, leaf: { ...w.leaf, panes, active } }
134
- })
135
- .filter((w): w is FloatWindow => w !== null)
136
- const autoHide = state.autoHide
137
- .map((e) => {
138
- if (!e.leaf.panes.some((p) => p.id === paneId)) return e
139
- const panes = e.leaf.panes.filter((p) => p.id !== paneId)
140
- if (panes.length === 0) return null
141
- const active = Math.max(0, Math.min(e.leaf.active, panes.length - 1))
142
- return { ...e, leaf: { ...e.leaf, panes, active } }
143
- })
144
- .filter((e): e is AutoHideEntry => e !== null)
145
- return { main, floating, autoHide }
146
- }
147
-
148
- // ---- mutations ------------------------------------------------------------
149
-
150
- /** Reorder a tab within its leaf, wherever that leaf lives (main or a float). */
151
- export function reorderTab(state: DockManagerState, tabsId: string, from: number, to: number): DockManagerState {
152
- if (state.main && containsTabs(state.main, tabsId)) {
153
- return { ...state, main: reorderPane(state.main, tabsId, from, to) }
154
- }
155
- return {
156
- ...state,
157
- floating: state.floating.map((w) => {
158
- if (w.leaf.id !== tabsId) return w
159
- const reordered = reorderPane(w.leaf, tabsId, from, to)
160
- return reordered.type === 'tabs' ? { ...w, leaf: reordered } : w
161
- }),
162
- }
163
- }
164
-
165
- /** Resize a group's children (splitter drag). Groups only exist in `main`
166
- * (floating windows are single leaves), so this targets the main tree. */
167
- export function resizeGroup(state: DockManagerState, groupId: string, sizes: number[]): DockManagerState {
168
- if (!state.main) return state
169
- return { ...state, main: setSizesInTree(state.main, groupId, sizes) }
170
- }
171
-
172
- function setSizesInTree(node: DockNode, groupId: string, sizes: number[]): DockNode {
173
- if (node.type === 'tabs') return node
174
- const children = node.children.map((c) => setSizesInTree(c, groupId, sizes))
175
- if (node.id === groupId) return { ...node, children, sizes: normalizeManagerSizes(sizes, children.length) }
176
- return { ...node, children }
177
- }
178
-
179
- function normalizeManagerSizes(sizes: number[], count: number): number[] {
180
- if (count <= 0) return []
181
- const src = sizes.length === count ? sizes : new Array(count).fill(1)
182
- const total = src.reduce((a, b) => a + (b > 0 ? b : 0), 0)
183
- return total <= 0 ? new Array(count).fill(1 / count) : src.map((s) => (s > 0 ? s : 0) / total)
184
- }
185
-
186
- /** Set the active tab of a leaf in main or a floating window. */
187
- export function setManagerActive(state: DockManagerState, tabsId: string, active: number): DockManagerState {
188
- const w = state.floating.find((f) => f.leaf.id === tabsId)
189
- if (w) {
190
- return {
191
- ...state,
192
- floating: state.floating.map((f) =>
193
- f.id === w.id ? { ...f, leaf: { ...f.leaf, active: clamp(active, f.leaf.panes.length) } } : f,
194
- ),
195
- }
196
- }
197
- // Auto-hidden leaf: activate a pane so its edge tab / flyout opens on it.
198
- const ah = state.autoHide.find((e) => e.leaf.id === tabsId)
199
- if (ah) {
200
- return {
201
- ...state,
202
- autoHide: state.autoHide.map((e) =>
203
- e.id === ah.id ? { ...e, leaf: { ...e.leaf, active: clamp(active, e.leaf.panes.length) } } : e,
204
- ),
205
- }
206
- }
207
- return { ...state, main: state.main ? setActiveInTree(state.main, tabsId, active) : state.main }
208
- }
209
-
210
- /**
211
- * Dock a pane onto a target leaf (`center` = new tab, edge = split). Works
212
- * across surfaces: a floating pane can dock into main, a main pane into a float
213
- * (centre only), etc. Edge zones only apply when the target is in `main`.
214
- */
215
- export function dockPaneOnto(
216
- state: DockManagerState,
217
- paneId: string,
218
- targetTabsId: string,
219
- zone: DockZone,
220
- genId: IdGen,
221
- ): DockManagerState {
222
- const moving = findPaneObject(state, paneId)
223
- if (!moving) return state
224
- const targetSurface = surfaceOfTabs(state, targetTabsId)
225
- if (!targetSurface) return state
226
-
227
- const stripped = stripPane(state, paneId)
228
- // The target leaf might have dissolved (it was the pane's own single leaf).
229
- if (surfaceOfTabs(stripped, targetTabsId) === null) return state
230
-
231
- if (targetSurface === 'main') {
232
- const base = stripped.main
233
- if (!base) return state
234
- return { ...stripped, main: dockInto(base, targetTabsId, moving, zone, genId) }
235
- }
236
- // Docking into a floating window: centre only (add as a tab).
237
- return {
238
- ...stripped,
239
- floating: stripped.floating.map((w) => {
240
- if (w.id !== targetSurface) return w
241
- const panes = [...w.leaf.panes, moving]
242
- return { ...w, leaf: { ...w.leaf, panes, active: panes.length - 1 } }
243
- }),
244
- }
245
- }
246
-
247
- /** Insert a NEW pane into the main area - the first leaf (as a tab), or as the
248
- * whole main when empty. Used to pop a window/pop-out back into the layout. */
249
- export function addPaneToMain(state: DockManagerState, p: DockPane, genId: IdGen): DockManagerState {
250
- if (!state.main) return { ...state, main: tabs(genId, [p], 0) }
251
- let added = false
252
- const walk = (n: DockNode): DockNode => {
253
- if (added) return n
254
- if (n.type === 'tabs') {
255
- added = true
256
- const panes = [...n.panes, p]
257
- return { ...n, panes, active: panes.length - 1 }
258
- }
259
- return { ...n, children: n.children.map(walk) }
260
- }
261
- return { ...state, main: walk(state.main) }
262
- }
263
-
264
- /** Dock a pane into an empty main area (main becomes a single leaf holding it). */
265
- export function dockPaneToEmptyMain(state: DockManagerState, paneId: string, genId: IdGen): DockManagerState {
266
- const moving = findPaneObject(state, paneId)
267
- if (!moving) return state
268
- const stripped = stripPane(state, paneId)
269
- if (stripped.main) return { ...stripped, main: stripped.main } // main not empty; caller should dock onto a leaf
270
- return { ...stripped, main: tabs(genId, [moving], 0) }
271
- }
272
-
273
- /** Pop a pane out into a new floating window at the given rect. */
274
- export function floatPane(
275
- state: DockManagerState,
276
- paneId: string,
277
- rect: { x: number; y: number; width: number; height: number },
278
- genId: IdGen,
279
- ): DockManagerState {
280
- const moving = findPaneObject(state, paneId)
281
- if (!moving) return state
282
- const loc = locatePane(state, paneId)
283
- // Already the sole pane of its own float - nothing to pop out.
284
- if (loc?.kind === 'floating') {
285
- const w = state.floating.find((f) => f.id === loc.windowId)
286
- if (w && w.leaf.panes.length === 1) return state
287
- }
288
- const stripped = stripPane(state, paneId)
289
- const win: FloatWindow = {
290
- id: genId(),
291
- leaf: tabs(genId, [moving], 0),
292
- x: rect.x,
293
- y: rect.y,
294
- width: rect.width,
295
- height: rect.height,
296
- z: maxZ(state) + 1,
297
- }
298
- return { ...stripped, floating: [...stripped.floating, win] }
299
- }
300
-
301
- /** Dock an entire floating window's panes back into a main leaf. */
302
- export function dockWindowOnto(
303
- state: DockManagerState,
304
- windowId: string,
305
- targetTabsId: string,
306
- zone: DockZone,
307
- genId: IdGen,
308
- ): DockManagerState {
309
- const win = state.floating.find((w) => w.id === windowId)
310
- if (!win) return state
311
- if (surfaceOfTabs(state, targetTabsId) !== 'main' || !state.main) return state
312
- const rest = state.floating.filter((w) => w.id !== windowId)
313
- if (zone === 'center') {
314
- return {
315
- ...state,
316
- floating: rest,
317
- main: state.main
318
- ? mergePanesInto(state.main, targetTabsId, win.leaf.panes)
319
- : state.main,
320
- }
321
- }
322
- // Edge: drop the window's whole leaf beside the target.
323
- const leaf = tabs(genId, win.leaf.panes, win.leaf.active)
324
- const dir = zone === 'left' || zone === 'right' ? 'row' : 'column'
325
- const before = zone === 'left' || zone === 'top'
326
- return {
327
- ...state,
328
- floating: rest,
329
- main: insertLeafBeside(state.main, targetTabsId, leaf, dir, before, genId),
330
- }
331
- }
332
-
333
- /** Move a floating window (top-left). */
334
- export function moveWindow(state: DockManagerState, windowId: string, x: number, y: number): DockManagerState {
335
- return { ...state, floating: state.floating.map((w) => (w.id === windowId ? { ...w, x, y } : w)) }
336
- }
337
-
338
- /** Resize a floating window. */
339
- export function resizeWindow(
340
- state: DockManagerState,
341
- windowId: string,
342
- width: number,
343
- height: number,
344
- ): DockManagerState {
345
- return {
346
- ...state,
347
- floating: state.floating.map((w) =>
348
- w.id === windowId ? { ...w, width: Math.max(140, width), height: Math.max(80, height) } : w,
349
- ),
350
- }
351
- }
352
-
353
- /** Raise a floating window to the top of the stack. */
354
- export function bringToFront(state: DockManagerState, windowId: string): DockManagerState {
355
- const top = maxZ(state)
356
- return { ...state, floating: state.floating.map((w) => (w.id === windowId ? { ...w, z: top + 1 } : w)) }
357
- }
358
-
359
- /** Collapse a floating window to just its title bar (or restore it). */
360
- export function setWindowMinimized(state: DockManagerState, windowId: string, minimized: boolean): DockManagerState {
361
- return {
362
- ...state,
363
- floating: state.floating.map((w) =>
364
- w.id === windowId ? { ...w, minimized, maximized: minimized ? false : w.maximized } : w,
365
- ),
366
- }
367
- }
368
-
369
- /** Toggle a floating window between filling the manager and its own rect. */
370
- export function toggleWindowMaximized(state: DockManagerState, windowId: string): DockManagerState {
371
- return {
372
- ...state,
373
- floating: state.floating.map((w) =>
374
- w.id === windowId ? { ...w, maximized: !w.maximized, minimized: false } : w,
375
- ),
376
- }
377
- }
378
-
379
- /** Close a floating window and all its panes. */
380
- export function closeWindow(state: DockManagerState, windowId: string): DockManagerState {
381
- return { ...state, floating: state.floating.filter((w) => w.id !== windowId) }
382
- }
383
-
384
- /** Find a tabs leaf anywhere (main or a floating window) by id. */
385
- export function findLeafById(state: DockManagerState, tabsId: string): DockTabs | null {
386
- const scan = (n: DockNode): DockTabs | null => {
387
- if (n.type === 'tabs') return n.id === tabsId ? n : null
388
- for (const c of n.children) { const hit = scan(c); if (hit) return hit }
389
- return null
390
- }
391
- if (state.main) { const m = scan(state.main); if (m) return m }
392
- const w = state.floating.find((f) => f.leaf.id === tabsId)
393
- if (w) return w.leaf
394
- const e = state.autoHide.find((x) => x.leaf.id === tabsId)
395
- return e ? e.leaf : null
396
- }
397
-
398
- /** Toggle a tiled leaf maximized (filling the docked area). Clears if it is gone. */
399
- export function toggleMaximizeLeaf(state: DockManagerState, tabsId: string): DockManagerState {
400
- const on = state.maximizedLeaf === tabsId
401
- return { ...state, maximizedLeaf: on ? null : tabsId }
402
- }
403
-
404
- /** Send a floating window's panel to an edge as an auto-hidden entry. */
405
- export function autoHideWindow(
406
- state: DockManagerState,
407
- windowId: string,
408
- side: DockSide,
409
- genId: IdGen,
410
- size = 260,
411
- ): DockManagerState {
412
- const w = state.floating.find((f) => f.id === windowId)
413
- if (!w) return state
414
- const entry: AutoHideEntry = { id: genId(), side, leaf: tabs(genId, w.leaf.panes, w.leaf.active), size }
415
- return { ...state, floating: state.floating.filter((f) => f.id !== windowId), autoHide: [...state.autoHide, entry] }
416
- }
417
-
418
- /** Collapse a MAIN leaf to an edge strip (auto-hide) on the given side. */
419
- export function autoHideLeaf(
420
- state: DockManagerState,
421
- tabsId: string,
422
- side: DockSide,
423
- size = 260,
424
- ): DockManagerState {
425
- if (!state.main) return state
426
- const { root, leaf } = removeLeaf(state.main, tabsId)
427
- if (!leaf) return state
428
- const entry: AutoHideEntry = { id: `ah-${tabsId}`, side, leaf, size }
429
- return { ...state, main: root, autoHide: [...state.autoHide, entry] }
430
- }
431
-
432
- /** Auto-hide a single pane to an edge (drag a tab to the manager border). The
433
- * pane leaves its leaf and becomes its own collapsed entry on that side. */
434
- export function autoHidePaneToSide(
435
- state: DockManagerState,
436
- paneId: string,
437
- side: DockSide,
438
- genId: IdGen,
439
- size = 260,
440
- ): DockManagerState {
441
- const moving = findPaneObject(state, paneId)
442
- if (!moving) return state
443
- const stripped = stripPane(state, paneId)
444
- const entry: AutoHideEntry = { id: genId(), side, leaf: tabs(genId, [moving], 0), size }
445
- return { ...stripped, autoHide: [...stripped.autoHide, entry] }
446
- }
447
-
448
- /**
449
- * Pin an auto-hidden entry back into the main dock area on its edge. `fraction`
450
- * is the share of the axis the re-docked panel should take (default 0.25).
451
- */
452
- export function pinAutoHidden(
453
- state: DockManagerState,
454
- entryId: string,
455
- genId: IdGen,
456
- fraction = 0.25,
457
- ): DockManagerState {
458
- const entry = state.autoHide.find((e) => e.id === entryId)
459
- if (!entry) return state
460
- const leaf = tabs(genId, entry.leaf.panes, entry.leaf.active)
461
- return {
462
- ...state,
463
- autoHide: state.autoHide.filter((e) => e.id !== entryId),
464
- main: dockLeafToEdge(state.main, leaf, entry.side, genId, fraction),
465
- }
466
- }
467
-
468
- /** Resize an auto-hide fly-out. */
469
- export function setAutoHideSize(state: DockManagerState, entryId: string, size: number): DockManagerState {
470
- return {
471
- ...state,
472
- autoHide: state.autoHide.map((e) => (e.id === entryId ? { ...e, size: Math.max(120, size) } : e)),
473
- }
474
- }
475
-
476
- /** Close a pane wherever it lives. */
477
- export function closePane(state: DockManagerState, paneId: string): DockManagerState {
478
- return stripPane(state, paneId)
479
- }
480
-
481
- /** Every pane id across all surfaces. */
482
- export function allManagerPaneIds(state: DockManagerState): string[] {
483
- const ids: string[] = []
484
- if (state.main) ids.push(...allPaneIds(state.main))
485
- for (const w of state.floating) ids.push(...w.leaf.panes.map((p) => p.id))
486
- for (const e of state.autoHide) ids.push(...e.leaf.panes.map((p) => p.id))
487
- return ids
488
- }
489
-
490
- /** Every id used anywhere in the workspace: node ids (groups + tabs leaves),
491
- * pane ids, floating-window ids and auto-hide entry ids. Used to keep a freshly
492
- * generated id unique even against a workspace restored from storage - the
493
- * manager's id counter is module-scoped and resets on reload, so without this
494
- * the same `dm-N` can be minted twice and collide (a keyed `{#each}` then throws
495
- * `each_key_duplicate`). */
496
- export function allManagerIds(state: DockManagerState): string[] {
497
- const ids: string[] = []
498
- const walk = (n: DockNode) => {
499
- ids.push(n.id)
500
- if (n.type === 'tabs') for (const p of n.panes) ids.push(p.id)
501
- else for (const c of n.children) walk(c)
502
- }
503
- if (state.main) walk(state.main)
504
- for (const w of state.floating) { ids.push(w.id); walk(w.leaf) }
505
- for (const e of state.autoHide) { ids.push(e.id); walk(e.leaf) }
506
- return ids
507
- }
508
-
509
- /** Reassign any duplicate group / tabs NODE id (and floating-window / auto-hide
510
- * entry id) so no keyed sibling list sees a collision. Pane ids are left alone -
511
- * they key the `pane` snippet to its content, so a rename would blank a panel;
512
- * a restored workspace's repeats are always on the generated node ids anyway.
513
- * Idempotent: a clean workspace is returned value-equal. */
514
- export function dedupeManagerNodeIds(state: DockManagerState): DockManagerState {
515
- const seen = new Set<string>()
516
- let n = 1
517
- const fresh = (): string => { let id = `dm-d${n++}`; while (seen.has(id)) id = `dm-d${n++}`; return id }
518
- const uniq = (id: string): string => {
519
- const next = seen.has(id) ? fresh() : id
520
- seen.add(next)
521
- return next
522
- }
523
- const walk = (node: DockNode): DockNode => {
524
- const id = uniq(node.id)
525
- if (node.type === 'tabs') return id === node.id ? node : { ...node, id }
526
- const children = node.children.map(walk)
527
- const same = id === node.id && children.every((c, i) => c === node.children[i])
528
- return same ? node : { ...node, id, children }
529
- }
530
- const main = state.main ? walk(state.main) : state.main
531
- let floatChanged = false
532
- const floating = state.floating.map((w) => {
533
- const id = uniq(w.id)
534
- const leaf = walk(w.leaf) as DockTabs
535
- if (id === w.id && leaf === w.leaf) return w
536
- floatChanged = true
537
- return { ...w, id, leaf }
538
- })
539
- let hideChanged = false
540
- const autoHide = state.autoHide.map((e) => {
541
- const id = uniq(e.id)
542
- const leaf = walk(e.leaf) as DockTabs
543
- if (id === e.id && leaf === e.leaf) return e
544
- hideChanged = true
545
- return { ...e, id, leaf }
546
- })
547
- // Preserve identity when nothing was duplicated - keeps the effect that seeds
548
- // the manager from re-running and avoids needless re-renders.
549
- if (main === state.main && !floatChanged && !hideChanged) return state
550
- return { ...state, main, floating: floatChanged ? floating : state.floating, autoHide: hideChanged ? autoHide : state.autoHide }
551
- }
552
-
553
- // ---- small tree helpers scoped to the manager -----------------------------
554
-
555
- function clamp(i: number, len: number): number {
556
- if (len <= 0) return 0
557
- return Math.max(0, Math.min(i, len - 1))
558
- }
559
-
560
- function setActiveInTree(node: DockNode, tabsId: string, active: number): DockNode {
561
- if (node.type === 'tabs') {
562
- return node.id === tabsId ? { ...node, active: clamp(active, node.panes.length) } : node
563
- }
564
- return { ...node, children: node.children.map((c) => setActiveInTree(c, tabsId, active)) }
565
- }
566
-
567
- function mergePanesInto(node: DockNode, tabsId: string, panes: DockPane[]): DockNode {
568
- if (node.type === 'tabs') {
569
- if (node.id !== tabsId) return node
570
- const merged = [...node.panes, ...panes]
571
- return { ...node, panes: merged, active: merged.length - 1 }
572
- }
573
- return { ...node, children: node.children.map((c) => mergePanesInto(c, tabsId, panes)) }
574
- }
575
-
576
- function insertLeafBeside(
577
- node: DockNode,
578
- targetTabsId: string,
579
- leaf: DockTabs,
580
- dir: 'row' | 'column',
581
- before: boolean,
582
- genId: IdGen,
583
- ): DockNode {
584
- // Reuse dockInto by moving the leaf's first pane, then merging the rest - but
585
- // simpler and exact: rebuild via dockLeafToEdge semantics locally. We wrap the
586
- // target subtree. Find + replace the target leaf with a group of [leaf,target].
587
- const replace = (n: DockNode): DockNode => {
588
- if (n.type === 'tabs') {
589
- if (n.id !== targetTabsId) return n
590
- const kids = before ? [leaf, n] : [n, leaf]
591
- return { type: 'group', id: genId(), direction: dir, children: kids, sizes: [0.5, 0.5] }
592
- }
593
- return { ...n, children: n.children.map(replace) }
594
- }
595
- return replace(node)
596
- }
1
+ /**
2
+ * dock-manager-model - the pure state behind <SvDockManager>. It wraps the base
3
+ * `dock-model` tree (the docked/tiled area) with the three "pro" docking
4
+ * surfaces a real IDE has:
5
+ *
6
+ * floating panes popped out into movable/resizable windows (a single tabs
7
+ * leaf each - a floating panel).
8
+ * autoHide leaves collapsed to an edge strip; a fly-out reveals them and
9
+ * "pin" re-docks them.
10
+ * main the tiled dock area itself (a `DockNode` tree, or null when
11
+ * everything has been floated / hidden).
12
+ *
13
+ * As with the base model, every operation is a pure transform returning a NEW
14
+ * state, so a whole workspace - tiled + floating + hidden - serializes to JSON
15
+ * and restores exactly. The Svelte view owns the DOM, gestures and z-order
16
+ * painting; all the surgery lives here and is unit-tested without a browser.
17
+ */
18
+ import {
19
+ tabs,
20
+ dockInto,
21
+ removePane as removePaneFromTree,
22
+ removeLeaf,
23
+ dockLeafToEdge,
24
+ reorderPane,
25
+ findTabsWithPane,
26
+ allPaneIds,
27
+ type DockNode,
28
+ type DockTabs,
29
+ type DockPane,
30
+ type DockZone,
31
+ type IdGen,
32
+ } from './dock-model'
33
+
34
+ /** Which edge a pane docks against when dropped. */
35
+ export type DockSide = Exclude<DockZone, 'center'>
36
+
37
+ /** A floating window: one tabs leaf shown in a movable/resizable frame. */
38
+ export type FloatWindow = {
39
+ id: string
40
+ leaf: DockTabs
41
+ x: number
42
+ y: number
43
+ width: number
44
+ height: number
45
+ /** Stacking order; higher is on top. */
46
+ z: number
47
+ /** Collapsed to just its title bar. */
48
+ minimized?: boolean
49
+ /** Expanded to fill the manager (its x/y/w/h are kept for restore). */
50
+ maximized?: boolean
51
+ }
52
+
53
+ /** A leaf collapsed to an edge; a fly-out reveals it, "pin" re-docks it. */
54
+ export type AutoHideEntry = {
55
+ id: string
56
+ side: DockSide
57
+ leaf: DockTabs
58
+ /** Fly-out panel size in px along the reveal axis. */
59
+ size: number
60
+ }
61
+
62
+ /** The whole dock manager: its layout tree plus floating and pinned panes. */
63
+ export type DockManagerState = {
64
+ main: DockNode | null
65
+ floating: FloatWindow[]
66
+ autoHide: AutoHideEntry[]
67
+ /** A tiled leaf shown maximized (filling the docked area), or null. */
68
+ maximizedLeaf?: string | null
69
+ }
70
+
71
+ // ---- queries --------------------------------------------------------------
72
+
73
+ /** Where a pane currently lives. */
74
+ export type PaneLocation =
75
+ | { kind: 'main' }
76
+ | { kind: 'floating'; windowId: string }
77
+ | { kind: 'autoHide'; entryId: string }
78
+
79
+ /** Find a pane in the layout tree by id, returning it with its parent for mutation. */
80
+ export function locatePane(state: DockManagerState, paneId: string): PaneLocation | null {
81
+ if (state.main && findTabsWithPane(state.main, paneId)) return { kind: 'main' }
82
+ for (const w of state.floating) {
83
+ if (w.leaf.panes.some((p) => p.id === paneId)) return { kind: 'floating', windowId: w.id }
84
+ }
85
+ for (const e of state.autoHide) {
86
+ if (e.leaf.panes.some((p) => p.id === paneId)) return { kind: 'autoHide', entryId: e.id }
87
+ }
88
+ return null
89
+ }
90
+
91
+ /** Which surface holds a tabs leaf: 'main', a window id, or null. */
92
+ export function surfaceOfTabs(state: DockManagerState, tabsId: string): 'main' | string | null {
93
+ if (state.main && containsTabs(state.main, tabsId)) return 'main'
94
+ const w = state.floating.find((f) => f.leaf.id === tabsId)
95
+ return w ? w.id : null
96
+ }
97
+
98
+ function containsTabs(node: DockNode, tabsId: string): boolean {
99
+ if (node.type === 'tabs') return node.id === tabsId
100
+ return node.children.some((c) => containsTabs(c, tabsId))
101
+ }
102
+
103
+ function findPaneObject(state: DockManagerState, paneId: string): DockPane | null {
104
+ const scan = (node: DockNode): DockPane | null => {
105
+ if (node.type === 'tabs') return node.panes.find((p) => p.id === paneId) ?? null
106
+ for (const c of node.children) { const hit = scan(c); if (hit) return hit }
107
+ return null
108
+ }
109
+ if (state.main) { const m = scan(state.main); if (m) return m }
110
+ for (const w of state.floating) { const f = w.leaf.panes.find((p) => p.id === paneId); if (f) return f }
111
+ for (const e of state.autoHide) { const a = e.leaf.panes.find((p) => p.id === paneId); if (a) return a }
112
+ return null
113
+ }
114
+
115
+ function maxZ(state: DockManagerState): number {
116
+ return state.floating.reduce((m, w) => Math.max(m, w.z), 0)
117
+ }
118
+
119
+ // ---- internal: remove a pane from wherever it is --------------------------
120
+
121
+ /** Remove a pane from every surface, dropping windows/entries it empties. */
122
+ function stripPane(state: DockManagerState, paneId: string): DockManagerState {
123
+ let main = state.main
124
+ if (main && findTabsWithPane(main, paneId)) {
125
+ main = removePaneFromTree(main, paneId).root
126
+ }
127
+ const floating = state.floating
128
+ .map((w) => {
129
+ if (!w.leaf.panes.some((p) => p.id === paneId)) return w
130
+ const panes = w.leaf.panes.filter((p) => p.id !== paneId)
131
+ if (panes.length === 0) return null
132
+ const active = Math.max(0, Math.min(w.leaf.active, panes.length - 1))
133
+ return { ...w, leaf: { ...w.leaf, panes, active } }
134
+ })
135
+ .filter((w): w is FloatWindow => w !== null)
136
+ const autoHide = state.autoHide
137
+ .map((e) => {
138
+ if (!e.leaf.panes.some((p) => p.id === paneId)) return e
139
+ const panes = e.leaf.panes.filter((p) => p.id !== paneId)
140
+ if (panes.length === 0) return null
141
+ const active = Math.max(0, Math.min(e.leaf.active, panes.length - 1))
142
+ return { ...e, leaf: { ...e.leaf, panes, active } }
143
+ })
144
+ .filter((e): e is AutoHideEntry => e !== null)
145
+ return { main, floating, autoHide }
146
+ }
147
+
148
+ // ---- mutations ------------------------------------------------------------
149
+
150
+ /** Reorder a tab within its leaf, wherever that leaf lives (main or a float). */
151
+ export function reorderTab(state: DockManagerState, tabsId: string, from: number, to: number): DockManagerState {
152
+ if (state.main && containsTabs(state.main, tabsId)) {
153
+ return { ...state, main: reorderPane(state.main, tabsId, from, to) }
154
+ }
155
+ return {
156
+ ...state,
157
+ floating: state.floating.map((w) => {
158
+ if (w.leaf.id !== tabsId) return w
159
+ const reordered = reorderPane(w.leaf, tabsId, from, to)
160
+ return reordered.type === 'tabs' ? { ...w, leaf: reordered } : w
161
+ }),
162
+ }
163
+ }
164
+
165
+ /** Resize a group's children (splitter drag). Groups only exist in `main`
166
+ * (floating windows are single leaves), so this targets the main tree. */
167
+ export function resizeGroup(state: DockManagerState, groupId: string, sizes: number[]): DockManagerState {
168
+ if (!state.main) return state
169
+ return { ...state, main: setSizesInTree(state.main, groupId, sizes) }
170
+ }
171
+
172
+ function setSizesInTree(node: DockNode, groupId: string, sizes: number[]): DockNode {
173
+ if (node.type === 'tabs') return node
174
+ const children = node.children.map((c) => setSizesInTree(c, groupId, sizes))
175
+ if (node.id === groupId) return { ...node, children, sizes: normalizeManagerSizes(sizes, children.length) }
176
+ return { ...node, children }
177
+ }
178
+
179
+ function normalizeManagerSizes(sizes: number[], count: number): number[] {
180
+ if (count <= 0) return []
181
+ const src = sizes.length === count ? sizes : new Array(count).fill(1)
182
+ const total = src.reduce((a, b) => a + (b > 0 ? b : 0), 0)
183
+ return total <= 0 ? new Array(count).fill(1 / count) : src.map((s) => (s > 0 ? s : 0) / total)
184
+ }
185
+
186
+ /** Set the active tab of a leaf in main or a floating window. */
187
+ export function setManagerActive(state: DockManagerState, tabsId: string, active: number): DockManagerState {
188
+ const w = state.floating.find((f) => f.leaf.id === tabsId)
189
+ if (w) {
190
+ return {
191
+ ...state,
192
+ floating: state.floating.map((f) =>
193
+ f.id === w.id ? { ...f, leaf: { ...f.leaf, active: clamp(active, f.leaf.panes.length) } } : f,
194
+ ),
195
+ }
196
+ }
197
+ // Auto-hidden leaf: activate a pane so its edge tab / flyout opens on it.
198
+ const ah = state.autoHide.find((e) => e.leaf.id === tabsId)
199
+ if (ah) {
200
+ return {
201
+ ...state,
202
+ autoHide: state.autoHide.map((e) =>
203
+ e.id === ah.id ? { ...e, leaf: { ...e.leaf, active: clamp(active, e.leaf.panes.length) } } : e,
204
+ ),
205
+ }
206
+ }
207
+ return { ...state, main: state.main ? setActiveInTree(state.main, tabsId, active) : state.main }
208
+ }
209
+
210
+ /**
211
+ * Dock a pane onto a target leaf (`center` = new tab, edge = split). Works
212
+ * across surfaces: a floating pane can dock into main, a main pane into a float
213
+ * (centre only), etc. Edge zones only apply when the target is in `main`.
214
+ */
215
+ export function dockPaneOnto(
216
+ state: DockManagerState,
217
+ paneId: string,
218
+ targetTabsId: string,
219
+ zone: DockZone,
220
+ genId: IdGen,
221
+ ): DockManagerState {
222
+ const moving = findPaneObject(state, paneId)
223
+ if (!moving) return state
224
+ const targetSurface = surfaceOfTabs(state, targetTabsId)
225
+ if (!targetSurface) return state
226
+
227
+ const stripped = stripPane(state, paneId)
228
+ // The target leaf might have dissolved (it was the pane's own single leaf).
229
+ if (surfaceOfTabs(stripped, targetTabsId) === null) return state
230
+
231
+ if (targetSurface === 'main') {
232
+ const base = stripped.main
233
+ if (!base) return state
234
+ return { ...stripped, main: dockInto(base, targetTabsId, moving, zone, genId) }
235
+ }
236
+ // Docking into a floating window: centre only (add as a tab).
237
+ return {
238
+ ...stripped,
239
+ floating: stripped.floating.map((w) => {
240
+ if (w.id !== targetSurface) return w
241
+ const panes = [...w.leaf.panes, moving]
242
+ return { ...w, leaf: { ...w.leaf, panes, active: panes.length - 1 } }
243
+ }),
244
+ }
245
+ }
246
+
247
+ /** Insert a NEW pane into the main area - the first leaf (as a tab), or as the
248
+ * whole main when empty. Used to pop a window/pop-out back into the layout. */
249
+ export function addPaneToMain(state: DockManagerState, p: DockPane, genId: IdGen): DockManagerState {
250
+ if (!state.main) return { ...state, main: tabs(genId, [p], 0) }
251
+ let added = false
252
+ const walk = (n: DockNode): DockNode => {
253
+ if (added) return n
254
+ if (n.type === 'tabs') {
255
+ added = true
256
+ const panes = [...n.panes, p]
257
+ return { ...n, panes, active: panes.length - 1 }
258
+ }
259
+ return { ...n, children: n.children.map(walk) }
260
+ }
261
+ return { ...state, main: walk(state.main) }
262
+ }
263
+
264
+ /** Dock a pane into an empty main area (main becomes a single leaf holding it). */
265
+ export function dockPaneToEmptyMain(state: DockManagerState, paneId: string, genId: IdGen): DockManagerState {
266
+ const moving = findPaneObject(state, paneId)
267
+ if (!moving) return state
268
+ const stripped = stripPane(state, paneId)
269
+ if (stripped.main) return { ...stripped, main: stripped.main } // main not empty; caller should dock onto a leaf
270
+ return { ...stripped, main: tabs(genId, [moving], 0) }
271
+ }
272
+
273
+ /** Pop a pane out into a new floating window at the given rect. */
274
+ export function floatPane(
275
+ state: DockManagerState,
276
+ paneId: string,
277
+ rect: { x: number; y: number; width: number; height: number },
278
+ genId: IdGen,
279
+ ): DockManagerState {
280
+ const moving = findPaneObject(state, paneId)
281
+ if (!moving) return state
282
+ const loc = locatePane(state, paneId)
283
+ // Already the sole pane of its own float - nothing to pop out.
284
+ if (loc?.kind === 'floating') {
285
+ const w = state.floating.find((f) => f.id === loc.windowId)
286
+ if (w && w.leaf.panes.length === 1) return state
287
+ }
288
+ const stripped = stripPane(state, paneId)
289
+ const win: FloatWindow = {
290
+ id: genId(),
291
+ leaf: tabs(genId, [moving], 0),
292
+ x: rect.x,
293
+ y: rect.y,
294
+ width: rect.width,
295
+ height: rect.height,
296
+ z: maxZ(state) + 1,
297
+ }
298
+ return { ...stripped, floating: [...stripped.floating, win] }
299
+ }
300
+
301
+ /** Dock an entire floating window's panes back into a main leaf. */
302
+ export function dockWindowOnto(
303
+ state: DockManagerState,
304
+ windowId: string,
305
+ targetTabsId: string,
306
+ zone: DockZone,
307
+ genId: IdGen,
308
+ ): DockManagerState {
309
+ const win = state.floating.find((w) => w.id === windowId)
310
+ if (!win) return state
311
+ if (surfaceOfTabs(state, targetTabsId) !== 'main' || !state.main) return state
312
+ const rest = state.floating.filter((w) => w.id !== windowId)
313
+ if (zone === 'center') {
314
+ return {
315
+ ...state,
316
+ floating: rest,
317
+ main: state.main
318
+ ? mergePanesInto(state.main, targetTabsId, win.leaf.panes)
319
+ : state.main,
320
+ }
321
+ }
322
+ // Edge: drop the window's whole leaf beside the target.
323
+ const leaf = tabs(genId, win.leaf.panes, win.leaf.active)
324
+ const dir = zone === 'left' || zone === 'right' ? 'row' : 'column'
325
+ const before = zone === 'left' || zone === 'top'
326
+ return {
327
+ ...state,
328
+ floating: rest,
329
+ main: insertLeafBeside(state.main, targetTabsId, leaf, dir, before, genId),
330
+ }
331
+ }
332
+
333
+ /** Move a floating window (top-left). */
334
+ export function moveWindow(state: DockManagerState, windowId: string, x: number, y: number): DockManagerState {
335
+ return { ...state, floating: state.floating.map((w) => (w.id === windowId ? { ...w, x, y } : w)) }
336
+ }
337
+
338
+ /** Resize a floating window. */
339
+ export function resizeWindow(
340
+ state: DockManagerState,
341
+ windowId: string,
342
+ width: number,
343
+ height: number,
344
+ ): DockManagerState {
345
+ return {
346
+ ...state,
347
+ floating: state.floating.map((w) =>
348
+ w.id === windowId ? { ...w, width: Math.max(140, width), height: Math.max(80, height) } : w,
349
+ ),
350
+ }
351
+ }
352
+
353
+ /** Raise a floating window to the top of the stack. */
354
+ export function bringToFront(state: DockManagerState, windowId: string): DockManagerState {
355
+ const top = maxZ(state)
356
+ return { ...state, floating: state.floating.map((w) => (w.id === windowId ? { ...w, z: top + 1 } : w)) }
357
+ }
358
+
359
+ /** Collapse a floating window to just its title bar (or restore it). */
360
+ export function setWindowMinimized(state: DockManagerState, windowId: string, minimized: boolean): DockManagerState {
361
+ return {
362
+ ...state,
363
+ floating: state.floating.map((w) =>
364
+ w.id === windowId ? { ...w, minimized, maximized: minimized ? false : w.maximized } : w,
365
+ ),
366
+ }
367
+ }
368
+
369
+ /** Toggle a floating window between filling the manager and its own rect. */
370
+ export function toggleWindowMaximized(state: DockManagerState, windowId: string): DockManagerState {
371
+ return {
372
+ ...state,
373
+ floating: state.floating.map((w) =>
374
+ w.id === windowId ? { ...w, maximized: !w.maximized, minimized: false } : w,
375
+ ),
376
+ }
377
+ }
378
+
379
+ /** Close a floating window and all its panes. */
380
+ export function closeWindow(state: DockManagerState, windowId: string): DockManagerState {
381
+ return { ...state, floating: state.floating.filter((w) => w.id !== windowId) }
382
+ }
383
+
384
+ /** Find a tabs leaf anywhere (main or a floating window) by id. */
385
+ export function findLeafById(state: DockManagerState, tabsId: string): DockTabs | null {
386
+ const scan = (n: DockNode): DockTabs | null => {
387
+ if (n.type === 'tabs') return n.id === tabsId ? n : null
388
+ for (const c of n.children) { const hit = scan(c); if (hit) return hit }
389
+ return null
390
+ }
391
+ if (state.main) { const m = scan(state.main); if (m) return m }
392
+ const w = state.floating.find((f) => f.leaf.id === tabsId)
393
+ if (w) return w.leaf
394
+ const e = state.autoHide.find((x) => x.leaf.id === tabsId)
395
+ return e ? e.leaf : null
396
+ }
397
+
398
+ /** Toggle a tiled leaf maximized (filling the docked area). Clears if it is gone. */
399
+ export function toggleMaximizeLeaf(state: DockManagerState, tabsId: string): DockManagerState {
400
+ const on = state.maximizedLeaf === tabsId
401
+ return { ...state, maximizedLeaf: on ? null : tabsId }
402
+ }
403
+
404
+ /** Send a floating window's panel to an edge as an auto-hidden entry. */
405
+ export function autoHideWindow(
406
+ state: DockManagerState,
407
+ windowId: string,
408
+ side: DockSide,
409
+ genId: IdGen,
410
+ size = 260,
411
+ ): DockManagerState {
412
+ const w = state.floating.find((f) => f.id === windowId)
413
+ if (!w) return state
414
+ const entry: AutoHideEntry = { id: genId(), side, leaf: tabs(genId, w.leaf.panes, w.leaf.active), size }
415
+ return { ...state, floating: state.floating.filter((f) => f.id !== windowId), autoHide: [...state.autoHide, entry] }
416
+ }
417
+
418
+ /** Collapse a MAIN leaf to an edge strip (auto-hide) on the given side. */
419
+ export function autoHideLeaf(
420
+ state: DockManagerState,
421
+ tabsId: string,
422
+ side: DockSide,
423
+ size = 260,
424
+ ): DockManagerState {
425
+ if (!state.main) return state
426
+ const { root, leaf } = removeLeaf(state.main, tabsId)
427
+ if (!leaf) return state
428
+ const entry: AutoHideEntry = { id: `ah-${tabsId}`, side, leaf, size }
429
+ return { ...state, main: root, autoHide: [...state.autoHide, entry] }
430
+ }
431
+
432
+ /** Auto-hide a single pane to an edge (drag a tab to the manager border). The
433
+ * pane leaves its leaf and becomes its own collapsed entry on that side. */
434
+ export function autoHidePaneToSide(
435
+ state: DockManagerState,
436
+ paneId: string,
437
+ side: DockSide,
438
+ genId: IdGen,
439
+ size = 260,
440
+ ): DockManagerState {
441
+ const moving = findPaneObject(state, paneId)
442
+ if (!moving) return state
443
+ const stripped = stripPane(state, paneId)
444
+ const entry: AutoHideEntry = { id: genId(), side, leaf: tabs(genId, [moving], 0), size }
445
+ return { ...stripped, autoHide: [...stripped.autoHide, entry] }
446
+ }
447
+
448
+ /**
449
+ * Pin an auto-hidden entry back into the main dock area on its edge. `fraction`
450
+ * is the share of the axis the re-docked panel should take (default 0.25).
451
+ */
452
+ export function pinAutoHidden(
453
+ state: DockManagerState,
454
+ entryId: string,
455
+ genId: IdGen,
456
+ fraction = 0.25,
457
+ ): DockManagerState {
458
+ const entry = state.autoHide.find((e) => e.id === entryId)
459
+ if (!entry) return state
460
+ const leaf = tabs(genId, entry.leaf.panes, entry.leaf.active)
461
+ return {
462
+ ...state,
463
+ autoHide: state.autoHide.filter((e) => e.id !== entryId),
464
+ main: dockLeafToEdge(state.main, leaf, entry.side, genId, fraction),
465
+ }
466
+ }
467
+
468
+ /** Resize an auto-hide fly-out. */
469
+ export function setAutoHideSize(state: DockManagerState, entryId: string, size: number): DockManagerState {
470
+ return {
471
+ ...state,
472
+ autoHide: state.autoHide.map((e) => (e.id === entryId ? { ...e, size: Math.max(120, size) } : e)),
473
+ }
474
+ }
475
+
476
+ /** Close a pane wherever it lives. */
477
+ export function closePane(state: DockManagerState, paneId: string): DockManagerState {
478
+ return stripPane(state, paneId)
479
+ }
480
+
481
+ /** Every pane id across all surfaces. */
482
+ export function allManagerPaneIds(state: DockManagerState): string[] {
483
+ const ids: string[] = []
484
+ if (state.main) ids.push(...allPaneIds(state.main))
485
+ for (const w of state.floating) ids.push(...w.leaf.panes.map((p) => p.id))
486
+ for (const e of state.autoHide) ids.push(...e.leaf.panes.map((p) => p.id))
487
+ return ids
488
+ }
489
+
490
+ /** Every id used anywhere in the workspace: node ids (groups + tabs leaves),
491
+ * pane ids, floating-window ids and auto-hide entry ids. Used to keep a freshly
492
+ * generated id unique even against a workspace restored from storage - the
493
+ * manager's id counter is module-scoped and resets on reload, so without this
494
+ * the same `dm-N` can be minted twice and collide (a keyed `{#each}` then throws
495
+ * `each_key_duplicate`). */
496
+ export function allManagerIds(state: DockManagerState): string[] {
497
+ const ids: string[] = []
498
+ const walk = (n: DockNode) => {
499
+ ids.push(n.id)
500
+ if (n.type === 'tabs') for (const p of n.panes) ids.push(p.id)
501
+ else for (const c of n.children) walk(c)
502
+ }
503
+ if (state.main) walk(state.main)
504
+ for (const w of state.floating) { ids.push(w.id); walk(w.leaf) }
505
+ for (const e of state.autoHide) { ids.push(e.id); walk(e.leaf) }
506
+ return ids
507
+ }
508
+
509
+ /** Reassign any duplicate group / tabs NODE id (and floating-window / auto-hide
510
+ * entry id) so no keyed sibling list sees a collision. Pane ids are left alone -
511
+ * they key the `pane` snippet to its content, so a rename would blank a panel;
512
+ * a restored workspace's repeats are always on the generated node ids anyway.
513
+ * Idempotent: a clean workspace is returned value-equal. */
514
+ export function dedupeManagerNodeIds(state: DockManagerState): DockManagerState {
515
+ const seen = new Set<string>()
516
+ let n = 1
517
+ const fresh = (): string => { let id = `dm-d${n++}`; while (seen.has(id)) id = `dm-d${n++}`; return id }
518
+ const uniq = (id: string): string => {
519
+ const next = seen.has(id) ? fresh() : id
520
+ seen.add(next)
521
+ return next
522
+ }
523
+ const walk = (node: DockNode): DockNode => {
524
+ const id = uniq(node.id)
525
+ if (node.type === 'tabs') return id === node.id ? node : { ...node, id }
526
+ const children = node.children.map(walk)
527
+ const same = id === node.id && children.every((c, i) => c === node.children[i])
528
+ return same ? node : { ...node, id, children }
529
+ }
530
+ const main = state.main ? walk(state.main) : state.main
531
+ let floatChanged = false
532
+ const floating = state.floating.map((w) => {
533
+ const id = uniq(w.id)
534
+ const leaf = walk(w.leaf) as DockTabs
535
+ if (id === w.id && leaf === w.leaf) return w
536
+ floatChanged = true
537
+ return { ...w, id, leaf }
538
+ })
539
+ let hideChanged = false
540
+ const autoHide = state.autoHide.map((e) => {
541
+ const id = uniq(e.id)
542
+ const leaf = walk(e.leaf) as DockTabs
543
+ if (id === e.id && leaf === e.leaf) return e
544
+ hideChanged = true
545
+ return { ...e, id, leaf }
546
+ })
547
+ // Preserve identity when nothing was duplicated - keeps the effect that seeds
548
+ // the manager from re-running and avoids needless re-renders.
549
+ if (main === state.main && !floatChanged && !hideChanged) return state
550
+ return { ...state, main, floating: floatChanged ? floating : state.floating, autoHide: hideChanged ? autoHide : state.autoHide }
551
+ }
552
+
553
+ // ---- small tree helpers scoped to the manager -----------------------------
554
+
555
+ function clamp(i: number, len: number): number {
556
+ if (len <= 0) return 0
557
+ return Math.max(0, Math.min(i, len - 1))
558
+ }
559
+
560
+ function setActiveInTree(node: DockNode, tabsId: string, active: number): DockNode {
561
+ if (node.type === 'tabs') {
562
+ return node.id === tabsId ? { ...node, active: clamp(active, node.panes.length) } : node
563
+ }
564
+ return { ...node, children: node.children.map((c) => setActiveInTree(c, tabsId, active)) }
565
+ }
566
+
567
+ function mergePanesInto(node: DockNode, tabsId: string, panes: DockPane[]): DockNode {
568
+ if (node.type === 'tabs') {
569
+ if (node.id !== tabsId) return node
570
+ const merged = [...node.panes, ...panes]
571
+ return { ...node, panes: merged, active: merged.length - 1 }
572
+ }
573
+ return { ...node, children: node.children.map((c) => mergePanesInto(c, tabsId, panes)) }
574
+ }
575
+
576
+ function insertLeafBeside(
577
+ node: DockNode,
578
+ targetTabsId: string,
579
+ leaf: DockTabs,
580
+ dir: 'row' | 'column',
581
+ before: boolean,
582
+ genId: IdGen,
583
+ ): DockNode {
584
+ // Reuse dockInto by moving the leaf's first pane, then merging the rest - but
585
+ // simpler and exact: rebuild via dockLeafToEdge semantics locally. We wrap the
586
+ // target subtree. Find + replace the target leaf with a group of [leaf,target].
587
+ const replace = (n: DockNode): DockNode => {
588
+ if (n.type === 'tabs') {
589
+ if (n.id !== targetTabsId) return n
590
+ const kids = before ? [leaf, n] : [n, leaf]
591
+ return { type: 'group', id: genId(), direction: dir, children: kids, sizes: [0.5, 0.5] }
592
+ }
593
+ return { ...n, children: n.children.map(replace) }
594
+ }
595
+ return replace(node)
596
+ }