horizon-layout 1.0.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.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/dist/HorizonLayout.svelte +798 -0
- package/dist/HorizonLayout.svelte.d.ts +49 -0
- package/dist/LayoutNode.svelte +98 -0
- package/dist/LayoutNode.svelte.d.ts +28 -0
- package/dist/PopoutContent.svelte +25 -0
- package/dist/PopoutContent.svelte.d.ts +10 -0
- package/dist/Split.svelte +197 -0
- package/dist/Split.svelte.d.ts +28 -0
- package/dist/TabGroup.svelte +226 -0
- package/dist/TabGroup.svelte.d.ts +22 -0
- package/dist/horizon-layout.css +459 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/internal-types.d.ts +8 -0
- package/dist/internal-types.js +1 -0
- package/dist/internal-utils.d.ts +6 -0
- package/dist/internal-utils.js +25 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +248 -0
- package/package.json +59 -0
package/dist/utils.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
2
|
+
import { DEFAULT_MIN_WIDTH_RATIO, DEFAULT_MIN_HEIGHT_RATIO } from "./internal-utils.js";
|
|
3
|
+
/// Remove a tab group that has become empty, then collapse any resulting
|
|
4
|
+
/// single-child splits up the tree.
|
|
5
|
+
export function simplifyTabGroup(tabGroup, nodeParentMap, config) {
|
|
6
|
+
if (tabGroup.tabs.length > 0)
|
|
7
|
+
return;
|
|
8
|
+
const parent = nodeParentMap.get(tabGroup);
|
|
9
|
+
if (!parent) {
|
|
10
|
+
config.root = undefined;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
parent.parent.views.splice(parent.index, 1);
|
|
14
|
+
parent.parent.splitPoints.splice(Math.min(parent.index, parent.parent.splitPoints.length - 1), 1);
|
|
15
|
+
/// Remove a split that has become a single view, then collapse any resulting
|
|
16
|
+
/// single-child splits up the tree.
|
|
17
|
+
const simplifySplit = (split) => {
|
|
18
|
+
if (split.views.length > 1)
|
|
19
|
+
return;
|
|
20
|
+
const only = split.views[0];
|
|
21
|
+
const splitParent = nodeParentMap.get(split);
|
|
22
|
+
if (splitParent) {
|
|
23
|
+
splitParent.parent.views[splitParent.index] = only;
|
|
24
|
+
simplifySplit(splitParent.parent);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
config.root = only;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
simplifySplit(parent.parent);
|
|
31
|
+
}
|
|
32
|
+
/// returns 'split' if the config is a SplitConfig, or 'tabGroup' if it's a TabGroupConfig
|
|
33
|
+
export function nodeConfigType(config) {
|
|
34
|
+
if ('splitPoints' in config) {
|
|
35
|
+
return 'split';
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
return 'tabGroup';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function buildNodeParentMap(root) {
|
|
42
|
+
const map = new SvelteMap();
|
|
43
|
+
function walk(node, parent = null) {
|
|
44
|
+
if (parent)
|
|
45
|
+
map.set(node, parent);
|
|
46
|
+
if (nodeConfigType(node) === 'split') {
|
|
47
|
+
const split = node;
|
|
48
|
+
for (let i = 0; i < split.views.length; i++) {
|
|
49
|
+
walk(split.views[i], { parent: split, index: i });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
walk(root);
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
/// returns true if the two configs are equivalent (i.e. they represent the same layout), false otherwise
|
|
57
|
+
export function areConfigsEquivalent(config1, config2) {
|
|
58
|
+
if (config1.maximizedView !== config2.maximizedView)
|
|
59
|
+
return false;
|
|
60
|
+
if ((config1.root === undefined) !== (config2.root === undefined))
|
|
61
|
+
return false;
|
|
62
|
+
const popouts1 = [...(config1.popouts ?? [])].sort();
|
|
63
|
+
const popouts2 = [...(config2.popouts ?? [])].sort();
|
|
64
|
+
if (popouts1.length !== popouts2.length)
|
|
65
|
+
return false;
|
|
66
|
+
for (let i = 0; i < popouts1.length; i++) {
|
|
67
|
+
if (popouts1[i] !== popouts2[i])
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const areNodeConfigsEquivalent = (node1, node2) => {
|
|
71
|
+
const node_type = nodeConfigType(node1);
|
|
72
|
+
if (node_type !== nodeConfigType(node2))
|
|
73
|
+
return false;
|
|
74
|
+
return node_type === 'split'
|
|
75
|
+
? areSplitConfigsEquivalent(node1, node2)
|
|
76
|
+
: areTabGroupConfigsEquivalent(node1, node2);
|
|
77
|
+
};
|
|
78
|
+
const areSplitConfigsEquivalent = (split1, split2) => {
|
|
79
|
+
if (split1.direction !== split2.direction)
|
|
80
|
+
return false;
|
|
81
|
+
if (split1.splitPoints.length !== split2.splitPoints.length)
|
|
82
|
+
return false;
|
|
83
|
+
for (let i = 0; i < split1.splitPoints.length; i++) {
|
|
84
|
+
if (split1.splitPoints[i] !== split2.splitPoints[i])
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (split1.views.length !== split2.views.length)
|
|
88
|
+
return false;
|
|
89
|
+
for (let i = 0; i < split1.views.length; i++) {
|
|
90
|
+
if (!areNodeConfigsEquivalent(split1.views[i], split2.views[i]))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
const areTabGroupConfigsEquivalent = (tabGroup1, tabGroup2) => {
|
|
96
|
+
if (tabGroup1.activeTabIndex !== tabGroup2.activeTabIndex)
|
|
97
|
+
return false;
|
|
98
|
+
if (tabGroup1.tabs.length !== tabGroup2.tabs.length)
|
|
99
|
+
return false;
|
|
100
|
+
for (let i = 0; i < tabGroup1.tabs.length; i++) {
|
|
101
|
+
if (tabGroup1.tabs[i] !== tabGroup2.tabs[i])
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
// we can only check config1 because of the early return
|
|
107
|
+
// if ((config1.root === undefined) !== (config2.root === undefined)) return false;
|
|
108
|
+
if (config1.root === undefined)
|
|
109
|
+
return true;
|
|
110
|
+
return areNodeConfigsEquivalent(config1.root, config2.root);
|
|
111
|
+
}
|
|
112
|
+
/// Validates a LayoutConfig against a views map.
|
|
113
|
+
/// Throws an Error if the config is invalid.
|
|
114
|
+
/// Pass the same minWidthRatio / minHeightRatio you use for HorizonLayout so that
|
|
115
|
+
/// split-point constraints are checked with the same thresholds.
|
|
116
|
+
export function validateConfig(config, views, options = {}) {
|
|
117
|
+
const { minWidthRatio = DEFAULT_MIN_WIDTH_RATIO, minHeightRatio = DEFAULT_MIN_HEIGHT_RATIO } = options;
|
|
118
|
+
if (config.maximizedView && !views.get(config.maximizedView)) {
|
|
119
|
+
throw new Error(`unknown maximizedView id "${config.maximizedView}"`);
|
|
120
|
+
}
|
|
121
|
+
for (const id of config.popouts ?? []) {
|
|
122
|
+
if (!views.get(id))
|
|
123
|
+
throw new Error(`unknown popout id "${id}"`);
|
|
124
|
+
}
|
|
125
|
+
if (!config.root)
|
|
126
|
+
return;
|
|
127
|
+
const seenIds = new Set();
|
|
128
|
+
const validateTabGroup = (tabGroup) => {
|
|
129
|
+
if (tabGroup.activeTabIndex < 0 || tabGroup.activeTabIndex >= tabGroup.tabs.length) {
|
|
130
|
+
throw new Error('activeTabIndex out of range');
|
|
131
|
+
}
|
|
132
|
+
for (const id of tabGroup.tabs) {
|
|
133
|
+
if (!views.get(id))
|
|
134
|
+
throw new Error(`unknown tab id "${id}"`);
|
|
135
|
+
if (seenIds.has(id))
|
|
136
|
+
throw new Error(`duplicate tab id "${id}"`);
|
|
137
|
+
seenIds.add(id);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const validateSplit = (split) => {
|
|
141
|
+
if (split.splitPoints.length !== split.views.length - 1) {
|
|
142
|
+
throw new Error('splitPoints.length must equal views.length - 1');
|
|
143
|
+
}
|
|
144
|
+
const minRatio = split.direction === 'horizontal' ? minWidthRatio : minHeightRatio;
|
|
145
|
+
for (let i = 0; i < split.splitPoints.length; i++) {
|
|
146
|
+
const point = split.splitPoints[i];
|
|
147
|
+
const prev = split.splitPoints[i - 1] ?? 0;
|
|
148
|
+
if (point < prev + minRatio || point > 1 - minRatio) {
|
|
149
|
+
throw new Error('splitPoints violate minRatio constraints');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const child of split.views) {
|
|
153
|
+
validateNode(child);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const validateNode = (node) => nodeConfigType(node) === 'split'
|
|
157
|
+
? validateSplit(node)
|
|
158
|
+
: validateTabGroup(node);
|
|
159
|
+
validateNode(config.root);
|
|
160
|
+
}
|
|
161
|
+
function parseNodeConfig(object, path) {
|
|
162
|
+
try {
|
|
163
|
+
return parseSplitConfig(object, path);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return parseTabGroupConfig(object, path);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function parseSplitConfig(object, path) {
|
|
170
|
+
if (typeof object !== 'object' || object === null)
|
|
171
|
+
throw new Error(`${path}: expected an object`);
|
|
172
|
+
const obj = object;
|
|
173
|
+
const { direction, views, splitPoints } = obj;
|
|
174
|
+
if (direction !== 'horizontal' && direction !== 'vertical')
|
|
175
|
+
throw new Error(`${path}.direction: expected "horizontal" or "vertical"`);
|
|
176
|
+
if (!Array.isArray(views) || views.length < 2)
|
|
177
|
+
throw new Error(`${path}.views: expected an array of at least 2 elements`);
|
|
178
|
+
if (!Array.isArray(splitPoints) || splitPoints.length < 1)
|
|
179
|
+
throw new Error(`${path}.splitPoints: expected a non-empty array`);
|
|
180
|
+
for (let i = 0; i < splitPoints.length; i++) {
|
|
181
|
+
if (typeof splitPoints[i] !== 'number')
|
|
182
|
+
throw new Error(`${path}.splitPoints[${i}]: expected a number`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
direction,
|
|
186
|
+
views: views.map((v, i) => parseNodeConfig(v, `${path}.views[${i}]`)),
|
|
187
|
+
splitPoints: [...splitPoints]
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseTabGroupConfig(object, path) {
|
|
191
|
+
if (typeof object !== 'object' || object === null)
|
|
192
|
+
throw new Error(`${path}: expected an object`);
|
|
193
|
+
const { tabs, activeTabIndex } = object;
|
|
194
|
+
if (!Array.isArray(tabs) || tabs.length < 1)
|
|
195
|
+
throw new Error(`${path}.tabs: expected a non-empty array`);
|
|
196
|
+
if (typeof activeTabIndex !== 'number')
|
|
197
|
+
throw new Error(`${path}.activeTabIndex: expected a number`);
|
|
198
|
+
return {
|
|
199
|
+
tabs: tabs.map((t, i) => parseId(t, `${path}.tabs[${i}]`)),
|
|
200
|
+
activeTabIndex
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function parseId(object, path) {
|
|
204
|
+
if (!object || typeof object !== 'string')
|
|
205
|
+
throw new Error(`${path}: expected a non-empty string`);
|
|
206
|
+
return object;
|
|
207
|
+
}
|
|
208
|
+
/// Parses an unknown value (e.g. from JSON.parse) into a LayoutConfig,
|
|
209
|
+
/// throwing an error if the value does not match the expected shape.
|
|
210
|
+
export function parseLayoutConfig(object) {
|
|
211
|
+
if (typeof object !== 'object' || object === null)
|
|
212
|
+
throw new Error('root: expected an object');
|
|
213
|
+
const { root, maximizedView, popouts } = object;
|
|
214
|
+
const result = {};
|
|
215
|
+
if (root != null)
|
|
216
|
+
result.root = parseNodeConfig(root, 'config.root');
|
|
217
|
+
if (maximizedView != null)
|
|
218
|
+
result.maximizedView = parseId(maximizedView, 'config.maximizedView');
|
|
219
|
+
if (Array.isArray(popouts) && popouts.length > 0)
|
|
220
|
+
result.popouts = popouts.map((p, i) => parseId(p, `config.popouts[${i}]`));
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
/// returns a deep clone of the given config
|
|
224
|
+
export function cloneConfig(config) {
|
|
225
|
+
const cloneNodeConfig = (node) => {
|
|
226
|
+
return nodeConfigType(node) === 'split'
|
|
227
|
+
? cloneSplitConfig(node)
|
|
228
|
+
: cloneTabGroupConfig(node);
|
|
229
|
+
};
|
|
230
|
+
const cloneSplitConfig = (split) => {
|
|
231
|
+
return {
|
|
232
|
+
direction: split.direction,
|
|
233
|
+
views: split.views.map(cloneNodeConfig),
|
|
234
|
+
splitPoints: [...split.splitPoints]
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
const cloneTabGroupConfig = (tabGroup) => {
|
|
238
|
+
return {
|
|
239
|
+
tabs: [...tabGroup.tabs],
|
|
240
|
+
activeTabIndex: tabGroup.activeTabIndex
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
...(config.root ? { root: cloneNodeConfig(config.root) } : {}),
|
|
245
|
+
...(config.maximizedView ? { maximizedView: config.maximizedView } : {}),
|
|
246
|
+
...(config.popouts && config.popouts.length > 0 ? { popouts: [...config.popouts] } : {})
|
|
247
|
+
};
|
|
248
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "horizon-layout",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist",
|
|
6
|
+
"!dist/**/*.test.*",
|
|
7
|
+
"!dist/**/*.spec.*"
|
|
8
|
+
],
|
|
9
|
+
"sideEffects": [
|
|
10
|
+
"**/*.css"
|
|
11
|
+
],
|
|
12
|
+
"svelte": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"svelte": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./horizon-layout.css": "./dist/horizon-layout.css"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"svelte": "^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@eslint/compat": "^2.1.0",
|
|
27
|
+
"@eslint/js": "^10.0.1",
|
|
28
|
+
"@sveltejs/adapter-auto": "^7.0.1",
|
|
29
|
+
"@sveltejs/kit": "^2.65.0",
|
|
30
|
+
"@sveltejs/package": "^2.5.8",
|
|
31
|
+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
32
|
+
"@types/node": "^25.9.3",
|
|
33
|
+
"eslint": "^10.4.1",
|
|
34
|
+
"eslint-config-prettier": "^10.1.8",
|
|
35
|
+
"eslint-plugin-svelte": "^3.19.0",
|
|
36
|
+
"globals": "^17.6.0",
|
|
37
|
+
"prettier": "^3.8.4",
|
|
38
|
+
"prettier-plugin-svelte": "^3.5.2",
|
|
39
|
+
"publint": "^0.3.21",
|
|
40
|
+
"svelte": "^5.56.3",
|
|
41
|
+
"svelte-check": "^4.6.0",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"typescript-eslint": "^8.61.0",
|
|
44
|
+
"vite": "^8.0.16"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"svelte"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "vite dev",
|
|
52
|
+
"build": "vite build && npm run prepack",
|
|
53
|
+
"preview": "vite preview",
|
|
54
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
55
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
56
|
+
"lint": "prettier --check . && eslint .",
|
|
57
|
+
"format": "prettier --write ."
|
|
58
|
+
}
|
|
59
|
+
}
|