@svgrid/grid 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +181 -181
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGridCellEditor.svelte +748 -744
- package/dist/SvGridChart.svelte +1716 -1716
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +116 -116
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/cdn/{GridMenus-DMihUtma.js → GridMenus-CYzOvmNQ.js} +3 -3
- package/dist/cdn/{GridMenus-nmNDj1a3.js → GridMenus-DFB6iwF8.js} +3 -3
- package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
- package/dist/cdn/{SvGridChart-CwhFz7GV.js → SvGridChart-BEJmNNx9.js} +1 -1
- package/dist/cdn/{SvGridChart-Bs2GIR2Q.js → SvGridChart-BwVos976.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-1WXuStzM.js → SvGridChartPanel-BOknOkrP.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-e5_nqNP9.js → SvGridChartPanel-D2PjII4O.js} +1 -1
- package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-00LPUHL1.js} +1 -1
- package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-BhUEJ5ki.js} +1 -1
- package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
- package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
- package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
- package/dist/cdn/{src-B_YS5AOc.js → src-C8QjSaq-.js} +42 -44
- package/dist/cdn/{src-DYXXpuSk.js → src-D0hO2eYN.js} +41 -43
- package/dist/cdn/svgrid.js +7 -7
- package/dist/cdn/svgrid.svelte-external.js +7 -7
- package/dist/chart-export.js +8 -8
- package/dist/editing.js +22 -13
- package/package.json +11 -11
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +181 -181
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.types.ts +2092 -2092
- package/src/SvGridCellEditor.svelte +748 -744
- package/src/SvGridChart.svelte +1716 -1716
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +116 -116
- package/src/SvListBox.svelte +334 -334
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1419 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +171 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.ts +202 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2321 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +618 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag.test.ts +401 -401
- package/src/row-drag.ts +419 -419
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +322 -322
package/src/dock-model.ts
CHANGED
|
@@ -1,374 +1,374 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dock-model - the pure, immutable tree behind <SvDockLayout>. A docking layout
|
|
3
|
-
* is a tree of two node kinds:
|
|
4
|
-
*
|
|
5
|
-
* group a row/column of children (nested groups or tab-leaves), each holding
|
|
6
|
-
* a normalized `size` weight, with resizable splitters between them.
|
|
7
|
-
* tabs a leaf: one or more panes shown as tabs (only `active` is visible).
|
|
8
|
-
*
|
|
9
|
-
* Every restructuring a user does by dragging - dock a pane to a neighbour's
|
|
10
|
-
* edge (split) or centre (add as a tab), close a pane, drag a splitter - is a
|
|
11
|
-
* pure transform here that returns a NEW tree. The Svelte view owns the DOM and
|
|
12
|
-
* the drag gestures; all the tree surgery lives here so it is unit-testable
|
|
13
|
-
* without a browser. Modelled on Smart's `smart-layout` group/item structure.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** One dockable pane: its id, title, and the state a tab needs to render. */
|
|
17
|
-
export type DockPane = {
|
|
18
|
-
id: string
|
|
19
|
-
title: string
|
|
20
|
-
/** Show a close (x) affordance on the tab. Default true. */
|
|
21
|
-
closable?: boolean
|
|
22
|
-
/** Minimum size (px) for this pane's leaf along the split axis. */
|
|
23
|
-
minSize?: number
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A leaf: a stack of panes rendered as tabs (`active` is the visible one). */
|
|
27
|
-
export type DockTabs = {
|
|
28
|
-
type: 'tabs'
|
|
29
|
-
id: string
|
|
30
|
-
panes: DockPane[]
|
|
31
|
-
active: number
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** A row (side-by-side) or column (stacked) of children with per-child sizes. */
|
|
35
|
-
export type DockGroup = {
|
|
36
|
-
type: 'group'
|
|
37
|
-
id: string
|
|
38
|
-
direction: 'row' | 'column'
|
|
39
|
-
children: DockNode[]
|
|
40
|
-
/** Size weight per child; parallel to `children`, normalized to sum ~1. */
|
|
41
|
-
sizes: number[]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A node in the layout tree - either a split group or a tabbed leaf. */
|
|
45
|
-
export type DockNode = DockGroup | DockTabs
|
|
46
|
-
|
|
47
|
-
/** Where a dragged pane lands relative to a target leaf. */
|
|
48
|
-
export type DockZone = 'left' | 'right' | 'top' | 'bottom' | 'center'
|
|
49
|
-
|
|
50
|
-
/** Supplies ids for newly created nodes, so layouts stay deterministic in tests. */
|
|
51
|
-
export type IdGen = () => string
|
|
52
|
-
|
|
53
|
-
// ---- construction helpers -------------------------------------------------
|
|
54
|
-
|
|
55
|
-
/** Build a {@link DockPane}. */
|
|
56
|
-
export function pane(id: string, title: string, closable = true): DockPane {
|
|
57
|
-
return { id, title, closable }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Largest per-pane `minSize` in a leaf (0 if none). Used by splitter clamping. */
|
|
61
|
-
export function leafMinSize(node: DockTabs): number {
|
|
62
|
-
return node.panes.reduce((m, p) => Math.max(m, p.minSize ?? 0), 0)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Build a tabbed leaf holding the given panes. */
|
|
66
|
-
export function tabs(genId: IdGen, panes: DockPane[], active = 0): DockTabs {
|
|
67
|
-
return { type: 'tabs', id: genId(), panes, active: clampIndex(active, panes.length) }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Build a split group: panes or nested groups laid out in a row or column. */
|
|
71
|
-
export function group(
|
|
72
|
-
genId: IdGen,
|
|
73
|
-
direction: 'row' | 'column',
|
|
74
|
-
children: DockNode[],
|
|
75
|
-
sizes?: number[],
|
|
76
|
-
): DockGroup {
|
|
77
|
-
return { type: 'group', id: genId(), direction, children, sizes: normalizeSizes(sizes, children.length) }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ---- small utilities ------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
function clampIndex(i: number, len: number): number {
|
|
83
|
-
if (len <= 0) return 0
|
|
84
|
-
return Math.max(0, Math.min(i, len - 1))
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Return sizes summing to 1, padded/trimmed to `count`. Even split when absent. */
|
|
88
|
-
export function normalizeSizes(sizes: number[] | undefined, count: number): number[] {
|
|
89
|
-
if (count <= 0) return []
|
|
90
|
-
const src = sizes && sizes.length === count ? sizes.slice() : new Array(count).fill(1)
|
|
91
|
-
const total = src.reduce((a, b) => a + (b > 0 ? b : 0), 0)
|
|
92
|
-
if (total <= 0) return new Array(count).fill(1 / count)
|
|
93
|
-
return src.map((s) => (s > 0 ? s : 0) / total)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---- queries --------------------------------------------------------------
|
|
97
|
-
|
|
98
|
-
/** The tabs leaf that holds `paneId`, or null. */
|
|
99
|
-
export function findTabsWithPane(root: DockNode, paneId: string): DockTabs | null {
|
|
100
|
-
if (root.type === 'tabs') return root.panes.some((p) => p.id === paneId) ? root : null
|
|
101
|
-
for (const c of root.children) {
|
|
102
|
-
const hit = findTabsWithPane(c, paneId)
|
|
103
|
-
if (hit) return hit
|
|
104
|
-
}
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Every pane id in the tree, in document order. */
|
|
109
|
-
export function allPaneIds(root: DockNode): string[] {
|
|
110
|
-
if (root.type === 'tabs') return root.panes.map((p) => p.id)
|
|
111
|
-
return root.children.flatMap(allPaneIds)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ---- normalization --------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Collapse the tree after edits: drop empty tabs and empty groups, unwrap a
|
|
118
|
-
* group that has a single child, and flatten a child group into its parent when
|
|
119
|
-
* they share a direction (so repeated docking never nests infinitely deep).
|
|
120
|
-
* Returns the tidied node, or null when nothing is left.
|
|
121
|
-
*/
|
|
122
|
-
export function normalize(node: DockNode): DockNode | null {
|
|
123
|
-
if (node.type === 'tabs') {
|
|
124
|
-
if (node.panes.length === 0) return null
|
|
125
|
-
return { ...node, active: clampIndex(node.active, node.panes.length) }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Normalize children first, dropping the empties.
|
|
129
|
-
const kept: DockNode[] = []
|
|
130
|
-
const keptSizes: number[] = []
|
|
131
|
-
node.children.forEach((child, i) => {
|
|
132
|
-
const n = normalize(child)
|
|
133
|
-
if (n) {
|
|
134
|
-
kept.push(n)
|
|
135
|
-
keptSizes.push(node.sizes[i] ?? 1)
|
|
136
|
-
}
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
if (kept.length === 0) return null
|
|
140
|
-
if (kept.length === 1) return kept[0]! // unwrap single-child group
|
|
141
|
-
|
|
142
|
-
// Flatten same-direction child groups into this one.
|
|
143
|
-
const flatChildren: DockNode[] = []
|
|
144
|
-
const flatSizes: number[] = []
|
|
145
|
-
kept.forEach((child, i) => {
|
|
146
|
-
if (child.type === 'group' && child.direction === node.direction) {
|
|
147
|
-
const share = keptSizes[i]!
|
|
148
|
-
child.children.forEach((gc, j) => {
|
|
149
|
-
flatChildren.push(gc)
|
|
150
|
-
flatSizes.push(share * (child.sizes[j] ?? 1 / child.children.length))
|
|
151
|
-
})
|
|
152
|
-
} else {
|
|
153
|
-
flatChildren.push(child)
|
|
154
|
-
flatSizes.push(keptSizes[i]!)
|
|
155
|
-
}
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
return { ...node, children: flatChildren, sizes: normalizeSizes(flatSizes, flatChildren.length) }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ---- mutations (return a new tree) ----------------------------------------
|
|
162
|
-
|
|
163
|
-
/** Set the active tab index of a tabs leaf. */
|
|
164
|
-
export function setActive(root: DockNode, tabsId: string, active: number): DockNode {
|
|
165
|
-
return mapNodes(root, (n) =>
|
|
166
|
-
n.type === 'tabs' && n.id === tabsId ? { ...n, active: clampIndex(active, n.panes.length) } : n,
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Replace a group's size weights (e.g. after a splitter drag). */
|
|
171
|
-
export function setSizes(root: DockNode, groupId: string, sizes: number[]): DockNode {
|
|
172
|
-
return mapNodes(root, (n) =>
|
|
173
|
-
n.type === 'group' && n.id === groupId ? { ...n, sizes: normalizeSizes(sizes, n.children.length) } : n,
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Reorder a pane WITHIN its tabs leaf (dragging a tab along the strip). `active`
|
|
179
|
-
* follows the pane that was showing. No-op if the indices are out of range.
|
|
180
|
-
*/
|
|
181
|
-
export function reorderPane(root: DockNode, tabsId: string, from: number, to: number): DockNode {
|
|
182
|
-
return mapNodes(root, (n) => {
|
|
183
|
-
if (n.type !== 'tabs' || n.id !== tabsId) return n
|
|
184
|
-
if (from < 0 || from >= n.panes.length) return n
|
|
185
|
-
const showing = n.panes[n.active]?.id
|
|
186
|
-
const panes = n.panes.slice()
|
|
187
|
-
const [moved] = panes.splice(from, 1)
|
|
188
|
-
if (!moved) return n
|
|
189
|
-
// `to` is an insertion point in [0, length] (length = append), not a select
|
|
190
|
-
// index - clampIndex would wrongly cap it at length-1.
|
|
191
|
-
panes.splice(Math.max(0, Math.min(to, panes.length)), 0, moved)
|
|
192
|
-
const active = panes.findIndex((p) => p.id === showing)
|
|
193
|
-
return { ...n, panes, active: active < 0 ? 0 : active }
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Remove a pane. Returns the tidied tree (or null if it emptied) plus the pane
|
|
199
|
-
* that was pulled out, so a caller can re-dock it elsewhere.
|
|
200
|
-
*/
|
|
201
|
-
export function removePane(root: DockNode, paneId: string): { root: DockNode | null; pane: DockPane | null } {
|
|
202
|
-
let removed: DockPane | null = null
|
|
203
|
-
const strip = (node: DockNode): DockNode => {
|
|
204
|
-
if (node.type === 'tabs') {
|
|
205
|
-
const idx = node.panes.findIndex((p) => p.id === paneId)
|
|
206
|
-
if (idx < 0) return node
|
|
207
|
-
removed = node.panes[idx]!
|
|
208
|
-
const panes = node.panes.filter((_, i) => i !== idx)
|
|
209
|
-
return { ...node, panes, active: clampIndex(node.active > idx ? node.active - 1 : node.active, panes.length) }
|
|
210
|
-
}
|
|
211
|
-
return { ...node, children: node.children.map(strip) }
|
|
212
|
-
}
|
|
213
|
-
const stripped = strip(root)
|
|
214
|
-
return { root: normalize(stripped), pane: removed }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Dock `movingPane` relative to the leaf `targetTabsId`. `center` adds it as a
|
|
219
|
-
* tab; an edge splits: the target is wrapped in (or extended into) a group whose
|
|
220
|
-
* direction matches the edge, with the new pane on the requested side.
|
|
221
|
-
*/
|
|
222
|
-
export function dockInto(
|
|
223
|
-
root: DockNode,
|
|
224
|
-
targetTabsId: string,
|
|
225
|
-
movingPane: DockPane,
|
|
226
|
-
zone: DockZone,
|
|
227
|
-
genId: IdGen,
|
|
228
|
-
): DockNode {
|
|
229
|
-
const dir: 'row' | 'column' = zone === 'left' || zone === 'right' ? 'row' : 'column'
|
|
230
|
-
const before = zone === 'left' || zone === 'top'
|
|
231
|
-
|
|
232
|
-
const transform = (node: DockNode): DockNode => {
|
|
233
|
-
if (node.type === 'tabs') {
|
|
234
|
-
if (node.id !== targetTabsId) return node
|
|
235
|
-
if (zone === 'center') {
|
|
236
|
-
const panes = [...node.panes, movingPane]
|
|
237
|
-
return { ...node, panes, active: panes.length - 1 }
|
|
238
|
-
}
|
|
239
|
-
// Edge on a bare leaf: wrap it in a new group with the new leaf beside it.
|
|
240
|
-
const leaf = tabs(genId, [movingPane], 0)
|
|
241
|
-
const kids = before ? [leaf, node] : [node, leaf]
|
|
242
|
-
return group(genId, dir, kids)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Group: if it directly contains the target leaf and matches the edge
|
|
246
|
-
// direction, insert a sibling in place (keeps the tree flat).
|
|
247
|
-
const idx = node.children.findIndex((c) => c.type === 'tabs' && c.id === targetTabsId)
|
|
248
|
-
if (idx >= 0 && zone !== 'center' && node.direction === dir) {
|
|
249
|
-
const leaf = tabs(genId, [movingPane], 0)
|
|
250
|
-
const children = node.children.slice()
|
|
251
|
-
const sizes = node.sizes.slice()
|
|
252
|
-
const half = (node.sizes[idx] ?? 1 / node.children.length) / 2
|
|
253
|
-
const insertAt = before ? idx : idx + 1
|
|
254
|
-
children.splice(insertAt, 0, leaf)
|
|
255
|
-
sizes.splice(idx, 1, half, half) // split the target's slot in two
|
|
256
|
-
// splice above replaced 1 with 2 at idx; the new leaf takes `before`/after
|
|
257
|
-
if (before) {
|
|
258
|
-
// children: [..., leaf, target, ...]; sizes already [half(leaf), half(target)]
|
|
259
|
-
} else {
|
|
260
|
-
// we inserted leaf AFTER target but sizes are [half, half] at idx/idx+1 - correct
|
|
261
|
-
}
|
|
262
|
-
return { ...node, children, sizes: normalizeSizes(sizes, children.length) }
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return { ...node, children: node.children.map(transform) }
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return normalize(transform(root)) ?? root
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Pull a whole tabs leaf out of the tree (for auto-hide). Returns the tidied
|
|
272
|
-
* tree (or null) plus the extracted leaf. */
|
|
273
|
-
export function removeLeaf(root: DockNode, tabsId: string): { root: DockNode | null; leaf: DockTabs | null } {
|
|
274
|
-
let taken: DockTabs | null = null
|
|
275
|
-
const strip = (node: DockNode): DockNode | null => {
|
|
276
|
-
if (node.type === 'tabs') {
|
|
277
|
-
if (node.id === tabsId) { taken = node; return null }
|
|
278
|
-
return node
|
|
279
|
-
}
|
|
280
|
-
const kids: DockNode[] = []
|
|
281
|
-
const sizes: number[] = []
|
|
282
|
-
node.children.forEach((c, i) => {
|
|
283
|
-
const n = strip(c)
|
|
284
|
-
if (n) { kids.push(n); sizes.push(node.sizes[i] ?? 1) }
|
|
285
|
-
})
|
|
286
|
-
if (kids.length === 0) return null
|
|
287
|
-
return { ...node, children: kids, sizes: normalizeSizes(sizes, kids.length) }
|
|
288
|
-
}
|
|
289
|
-
const stripped = strip(root)
|
|
290
|
-
return { root: stripped ? normalize(stripped) : null, leaf: taken }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Dock a whole leaf against an EDGE of the entire tree (for pinning an
|
|
294
|
-
* auto-hidden panel back in). Wraps the root in a new group as needed; the
|
|
295
|
-
* pinned leaf takes `fraction` of that axis (clamped 0.1..0.6), the rest keeps
|
|
296
|
-
* the existing content. */
|
|
297
|
-
export function dockLeafToEdge(
|
|
298
|
-
root: DockNode | null,
|
|
299
|
-
leaf: DockTabs,
|
|
300
|
-
side: Exclude<DockZone, 'center'>,
|
|
301
|
-
genId: IdGen,
|
|
302
|
-
fraction = 0.25,
|
|
303
|
-
): DockNode {
|
|
304
|
-
if (!root) return leaf
|
|
305
|
-
const dir: 'row' | 'column' = side === 'left' || side === 'right' ? 'row' : 'column'
|
|
306
|
-
const before = side === 'left' || side === 'top'
|
|
307
|
-
const f = Math.min(0.6, Math.max(0.1, fraction))
|
|
308
|
-
const kids = before ? [leaf, root] : [root, leaf]
|
|
309
|
-
const sizes = before ? [f, 1 - f] : [1 - f, f]
|
|
310
|
-
return normalize(group(genId, dir, kids, sizes)) ?? leaf
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Move an existing pane to a new dock position (remove then dock). */
|
|
314
|
-
export function movePane(
|
|
315
|
-
root: DockNode,
|
|
316
|
-
paneId: string,
|
|
317
|
-
targetTabsId: string,
|
|
318
|
-
zone: DockZone,
|
|
319
|
-
genId: IdGen,
|
|
320
|
-
): DockNode {
|
|
321
|
-
const { root: without, pane: moved } = removePane(root, paneId)
|
|
322
|
-
if (!without || !moved) return root
|
|
323
|
-
// If removing the pane dissolved the target leaf (it was the pane's own
|
|
324
|
-
// single-pane leaf), there is nothing to dock against - leave the tree as-is.
|
|
325
|
-
if (!containsTabs(without, targetTabsId)) return root
|
|
326
|
-
return dockInto(without, targetTabsId, moved, zone, genId)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function containsTabs(node: DockNode, tabsId: string): boolean {
|
|
330
|
-
if (node.type === 'tabs') return node.id === tabsId
|
|
331
|
-
return node.children.some((c) => containsTabs(c, tabsId))
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ---- internal -------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
/** Map over every node, replacing where `fn` returns a new object. */
|
|
337
|
-
function mapNodes(node: DockNode, fn: (n: DockNode) => DockNode): DockNode {
|
|
338
|
-
const mapped = fn(node)
|
|
339
|
-
if (mapped.type === 'group') {
|
|
340
|
-
return { ...mapped, children: mapped.children.map((c) => mapNodes(c, fn)) }
|
|
341
|
-
}
|
|
342
|
-
return mapped
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// ---- consumer builders (auto ids) -----------------------------------------
|
|
346
|
-
// Ergonomic constructors for building an initial layout without threading an
|
|
347
|
-
// id generator. Node ids only need to be unique; a running counter suffices.
|
|
348
|
-
|
|
349
|
-
let AUTO = 0
|
|
350
|
-
const autoId: IdGen = () => `d${AUTO++}`
|
|
351
|
-
|
|
352
|
-
/** A pane (tab). Alias of `pane`, named for the public builder set. */
|
|
353
|
-
export function dockPane(
|
|
354
|
-
id: string,
|
|
355
|
-
title: string,
|
|
356
|
-
opts?: boolean | { closable?: boolean; minSize?: number },
|
|
357
|
-
): DockPane {
|
|
358
|
-
const o = typeof opts === 'boolean' ? { closable: opts } : (opts ?? {})
|
|
359
|
-
return { id, title, closable: o.closable ?? true, ...(o.minSize != null ? { minSize: o.minSize } : {}) }
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** A tab-leaf holding one or more panes. */
|
|
363
|
-
export function dockTabs(panes: DockPane[], active = 0): DockTabs {
|
|
364
|
-
return tabs(autoId, panes, active)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** A row (side-by-side) or column (stacked) of children. */
|
|
368
|
-
export function dockGroup(
|
|
369
|
-
direction: 'row' | 'column',
|
|
370
|
-
children: DockNode[],
|
|
371
|
-
sizes?: number[],
|
|
372
|
-
): DockGroup {
|
|
373
|
-
return group(autoId, direction, children, sizes)
|
|
374
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dock-model - the pure, immutable tree behind <SvDockLayout>. A docking layout
|
|
3
|
+
* is a tree of two node kinds:
|
|
4
|
+
*
|
|
5
|
+
* group a row/column of children (nested groups or tab-leaves), each holding
|
|
6
|
+
* a normalized `size` weight, with resizable splitters between them.
|
|
7
|
+
* tabs a leaf: one or more panes shown as tabs (only `active` is visible).
|
|
8
|
+
*
|
|
9
|
+
* Every restructuring a user does by dragging - dock a pane to a neighbour's
|
|
10
|
+
* edge (split) or centre (add as a tab), close a pane, drag a splitter - is a
|
|
11
|
+
* pure transform here that returns a NEW tree. The Svelte view owns the DOM and
|
|
12
|
+
* the drag gestures; all the tree surgery lives here so it is unit-testable
|
|
13
|
+
* without a browser. Modelled on Smart's `smart-layout` group/item structure.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One dockable pane: its id, title, and the state a tab needs to render. */
|
|
17
|
+
export type DockPane = {
|
|
18
|
+
id: string
|
|
19
|
+
title: string
|
|
20
|
+
/** Show a close (x) affordance on the tab. Default true. */
|
|
21
|
+
closable?: boolean
|
|
22
|
+
/** Minimum size (px) for this pane's leaf along the split axis. */
|
|
23
|
+
minSize?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A leaf: a stack of panes rendered as tabs (`active` is the visible one). */
|
|
27
|
+
export type DockTabs = {
|
|
28
|
+
type: 'tabs'
|
|
29
|
+
id: string
|
|
30
|
+
panes: DockPane[]
|
|
31
|
+
active: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A row (side-by-side) or column (stacked) of children with per-child sizes. */
|
|
35
|
+
export type DockGroup = {
|
|
36
|
+
type: 'group'
|
|
37
|
+
id: string
|
|
38
|
+
direction: 'row' | 'column'
|
|
39
|
+
children: DockNode[]
|
|
40
|
+
/** Size weight per child; parallel to `children`, normalized to sum ~1. */
|
|
41
|
+
sizes: number[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A node in the layout tree - either a split group or a tabbed leaf. */
|
|
45
|
+
export type DockNode = DockGroup | DockTabs
|
|
46
|
+
|
|
47
|
+
/** Where a dragged pane lands relative to a target leaf. */
|
|
48
|
+
export type DockZone = 'left' | 'right' | 'top' | 'bottom' | 'center'
|
|
49
|
+
|
|
50
|
+
/** Supplies ids for newly created nodes, so layouts stay deterministic in tests. */
|
|
51
|
+
export type IdGen = () => string
|
|
52
|
+
|
|
53
|
+
// ---- construction helpers -------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Build a {@link DockPane}. */
|
|
56
|
+
export function pane(id: string, title: string, closable = true): DockPane {
|
|
57
|
+
return { id, title, closable }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Largest per-pane `minSize` in a leaf (0 if none). Used by splitter clamping. */
|
|
61
|
+
export function leafMinSize(node: DockTabs): number {
|
|
62
|
+
return node.panes.reduce((m, p) => Math.max(m, p.minSize ?? 0), 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a tabbed leaf holding the given panes. */
|
|
66
|
+
export function tabs(genId: IdGen, panes: DockPane[], active = 0): DockTabs {
|
|
67
|
+
return { type: 'tabs', id: genId(), panes, active: clampIndex(active, panes.length) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a split group: panes or nested groups laid out in a row or column. */
|
|
71
|
+
export function group(
|
|
72
|
+
genId: IdGen,
|
|
73
|
+
direction: 'row' | 'column',
|
|
74
|
+
children: DockNode[],
|
|
75
|
+
sizes?: number[],
|
|
76
|
+
): DockGroup {
|
|
77
|
+
return { type: 'group', id: genId(), direction, children, sizes: normalizeSizes(sizes, children.length) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- small utilities ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function clampIndex(i: number, len: number): number {
|
|
83
|
+
if (len <= 0) return 0
|
|
84
|
+
return Math.max(0, Math.min(i, len - 1))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Return sizes summing to 1, padded/trimmed to `count`. Even split when absent. */
|
|
88
|
+
export function normalizeSizes(sizes: number[] | undefined, count: number): number[] {
|
|
89
|
+
if (count <= 0) return []
|
|
90
|
+
const src = sizes && sizes.length === count ? sizes.slice() : new Array(count).fill(1)
|
|
91
|
+
const total = src.reduce((a, b) => a + (b > 0 ? b : 0), 0)
|
|
92
|
+
if (total <= 0) return new Array(count).fill(1 / count)
|
|
93
|
+
return src.map((s) => (s > 0 ? s : 0) / total)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- queries --------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** The tabs leaf that holds `paneId`, or null. */
|
|
99
|
+
export function findTabsWithPane(root: DockNode, paneId: string): DockTabs | null {
|
|
100
|
+
if (root.type === 'tabs') return root.panes.some((p) => p.id === paneId) ? root : null
|
|
101
|
+
for (const c of root.children) {
|
|
102
|
+
const hit = findTabsWithPane(c, paneId)
|
|
103
|
+
if (hit) return hit
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every pane id in the tree, in document order. */
|
|
109
|
+
export function allPaneIds(root: DockNode): string[] {
|
|
110
|
+
if (root.type === 'tabs') return root.panes.map((p) => p.id)
|
|
111
|
+
return root.children.flatMap(allPaneIds)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- normalization --------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collapse the tree after edits: drop empty tabs and empty groups, unwrap a
|
|
118
|
+
* group that has a single child, and flatten a child group into its parent when
|
|
119
|
+
* they share a direction (so repeated docking never nests infinitely deep).
|
|
120
|
+
* Returns the tidied node, or null when nothing is left.
|
|
121
|
+
*/
|
|
122
|
+
export function normalize(node: DockNode): DockNode | null {
|
|
123
|
+
if (node.type === 'tabs') {
|
|
124
|
+
if (node.panes.length === 0) return null
|
|
125
|
+
return { ...node, active: clampIndex(node.active, node.panes.length) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Normalize children first, dropping the empties.
|
|
129
|
+
const kept: DockNode[] = []
|
|
130
|
+
const keptSizes: number[] = []
|
|
131
|
+
node.children.forEach((child, i) => {
|
|
132
|
+
const n = normalize(child)
|
|
133
|
+
if (n) {
|
|
134
|
+
kept.push(n)
|
|
135
|
+
keptSizes.push(node.sizes[i] ?? 1)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (kept.length === 0) return null
|
|
140
|
+
if (kept.length === 1) return kept[0]! // unwrap single-child group
|
|
141
|
+
|
|
142
|
+
// Flatten same-direction child groups into this one.
|
|
143
|
+
const flatChildren: DockNode[] = []
|
|
144
|
+
const flatSizes: number[] = []
|
|
145
|
+
kept.forEach((child, i) => {
|
|
146
|
+
if (child.type === 'group' && child.direction === node.direction) {
|
|
147
|
+
const share = keptSizes[i]!
|
|
148
|
+
child.children.forEach((gc, j) => {
|
|
149
|
+
flatChildren.push(gc)
|
|
150
|
+
flatSizes.push(share * (child.sizes[j] ?? 1 / child.children.length))
|
|
151
|
+
})
|
|
152
|
+
} else {
|
|
153
|
+
flatChildren.push(child)
|
|
154
|
+
flatSizes.push(keptSizes[i]!)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return { ...node, children: flatChildren, sizes: normalizeSizes(flatSizes, flatChildren.length) }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- mutations (return a new tree) ----------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Set the active tab index of a tabs leaf. */
|
|
164
|
+
export function setActive(root: DockNode, tabsId: string, active: number): DockNode {
|
|
165
|
+
return mapNodes(root, (n) =>
|
|
166
|
+
n.type === 'tabs' && n.id === tabsId ? { ...n, active: clampIndex(active, n.panes.length) } : n,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Replace a group's size weights (e.g. after a splitter drag). */
|
|
171
|
+
export function setSizes(root: DockNode, groupId: string, sizes: number[]): DockNode {
|
|
172
|
+
return mapNodes(root, (n) =>
|
|
173
|
+
n.type === 'group' && n.id === groupId ? { ...n, sizes: normalizeSizes(sizes, n.children.length) } : n,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Reorder a pane WITHIN its tabs leaf (dragging a tab along the strip). `active`
|
|
179
|
+
* follows the pane that was showing. No-op if the indices are out of range.
|
|
180
|
+
*/
|
|
181
|
+
export function reorderPane(root: DockNode, tabsId: string, from: number, to: number): DockNode {
|
|
182
|
+
return mapNodes(root, (n) => {
|
|
183
|
+
if (n.type !== 'tabs' || n.id !== tabsId) return n
|
|
184
|
+
if (from < 0 || from >= n.panes.length) return n
|
|
185
|
+
const showing = n.panes[n.active]?.id
|
|
186
|
+
const panes = n.panes.slice()
|
|
187
|
+
const [moved] = panes.splice(from, 1)
|
|
188
|
+
if (!moved) return n
|
|
189
|
+
// `to` is an insertion point in [0, length] (length = append), not a select
|
|
190
|
+
// index - clampIndex would wrongly cap it at length-1.
|
|
191
|
+
panes.splice(Math.max(0, Math.min(to, panes.length)), 0, moved)
|
|
192
|
+
const active = panes.findIndex((p) => p.id === showing)
|
|
193
|
+
return { ...n, panes, active: active < 0 ? 0 : active }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Remove a pane. Returns the tidied tree (or null if it emptied) plus the pane
|
|
199
|
+
* that was pulled out, so a caller can re-dock it elsewhere.
|
|
200
|
+
*/
|
|
201
|
+
export function removePane(root: DockNode, paneId: string): { root: DockNode | null; pane: DockPane | null } {
|
|
202
|
+
let removed: DockPane | null = null
|
|
203
|
+
const strip = (node: DockNode): DockNode => {
|
|
204
|
+
if (node.type === 'tabs') {
|
|
205
|
+
const idx = node.panes.findIndex((p) => p.id === paneId)
|
|
206
|
+
if (idx < 0) return node
|
|
207
|
+
removed = node.panes[idx]!
|
|
208
|
+
const panes = node.panes.filter((_, i) => i !== idx)
|
|
209
|
+
return { ...node, panes, active: clampIndex(node.active > idx ? node.active - 1 : node.active, panes.length) }
|
|
210
|
+
}
|
|
211
|
+
return { ...node, children: node.children.map(strip) }
|
|
212
|
+
}
|
|
213
|
+
const stripped = strip(root)
|
|
214
|
+
return { root: normalize(stripped), pane: removed }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Dock `movingPane` relative to the leaf `targetTabsId`. `center` adds it as a
|
|
219
|
+
* tab; an edge splits: the target is wrapped in (or extended into) a group whose
|
|
220
|
+
* direction matches the edge, with the new pane on the requested side.
|
|
221
|
+
*/
|
|
222
|
+
export function dockInto(
|
|
223
|
+
root: DockNode,
|
|
224
|
+
targetTabsId: string,
|
|
225
|
+
movingPane: DockPane,
|
|
226
|
+
zone: DockZone,
|
|
227
|
+
genId: IdGen,
|
|
228
|
+
): DockNode {
|
|
229
|
+
const dir: 'row' | 'column' = zone === 'left' || zone === 'right' ? 'row' : 'column'
|
|
230
|
+
const before = zone === 'left' || zone === 'top'
|
|
231
|
+
|
|
232
|
+
const transform = (node: DockNode): DockNode => {
|
|
233
|
+
if (node.type === 'tabs') {
|
|
234
|
+
if (node.id !== targetTabsId) return node
|
|
235
|
+
if (zone === 'center') {
|
|
236
|
+
const panes = [...node.panes, movingPane]
|
|
237
|
+
return { ...node, panes, active: panes.length - 1 }
|
|
238
|
+
}
|
|
239
|
+
// Edge on a bare leaf: wrap it in a new group with the new leaf beside it.
|
|
240
|
+
const leaf = tabs(genId, [movingPane], 0)
|
|
241
|
+
const kids = before ? [leaf, node] : [node, leaf]
|
|
242
|
+
return group(genId, dir, kids)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Group: if it directly contains the target leaf and matches the edge
|
|
246
|
+
// direction, insert a sibling in place (keeps the tree flat).
|
|
247
|
+
const idx = node.children.findIndex((c) => c.type === 'tabs' && c.id === targetTabsId)
|
|
248
|
+
if (idx >= 0 && zone !== 'center' && node.direction === dir) {
|
|
249
|
+
const leaf = tabs(genId, [movingPane], 0)
|
|
250
|
+
const children = node.children.slice()
|
|
251
|
+
const sizes = node.sizes.slice()
|
|
252
|
+
const half = (node.sizes[idx] ?? 1 / node.children.length) / 2
|
|
253
|
+
const insertAt = before ? idx : idx + 1
|
|
254
|
+
children.splice(insertAt, 0, leaf)
|
|
255
|
+
sizes.splice(idx, 1, half, half) // split the target's slot in two
|
|
256
|
+
// splice above replaced 1 with 2 at idx; the new leaf takes `before`/after
|
|
257
|
+
if (before) {
|
|
258
|
+
// children: [..., leaf, target, ...]; sizes already [half(leaf), half(target)]
|
|
259
|
+
} else {
|
|
260
|
+
// we inserted leaf AFTER target but sizes are [half, half] at idx/idx+1 - correct
|
|
261
|
+
}
|
|
262
|
+
return { ...node, children, sizes: normalizeSizes(sizes, children.length) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { ...node, children: node.children.map(transform) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return normalize(transform(root)) ?? root
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Pull a whole tabs leaf out of the tree (for auto-hide). Returns the tidied
|
|
272
|
+
* tree (or null) plus the extracted leaf. */
|
|
273
|
+
export function removeLeaf(root: DockNode, tabsId: string): { root: DockNode | null; leaf: DockTabs | null } {
|
|
274
|
+
let taken: DockTabs | null = null
|
|
275
|
+
const strip = (node: DockNode): DockNode | null => {
|
|
276
|
+
if (node.type === 'tabs') {
|
|
277
|
+
if (node.id === tabsId) { taken = node; return null }
|
|
278
|
+
return node
|
|
279
|
+
}
|
|
280
|
+
const kids: DockNode[] = []
|
|
281
|
+
const sizes: number[] = []
|
|
282
|
+
node.children.forEach((c, i) => {
|
|
283
|
+
const n = strip(c)
|
|
284
|
+
if (n) { kids.push(n); sizes.push(node.sizes[i] ?? 1) }
|
|
285
|
+
})
|
|
286
|
+
if (kids.length === 0) return null
|
|
287
|
+
return { ...node, children: kids, sizes: normalizeSizes(sizes, kids.length) }
|
|
288
|
+
}
|
|
289
|
+
const stripped = strip(root)
|
|
290
|
+
return { root: stripped ? normalize(stripped) : null, leaf: taken }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Dock a whole leaf against an EDGE of the entire tree (for pinning an
|
|
294
|
+
* auto-hidden panel back in). Wraps the root in a new group as needed; the
|
|
295
|
+
* pinned leaf takes `fraction` of that axis (clamped 0.1..0.6), the rest keeps
|
|
296
|
+
* the existing content. */
|
|
297
|
+
export function dockLeafToEdge(
|
|
298
|
+
root: DockNode | null,
|
|
299
|
+
leaf: DockTabs,
|
|
300
|
+
side: Exclude<DockZone, 'center'>,
|
|
301
|
+
genId: IdGen,
|
|
302
|
+
fraction = 0.25,
|
|
303
|
+
): DockNode {
|
|
304
|
+
if (!root) return leaf
|
|
305
|
+
const dir: 'row' | 'column' = side === 'left' || side === 'right' ? 'row' : 'column'
|
|
306
|
+
const before = side === 'left' || side === 'top'
|
|
307
|
+
const f = Math.min(0.6, Math.max(0.1, fraction))
|
|
308
|
+
const kids = before ? [leaf, root] : [root, leaf]
|
|
309
|
+
const sizes = before ? [f, 1 - f] : [1 - f, f]
|
|
310
|
+
return normalize(group(genId, dir, kids, sizes)) ?? leaf
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Move an existing pane to a new dock position (remove then dock). */
|
|
314
|
+
export function movePane(
|
|
315
|
+
root: DockNode,
|
|
316
|
+
paneId: string,
|
|
317
|
+
targetTabsId: string,
|
|
318
|
+
zone: DockZone,
|
|
319
|
+
genId: IdGen,
|
|
320
|
+
): DockNode {
|
|
321
|
+
const { root: without, pane: moved } = removePane(root, paneId)
|
|
322
|
+
if (!without || !moved) return root
|
|
323
|
+
// If removing the pane dissolved the target leaf (it was the pane's own
|
|
324
|
+
// single-pane leaf), there is nothing to dock against - leave the tree as-is.
|
|
325
|
+
if (!containsTabs(without, targetTabsId)) return root
|
|
326
|
+
return dockInto(without, targetTabsId, moved, zone, genId)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function containsTabs(node: DockNode, tabsId: string): boolean {
|
|
330
|
+
if (node.type === 'tabs') return node.id === tabsId
|
|
331
|
+
return node.children.some((c) => containsTabs(c, tabsId))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---- internal -------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Map over every node, replacing where `fn` returns a new object. */
|
|
337
|
+
function mapNodes(node: DockNode, fn: (n: DockNode) => DockNode): DockNode {
|
|
338
|
+
const mapped = fn(node)
|
|
339
|
+
if (mapped.type === 'group') {
|
|
340
|
+
return { ...mapped, children: mapped.children.map((c) => mapNodes(c, fn)) }
|
|
341
|
+
}
|
|
342
|
+
return mapped
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- consumer builders (auto ids) -----------------------------------------
|
|
346
|
+
// Ergonomic constructors for building an initial layout without threading an
|
|
347
|
+
// id generator. Node ids only need to be unique; a running counter suffices.
|
|
348
|
+
|
|
349
|
+
let AUTO = 0
|
|
350
|
+
const autoId: IdGen = () => `d${AUTO++}`
|
|
351
|
+
|
|
352
|
+
/** A pane (tab). Alias of `pane`, named for the public builder set. */
|
|
353
|
+
export function dockPane(
|
|
354
|
+
id: string,
|
|
355
|
+
title: string,
|
|
356
|
+
opts?: boolean | { closable?: boolean; minSize?: number },
|
|
357
|
+
): DockPane {
|
|
358
|
+
const o = typeof opts === 'boolean' ? { closable: opts } : (opts ?? {})
|
|
359
|
+
return { id, title, closable: o.closable ?? true, ...(o.minSize != null ? { minSize: o.minSize } : {}) }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** A tab-leaf holding one or more panes. */
|
|
363
|
+
export function dockTabs(panes: DockPane[], active = 0): DockTabs {
|
|
364
|
+
return tabs(autoId, panes, active)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A row (side-by-side) or column (stacked) of children. */
|
|
368
|
+
export function dockGroup(
|
|
369
|
+
direction: 'row' | 'column',
|
|
370
|
+
children: DockNode[],
|
|
371
|
+
sizes?: number[],
|
|
372
|
+
): DockGroup {
|
|
373
|
+
return group(autoId, direction, children, sizes)
|
|
374
|
+
}
|