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
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { mount, onMount, unmount, untrack, type Snippet } from 'svelte';
|
|
3
|
+
import type {
|
|
4
|
+
Id,
|
|
5
|
+
KeyboardControls,
|
|
6
|
+
LayoutConfig,
|
|
7
|
+
NodeConfig,
|
|
8
|
+
ParentEntry,
|
|
9
|
+
SplitConfig,
|
|
10
|
+
TabGroupConfig,
|
|
11
|
+
View
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import HorizonLayoutNode from './LayoutNode.svelte';
|
|
14
|
+
import HorizonPopoutContent from './PopoutContent.svelte';
|
|
15
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
16
|
+
import {
|
|
17
|
+
buildNodeParentMap,
|
|
18
|
+
simplifyTabGroup,
|
|
19
|
+
areConfigsEquivalent,
|
|
20
|
+
nodeConfigType,
|
|
21
|
+
cloneConfig,
|
|
22
|
+
validateConfig
|
|
23
|
+
} from './utils.js';
|
|
24
|
+
import { DEFAULT_MIN_WIDTH_RATIO, DEFAULT_MIN_HEIGHT_RATIO } from './internal-utils.js';
|
|
25
|
+
import { dropTargetType } from './internal-utils.js';
|
|
26
|
+
import type { DropTarget } from './internal-types.js';
|
|
27
|
+
|
|
28
|
+
interface Popout {
|
|
29
|
+
window: Window;
|
|
30
|
+
mountedContent: Record<string, unknown>;
|
|
31
|
+
handleClose: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Direction = 'left' | 'right' | 'up' | 'down';
|
|
35
|
+
|
|
36
|
+
// State held for the duration of a drag operation.
|
|
37
|
+
type DragData = {
|
|
38
|
+
configBeforeDrag: LayoutConfig;
|
|
39
|
+
tabId: Id;
|
|
40
|
+
// The tab group and drop target currently being hovered over, or null.
|
|
41
|
+
hover: { tabGroup: TabGroupConfig; target: DropTarget } | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
config = $bindable(),
|
|
46
|
+
views,
|
|
47
|
+
tabgroupControls = [],
|
|
48
|
+
disableResizeSplits = false,
|
|
49
|
+
disableDragAndDrop = false,
|
|
50
|
+
showSplitRatio = false,
|
|
51
|
+
minWidthRatio = DEFAULT_MIN_WIDTH_RATIO,
|
|
52
|
+
minHeightRatio = DEFAULT_MIN_HEIGHT_RATIO,
|
|
53
|
+
maxDepth = 6,
|
|
54
|
+
hideTabBar = false,
|
|
55
|
+
keyboardControls = {
|
|
56
|
+
splitControls: [
|
|
57
|
+
// Escape deselects/Blurs the split resizer.
|
|
58
|
+
{
|
|
59
|
+
shortcuts: [{ key: 'Escape' }],
|
|
60
|
+
action: (_, event) => (event.currentTarget as HTMLElement).blur()
|
|
61
|
+
},
|
|
62
|
+
// Arrow keys move the resizer by 1 % steps.
|
|
63
|
+
{
|
|
64
|
+
shortcuts: [{ key: 'ArrowLeft' }, { key: 'ArrowUp' }],
|
|
65
|
+
action: (data) =>
|
|
66
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! - 0.01)
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
shortcuts: [{ key: 'ArrowRight' }, { key: 'ArrowDown' }],
|
|
70
|
+
action: (data) =>
|
|
71
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! + 0.01)
|
|
72
|
+
},
|
|
73
|
+
// Shift → 10 % steps.
|
|
74
|
+
{
|
|
75
|
+
shortcuts: [
|
|
76
|
+
{ modifier: 'shift', key: 'ArrowLeft' },
|
|
77
|
+
{ modifier: 'shift', key: 'ArrowUp' }
|
|
78
|
+
],
|
|
79
|
+
action: (data) =>
|
|
80
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! - 0.1)
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
shortcuts: [
|
|
84
|
+
{ modifier: 'shift', key: 'ArrowRight' },
|
|
85
|
+
{ modifier: 'shift', key: 'ArrowDown' }
|
|
86
|
+
],
|
|
87
|
+
action: (data) =>
|
|
88
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! + 0.1)
|
|
89
|
+
},
|
|
90
|
+
// Ctrl → 0.1 % steps.
|
|
91
|
+
{
|
|
92
|
+
shortcuts: [
|
|
93
|
+
{ modifier: 'ctrl', key: 'ArrowLeft' },
|
|
94
|
+
{ modifier: 'ctrl', key: 'ArrowUp' }
|
|
95
|
+
],
|
|
96
|
+
action: (data) =>
|
|
97
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! - 0.001)
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
shortcuts: [
|
|
101
|
+
{ modifier: 'ctrl', key: 'ArrowRight' },
|
|
102
|
+
{ modifier: 'ctrl', key: 'ArrowDown' }
|
|
103
|
+
],
|
|
104
|
+
action: (data) =>
|
|
105
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! + 0.001)
|
|
106
|
+
},
|
|
107
|
+
// Alt → 0.01 % steps.
|
|
108
|
+
{
|
|
109
|
+
shortcuts: [
|
|
110
|
+
{ modifier: 'alt', key: 'ArrowLeft' },
|
|
111
|
+
{ modifier: 'alt', key: 'ArrowUp' }
|
|
112
|
+
],
|
|
113
|
+
action: (data) =>
|
|
114
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! - 0.0001)
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
shortcuts: [
|
|
118
|
+
{ modifier: 'alt', key: 'ArrowRight' },
|
|
119
|
+
{ modifier: 'alt', key: 'ArrowDown' }
|
|
120
|
+
],
|
|
121
|
+
action: (data) =>
|
|
122
|
+
moveSplit(data.config, data.index, data.config.splitPoints[data.index]! + 0.0001)
|
|
123
|
+
},
|
|
124
|
+
// Home -> snap the resizer to the minimum valid position.
|
|
125
|
+
{
|
|
126
|
+
shortcuts: [{ key: 'Home' }],
|
|
127
|
+
action: (data) => moveSplit(data.config, data.index, 0)
|
|
128
|
+
},
|
|
129
|
+
// End -> snap the resizer to the maximum valid position.
|
|
130
|
+
{
|
|
131
|
+
shortcuts: [{ key: 'End' }],
|
|
132
|
+
action: (data) => moveSplit(data.config, data.index, 1)
|
|
133
|
+
}
|
|
134
|
+
],
|
|
135
|
+
tabGroupControls: [
|
|
136
|
+
// Escape Unselects/Blurs the tab group.
|
|
137
|
+
{
|
|
138
|
+
shortcuts: [{ key: 'Escape' }],
|
|
139
|
+
action: (_, event) => (event.currentTarget as HTMLElement).blur()
|
|
140
|
+
},
|
|
141
|
+
// Arrow keys cycle the active tab.
|
|
142
|
+
{
|
|
143
|
+
shortcuts: [{ key: 'ArrowRight' }, { key: 'ArrowDown' }],
|
|
144
|
+
action: (tg) => selectTab(tg, (tg.activeTabIndex + 1) % tg.tabs.length)
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
shortcuts: [{ key: 'ArrowLeft' }, { key: 'ArrowUp' }],
|
|
148
|
+
action: (tg) => selectTab(tg, (tg.activeTabIndex + tg.tabs.length - 1) % tg.tabs.length)
|
|
149
|
+
},
|
|
150
|
+
// Home/End -> select the first/last tab.
|
|
151
|
+
{
|
|
152
|
+
shortcuts: [{ key: 'Home' }],
|
|
153
|
+
action: (tg) => selectTab(tg, 0)
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
shortcuts: [{ key: 'End' }],
|
|
157
|
+
action: (tg) => selectTab(tg, tg.tabs.length - 1)
|
|
158
|
+
},
|
|
159
|
+
// Shift + Arrow reorders tabs within the group.
|
|
160
|
+
{
|
|
161
|
+
shortcuts: [
|
|
162
|
+
{ modifier: 'shift', key: 'ArrowRight' },
|
|
163
|
+
{ modifier: 'shift', key: 'ArrowDown' }
|
|
164
|
+
],
|
|
165
|
+
action: (tg) => moveTabToIndex(tg, (tg.activeTabIndex + 1) % tg.tabs.length)
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
shortcuts: [
|
|
169
|
+
{ modifier: 'shift', key: 'ArrowLeft' },
|
|
170
|
+
{ modifier: 'shift', key: 'ArrowUp' }
|
|
171
|
+
],
|
|
172
|
+
action: (tg) =>
|
|
173
|
+
moveTabToIndex(tg, (tg.activeTabIndex + tg.tabs.length - 1) % tg.tabs.length)
|
|
174
|
+
},
|
|
175
|
+
// Shift + Home/End moves the active tab to the start/end of the group.
|
|
176
|
+
{
|
|
177
|
+
shortcuts: [{ modifier: 'shift', key: 'Home' }],
|
|
178
|
+
action: (tg) => moveTabToIndex(tg, 0)
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
shortcuts: [{ modifier: 'shift', key: 'End' }],
|
|
182
|
+
action: (tg) => moveTabToIndex(tg, tg.tabs.length - 1)
|
|
183
|
+
},
|
|
184
|
+
// Ctrl + Arrow moves the active tab to the nearest group in that direction.
|
|
185
|
+
{
|
|
186
|
+
shortcuts: [{ modifier: 'ctrl', key: 'ArrowRight' }],
|
|
187
|
+
action: (tg) => moveActiveTabToClosestTabGroup(tg, 'right')
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
shortcuts: [{ modifier: 'ctrl', key: 'ArrowDown' }],
|
|
191
|
+
action: (tg) => moveActiveTabToClosestTabGroup(tg, 'down')
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
shortcuts: [{ modifier: 'ctrl', key: 'ArrowLeft' }],
|
|
195
|
+
action: (tg) => moveActiveTabToClosestTabGroup(tg, 'left')
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
shortcuts: [{ modifier: 'ctrl', key: 'ArrowUp' }],
|
|
199
|
+
action: (tg) => moveActiveTabToClosestTabGroup(tg, 'up')
|
|
200
|
+
},
|
|
201
|
+
// Ctrl + Home/End moves the active tab to the first/last group in the layout.
|
|
202
|
+
{
|
|
203
|
+
shortcuts: [{ modifier: 'ctrl', key: 'Home' }],
|
|
204
|
+
action: (tg) => moveActiveTabToTabGroup(tg, firstTabGroup())
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
shortcuts: [{ modifier: 'ctrl', key: 'End' }],
|
|
208
|
+
action: (tg) => moveActiveTabToTabGroup(tg, lastTabGroup())
|
|
209
|
+
},
|
|
210
|
+
// Alt + Arrow splits the active tab out into a new pane in that direction.
|
|
211
|
+
{
|
|
212
|
+
shortcuts: [{ modifier: 'alt', key: 'ArrowRight' }],
|
|
213
|
+
action: (tg) => splitTabGroup(tg, 'right')
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
shortcuts: [{ modifier: 'alt', key: 'ArrowDown' }],
|
|
217
|
+
action: (tg) => splitTabGroup(tg, 'down')
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
shortcuts: [{ modifier: 'alt', key: 'ArrowLeft' }],
|
|
221
|
+
action: (tg) => splitTabGroup(tg, 'left')
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
shortcuts: [{ modifier: 'alt', key: 'ArrowUp' }],
|
|
225
|
+
action: (tg) => splitTabGroup(tg, 'up')
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
formatRatio = (ratio: number) => `${(ratio * 100).toFixed(2)}%`,
|
|
230
|
+
formatRatioForAria = (ratio: number) => Number((ratio * 100).toFixed(2)),
|
|
231
|
+
onPopoutClose,
|
|
232
|
+
onPopoutBlocked,
|
|
233
|
+
baseClass = 'horizon-layout',
|
|
234
|
+
skipValidation = false
|
|
235
|
+
}: {
|
|
236
|
+
/** The layout configuration. Bind this to keep it in sync with user interactions. */
|
|
237
|
+
config: LayoutConfig;
|
|
238
|
+
/** Map from view Id to its View descriptor. */
|
|
239
|
+
views: SvelteMap<Id, View>;
|
|
240
|
+
/** Snippets rendered in every tab-group's toolbar (e.g. a maximise button). */
|
|
241
|
+
tabgroupControls?: Snippet<[Id]>[];
|
|
242
|
+
/** Prevent the user from dragging split dividers. */
|
|
243
|
+
disableResizeSplits?: boolean;
|
|
244
|
+
/** Prevent drag-and-drop tab rearrangement. */
|
|
245
|
+
disableDragAndDrop?: boolean;
|
|
246
|
+
/** Show the current split ratio as a floating label while resizing. */
|
|
247
|
+
showSplitRatio?: boolean;
|
|
248
|
+
/** Minimum allowed width as a fraction of the split container. */
|
|
249
|
+
minWidthRatio?: number;
|
|
250
|
+
/** Minimum allowed height as a fraction of the split container. */
|
|
251
|
+
minHeightRatio?: number;
|
|
252
|
+
/** Maximum nesting depth for splits (prevents infinite subdivision). */
|
|
253
|
+
maxDepth?: number;
|
|
254
|
+
/** Hide the tab bar entirely (useful when every group has exactly one tab). */
|
|
255
|
+
hideTabBar?: boolean;
|
|
256
|
+
/** Override the default keyboard shortcuts for splits and tab groups. */
|
|
257
|
+
keyboardControls?: KeyboardControls;
|
|
258
|
+
/** Format a split ratio as a display string. Default: "50.00%". */
|
|
259
|
+
formatRatio?: (ratio: number) => string;
|
|
260
|
+
/** Format a split ratio as a number for aria-valuenow. Default: 50.00. */
|
|
261
|
+
formatRatioForAria?: (ratio: number) => number;
|
|
262
|
+
/** Called when a popout window is closed by the user. */
|
|
263
|
+
onPopoutClose?: (viewId: Id) => void;
|
|
264
|
+
/**
|
|
265
|
+
* Called when the browser blocks a popout window after the fallback timeout.
|
|
266
|
+
* If not provided, the blocked tab is automatically moved into the main layout.
|
|
267
|
+
*/
|
|
268
|
+
onPopoutBlocked?: (id: Id) => void;
|
|
269
|
+
/** Root CSS class applied to the layout element and used as a BEM prefix. */
|
|
270
|
+
baseClass?: string;
|
|
271
|
+
/**
|
|
272
|
+
* Skip config validation on every update. Set this when you have already
|
|
273
|
+
* validated the config yourself (e.g. with validateConfig) and want to avoid
|
|
274
|
+
* the redundant check.
|
|
275
|
+
*/
|
|
276
|
+
skipValidation?: boolean;
|
|
277
|
+
} = $props();
|
|
278
|
+
|
|
279
|
+
// The layout operates on a validated internal copy so that invalid external
|
|
280
|
+
// configs are caught at the boundary without corrupting the running state.
|
|
281
|
+
// This also permits the drag-and-drop logic to make temporary modifications (hiding the currently dragged tab)
|
|
282
|
+
// without affecting the external config until the drag is complete.
|
|
283
|
+
let internalConfig = $state<LayoutConfig | null>(null);
|
|
284
|
+
let dragData = $state<DragData | null>(null);
|
|
285
|
+
|
|
286
|
+
// Precomputed map from every NodeConfig to its parent split + child index.
|
|
287
|
+
// Rebuilt whenever internalConfig changes.
|
|
288
|
+
//
|
|
289
|
+
// This isn't the most performant approach, but
|
|
290
|
+
// it has the advantage of being simpler than
|
|
291
|
+
// incrementally maintaining the map during tree mutations
|
|
292
|
+
// or using a cache with complex invalidation logic.
|
|
293
|
+
let nodeParentMap: SvelteMap<NodeConfig, ParentEntry> = $derived(
|
|
294
|
+
internalConfig?.root ? buildNodeParentMap(internalConfig.root) : new SvelteMap()
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Propagate external config changes → internalConfig.
|
|
298
|
+
//
|
|
299
|
+
// External config is always authoritative.
|
|
300
|
+
// If it changes during a drag operation, the drag is cancelled
|
|
301
|
+
// and the new config is applied immediately.
|
|
302
|
+
$effect(() => {
|
|
303
|
+
const inputConfig = config;
|
|
304
|
+
|
|
305
|
+
if (untrack(() => internalConfig && areConfigsEquivalent(internalConfig, inputConfig))) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (untrack(() => dragData)) {
|
|
310
|
+
dragData = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
internalConfig = createInternalConfig(inputConfig);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Propagate internalConfig changes → external config binding.
|
|
317
|
+
// Skipped while a drag is in progress to avoid propagating temporary intermediate states.
|
|
318
|
+
$effect(() => {
|
|
319
|
+
if (dragData || !internalConfig) return;
|
|
320
|
+
if (
|
|
321
|
+
!areConfigsEquivalent(
|
|
322
|
+
internalConfig,
|
|
323
|
+
untrack(() => config)
|
|
324
|
+
)
|
|
325
|
+
) {
|
|
326
|
+
config = cloneConfig(internalConfig);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Validates and deep-clones an external LayoutConfig.
|
|
331
|
+
// Returns null and logs an error if any constraint is violated.
|
|
332
|
+
function createInternalConfig(input: LayoutConfig): LayoutConfig | null {
|
|
333
|
+
if (!skipValidation) {
|
|
334
|
+
if (minWidthRatio < 0 || minWidthRatio > 0.5) {
|
|
335
|
+
console.error('HorizonLayout: minWidthRatio must be between 0 and 0.5');
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (minHeightRatio < 0 || minHeightRatio > 0.5) {
|
|
339
|
+
console.error('HorizonLayout: minHeightRatio must be between 0 and 0.5');
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
if (maxDepth < 1) {
|
|
343
|
+
console.error('HorizonLayout: maxDepth must be at least 1');
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
validateConfig(input, views, { minWidthRatio, minHeightRatio });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
console.error(`HorizonLayout: ${(e as Error).message}`);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return cloneConfig(input);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function moveSplit(split: SplitConfig, index: number, ratio: number) {
|
|
359
|
+
const minRatio = split.direction === 'horizontal' ? minWidthRatio : minHeightRatio;
|
|
360
|
+
const min = (split.splitPoints[index - 1] ?? 0) + minRatio;
|
|
361
|
+
const max = (split.splitPoints[index + 1] ?? 1) - minRatio;
|
|
362
|
+
const clamped = Number(Math.min(Math.max(ratio, min), max).toFixed(4));
|
|
363
|
+
if (clamped !== split.splitPoints[index]) split.splitPoints[index] = clamped;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function selectTab(tabGroup: TabGroupConfig, index: number) {
|
|
367
|
+
if (index !== tabGroup.activeTabIndex) tabGroup.activeTabIndex = index;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Move the active tab to targetIndex, keeping focus on the moved tab.
|
|
371
|
+
function moveTabToIndex(tabGroup: TabGroupConfig, targetIndex: number) {
|
|
372
|
+
if (targetIndex === tabGroup.activeTabIndex) return;
|
|
373
|
+
const [tab] = tabGroup.tabs.splice(tabGroup.activeTabIndex, 1);
|
|
374
|
+
if (!tab) return;
|
|
375
|
+
tabGroup.tabs.splice(targetIndex, 0, tab);
|
|
376
|
+
tabGroup.activeTabIndex = targetIndex;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function firstTabGroup(node: NodeConfig = internalConfig!.root!): TabGroupConfig {
|
|
380
|
+
if (nodeConfigType(node) === 'tabGroup') return node as TabGroupConfig;
|
|
381
|
+
return firstTabGroup((node as SplitConfig).views[0]!);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function lastTabGroup(node: NodeConfig = internalConfig!.root!): TabGroupConfig {
|
|
385
|
+
if (nodeConfigType(node) === 'tabGroup') return node as TabGroupConfig;
|
|
386
|
+
const vs = (node as SplitConfig).views;
|
|
387
|
+
return lastTabGroup(vs[vs.length - 1]!);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function moveActiveTabToTabGroup(source: TabGroupConfig, destination: TabGroupConfig) {
|
|
391
|
+
if (source === destination) return;
|
|
392
|
+
|
|
393
|
+
const sourceTab = source.tabs[source.activeTabIndex]!;
|
|
394
|
+
|
|
395
|
+
source.tabs.splice(source.activeTabIndex, 1);
|
|
396
|
+
source.activeTabIndex = Math.min(source.activeTabIndex, source.tabs.length - 1);
|
|
397
|
+
simplifyTabGroup(source, nodeParentMap, internalConfig!);
|
|
398
|
+
|
|
399
|
+
destination.activeTabIndex = destination.tabs.length;
|
|
400
|
+
destination.tabs.push(sourceTab);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Walk up the tree to find the nearest split on the target axis, then pick
|
|
404
|
+
// the adjacent pane in the requested direction.
|
|
405
|
+
function moveActiveTabToClosestTabGroup(tabGroup: TabGroupConfig, direction: Direction) {
|
|
406
|
+
const directionAxis = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical';
|
|
407
|
+
const isDirectionLeftOrUp = direction === 'left' || direction === 'up';
|
|
408
|
+
|
|
409
|
+
const edgeTabGroup = (root: NodeConfig, direction: Direction): TabGroupConfig => {
|
|
410
|
+
if (nodeConfigType(root) === 'tabGroup') return root as TabGroupConfig;
|
|
411
|
+
const split = root as SplitConfig;
|
|
412
|
+
const index = isDirectionLeftOrUp ? split.views.length - 1 : 0;
|
|
413
|
+
return edgeTabGroup(split.views[index]!, direction);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const findClosestTabGroupAndMoveTab = (child: NodeConfig) => {
|
|
417
|
+
const parentData = nodeParentMap.get(child);
|
|
418
|
+
if (!parentData) return;
|
|
419
|
+
|
|
420
|
+
if (parentData.parent.direction !== directionAxis) {
|
|
421
|
+
return findClosestTabGroupAndMoveTab(parentData.parent);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const targetIndex = parentData.index + (isDirectionLeftOrUp ? -1 : 1);
|
|
425
|
+
|
|
426
|
+
if (targetIndex < 0 || targetIndex >= parentData.parent.views.length) {
|
|
427
|
+
return findClosestTabGroupAndMoveTab(parentData.parent);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
moveActiveTabToTabGroup(
|
|
431
|
+
tabGroup,
|
|
432
|
+
edgeTabGroup(parentData.parent.views[targetIndex]!, direction)
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
findClosestTabGroupAndMoveTab(tabGroup);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function paneMidpoint(parent: ParentEntry): number {
|
|
440
|
+
return (
|
|
441
|
+
((parent.parent.splitPoints[parent.index - 1] ?? 0) +
|
|
442
|
+
(parent.parent.splitPoints[parent.index] ?? 1)) /
|
|
443
|
+
2
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function canSplitTabGroup(tabGroup: TabGroupConfig, splitDirection: SplitConfig['direction']) {
|
|
448
|
+
const parent = nodeParentMap.get(tabGroup);
|
|
449
|
+
if (parent?.parent.direction === splitDirection) {
|
|
450
|
+
return (
|
|
451
|
+
((parent.parent.splitPoints[parent.index] ?? 1) -
|
|
452
|
+
(parent.parent.splitPoints[parent.index - 1] ?? 0)) /
|
|
453
|
+
2 >=
|
|
454
|
+
(splitDirection === 'horizontal' ? minWidthRatio : minHeightRatio)
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const getNodeDepth = (node: NodeConfig, depth = 1): number => {
|
|
459
|
+
const parent = nodeParentMap.get(node);
|
|
460
|
+
if (!parent) return depth;
|
|
461
|
+
return getNodeDepth(parent.parent, depth + 1);
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
return getNodeDepth(tabGroup) + 1 <= maxDepth;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function canDrop(tabGroup: TabGroupConfig, target: DropTarget) {
|
|
468
|
+
if (dropTargetType(target) === 'tab') return true;
|
|
469
|
+
return canSplitTabGroup(
|
|
470
|
+
tabGroup,
|
|
471
|
+
target.side === 'left' || target.side === 'right' ? 'horizontal' : 'vertical'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function splitTabGroup(tabGroup: TabGroupConfig, direction: Direction) {
|
|
476
|
+
if (tabGroup.tabs.length === 1) return;
|
|
477
|
+
|
|
478
|
+
const tabToSplitOut = tabGroup.tabs[tabGroup.activeTabIndex]!;
|
|
479
|
+
const oldActiveTabIndex = tabGroup.activeTabIndex;
|
|
480
|
+
const splitDirection =
|
|
481
|
+
direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical';
|
|
482
|
+
const newTabGroup: TabGroupConfig = { tabs: [tabToSplitOut], activeTabIndex: 0 };
|
|
483
|
+
const parent = nodeParentMap.get(tabGroup);
|
|
484
|
+
const isCurrentTabFirst = direction === 'left' || direction === 'up';
|
|
485
|
+
const createsNestedSplit = !parent || parent.parent.direction !== splitDirection;
|
|
486
|
+
|
|
487
|
+
if (!canSplitTabGroup(tabGroup, splitDirection)) return;
|
|
488
|
+
|
|
489
|
+
tabGroup.tabs = tabGroup.tabs.filter((_, index) => index !== oldActiveTabIndex) as [
|
|
490
|
+
Id,
|
|
491
|
+
...Id[]
|
|
492
|
+
];
|
|
493
|
+
tabGroup.activeTabIndex = Math.min(oldActiveTabIndex, tabGroup.tabs.length - 1);
|
|
494
|
+
|
|
495
|
+
if (createsNestedSplit) {
|
|
496
|
+
const split: SplitConfig = {
|
|
497
|
+
direction: splitDirection,
|
|
498
|
+
views: isCurrentTabFirst ? [newTabGroup, tabGroup] : [tabGroup, newTabGroup],
|
|
499
|
+
splitPoints: [0.5]
|
|
500
|
+
};
|
|
501
|
+
const tabgroupParent = nodeParentMap.get(tabGroup);
|
|
502
|
+
if (tabgroupParent) {
|
|
503
|
+
tabgroupParent.parent.views[tabgroupParent.index] = split;
|
|
504
|
+
} else {
|
|
505
|
+
internalConfig!.root = split;
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
// Insert into the existing split rather than creating a new nested one
|
|
509
|
+
const insertIndex = parent.index + (isCurrentTabFirst ? 0 : 1);
|
|
510
|
+
parent.parent.views.splice(insertIndex, 0, newTabGroup);
|
|
511
|
+
// Split the target pane in half
|
|
512
|
+
parent.parent.splitPoints.splice(parent.index, 0, paneMidpoint(parent));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function onHoverEnter(tabGroup: TabGroupConfig, target: DropTarget) {
|
|
517
|
+
if (dragData) dragData.hover = { tabGroup, target };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function onHoverExit(tabGroup: TabGroupConfig) {
|
|
521
|
+
if (dragData?.hover?.tabGroup === tabGroup) dragData.hover = null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function onStartTabDrag(event: DragEvent, tabGroup: TabGroupConfig, tabId: Id) {
|
|
525
|
+
if (dragData) return;
|
|
526
|
+
|
|
527
|
+
const tabIndex = tabGroup.tabs.indexOf(tabId);
|
|
528
|
+
if (tabIndex === -1) return;
|
|
529
|
+
const activeTabIndex = tabGroup.activeTabIndex;
|
|
530
|
+
|
|
531
|
+
if (event.dataTransfer) {
|
|
532
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
533
|
+
if (event.currentTarget instanceof HTMLElement) {
|
|
534
|
+
event.dataTransfer.setDragImage(
|
|
535
|
+
event.currentTarget,
|
|
536
|
+
event.currentTarget.offsetWidth / 2,
|
|
537
|
+
event.currentTarget.offsetHeight / 2
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
event.target!.addEventListener('dragend', onDrop, { once: true });
|
|
543
|
+
window.addEventListener('dragend', onDrop, { once: true });
|
|
544
|
+
window.addEventListener('drop', onDrop, { once: true });
|
|
545
|
+
|
|
546
|
+
requestAnimationFrame(() => {
|
|
547
|
+
dragData = {
|
|
548
|
+
configBeforeDrag: cloneConfig(internalConfig!),
|
|
549
|
+
tabId,
|
|
550
|
+
hover: null
|
|
551
|
+
};
|
|
552
|
+
tabGroup.tabs.splice(tabIndex, 1);
|
|
553
|
+
if (tabGroup.tabs.length > 0) {
|
|
554
|
+
if (activeTabIndex === tabIndex) {
|
|
555
|
+
tabGroup.activeTabIndex = Math.min(tabIndex, tabGroup.tabs.length - 1);
|
|
556
|
+
} else if (activeTabIndex > tabIndex) {
|
|
557
|
+
tabGroup.activeTabIndex = activeTabIndex - 1;
|
|
558
|
+
} else {
|
|
559
|
+
tabGroup.activeTabIndex = activeTabIndex;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
simplifyTabGroup(tabGroup, nodeParentMap, internalConfig!);
|
|
563
|
+
if (dragData) dragData.hover = null;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function onDrop() {
|
|
568
|
+
if (!dragData) return;
|
|
569
|
+
|
|
570
|
+
const { tabId, configBeforeDrag, hover } = dragData;
|
|
571
|
+
dragData = null;
|
|
572
|
+
|
|
573
|
+
if (hover) {
|
|
574
|
+
const { tabGroup, target } = hover;
|
|
575
|
+
|
|
576
|
+
if (target.tabIndex !== undefined) {
|
|
577
|
+
let insertIndex = Math.min(Math.max(target.tabIndex, 0), tabGroup.tabs.length);
|
|
578
|
+
tabGroup.tabs.splice(insertIndex, 0, tabId);
|
|
579
|
+
tabGroup.activeTabIndex = insertIndex;
|
|
580
|
+
} else {
|
|
581
|
+
const { side } = target;
|
|
582
|
+
const splitDirection = side === 'left' || side === 'right' ? 'horizontal' : 'vertical';
|
|
583
|
+
const isAfter = side === 'right' || side === 'bottom';
|
|
584
|
+
|
|
585
|
+
const parent = nodeParentMap.get(tabGroup);
|
|
586
|
+
if (parent) {
|
|
587
|
+
if (parent.parent.direction === splitDirection) {
|
|
588
|
+
const insertIndex = parent.index + (isAfter ? 1 : 0);
|
|
589
|
+
parent.parent.views.splice(insertIndex, 0, {
|
|
590
|
+
tabs: [tabId],
|
|
591
|
+
activeTabIndex: 0
|
|
592
|
+
});
|
|
593
|
+
// Split the target pane in half
|
|
594
|
+
parent.parent.splitPoints.splice(parent.index, 0, paneMidpoint(parent));
|
|
595
|
+
} else {
|
|
596
|
+
const newSplit: SplitConfig = {
|
|
597
|
+
direction: splitDirection,
|
|
598
|
+
views: isAfter
|
|
599
|
+
? [tabGroup, { tabs: [tabId], activeTabIndex: 0 }]
|
|
600
|
+
: [{ tabs: [tabId], activeTabIndex: 0 }, tabGroup],
|
|
601
|
+
splitPoints: [0.5]
|
|
602
|
+
};
|
|
603
|
+
parent.parent.views[parent.index] = newSplit;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
internalConfig!.root = {
|
|
607
|
+
direction: splitDirection,
|
|
608
|
+
views: isAfter
|
|
609
|
+
? [tabGroup, { tabs: [tabId], activeTabIndex: 0 }]
|
|
610
|
+
: [{ tabs: [tabId], activeTabIndex: 0 }, tabGroup],
|
|
611
|
+
splitPoints: [0.5]
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
internalConfig = configBeforeDrag;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const POPOUT_BLOCKED_TIMEOUT_MS = 500;
|
|
621
|
+
|
|
622
|
+
const popouts = new SvelteMap<Id, Popout>();
|
|
623
|
+
// Maps view id → timestamp (ms) when the popout was first blocked by the browser.
|
|
624
|
+
const pendingPopouts = new SvelteMap<Id, number>();
|
|
625
|
+
let retryRafId: ReturnType<typeof requestAnimationFrame> | null = null;
|
|
626
|
+
|
|
627
|
+
$effect(() => {
|
|
628
|
+
if (!internalConfig) return;
|
|
629
|
+
const wanted = new Set(internalConfig.popouts ?? []);
|
|
630
|
+
|
|
631
|
+
// Close any popouts that are no longer in the config.
|
|
632
|
+
for (const id of [...popouts.keys()]) {
|
|
633
|
+
if (!wanted.has(id)) cleanupPopout(id, true);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Drop pending entries for ids that are no longer wanted.
|
|
637
|
+
for (const id of pendingPopouts.keys()) {
|
|
638
|
+
if (!wanted.has(id)) pendingPopouts.delete(id);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Open any new popouts.
|
|
642
|
+
for (const id of wanted) {
|
|
643
|
+
const view = views.get(id);
|
|
644
|
+
if (view) openPopout(id, view);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (pendingPopouts.size > 0 && retryRafId === null) {
|
|
648
|
+
retryRafId = requestAnimationFrame(retryPendingPopouts);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Close all popout windows when the base window closes or the component is unmounted.
|
|
653
|
+
function closeAllPopouts() {
|
|
654
|
+
for (const id of [...popouts.keys()]) {
|
|
655
|
+
cleanupPopout(id, true);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function retryPendingPopouts() {
|
|
660
|
+
const now = Date.now();
|
|
661
|
+
for (const [id, since] of [...pendingPopouts.entries()]) {
|
|
662
|
+
if (now - since >= POPOUT_BLOCKED_TIMEOUT_MS) {
|
|
663
|
+
pendingPopouts.delete(id);
|
|
664
|
+
handleBlockedPopout(id);
|
|
665
|
+
} else {
|
|
666
|
+
pendingPopouts.delete(id);
|
|
667
|
+
const view = views.get(id);
|
|
668
|
+
if (view) openPopout(id, view, since);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
retryRafId = pendingPopouts.size > 0 ? requestAnimationFrame(retryPendingPopouts) : null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function handleBlockedPopout(id: Id) {
|
|
675
|
+
if (!internalConfig) return;
|
|
676
|
+
|
|
677
|
+
if (internalConfig.popouts) {
|
|
678
|
+
const remaining = internalConfig.popouts.filter((p) => p !== id);
|
|
679
|
+
internalConfig.popouts = remaining.length ? remaining : undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (onPopoutBlocked) {
|
|
683
|
+
onPopoutBlocked(id);
|
|
684
|
+
} else {
|
|
685
|
+
if (internalConfig.root) {
|
|
686
|
+
const target = lastTabGroup();
|
|
687
|
+
target.tabs.push(id);
|
|
688
|
+
target.activeTabIndex = target.tabs.length - 1;
|
|
689
|
+
} else {
|
|
690
|
+
internalConfig.root = { tabs: [id], activeTabIndex: 0 };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
onMount(() => {
|
|
696
|
+
window.addEventListener('beforeunload', closeAllPopouts);
|
|
697
|
+
window.addEventListener('pagehide', closeAllPopouts);
|
|
698
|
+
return () => {
|
|
699
|
+
window.removeEventListener('beforeunload', closeAllPopouts);
|
|
700
|
+
window.removeEventListener('pagehide', closeAllPopouts);
|
|
701
|
+
if (retryRafId !== null) cancelAnimationFrame(retryRafId);
|
|
702
|
+
closeAllPopouts();
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
function cleanupPopout(id: Id, closeWindow: boolean) {
|
|
707
|
+
const popout = popouts.get(id);
|
|
708
|
+
if (!popout) return;
|
|
709
|
+
|
|
710
|
+
popouts.delete(id);
|
|
711
|
+
popout.window.removeEventListener('pagehide', popout.handleClose);
|
|
712
|
+
popout.window.removeEventListener('beforeunload', popout.handleClose);
|
|
713
|
+
|
|
714
|
+
void unmount(popout.mountedContent);
|
|
715
|
+
|
|
716
|
+
if (closeWindow && !popout.window.closed) {
|
|
717
|
+
popout.window.close();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function openPopout(id: Id, view: View, blockedSince?: number) {
|
|
722
|
+
if (popouts.has(id) || pendingPopouts.has(id)) return;
|
|
723
|
+
|
|
724
|
+
const removePopoutFromConfig = (id: Id) => {
|
|
725
|
+
if (!internalConfig?.popouts) return;
|
|
726
|
+
const remaining = internalConfig.popouts.filter((p) => p !== id);
|
|
727
|
+
internalConfig.popouts = remaining.length ? remaining : undefined;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const popoutWindow = window.open('', '_blank');
|
|
731
|
+
if (!popoutWindow) {
|
|
732
|
+
pendingPopouts.set(id, blockedSince ?? Date.now());
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Mirror host-page styles so Svelte components render correctly in the new window.
|
|
737
|
+
for (const node of document.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
|
|
738
|
+
'link[rel~="stylesheet"], style'
|
|
739
|
+
)) {
|
|
740
|
+
const clone = node.cloneNode(true);
|
|
741
|
+
if (node instanceof HTMLLinkElement && clone instanceof HTMLLinkElement) {
|
|
742
|
+
clone.href = node.href;
|
|
743
|
+
}
|
|
744
|
+
popoutWindow.document.head.appendChild(clone);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const mountedContent = mount(HorizonPopoutContent, {
|
|
748
|
+
target: popoutWindow.document.body,
|
|
749
|
+
props: { popout: popoutWindow, title: view.title, view, baseClass }
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const handleClose = () => {
|
|
753
|
+
if (!popouts.has(id)) return;
|
|
754
|
+
cleanupPopout(id, false);
|
|
755
|
+
removePopoutFromConfig(id);
|
|
756
|
+
onPopoutClose?.(id);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
popouts.set(id, { window: popoutWindow, mountedContent, handleClose });
|
|
760
|
+
popoutWindow.addEventListener('pagehide', handleClose);
|
|
761
|
+
popoutWindow.addEventListener('beforeunload', handleClose);
|
|
762
|
+
}
|
|
763
|
+
</script>
|
|
764
|
+
|
|
765
|
+
<div class={baseClass}>
|
|
766
|
+
{#if internalConfig}
|
|
767
|
+
{#if internalConfig.maximizedView}
|
|
768
|
+
{@const view = views.get(internalConfig.maximizedView)}
|
|
769
|
+
<div class="{baseClass}__content {baseClass}__content--maximized" aria-label={view?.title}>
|
|
770
|
+
{@render view?.snippet()}
|
|
771
|
+
</div>
|
|
772
|
+
{:else if internalConfig.root}
|
|
773
|
+
<div class="{baseClass}__content">
|
|
774
|
+
<HorizonLayoutNode
|
|
775
|
+
bind:config={internalConfig.root}
|
|
776
|
+
{views}
|
|
777
|
+
{tabgroupControls}
|
|
778
|
+
{disableResizeSplits}
|
|
779
|
+
{disableDragAndDrop}
|
|
780
|
+
isDragging={dragData !== null}
|
|
781
|
+
{onStartTabDrag}
|
|
782
|
+
{onHoverEnter}
|
|
783
|
+
{onHoverExit}
|
|
784
|
+
{canDrop}
|
|
785
|
+
remainingDepth={maxDepth}
|
|
786
|
+
{showSplitRatio}
|
|
787
|
+
{minWidthRatio}
|
|
788
|
+
{minHeightRatio}
|
|
789
|
+
{hideTabBar}
|
|
790
|
+
{keyboardControls}
|
|
791
|
+
{formatRatio}
|
|
792
|
+
{formatRatioForAria}
|
|
793
|
+
{baseClass}
|
|
794
|
+
></HorizonLayoutNode>
|
|
795
|
+
</div>
|
|
796
|
+
{/if}
|
|
797
|
+
{/if}
|
|
798
|
+
</div>
|