@xui/dock-manager 2.0.0-alpha.11

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.
@@ -0,0 +1,2311 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, inject, TemplateRef, Directive, InjectionToken, ElementRef, effect, ViewContainerRef, contentChildren, model, output, signal, computed, DestroyRef, forwardRef, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { NgTemplateOutlet } from '@angular/common';
4
+ import { provideIcons, NgIcon } from '@ng-icons/core';
5
+ import { matOpenInNewRound, matCloseFullscreenRound, matOpenInFullRound, matPushPinRound, matDockRound, matCloseRound } from '@ng-icons/material-icons/round';
6
+ import { xui } from '@xui/core';
7
+ import { injectXDirection, arrowDirectionOnAxis } from '@xui/core/a11y';
8
+ import { XuiIcon } from '@xui/icon';
9
+
10
+ /**
11
+ * Declares the body of one content pane.
12
+ *
13
+ * The `contentId` links the template to the `contentId` of a
14
+ * {@link XuiDockContentPane} in the layout — the dock manager's equivalent of
15
+ * Ignite UI's `slot="…"` children.
16
+ *
17
+ * ```html
18
+ * <xui-dock-manager [(layout)]="layout">
19
+ * <ng-template xuiDockContent="explorer">…</ng-template>
20
+ * <ng-template xuiDockContent="editor">…</ng-template>
21
+ * </xui-dock-manager>
22
+ * ```
23
+ *
24
+ * The template is instantiated once, the first time its pane is shown, and the
25
+ * resulting view is then kept for as long as the pane is in the layout — docking,
26
+ * floating, unpinning and tab switching all move its DOM rather than rebuilding
27
+ * it, so scroll offsets, half-typed input and component state survive.
28
+ */
29
+ class XuiDockContent {
30
+ /** Matches the `contentId` of a content pane in the layout. */
31
+ contentId = input.required({ ...(ngDevMode ? { debugName: "contentId" } : /* istanbul ignore next */ {}), alias: 'xuiDockContent' });
32
+ template = inject(TemplateRef);
33
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockContent, deps: [], target: i0.ɵɵFactoryTarget.Directive });
34
+ /** @nocollapse */ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: XuiDockContent, isStandalone: true, selector: "ng-template[xuiDockContent]", inputs: { contentId: { classPropertyName: "contentId", publicName: "xuiDockContent", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
35
+ }
36
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockContent, decorators: [{
37
+ type: Directive,
38
+ args: [{ selector: 'ng-template[xuiDockContent]' }]
39
+ }], propDecorators: { contentId: [{ type: i0.Input, args: [{ isSignal: true, alias: "xuiDockContent", required: true }] }] } });
40
+ const XUI_DOCK_CONTENT_MOUNTER = new InjectionToken('XuiDockContentMounter');
41
+ /**
42
+ * Fills its host element with the content view for a `contentId`.
43
+ *
44
+ * Used internally by `xui-dock-manager` wherever a pane body is rendered.
45
+ */
46
+ class XuiDockContentOutlet {
47
+ host = inject(ElementRef).nativeElement;
48
+ mounter = inject(XUI_DOCK_CONTENT_MOUNTER);
49
+ contentId = input.required({ ...(ngDevMode ? { debugName: "contentId" } : /* istanbul ignore next */ {}), alias: 'xuiDockContentOutlet' });
50
+ constructor() {
51
+ effect(onCleanup => {
52
+ const contentId = this.contentId();
53
+ this.mounter.mountContent(contentId, this.host);
54
+ // Angular gives no ordering guarantee between the old outlet's cleanup and
55
+ // the new outlet's mount, so release is conditional on the nodes still
56
+ // being here — otherwise a re-layout could pull content out of the element
57
+ // that just claimed it.
58
+ onCleanup(() => this.mounter.releaseContent(contentId, this.host));
59
+ });
60
+ }
61
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockContentOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
62
+ /** @nocollapse */ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: XuiDockContentOutlet, isStandalone: true, selector: "[xuiDockContentOutlet]", inputs: { contentId: { classPropertyName: "contentId", publicName: "xuiDockContentOutlet", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
63
+ }
64
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockContentOutlet, decorators: [{
65
+ type: Directive,
66
+ args: [{ selector: '[xuiDockContentOutlet]' }]
67
+ }], ctorParameters: () => [], propDecorators: { contentId: [{ type: i0.Input, args: [{ isSignal: true, alias: "xuiDockContentOutlet", required: true }] }] } });
68
+
69
+ /**
70
+ * The dock manager's layout is a plain, serialisable tree — the same shape Ignite
71
+ * UI's `IgcDockManagerLayout` uses, so layouts port across with only the `Igc`
72
+ * prefix dropped. Nothing in the tree holds a DOM reference or an Angular type,
73
+ * which is what makes a layout safe to persist and restore.
74
+ */
75
+ function isContentPane(pane) {
76
+ return pane.type === 'contentPane';
77
+ }
78
+ function isSplitPane(pane) {
79
+ return pane.type === 'splitPane';
80
+ }
81
+ function isTabGroupPane(pane) {
82
+ return pane.type === 'tabGroupPane';
83
+ }
84
+ function isDocumentHost(pane) {
85
+ return pane.type === 'documentHost';
86
+ }
87
+ function isParentPane(pane) {
88
+ return pane.type === 'splitPane' || pane.type === 'tabGroupPane';
89
+ }
90
+
91
+ /**
92
+ * Tree operations over a {@link XuiDockManagerLayout}.
93
+ *
94
+ * Every mutating function works **in place** on the layout object it is given and
95
+ * leaves the pane objects themselves identity-stable: docking a pane re-parents
96
+ * the very same object rather than a copy. The dock manager depends on that — it
97
+ * keys rendered panes, cached content views and drag targets off object identity,
98
+ * so a pane keeps its DOM (and therefore its scroll position, form state and
99
+ * focus) as it moves around the layout.
100
+ *
101
+ * Callers that need an undo buffer should snapshot with {@link cloneDockLayout}
102
+ * before mutating.
103
+ */
104
+ const paneKeys = new WeakMap();
105
+ let nextPaneKey = 0;
106
+ /**
107
+ * A stable string key for a pane object, allocated on first use.
108
+ *
109
+ * Used for `@for` tracking and for finding the pane behind a DOM element during a
110
+ * drag. Keyed by object identity, so it survives every operation in this file and
111
+ * is *not* preserved by {@link cloneDockLayout}.
112
+ */
113
+ function dockPaneKey(pane) {
114
+ let key = paneKeys.get(pane);
115
+ if (!key) {
116
+ key = `pane-${++nextPaneKey}`;
117
+ paneKeys.set(pane, key);
118
+ }
119
+ return key;
120
+ }
121
+ /**
122
+ * A structural copy of the layout, safe to keep as an undo snapshot.
123
+ *
124
+ * A round-trip through JSON, which the layout's own contract allows: every node
125
+ * is plain data. The copy is a *different* set of objects, so its panes get fresh
126
+ * {@link dockPaneKey}s and restoring it replaces the rendered panes rather than
127
+ * moving them.
128
+ */
129
+ function cloneDockLayout(layout) {
130
+ return JSON.parse(JSON.stringify(layout));
131
+ }
132
+ /** The child panes of `pane`, or an empty array for a leaf. */
133
+ function dockChildren(pane) {
134
+ if (isParentPane(pane)) {
135
+ return pane.panes;
136
+ }
137
+ return isDocumentHost(pane) ? [pane.rootPane] : [];
138
+ }
139
+ /** Every root of the layout: the docked tree, then each floating window. */
140
+ function dockRoots(layout) {
141
+ return [layout.rootPane, ...(layout.floatingPanes ?? [])];
142
+ }
143
+ /**
144
+ * Depth-first walk over every pane in the layout, floating windows included.
145
+ * Returning `false` from `visitor` skips that pane's subtree.
146
+ */
147
+ function visitDockPanes(layout, visitor) {
148
+ const walk = (pane) => {
149
+ if (visitor(pane) === false) {
150
+ return;
151
+ }
152
+ for (const child of [...dockChildren(pane)]) {
153
+ walk(child);
154
+ }
155
+ };
156
+ for (const root of dockRoots(layout)) {
157
+ walk(root);
158
+ }
159
+ }
160
+ /** The pane whose `panes` array holds `pane`, or `null` for a root. */
161
+ function findDockParent(layout, pane) {
162
+ let found = null;
163
+ visitDockPanes(layout, candidate => {
164
+ if (found) {
165
+ return false;
166
+ }
167
+ if (isParentPane(candidate) && candidate.panes.includes(pane)) {
168
+ found = candidate;
169
+ return false;
170
+ }
171
+ return undefined;
172
+ });
173
+ return found;
174
+ }
175
+ /** `pane`'s ancestors, nearest first. */
176
+ function dockAncestors(layout, pane) {
177
+ const path = [];
178
+ const walk = (current, trail) => {
179
+ if (current === pane) {
180
+ path.push(...trail);
181
+ return true;
182
+ }
183
+ for (const child of dockChildren(current)) {
184
+ if (walk(child, [current, ...trail])) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ };
190
+ for (const root of dockRoots(layout)) {
191
+ if (walk(root, [])) {
192
+ break;
193
+ }
194
+ }
195
+ return path;
196
+ }
197
+ /** The layout's document host, if it has one. */
198
+ function findDocumentHost(layout) {
199
+ let host = null;
200
+ visitDockPanes(layout, pane => {
201
+ if (host) {
202
+ return false;
203
+ }
204
+ if (isDocumentHost(pane)) {
205
+ host = pane;
206
+ return false;
207
+ }
208
+ return undefined;
209
+ });
210
+ return host;
211
+ }
212
+ /** Whether `pane` sits inside the document host. */
213
+ function isInDocumentHost(layout, pane) {
214
+ return dockAncestors(layout, pane).some(isDocumentHost);
215
+ }
216
+ /** Whether `pane` is the root of a floating window. */
217
+ function isFloatingRoot(layout, pane) {
218
+ return (layout.floatingPanes ?? []).includes(pane);
219
+ }
220
+ /** Whether `pane` is a root that must not be collapsed away. */
221
+ function isDockRoot(layout, pane) {
222
+ return pane === layout.rootPane || isFloatingRoot(layout, pane);
223
+ }
224
+ /** Every content pane in the layout, in tree order. */
225
+ function collectContentPanes(layout) {
226
+ const out = [];
227
+ visitDockPanes(layout, pane => {
228
+ if (isContentPane(pane)) {
229
+ out.push(pane);
230
+ }
231
+ });
232
+ return out;
233
+ }
234
+ /** The content pane that is currently maximized, if any. */
235
+ function findMaximizedPane(layout) {
236
+ return collectContentPanes(layout).find(pane => pane.isMaximized) ?? null;
237
+ }
238
+ /**
239
+ * Whether the pane takes up space in the docked layout. An unpinned content pane
240
+ * does not, and neither does a split pane or tab group whose whole subtree is
241
+ * unpinned — otherwise collapsing a sidebar would leave a gap behind it.
242
+ */
243
+ function isDockPaneVisible(pane) {
244
+ if (isContentPane(pane)) {
245
+ return pane.isPinned !== false;
246
+ }
247
+ if (isDocumentHost(pane)) {
248
+ return true;
249
+ }
250
+ return pane.panes.some(isDockPaneVisible);
251
+ }
252
+ /** The children of `pane` that take up space, in order. */
253
+ function visibleDockChildren(pane) {
254
+ return [...dockChildren(pane)].filter(isDockPaneVisible);
255
+ }
256
+ /** The tab a tab group shows: its `selectedIndex`, or the first pinned tab. */
257
+ function selectedTabPane(group) {
258
+ const visible = group.panes.filter(isDockPaneVisible);
259
+ if (!visible.length) {
260
+ return null;
261
+ }
262
+ const selected = group.panes[group.selectedIndex ?? 0];
263
+ return selected && visible.includes(selected) ? selected : visible[0];
264
+ }
265
+ /**
266
+ * Which edge an unpinned pane collapses to when `unpinnedLocation` is unset.
267
+ *
268
+ * Follows the pane's own position in the tree — a pane in the first branch of a
269
+ * horizontal split goes to the inline start, one in the last branch to the inline
270
+ * end — so a sidebar collapses to the side it was already on.
271
+ */
272
+ function defaultUnpinnedLocation(layout, pane) {
273
+ let child = pane;
274
+ for (const ancestor of dockAncestors(layout, pane)) {
275
+ if (isSplitPane(ancestor) && ancestor.panes.length > 1) {
276
+ const first = ancestor.panes.indexOf(child) === 0;
277
+ if (ancestor.orientation === 'horizontal') {
278
+ return first ? 'start' : 'end';
279
+ }
280
+ return first ? 'top' : 'bottom';
281
+ }
282
+ child = ancestor;
283
+ }
284
+ return 'end';
285
+ }
286
+ /** The unpinned content panes, grouped by the edge their tabs sit on. */
287
+ function unpinnedDockPanes(layout) {
288
+ const groups = { start: [], end: [], top: [], bottom: [] };
289
+ for (const pane of collectContentPanes(layout)) {
290
+ if (pane.isPinned === false) {
291
+ groups[pane.unpinnedLocation ?? defaultUnpinnedLocation(layout, pane)].push(pane);
292
+ }
293
+ }
294
+ return groups;
295
+ }
296
+ /**
297
+ * Tidy the tree after a mutation: drop empty parents, unwrap split panes down to
298
+ * a single child, merge nested splits that run along the same axis, and clamp
299
+ * every tab group's selected index.
300
+ *
301
+ * Roots survive all of this — `layout.rootPane`, a floating window's root and a
302
+ * document host's root pane are structural and stay put even when empty. A root
303
+ * left with a single split pane beneath it absorbs that child rather than being
304
+ * replaced by it.
305
+ */
306
+ function normalizeDockLayout(layout) {
307
+ const root = normalizePane(layout.rootPane, true);
308
+ layout.rootPane = (root ?? emptySplitPane());
309
+ const floating = [];
310
+ for (const pane of layout.floatingPanes ?? []) {
311
+ const normalized = normalizePane(pane, true);
312
+ // A floating window whose last pane was closed or docked away is gone.
313
+ if (normalized && isSplitPane(normalized) && normalized.panes.length) {
314
+ floating.push(normalized);
315
+ }
316
+ }
317
+ layout.floatingPanes = floating;
318
+ }
319
+ function emptySplitPane() {
320
+ return { type: 'splitPane', orientation: 'horizontal', panes: [], allowEmpty: true };
321
+ }
322
+ function normalizePane(pane, isRoot) {
323
+ if (isContentPane(pane)) {
324
+ return pane;
325
+ }
326
+ if (isDocumentHost(pane)) {
327
+ const inner = normalizePane(pane.rootPane, true);
328
+ pane.rootPane = inner && isSplitPane(inner) ? inner : emptySplitPane();
329
+ // The document host is structural: an empty editor area is a valid state.
330
+ return pane;
331
+ }
332
+ if (isTabGroupPane(pane)) {
333
+ if (!pane.panes.length && !pane.allowEmpty && !isRoot) {
334
+ return null;
335
+ }
336
+ pane.selectedIndex = Math.min(Math.max(pane.selectedIndex ?? 0, 0), Math.max(0, pane.panes.length - 1));
337
+ return pane;
338
+ }
339
+ const children = [];
340
+ for (const child of pane.panes) {
341
+ const normalized = normalizePane(child, false);
342
+ if (!normalized) {
343
+ continue;
344
+ }
345
+ // A split inside a split along the same axis is the same layout with an extra
346
+ // level of nesting; splice it away so repeated docking cannot grow the tree
347
+ // without bound.
348
+ if (isSplitPane(normalized) && normalized.orientation === pane.orientation && normalized.panes.length > 1) {
349
+ children.push(...redistribute(normalized.panes, normalized.size ?? 1));
350
+ continue;
351
+ }
352
+ children.push(normalized);
353
+ }
354
+ pane.panes = children;
355
+ if (!children.length) {
356
+ return pane.allowEmpty || isRoot ? pane : null;
357
+ }
358
+ if (children.length === 1) {
359
+ const only = children[0];
360
+ if (!isRoot) {
361
+ // The wrapper's share of the grandparent becomes the child's.
362
+ only.size = pane.size ?? only.size;
363
+ return only;
364
+ }
365
+ // A root cannot be replaced — floating geometry and the caller's reference
366
+ // hang off it — so it absorbs its lone child's axis and children instead.
367
+ // Without this, every drop onto the root would leave another dead level.
368
+ if (isSplitPane(only)) {
369
+ pane.orientation = only.orientation;
370
+ pane.panes = only.panes;
371
+ }
372
+ }
373
+ return pane;
374
+ }
375
+ /** Rescale `panes` so their weights sum to `total`, keeping their proportions. */
376
+ function redistribute(panes, total) {
377
+ const sum = panes.reduce((acc, pane) => acc + (pane.size ?? 1), 0) || panes.length;
378
+ for (const pane of panes) {
379
+ pane.size = ((pane.size ?? 1) / sum) * total;
380
+ }
381
+ return panes;
382
+ }
383
+ /**
384
+ * Detach `pane` from wherever it sits — a parent's `panes`, or the floating
385
+ * window list — and tidy up behind it. Returns `false` if it was not in the
386
+ * layout at all.
387
+ */
388
+ function removeDockPane(layout, pane) {
389
+ const floating = layout.floatingPanes ?? [];
390
+ const floatingIndex = floating.indexOf(pane);
391
+ if (floatingIndex >= 0) {
392
+ floating.splice(floatingIndex, 1);
393
+ normalizeDockLayout(layout);
394
+ return true;
395
+ }
396
+ const parent = findDockParent(layout, pane);
397
+ if (!parent) {
398
+ return false;
399
+ }
400
+ parent.panes.splice(parent.panes.indexOf(pane), 1);
401
+ normalizeDockLayout(layout);
402
+ return true;
403
+ }
404
+ /** The axis a docking position splits along. */
405
+ function axisOf(position) {
406
+ return position === 'start' || position === 'end' ? 'horizontal' : 'vertical';
407
+ }
408
+ /** Whether the dragged pane goes before the target along that axis. */
409
+ function isBefore(position) {
410
+ return position === 'start' || position === 'top';
411
+ }
412
+ /**
413
+ * Whether `pane` may be dropped onto `target`.
414
+ *
415
+ * A `documentOnly` pane is confined to the document host; anything else may dock
416
+ * anywhere. A pane can never be dropped onto itself or into its own subtree.
417
+ */
418
+ function canDockInto(layout, pane, target, position) {
419
+ if (pane === target) {
420
+ return false;
421
+ }
422
+ if (dockAncestors(layout, target).includes(pane)) {
423
+ return false;
424
+ }
425
+ if (isContentPane(pane) && pane.allowDocking === false) {
426
+ return false;
427
+ }
428
+ if (!documentOnlyPanes(pane)) {
429
+ return true;
430
+ }
431
+ // Docking against the document host's outer edges would put the document
432
+ // *beside* the editor area rather than in it, so only `center` qualifies.
433
+ return isDocumentHost(target) ? position === 'center' : isInDocumentHost(layout, target);
434
+ }
435
+ /** Whether every content pane in `pane`'s subtree is `documentOnly`. */
436
+ function documentOnlyPanes(pane) {
437
+ const contents = [];
438
+ const walk = (current) => {
439
+ if (isContentPane(current)) {
440
+ contents.push(current);
441
+ return;
442
+ }
443
+ for (const child of dockChildren(current)) {
444
+ walk(child);
445
+ }
446
+ };
447
+ walk(pane);
448
+ return contents.length > 0 && contents.every(content => content.documentOnly);
449
+ }
450
+ /**
451
+ * Attach `pane` to the layout next to `target`.
452
+ *
453
+ * `center` tabs the pane together with the target — turning a lone content pane
454
+ * into a two-tab group — while the four edge positions split the space. An edge
455
+ * drop reuses the target's parent when it already runs along the right axis, so
456
+ * dropping three panes to the right of each other yields one three-way split
457
+ * rather than three nested ones.
458
+ */
459
+ function insertDockPane(layout, pane, target, position) {
460
+ if (position === 'center') {
461
+ return insertIntoCenter(layout, pane, target);
462
+ }
463
+ const axis = axisOf(position);
464
+ const before = isBefore(position);
465
+ const parent = findDockParent(layout, target);
466
+ if (parent && isSplitPane(parent) && parent.orientation === axis) {
467
+ const index = parent.panes.indexOf(target);
468
+ // Split the target's own share rather than adding weight, so the panes either
469
+ // side of it keep the proportions they had.
470
+ const share = target.size ?? 1;
471
+ target.size = share / 2;
472
+ pane.size = share / 2;
473
+ parent.panes.splice(before ? index : index + 1, 0, pane);
474
+ normalizeDockLayout(layout);
475
+ return true;
476
+ }
477
+ // A root has no parent to insert into, so it grows a level instead: it keeps
478
+ // its own identity (and any floating geometry) and adopts the new axis.
479
+ if (!parent && isSplitPane(target) && isDockRoot(layout, target)) {
480
+ if (target.orientation !== axis && target.panes.length > 1) {
481
+ const inner = { type: 'splitPane', orientation: target.orientation, panes: target.panes };
482
+ target.panes = [inner];
483
+ }
484
+ target.orientation = axis;
485
+ target.panes.splice(before ? 0 : target.panes.length, 0, pane);
486
+ normalizeDockLayout(layout);
487
+ return true;
488
+ }
489
+ const wrapper = {
490
+ type: 'splitPane',
491
+ orientation: axis,
492
+ size: target.size ?? 1,
493
+ panes: before ? [pane, target] : [target, pane]
494
+ };
495
+ target.size = 1;
496
+ pane.size = 1;
497
+ return replacePane(layout, target, wrapper);
498
+ }
499
+ function insertIntoCenter(layout, pane, target) {
500
+ const incoming = isContentPane(pane) ? [pane] : tabbableContentPanes(pane);
501
+ if (isDocumentHost(target)) {
502
+ return insertIntoCenter(layout, pane, target.rootPane);
503
+ }
504
+ if (isTabGroupPane(target) && incoming.length) {
505
+ target.panes.push(...incoming);
506
+ target.selectedIndex = target.panes.length - 1;
507
+ normalizeDockLayout(layout);
508
+ return true;
509
+ }
510
+ if (isSplitPane(target)) {
511
+ // Nothing to tab with — an empty split pane (a bare document host) simply
512
+ // adopts the pane.
513
+ target.panes.push(pane);
514
+ normalizeDockLayout(layout);
515
+ return true;
516
+ }
517
+ if (!isContentPane(target) || !incoming.length) {
518
+ return false;
519
+ }
520
+ const group = {
521
+ type: 'tabGroupPane',
522
+ size: target.size ?? 1,
523
+ panes: [target, ...incoming],
524
+ // Select the pane that was just dropped, the way a dragged browser tab lands
525
+ // in front.
526
+ selectedIndex: 1
527
+ };
528
+ target.size = 1;
529
+ return replacePane(layout, target, group);
530
+ }
531
+ /** The content panes of a subtree, flattened, for merging into a tab group. */
532
+ function tabbableContentPanes(pane) {
533
+ const out = [];
534
+ const walk = (current) => {
535
+ if (isContentPane(current)) {
536
+ out.push(current);
537
+ return;
538
+ }
539
+ for (const child of dockChildren(current)) {
540
+ walk(child);
541
+ }
542
+ };
543
+ walk(pane);
544
+ return out;
545
+ }
546
+ /** Swap `target` for `replacement` wherever it sits, root positions included. */
547
+ function replacePane(layout, target, replacement) {
548
+ if (target === layout.rootPane) {
549
+ layout.rootPane = replacement;
550
+ normalizeDockLayout(layout);
551
+ return true;
552
+ }
553
+ const floating = layout.floatingPanes ?? [];
554
+ const floatingIndex = floating.indexOf(target);
555
+ if (floatingIndex >= 0) {
556
+ floating[floatingIndex] = replacement;
557
+ normalizeDockLayout(layout);
558
+ return true;
559
+ }
560
+ const parent = findDockParent(layout, target);
561
+ if (parent) {
562
+ parent.panes[parent.panes.indexOf(target)] = replacement;
563
+ normalizeDockLayout(layout);
564
+ return true;
565
+ }
566
+ const host = dockAncestors(layout, target).find(isDocumentHost);
567
+ if (host && isSplitPane(replacement)) {
568
+ host.rootPane = replacement;
569
+ normalizeDockLayout(layout);
570
+ return true;
571
+ }
572
+ return false;
573
+ }
574
+ /** Move `pane` out of the layout and into a floating window of its own. */
575
+ function floatDockPane(layout, pane, location, size) {
576
+ if (isContentPane(pane) && pane.allowFloating === false) {
577
+ return null;
578
+ }
579
+ // Already floating: this is a move, not a detach.
580
+ if (isSplitPane(pane) && isFloatingRoot(layout, pane)) {
581
+ pane.floatingLocation = location;
582
+ return pane;
583
+ }
584
+ if (!removeDockPane(layout, pane)) {
585
+ return null;
586
+ }
587
+ pane.size = 1;
588
+ const window = {
589
+ type: 'splitPane',
590
+ orientation: 'horizontal',
591
+ panes: [pane],
592
+ floatingLocation: location,
593
+ floatingWidth: size?.width ?? 400,
594
+ floatingHeight: size?.height ?? 300,
595
+ floatingResizable: true
596
+ };
597
+ layout.floatingPanes = [...(layout.floatingPanes ?? []), window];
598
+ normalizeDockLayout(layout);
599
+ return window;
600
+ }
601
+ /** Move `pane` from wherever it is to `position` relative to `target`. */
602
+ function dockPaneAt(layout, pane, target, position) {
603
+ if (!canDockInto(layout, pane, target, position)) {
604
+ return false;
605
+ }
606
+ removeDockPane(layout, pane);
607
+ // Geometry only means something while the pane is a floating window; leaving it
608
+ // behind would resurrect the old size the next time it is floated.
609
+ if (isSplitPane(pane)) {
610
+ delete pane.floatingLocation;
611
+ delete pane.floatingWidth;
612
+ delete pane.floatingHeight;
613
+ delete pane.floatingResizable;
614
+ }
615
+ // `removeDockPane` normalises, which can unwrap the target's parent — but never
616
+ // the target itself, so it is still a valid insertion point.
617
+ return insertDockPane(layout, pane, target, position);
618
+ }
619
+
620
+ /** Thickness of the edge strips that hold unpinned panes' tabs, in pixels. */
621
+ const STRIP_SIZE = 32;
622
+ /** How close to the dock manager's own edge a drop docks against the whole layout. */
623
+ const OUTER_EDGE = 28;
624
+ /** A pane never resizes below this, so its header always stays grabbable. */
625
+ const MIN_PANE_SIZE = 48;
626
+ /** Where a floating window sits when its layout gives no `floatingLocation`. */
627
+ const FLOATING_ORIGIN = { x: 40, y: 40 };
628
+ /** Pointer travel before a press on a header turns into a drag. */
629
+ const DRAG_THRESHOLD = 4;
630
+ /** The centre of a pane is a "tab it here" target; outside it, the nearest edge wins. */
631
+ const CENTRE_ZONE = 0.3;
632
+ /** Size of one docking indicator square, and the gap between them. */
633
+ const INDICATOR_SIZE = 30;
634
+ const INDICATOR_GAP = 4;
635
+ /** How far the outer ring of indicators sits from the dock manager's frame. */
636
+ const INDICATOR_INSET = 8;
637
+ /** Below this the joystick would not fit inside the pane, so it is not offered. */
638
+ const INDICATOR_MIN_PANE = 96;
639
+ /**
640
+ * An IDE-style docking layout: resizable split panes, tab groups, a document
641
+ * host, collapsible edge panels and floating windows, all driven by one
642
+ * serialisable {@link XuiDockManagerLayout} tree.
643
+ *
644
+ * ```html
645
+ * <xui-dock-manager [(layout)]="layout" class="h-[32rem]">
646
+ * <ng-template xuiDockContent="explorer">…</ng-template>
647
+ * <ng-template xuiDockContent="editor">…</ng-template>
648
+ * </xui-dock-manager>
649
+ * ```
650
+ *
651
+ * The layout shape follows Ignite UI's `IgcDockManagerLayout`, so existing
652
+ * layouts port over by dropping the `Igc` prefix and using string literals in
653
+ * place of its enums.
654
+ *
655
+ * Panes carry no content of their own: each one names a `contentId`, and the
656
+ * matching `[xuiDockContent]` template supplies the body. Those bodies are
657
+ * instantiated once and then *moved* as the layout changes, so a pane keeps its
658
+ * scroll position and component state when it is dragged to another dock, tabbed,
659
+ * floated or collapsed.
660
+ *
661
+ * `layout` is two-way bindable and the dock manager edits the tree **in place**
662
+ * before emitting, which is what keeps pane identity stable across a drag. Bind a
663
+ * signal to it and snapshot with `cloneDockLayout()` if you need an undo buffer.
664
+ *
665
+ * Dragging is a pointer gesture. Picking up a pane raises Visual Studio's docking
666
+ * targets: a five-way joystick over whichever pane is under the pointer — four
667
+ * arms to split it, the centre to tab with it — and an outer ring at the dock
668
+ * manager's edges to dock against the whole layout. Only the targets the dragged
669
+ * pane is allowed to take are drawn, and the one that would be used is
670
+ * highlighted while an outline previews the space it would claim.
671
+ *
672
+ * Off the targets, the two gestures differ. A header or tab drag has no other
673
+ * purpose, so the whole pane stays live and the nearest edge wins; dropping
674
+ * outside the layout floats the pane. Dragging a floating window's title bar is
675
+ * mostly about *moving* the window, so it docks from the targets alone.
676
+ *
677
+ * Every other operation — close, pin, maximize, float, tab selection, splitter
678
+ * resize — is reachable from the keyboard, and the public methods (`closePane`,
679
+ * `unpinPane`, `dockPane`, …) drive the same paths for code. Docking itself has no
680
+ * keyboard equivalent yet.
681
+ */
682
+ class XuiDockManager {
683
+ el = inject(ElementRef).nativeElement;
684
+ document = this.el.ownerDocument;
685
+ vcr = inject(ViewContainerRef);
686
+ direction = injectXDirection();
687
+ /** Detached parking space for the content views of panes that are not on screen. */
688
+ holder = this.document.createElement('div');
689
+ views = new Map();
690
+ // `descendants` so the templates may sit inside a wrapper element rather than
691
+ // having to be immediate children.
692
+ contents = contentChildren(XuiDockContent, { ...(ngDevMode ? { debugName: "contents" } : /* istanbul ignore next */ {}), descendants: true });
693
+ class = input('', /* @ts-ignore */
694
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
695
+ /**
696
+ * The layout tree. Two-way bindable, and edited **in place** — see the class
697
+ * docs for why, and use `cloneDockLayout()` for snapshots.
698
+ */
699
+ layout = model.required(/* @ts-ignore */
700
+ ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
701
+ /** A pane was closed and removed from the layout. */
702
+ paneClose = output();
703
+ /** The pane the user last interacted with, or `null` once it is gone. */
704
+ activePaneChange = output();
705
+ panePinnedChange = output();
706
+ paneMaximizedChange = output();
707
+ paneFloatingChange = output();
708
+ /** A pointer drag on a pane header or tab began. */
709
+ paneDragStart = output();
710
+ /** A pointer drag ended, whether or not it changed the layout. */
711
+ paneDragEnd = output();
712
+ /** The pane last interacted with. Drives the active header accent. */
713
+ activePane = signal(null, /* @ts-ignore */
714
+ ...(ngDevMode ? [{ debugName: "activePane" }] : /* istanbul ignore next */ []));
715
+ drag = signal(null, /* @ts-ignore */
716
+ ...(ngDevMode ? [{ debugName: "drag" }] : /* istanbul ignore next */ []));
717
+ dropTarget = signal(null, /* @ts-ignore */
718
+ ...(ngDevMode ? [{ debugName: "dropTarget" }] : /* istanbul ignore next */ []));
719
+ dropRect = signal(null, /* @ts-ignore */
720
+ ...(ngDevMode ? [{ debugName: "dropRect" }] : /* istanbul ignore next */ []));
721
+ dockIndicators = signal([], /* @ts-ignore */
722
+ ...(ngDevMode ? [{ debugName: "dockIndicators" }] : /* istanbul ignore next */ []));
723
+ /** The unpinned pane whose fly-out is open. */
724
+ flyout = signal(null, /* @ts-ignore */
725
+ ...(ngDevMode ? [{ debugName: "flyout" }] : /* istanbul ignore next */ []));
726
+ maximized = computed(() => findMaximizedPane(this.layout()), /* @ts-ignore */
727
+ ...(ngDevMode ? [{ debugName: "maximized" }] : /* istanbul ignore next */ []));
728
+ unpinned = computed(() => unpinnedDockPanes(this.layout()), /* @ts-ignore */
729
+ ...(ngDevMode ? [{ debugName: "unpinned" }] : /* istanbul ignore next */ []));
730
+ floatingPanes = computed(() => this.layout().floatingPanes ?? [], /* @ts-ignore */
731
+ ...(ngDevMode ? [{ debugName: "floatingPanes" }] : /* istanbul ignore next */ []));
732
+ dragGhost = computed(() => {
733
+ const state = this.drag();
734
+ // A floating window drags itself, so it needs no ghost.
735
+ return state && !state.window ? { x: state.x, y: state.y, label: this.headerOf(state.pane) } : null;
736
+ }, /* @ts-ignore */
737
+ ...(ngDevMode ? [{ debugName: "dragGhost" }] : /* istanbul ignore next */ []));
738
+ /** Exposed to the template so an indicator's box matches its hit area exactly. */
739
+ INDICATOR_SIZE = INDICATOR_SIZE;
740
+ /** Exposed to the template; the ids double as the axis to resize along. */
741
+ RESIZE_HANDLES = [
742
+ { id: 'top', class: 'absolute -top-1 inset-x-2 h-2 cursor-ns-resize' },
743
+ { id: 'bottom', class: 'absolute -bottom-1 inset-x-2 h-2 cursor-ns-resize' },
744
+ { id: 'left', class: 'absolute -left-1 inset-y-2 w-2 cursor-ew-resize' },
745
+ { id: 'right', class: 'absolute -right-1 inset-y-2 w-2 cursor-ew-resize' },
746
+ { id: 'top-left', class: 'absolute -top-1 -left-1 size-3 cursor-nwse-resize' },
747
+ { id: 'top-right', class: 'absolute -top-1 -right-1 size-3 cursor-nesw-resize' },
748
+ { id: 'bottom-left', class: 'absolute -bottom-1 -left-1 size-3 cursor-nesw-resize' },
749
+ { id: 'bottom-right', class: 'absolute -right-1 -bottom-1 size-3 cursor-nwse-resize' }
750
+ ];
751
+ constructor() {
752
+ // Content views outlive the pane bodies they are mounted into, so they are
753
+ // only destroyed once their pane leaves the layout for good.
754
+ effect(() => {
755
+ const live = new Set(collectContentPanes(this.layout()).map(pane => pane.contentId));
756
+ for (const [contentId, view] of this.views) {
757
+ if (!live.has(contentId)) {
758
+ view.destroy();
759
+ this.views.delete(contentId);
760
+ }
761
+ }
762
+ const active = this.activePane();
763
+ if (active && !live.has(active.contentId)) {
764
+ this.activePane.set(null);
765
+ this.activePaneChange.emit(null);
766
+ }
767
+ const flyout = this.flyout();
768
+ if (flyout && (!live.has(flyout.contentId) || flyout.isPinned !== false)) {
769
+ this.flyout.set(null);
770
+ }
771
+ });
772
+ // A fly-out is a transient overlay: a pointer press anywhere that is not the
773
+ // fly-out or an edge strip puts it away again.
774
+ effect(onCleanup => {
775
+ if (!this.flyout()) {
776
+ return;
777
+ }
778
+ const onPointerDown = (event) => {
779
+ if (event.target?.closest('[data-dock-flyout], [data-dock-strip]')) {
780
+ return;
781
+ }
782
+ this.flyout.set(null);
783
+ };
784
+ this.document.addEventListener('pointerdown', onPointerDown, true);
785
+ onCleanup(() => this.document.removeEventListener('pointerdown', onPointerDown, true));
786
+ });
787
+ inject(DestroyRef).onDestroy(() => {
788
+ for (const view of this.views.values()) {
789
+ view.destroy();
790
+ }
791
+ this.views.clear();
792
+ this.stopPointerTracking();
793
+ });
794
+ }
795
+ // ─── content mounting ─────────────────────────────────────────────────────
796
+ mountContent(contentId, host) {
797
+ let view = this.views.get(contentId);
798
+ if (!view) {
799
+ const template = this.contents().find(content => content.contentId() === contentId)?.template;
800
+ if (!template) {
801
+ return;
802
+ }
803
+ view = this.vcr.createEmbeddedView(template);
804
+ this.views.set(contentId, view);
805
+ }
806
+ for (const node of view.rootNodes) {
807
+ host.appendChild(node);
808
+ }
809
+ }
810
+ releaseContent(contentId, host) {
811
+ const view = this.views.get(contentId);
812
+ if (!view) {
813
+ return;
814
+ }
815
+ for (const node of view.rootNodes) {
816
+ if (node.parentNode === host) {
817
+ this.holder.appendChild(node);
818
+ }
819
+ }
820
+ }
821
+ // ─── public API ───────────────────────────────────────────────────────────
822
+ /** Remove `pane` from the layout. Respects `allowClose`. */
823
+ closePane(pane) {
824
+ if (pane.allowClose === false) {
825
+ return;
826
+ }
827
+ const layout = this.layout();
828
+ if (removeDockPane(layout, pane)) {
829
+ pane.isMaximized = false;
830
+ this.commit();
831
+ this.paneClose.emit(pane);
832
+ }
833
+ }
834
+ /** Close every pane in a floating window. */
835
+ closeWindow(window) {
836
+ for (const pane of this.contentPanesOf(window)) {
837
+ this.closePane(pane);
838
+ }
839
+ }
840
+ /** Collapse `pane` to a tab on the nearest edge, keeping its place in the tree. */
841
+ unpinPane(pane) {
842
+ if (pane.allowPinning === false || pane.documentOnly || pane.isPinned === false) {
843
+ return;
844
+ }
845
+ // A pane inside a floating window has no edge to collapse to.
846
+ if (this.isFloating(pane)) {
847
+ this.dockBackPane(pane);
848
+ }
849
+ pane.isPinned = false;
850
+ pane.isMaximized = false;
851
+ this.flyout.set(null);
852
+ this.commit();
853
+ this.panePinnedChange.emit({ pane, value: false });
854
+ }
855
+ /** Restore `pane` to the position it was collapsed from. */
856
+ pinPane(pane) {
857
+ if (pane.isPinned !== false) {
858
+ return;
859
+ }
860
+ pane.isPinned = true;
861
+ this.flyout.set(null);
862
+ this.commit();
863
+ this.panePinnedChange.emit({ pane, value: true });
864
+ }
865
+ togglePinned(pane) {
866
+ if (pane.isPinned === false) {
867
+ this.pinPane(pane);
868
+ }
869
+ else {
870
+ this.unpinPane(pane);
871
+ }
872
+ }
873
+ /** Blow `pane` up to fill the whole dock manager, hiding everything else. */
874
+ maximizePane(pane) {
875
+ if (pane.allowMaximize === false) {
876
+ return;
877
+ }
878
+ for (const other of collectContentPanes(this.layout())) {
879
+ other.isMaximized = other === pane;
880
+ }
881
+ this.commit();
882
+ this.paneMaximizedChange.emit({ pane, value: true });
883
+ }
884
+ restorePane(pane) {
885
+ pane.isMaximized = false;
886
+ this.commit();
887
+ this.paneMaximizedChange.emit({ pane, value: false });
888
+ }
889
+ toggleMaximized(pane) {
890
+ if (pane.isMaximized) {
891
+ this.restorePane(pane);
892
+ }
893
+ else {
894
+ this.maximizePane(pane);
895
+ }
896
+ }
897
+ /** Move `pane` into a floating window of its own. */
898
+ floatPane(pane, location, size) {
899
+ const rect = this.el.getBoundingClientRect();
900
+ const fallback = { x: Math.round(rect.width / 4), y: Math.round(rect.height / 4) };
901
+ if (isContentPane(pane)) {
902
+ pane.isPinned = true;
903
+ pane.isMaximized = false;
904
+ }
905
+ if (!floatDockPane(this.layout(), pane, location ?? fallback, size)) {
906
+ return;
907
+ }
908
+ this.commit();
909
+ for (const content of this.contentPanesOf(pane)) {
910
+ this.paneFloatingChange.emit({ pane: content, value: true });
911
+ }
912
+ }
913
+ /** Dock `pane` at `position` relative to `target`. */
914
+ dockPane(pane, target, position) {
915
+ const wasFloating = this.isFloating(pane);
916
+ if (!dockPaneAt(this.layout(), pane, target, position)) {
917
+ return false;
918
+ }
919
+ this.commit();
920
+ if (wasFloating) {
921
+ for (const content of this.contentPanesOf(pane)) {
922
+ this.paneFloatingChange.emit({ pane: content, value: false });
923
+ }
924
+ }
925
+ return true;
926
+ }
927
+ /** Make `pane` the active one, selecting its tab and opening its fly-out. */
928
+ selectPane(pane) {
929
+ const group = this.tabGroupOf(pane);
930
+ if (group) {
931
+ group.selectedIndex = group.panes.indexOf(pane);
932
+ this.commit();
933
+ }
934
+ if (pane.isPinned === false) {
935
+ this.flyout.set(pane);
936
+ }
937
+ this.activate(pane);
938
+ }
939
+ // ─── template helpers ─────────────────────────────────────────────────────
940
+ key = dockPaneKey;
941
+ childrenOf(pane) {
942
+ return visibleDockChildren(pane);
943
+ }
944
+ unpinnedAt(location) {
945
+ return this.unpinned()[location];
946
+ }
947
+ pinnedTabs(group) {
948
+ return group.panes.filter(pane => pane.isPinned !== false);
949
+ }
950
+ selectedTab(group) {
951
+ return selectedTabPane(group);
952
+ }
953
+ flexOf(pane) {
954
+ return `${pane.size ?? 1} 1 0px`;
955
+ }
956
+ headerOf(pane) {
957
+ const contents = this.contentPanesOf(pane);
958
+ return contents[0]?.header || contents[0]?.contentId || 'panes';
959
+ }
960
+ /** The pane a floating window's title bar drags: its lone pane, or the root. */
961
+ floatingContent(window) {
962
+ return this.singlePane(window) ?? window;
963
+ }
964
+ singlePane(window) {
965
+ const only = window.panes.length === 1 ? window.panes[0] : null;
966
+ return only && isContentPane(only) ? only : null;
967
+ }
968
+ floatingTitle(window) {
969
+ const contents = this.contentPanesOf(window);
970
+ if (contents.length === 1) {
971
+ return contents[0].header || contents[0].contentId;
972
+ }
973
+ return `${contents.length} panes`;
974
+ }
975
+ isFloating(pane) {
976
+ const windows = this.layout().floatingPanes ?? [];
977
+ return windows.some(window => window === pane || this.contentPanesOf(window).includes(pane));
978
+ }
979
+ toggleFlyout(pane) {
980
+ this.flyout.update(current => (current === pane ? null : pane));
981
+ if (this.flyout()) {
982
+ this.activate(pane);
983
+ }
984
+ }
985
+ activate(pane) {
986
+ if (this.activePane() === pane) {
987
+ return;
988
+ }
989
+ this.activePane.set(pane);
990
+ this.activePaneChange.emit(pane);
991
+ }
992
+ onEscape() {
993
+ if (this.drag()) {
994
+ this.cancelDrag();
995
+ return;
996
+ }
997
+ this.flyout.set(null);
998
+ }
999
+ /** Dock a whole floating window back into the layout root. */
1000
+ dockBack(window) {
1001
+ this.dockPane(window, this.layout().rootPane, 'end');
1002
+ }
1003
+ dockBackPane(pane) {
1004
+ this.dockPane(pane, this.layout().rootPane, 'end');
1005
+ }
1006
+ bringToFront(window) {
1007
+ const windows = this.layout().floatingPanes ?? [];
1008
+ if (windows[windows.length - 1] === window) {
1009
+ return;
1010
+ }
1011
+ this.layout().floatingPanes = [...windows.filter(other => other !== window), window];
1012
+ this.commit();
1013
+ }
1014
+ // ─── splitter resizing ────────────────────────────────────────────────────
1015
+ startSplitterDrag(parent, gutter, event) {
1016
+ const gutterEl = event.currentTarget;
1017
+ const beforeEl = gutterEl.previousElementSibling;
1018
+ const afterEl = gutterEl.nextElementSibling;
1019
+ if (!beforeEl || !afterEl) {
1020
+ return;
1021
+ }
1022
+ event.preventDefault();
1023
+ const vertical = parent.orientation === 'vertical';
1024
+ const children = this.childrenOf(parent);
1025
+ const before = children[gutter];
1026
+ const after = children[gutter + 1];
1027
+ const beforePx = vertical ? beforeEl.offsetHeight : beforeEl.offsetWidth;
1028
+ const afterPx = vertical ? afterEl.offsetHeight : afterEl.offsetWidth;
1029
+ const start = vertical ? event.clientY : event.clientX;
1030
+ // Dragging towards the inline end grows the leading pane; in RTL that is
1031
+ // leftwards, so the horizontal delta is negated.
1032
+ const sign = !vertical && this.direction() === 'rtl' ? -1 : 1;
1033
+ const onMove = (moveEvent) => {
1034
+ const position = vertical ? moveEvent.clientY : moveEvent.clientX;
1035
+ const delta = this.clampSplitterDelta((position - start) * sign, beforePx, afterPx);
1036
+ this.resizeSiblings(before, after, beforePx + delta, beforePx + afterPx);
1037
+ };
1038
+ this.trackPointer(onMove);
1039
+ }
1040
+ onSplitterKeydown(parent, gutter, event) {
1041
+ const vertical = parent.orientation === 'vertical';
1042
+ const arrow = arrowDirectionOnAxis(event.key, this.direction(), vertical ? 'vertical' : 'horizontal');
1043
+ const gutterEl = event.currentTarget;
1044
+ const beforeEl = gutterEl.previousElementSibling;
1045
+ const afterEl = gutterEl.nextElementSibling;
1046
+ if (!arrow || !beforeEl || !afterEl) {
1047
+ return;
1048
+ }
1049
+ event.preventDefault();
1050
+ const children = this.childrenOf(parent);
1051
+ const beforePx = vertical ? beforeEl.offsetHeight : beforeEl.offsetWidth;
1052
+ const afterPx = vertical ? afterEl.offsetHeight : afterEl.offsetWidth;
1053
+ const step = (event.shiftKey ? 40 : 10) * (arrow === 'next' ? 1 : -1);
1054
+ const delta = this.clampSplitterDelta(step, beforePx, afterPx);
1055
+ this.resizeSiblings(children[gutter], children[gutter + 1], beforePx + delta, beforePx + afterPx);
1056
+ }
1057
+ clampSplitterDelta(delta, beforePx, afterPx) {
1058
+ return Math.max(MIN_PANE_SIZE - beforePx, Math.min(afterPx - MIN_PANE_SIZE, delta));
1059
+ }
1060
+ /** Re-weight two siblings so `before` takes `beforePx` of their shared `totalPx`. */
1061
+ resizeSiblings(before, after, beforePx, totalPx) {
1062
+ if (totalPx <= 0) {
1063
+ return;
1064
+ }
1065
+ const share = (before.size ?? 1) + (after.size ?? 1);
1066
+ before.size = (share * beforePx) / totalPx;
1067
+ after.size = share - before.size;
1068
+ this.commit({ normalize: false });
1069
+ }
1070
+ // ─── floating windows ─────────────────────────────────────────────────────
1071
+ startFloatingResize(window, handle, event) {
1072
+ event.preventDefault();
1073
+ event.stopPropagation();
1074
+ const startX = event.clientX;
1075
+ const startY = event.clientY;
1076
+ const origin = window.floatingLocation ?? FLOATING_ORIGIN;
1077
+ const width = window.floatingWidth ?? 400;
1078
+ const height = window.floatingHeight ?? 300;
1079
+ // In RTL the window is positioned from the right edge, so a rightward drag
1080
+ // moves its inline start the other way.
1081
+ const sign = this.direction() === 'rtl' ? -1 : 1;
1082
+ const onMove = (moveEvent) => {
1083
+ const dx = (moveEvent.clientX - startX) * sign;
1084
+ const dy = moveEvent.clientY - startY;
1085
+ const location = { ...origin };
1086
+ let nextWidth = width;
1087
+ let nextHeight = height;
1088
+ if (handle.includes('left')) {
1089
+ nextWidth = Math.max(MIN_PANE_SIZE * 2, width - dx);
1090
+ location.x = origin.x + (width - nextWidth);
1091
+ }
1092
+ else if (handle.includes('right')) {
1093
+ nextWidth = Math.max(MIN_PANE_SIZE * 2, width + dx);
1094
+ }
1095
+ if (handle.includes('top')) {
1096
+ nextHeight = Math.max(MIN_PANE_SIZE * 2, height - dy);
1097
+ location.y = origin.y + (height - nextHeight);
1098
+ }
1099
+ else if (handle.includes('bottom')) {
1100
+ nextHeight = Math.max(MIN_PANE_SIZE * 2, height + dy);
1101
+ }
1102
+ window.floatingLocation = location;
1103
+ window.floatingWidth = nextWidth;
1104
+ window.floatingHeight = nextHeight;
1105
+ this.commit({ normalize: false });
1106
+ };
1107
+ this.trackPointer(onMove);
1108
+ }
1109
+ // ─── dragging panes and docking ───────────────────────────────────────────
1110
+ /**
1111
+ * Start a drag on a pane header, a tab, or a floating window's title bar.
1112
+ *
1113
+ * A window drag moves the window as it goes and docks it if it is released over
1114
+ * a valid target; a pane drag shows a ghost and either docks the pane or floats
1115
+ * it where it was dropped.
1116
+ */
1117
+ startPaneDrag(pane, window, event) {
1118
+ if (event.button !== 0) {
1119
+ return;
1120
+ }
1121
+ const startX = event.clientX;
1122
+ const startY = event.clientY;
1123
+ const origin = window?.floatingLocation ?? FLOATING_ORIGIN;
1124
+ const sign = this.direction() === 'rtl' ? -1 : 1;
1125
+ let started = false;
1126
+ const onMove = (moveEvent) => {
1127
+ const dx = moveEvent.clientX - startX;
1128
+ const dy = moveEvent.clientY - startY;
1129
+ if (!started) {
1130
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) {
1131
+ return;
1132
+ }
1133
+ started = true;
1134
+ this.paneDragStart.emit(pane);
1135
+ }
1136
+ if (window) {
1137
+ window.floatingLocation = { x: origin.x + dx * sign, y: origin.y + dy };
1138
+ this.commit({ normalize: false });
1139
+ }
1140
+ this.drag.set({ pane, window, x: moveEvent.clientX, y: moveEvent.clientY });
1141
+ this.updateDropTarget(moveEvent.clientX, moveEvent.clientY);
1142
+ };
1143
+ const onUp = (upEvent) => {
1144
+ if (!started) {
1145
+ return;
1146
+ }
1147
+ const target = this.dropTarget();
1148
+ this.clearDrag();
1149
+ if (target) {
1150
+ this.dockPane(pane, target.pane, target.position);
1151
+ }
1152
+ else if (!window) {
1153
+ this.floatAtPointer(pane, upEvent.clientX, upEvent.clientY);
1154
+ }
1155
+ this.paneDragEnd.emit(pane);
1156
+ };
1157
+ this.trackPointer(onMove, onUp);
1158
+ }
1159
+ cancelDrag() {
1160
+ const state = this.drag();
1161
+ this.clearDrag();
1162
+ this.stopPointerTracking();
1163
+ if (state) {
1164
+ this.paneDragEnd.emit(state.pane);
1165
+ }
1166
+ }
1167
+ clearDrag() {
1168
+ this.drag.set(null);
1169
+ this.dropTarget.set(null);
1170
+ this.dropRect.set(null);
1171
+ this.dockIndicators.set([]);
1172
+ }
1173
+ floatAtPointer(pane, clientX, clientY) {
1174
+ const rect = this.el.getBoundingClientRect();
1175
+ const inlineOffset = this.direction() === 'rtl' ? rect.right - clientX : clientX - rect.left;
1176
+ this.floatPane(pane, { x: Math.round(inlineOffset - 40), y: Math.round(clientY - rect.top - 12) });
1177
+ }
1178
+ /**
1179
+ * Work out where a drop at these coordinates would land, and size the preview
1180
+ * rectangle to match.
1181
+ *
1182
+ * The dock manager's own edges win over whatever pane happens to be under the
1183
+ * pointer, so there is always a way to dock against the full height or width of
1184
+ * the layout.
1185
+ */
1186
+ updateDropTarget(clientX, clientY) {
1187
+ const state = this.drag();
1188
+ if (!state) {
1189
+ return;
1190
+ }
1191
+ const hostRect = this.el.getBoundingClientRect();
1192
+ const hovered = this.paneAt(clientX, clientY);
1193
+ const indicators = this.buildIndicators(state.pane, hovered, hostRect);
1194
+ const x = clientX - hostRect.left;
1195
+ const y = clientY - hostRect.top;
1196
+ // Landing on an indicator is an explicit choice and always wins.
1197
+ const hit = indicators.find(indicator => x >= indicator.left &&
1198
+ x <= indicator.left + INDICATOR_SIZE &&
1199
+ y >= indicator.top &&
1200
+ y <= indicator.top + INDICATOR_SIZE);
1201
+ const target = hit
1202
+ ? { pane: hit.pane, position: hit.position }
1203
+ : this.looseTargetAt(state, hovered, clientX, clientY);
1204
+ this.dockIndicators.set(indicators.map(indicator => ({
1205
+ ...indicator,
1206
+ active: !!target && indicator.pane === target.pane && indicator.position === target.position
1207
+ })));
1208
+ if (!target) {
1209
+ this.dropTarget.set(null);
1210
+ this.dropRect.set(null);
1211
+ return;
1212
+ }
1213
+ const rect = this.elementForPane(target.pane)?.getBoundingClientRect() ?? hostRect;
1214
+ this.dropTarget.set(target);
1215
+ this.dropRect.set(this.previewRect(rect, hostRect, target.position));
1216
+ }
1217
+ /**
1218
+ * The target for a drop that missed every indicator.
1219
+ *
1220
+ * A header or tab drag has no purpose other than docking, so the whole pane
1221
+ * stays live and the nearest edge wins. Dragging a floating window is mostly
1222
+ * about *moving* it, so it docks from the indicators alone.
1223
+ */
1224
+ looseTargetAt(state, hovered, clientX, clientY) {
1225
+ if (state.window) {
1226
+ return null;
1227
+ }
1228
+ const hostRect = this.el.getBoundingClientRect();
1229
+ const target = this.outerEdgeAt(clientX, clientY, hostRect) ?? this.zoneTargetAt(hovered, clientX, clientY);
1230
+ return target && canDockInto(this.layout(), state.pane, target.pane, target.position) ? target : null;
1231
+ }
1232
+ /**
1233
+ * The joystick over the pane under the pointer, plus the outer ring that docks
1234
+ * against the whole layout — Visual Studio's docking targets.
1235
+ *
1236
+ * Only the positions the dragged pane is actually allowed to take are built, so
1237
+ * a document dragged over a tool window simply offers nothing.
1238
+ */
1239
+ buildIndicators(dragged, hovered, hostRect) {
1240
+ const ltr = this.direction() !== 'rtl';
1241
+ const root = this.layout().rootPane;
1242
+ const step = INDICATOR_SIZE + INDICATOR_GAP;
1243
+ const half = INDICATOR_SIZE / 2;
1244
+ const far = INDICATOR_INSET + INDICATOR_SIZE;
1245
+ const candidates = [];
1246
+ const push = (pane, side, left, top) => {
1247
+ const position = side === 'center'
1248
+ ? 'center'
1249
+ : side === 'top' || side === 'bottom'
1250
+ ? side
1251
+ : (side === 'left') === ltr
1252
+ ? 'start'
1253
+ : 'end';
1254
+ if (canDockInto(this.layout(), dragged, pane, position)) {
1255
+ candidates.push({ pane, position, side, left, top });
1256
+ }
1257
+ };
1258
+ // The outer ring, pinned to the middle of each of the dock manager's edges.
1259
+ push(root, 'top', hostRect.width / 2 - half, INDICATOR_INSET);
1260
+ push(root, 'bottom', hostRect.width / 2 - half, hostRect.height - far);
1261
+ push(root, 'left', INDICATOR_INSET, hostRect.height / 2 - half);
1262
+ push(root, 'right', hostRect.width - far, hostRect.height / 2 - half);
1263
+ if (hovered) {
1264
+ const rect = this.elementForPane(hovered)?.getBoundingClientRect();
1265
+ if (rect && rect.width >= INDICATOR_MIN_PANE && rect.height >= INDICATOR_MIN_PANE) {
1266
+ const left = rect.left - hostRect.left + rect.width / 2 - half;
1267
+ const top = rect.top - hostRect.top + rect.height / 2 - half;
1268
+ push(hovered, 'center', left, top);
1269
+ push(hovered, 'left', left - step, top);
1270
+ push(hovered, 'right', left + step, top);
1271
+ push(hovered, 'top', left, top - step);
1272
+ push(hovered, 'bottom', left, top + step);
1273
+ }
1274
+ }
1275
+ return candidates.map(candidate => ({
1276
+ ...candidate,
1277
+ key: `${dockPaneKey(candidate.pane)}-${candidate.position}-${candidate.side}`,
1278
+ active: false
1279
+ }));
1280
+ }
1281
+ /** A drop within `OUTER_EDGE` of the dock manager's frame docks the whole layout. */
1282
+ outerEdgeAt(clientX, clientY, hostRect) {
1283
+ if (clientX < hostRect.left || clientX > hostRect.right || clientY < hostRect.top || clientY > hostRect.bottom) {
1284
+ return null;
1285
+ }
1286
+ const root = this.layout().rootPane;
1287
+ const ltr = this.direction() !== 'rtl';
1288
+ if (clientX - hostRect.left < OUTER_EDGE) {
1289
+ return { pane: root, position: ltr ? 'start' : 'end' };
1290
+ }
1291
+ if (hostRect.right - clientX < OUTER_EDGE) {
1292
+ return { pane: root, position: ltr ? 'end' : 'start' };
1293
+ }
1294
+ if (clientY - hostRect.top < OUTER_EDGE) {
1295
+ return { pane: root, position: 'top' };
1296
+ }
1297
+ if (hostRect.bottom - clientY < OUTER_EDGE) {
1298
+ return { pane: root, position: 'bottom' };
1299
+ }
1300
+ return null;
1301
+ }
1302
+ /**
1303
+ * The pane under the pointer.
1304
+ *
1305
+ * Hit-tested against the rendered rectangles rather than `elementFromPoint`, so
1306
+ * the drag ghost, the indicators and the floating window riding along with the
1307
+ * pointer cannot get in the way. The innermost rectangle wins, and on a tie the
1308
+ * one painted last does.
1309
+ */
1310
+ paneAt(clientX, clientY) {
1311
+ const dragged = this.drag()?.window;
1312
+ const draggedEl = dragged ? this.el.querySelector(`[data-dock-window="${dockPaneKey(dragged)}"]`) : null;
1313
+ let element = null;
1314
+ let smallest = Number.POSITIVE_INFINITY;
1315
+ for (const candidate of this.el.querySelectorAll('[data-dock-key]')) {
1316
+ // A collapsed pane's fly-out is a transient overlay, not a dock.
1317
+ if (draggedEl?.contains(candidate) || candidate.closest('[data-dock-flyout]')) {
1318
+ continue;
1319
+ }
1320
+ const bounds = candidate.getBoundingClientRect();
1321
+ if (clientX < bounds.left ||
1322
+ clientX > bounds.right ||
1323
+ clientY < bounds.top ||
1324
+ clientY > bounds.bottom ||
1325
+ bounds.width * bounds.height > smallest) {
1326
+ continue;
1327
+ }
1328
+ element = candidate;
1329
+ smallest = bounds.width * bounds.height;
1330
+ }
1331
+ return element ? this.paneForKey(element.dataset['dockKey'] ?? '') : null;
1332
+ }
1333
+ /** Split `pane` along whichever edge the pointer sits nearest, or tab at its centre. */
1334
+ zoneTargetAt(pane, clientX, clientY) {
1335
+ const rect = pane && this.elementForPane(pane)?.getBoundingClientRect();
1336
+ if (!pane || !rect) {
1337
+ return null;
1338
+ }
1339
+ const u = rect.width ? (clientX - rect.left) / rect.width : 0.5;
1340
+ const v = rect.height ? (clientY - rect.top) / rect.height : 0.5;
1341
+ if (u > CENTRE_ZONE && u < 1 - CENTRE_ZONE && v > CENTRE_ZONE && v < 1 - CENTRE_ZONE) {
1342
+ return { pane, position: 'center' };
1343
+ }
1344
+ const ltr = this.direction() !== 'rtl';
1345
+ const edges = [
1346
+ { position: (ltr ? 'start' : 'end'), fraction: u },
1347
+ { position: (ltr ? 'end' : 'start'), fraction: 1 - u },
1348
+ { position: 'top', fraction: v },
1349
+ { position: 'bottom', fraction: 1 - v }
1350
+ ];
1351
+ return { pane, position: edges.reduce((a, b) => (b.fraction < a.fraction ? b : a)).position };
1352
+ }
1353
+ /** The slice of `rect` a drop would occupy, in coordinates relative to the host. */
1354
+ previewRect(rect, hostRect, position) {
1355
+ const left = rect.left - hostRect.left;
1356
+ const top = rect.top - hostRect.top;
1357
+ const ltr = this.direction() !== 'rtl';
1358
+ const half = { width: rect.width / 2, height: rect.height / 2 };
1359
+ switch (position) {
1360
+ case 'center':
1361
+ return { left, top, width: rect.width, height: rect.height };
1362
+ case 'top':
1363
+ return { left, top, width: rect.width, height: half.height };
1364
+ case 'bottom':
1365
+ return { left, top: top + half.height, width: rect.width, height: half.height };
1366
+ case 'start':
1367
+ return { left: ltr ? left : left + half.width, top, width: half.width, height: rect.height };
1368
+ case 'end':
1369
+ return { left: ltr ? left + half.width : left, top, width: half.width, height: rect.height };
1370
+ }
1371
+ }
1372
+ elementForPane(pane) {
1373
+ return this.el.querySelector(`[data-dock-key="${dockPaneKey(pane)}"]`);
1374
+ }
1375
+ paneForKey(key) {
1376
+ let found = null;
1377
+ const walk = (pane) => {
1378
+ if (dockPaneKey(pane) === key) {
1379
+ found = pane;
1380
+ return;
1381
+ }
1382
+ if (isSplitPane(pane) || isTabGroupPane(pane)) {
1383
+ pane.panes.forEach(walk);
1384
+ }
1385
+ else if (isDocumentHost(pane)) {
1386
+ walk(pane.rootPane);
1387
+ }
1388
+ };
1389
+ const layout = this.layout();
1390
+ for (const root of [layout.rootPane, ...(layout.floatingPanes ?? [])]) {
1391
+ if (found) {
1392
+ break;
1393
+ }
1394
+ walk(root);
1395
+ }
1396
+ return found;
1397
+ }
1398
+ // ─── pointer plumbing ─────────────────────────────────────────────────────
1399
+ release = null;
1400
+ /** Follow the pointer until it is released, then clean up after ourselves. */
1401
+ trackPointer(onMove, onUp) {
1402
+ this.stopPointerTracking();
1403
+ const move = (event) => onMove(event);
1404
+ const up = (event) => {
1405
+ this.stopPointerTracking();
1406
+ onUp?.(event);
1407
+ };
1408
+ this.document.addEventListener('pointermove', move);
1409
+ this.document.addEventListener('pointerup', up);
1410
+ this.document.addEventListener('pointercancel', up);
1411
+ this.release = () => {
1412
+ this.document.removeEventListener('pointermove', move);
1413
+ this.document.removeEventListener('pointerup', up);
1414
+ this.document.removeEventListener('pointercancel', up);
1415
+ };
1416
+ }
1417
+ stopPointerTracking() {
1418
+ this.release?.();
1419
+ this.release = null;
1420
+ }
1421
+ // ─── layout bookkeeping ───────────────────────────────────────────────────
1422
+ /**
1423
+ * Publish the in-place edits made to the tree.
1424
+ *
1425
+ * Skip normalisation for the continuous gestures — a resize or a window move
1426
+ * cannot change the tree's shape, and re-walking it on every pointer event is
1427
+ * wasted work.
1428
+ */
1429
+ commit(options) {
1430
+ const layout = this.layout();
1431
+ if (options?.normalize !== false) {
1432
+ normalizeDockLayout(layout);
1433
+ }
1434
+ this.layout.set({ ...layout });
1435
+ }
1436
+ contentPanesOf(pane) {
1437
+ const out = [];
1438
+ const walk = (current) => {
1439
+ if (isContentPane(current)) {
1440
+ out.push(current);
1441
+ }
1442
+ else if (isSplitPane(current) || isTabGroupPane(current)) {
1443
+ current.panes.forEach(walk);
1444
+ }
1445
+ else if (isDocumentHost(current)) {
1446
+ walk(current.rootPane);
1447
+ }
1448
+ };
1449
+ walk(pane);
1450
+ return out;
1451
+ }
1452
+ tabGroupOf(pane) {
1453
+ let group = null;
1454
+ const walk = (current) => {
1455
+ if (group) {
1456
+ return;
1457
+ }
1458
+ if (isTabGroupPane(current)) {
1459
+ if (current.panes.includes(pane)) {
1460
+ group = current;
1461
+ return;
1462
+ }
1463
+ return;
1464
+ }
1465
+ if (isSplitPane(current)) {
1466
+ current.panes.forEach(walk);
1467
+ }
1468
+ else if (isDocumentHost(current)) {
1469
+ walk(current.rootPane);
1470
+ }
1471
+ };
1472
+ const layout = this.layout();
1473
+ for (const root of [layout.rootPane, ...(layout.floatingPanes ?? [])]) {
1474
+ walk(root);
1475
+ }
1476
+ return group;
1477
+ }
1478
+ // ─── keyboard ─────────────────────────────────────────────────────────────
1479
+ onTabKeydown(group, event) {
1480
+ const tabs = this.pinnedTabs(group);
1481
+ if (!tabs.length) {
1482
+ return;
1483
+ }
1484
+ const step = arrowDirectionOnAxis(event.key, this.direction(), 'horizontal');
1485
+ const current = tabs.indexOf(this.selectedTab(group));
1486
+ let next;
1487
+ if (step === 'next') {
1488
+ next = (current + 1) % tabs.length;
1489
+ }
1490
+ else if (step === 'previous') {
1491
+ next = (current - 1 + tabs.length) % tabs.length;
1492
+ }
1493
+ else if (event.key === 'Home') {
1494
+ next = 0;
1495
+ }
1496
+ else if (event.key === 'End') {
1497
+ next = tabs.length - 1;
1498
+ }
1499
+ else {
1500
+ return;
1501
+ }
1502
+ event.preventDefault();
1503
+ this.selectPane(tabs[next]);
1504
+ // Roving tabindex: follow the selection with focus.
1505
+ const strip = event.currentTarget.parentElement;
1506
+ strip?.querySelectorAll('[role="tab"]')[next]?.focus();
1507
+ }
1508
+ // ─── classes ──────────────────────────────────────────────────────────────
1509
+ computedClass = computed(() => xui('bg-surface text-foreground relative flex flex-col overflow-hidden text-sm select-none', this.class()), /* @ts-ignore */
1510
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1511
+ splitClass(pane) {
1512
+ return xui('flex min-h-0 min-w-0 flex-1', isSplitPane(pane) && pane.orientation === 'vertical' && 'flex-col');
1513
+ }
1514
+ frameClass(pane) {
1515
+ return xui('border-border bg-surface flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border', this.isActiveFrame(pane) && 'border-primary-muted');
1516
+ }
1517
+ /** Whether the pane on screen in this frame is the active one. */
1518
+ isActiveFrame(pane) {
1519
+ const active = this.activePane();
1520
+ if (!active) {
1521
+ return false;
1522
+ }
1523
+ return isTabGroupPane(pane) ? this.selectedTab(pane) === active : pane === active;
1524
+ }
1525
+ headerClass(pane) {
1526
+ return xui('border-border bg-surface-raised text-foreground-muted flex h-8 shrink-0 items-center gap-1 border-b ps-2 pe-1', 'text-xs font-medium',
1527
+ // The header is a drag handle; without this a touch drag scrolls the page
1528
+ // instead of moving the pane.
1529
+ 'touch-none', isContentPane(pane) && 'cursor-grab');
1530
+ }
1531
+ tabStripClass() {
1532
+ return xui('border-border bg-surface-raised flex h-8 shrink-0 touch-none items-center gap-0.5 border-b px-1');
1533
+ }
1534
+ tabClass(group, tab) {
1535
+ return xui('group relative flex h-8 max-w-40 shrink-0 cursor-grab items-center gap-1 rounded-t px-2 text-xs', 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus', tab === this.selectedTab(group)
1536
+ ? 'bg-surface text-foreground border-border border-x border-t'
1537
+ : 'text-foreground-muted hover:bg-hover-overlay hover:text-foreground');
1538
+ }
1539
+ tabCloseClass() {
1540
+ return xui('hover:bg-hover-overlay grid size-4 shrink-0 place-items-center rounded opacity-60 hover:opacity-100');
1541
+ }
1542
+ actionClass() {
1543
+ return xui('text-foreground-muted hover:bg-hover-overlay hover:text-foreground grid size-6 shrink-0 place-items-center', 'rounded focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus');
1544
+ }
1545
+ gutterClass(pane) {
1546
+ return xui('group relative z-10 flex shrink-0 touch-none items-center justify-center', 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-focus', isSplitPane(pane) && pane.orientation === 'vertical'
1547
+ ? 'h-1.5 w-full cursor-row-resize'
1548
+ : 'w-1.5 cursor-col-resize');
1549
+ }
1550
+ gutterHandleClass(pane) {
1551
+ return xui('bg-border group-hover:bg-primary rounded-full transition-colors', isSplitPane(pane) && pane.orientation === 'vertical' ? 'h-0.5 w-8' : 'h-8 w-0.5');
1552
+ }
1553
+ stripClass(location) {
1554
+ const vertical = location === 'start' || location === 'end';
1555
+ return xui('border-border bg-surface-inset flex shrink-0 items-start gap-1 p-1', vertical ? 'w-8 flex-col' : 'h-8', location === 'start' && 'border-e', location === 'end' && 'border-s', location === 'top' && 'border-b', location === 'bottom' && 'border-t');
1556
+ }
1557
+ stripTabClass(location, pane) {
1558
+ const vertical = location === 'start' || location === 'end';
1559
+ return xui('text-foreground-muted hover:bg-hover-overlay hover:text-foreground max-h-40 max-w-40 truncate rounded px-1.5 py-1', 'text-xs focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus', vertical && '[writing-mode:vertical-rl]', pane === this.flyout() && 'bg-primary-muted text-foreground');
1560
+ }
1561
+ flyoutClass() {
1562
+ return xui('bg-surface border-border absolute z-20 flex overflow-hidden border shadow-lg');
1563
+ }
1564
+ flyoutStyle(pane) {
1565
+ const location = pane.unpinnedLocation ?? defaultUnpinnedLocation(this.layout(), pane);
1566
+ const size = `${pane.unpinnedSize ?? 280}px`;
1567
+ const offset = `${STRIP_SIZE}px`;
1568
+ switch (location) {
1569
+ case 'start':
1570
+ return { insetInlineStart: offset, top: '0', bottom: '0', width: size };
1571
+ case 'end':
1572
+ return { insetInlineEnd: offset, top: '0', bottom: '0', width: size };
1573
+ case 'top':
1574
+ return { top: offset, insetInlineStart: '0', insetInlineEnd: '0', height: size };
1575
+ case 'bottom':
1576
+ return { bottom: offset, insetInlineStart: '0', insetInlineEnd: '0', height: size };
1577
+ }
1578
+ }
1579
+ floatingClass() {
1580
+ return xui('bg-surface border-border absolute z-40 flex flex-col overflow-hidden rounded-lg border shadow-xl');
1581
+ }
1582
+ floatingStyle(window) {
1583
+ const location = window.floatingLocation ?? FLOATING_ORIGIN;
1584
+ return {
1585
+ insetInlineStart: `${location.x}px`,
1586
+ top: `${location.y}px`,
1587
+ width: `${window.floatingWidth ?? 400}px`,
1588
+ height: `${window.floatingHeight ?? 300}px`
1589
+ };
1590
+ }
1591
+ indicatorClass(indicator) {
1592
+ return xui(
1593
+ // Never interactive: the pointer is already captured by the drag, and the
1594
+ // indicators are hit-tested by rectangle rather than by event target.
1595
+ 'bg-surface-overlay pointer-events-none absolute z-55 rounded border shadow-md', indicator.active ? 'border-primary' : 'border-border-strong');
1596
+ }
1597
+ /**
1598
+ * The shaded block inside an indicator, showing which part of the target the
1599
+ * pane will take — the same visual language as Visual Studio's dock targets.
1600
+ */
1601
+ indicatorFillClass(indicator) {
1602
+ const sides = {
1603
+ center: 'inset-1',
1604
+ left: 'top-1 bottom-1 left-1 w-1/3',
1605
+ right: 'top-1 bottom-1 right-1 w-1/3',
1606
+ top: 'top-1 right-1 left-1 h-1/3',
1607
+ bottom: 'right-1 bottom-1 left-1 h-1/3'
1608
+ };
1609
+ return xui('absolute rounded-sm', sides[indicator.side], indicator.active ? 'bg-primary' : 'bg-foreground-subtle/50');
1610
+ }
1611
+ titleBarClass() {
1612
+ return xui('border-border bg-surface-raised text-foreground-muted flex h-7 shrink-0 cursor-grab items-center gap-1', 'touch-none border-b ps-2 pe-1 text-xs font-medium');
1613
+ }
1614
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockManager, deps: [], target: i0.ɵɵFactoryTarget.Component });
1615
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiDockManager, isStandalone: true, selector: "xui-dock-manager", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { layout: "layoutChange", paneClose: "paneClose", activePaneChange: "activePaneChange", panePinnedChange: "panePinnedChange", paneMaximizedChange: "paneMaximizedChange", paneFloatingChange: "paneFloatingChange", paneDragStart: "paneDragStart", paneDragEnd: "paneDragEnd" }, host: { listeners: { "keydown.escape": "onEscape()" }, properties: { "class": "computedClass()" } }, providers: [{ provide: XUI_DOCK_CONTENT_MOUNTER, useExisting: forwardRef((() => XuiDockManager)) }], queries: [{ propertyName: "contents", predicate: XuiDockContent, descendants: true, isSignal: true }], ngImport: i0, template: `
1616
+ <!-- ─── one content pane: header chrome plus the body outlet ─────────── -->
1617
+ <ng-template #contentTpl let-pane let-hideHeader="hideHeader">
1618
+ <div
1619
+ [class]="frameClass(pane)"
1620
+ [attr.data-dock-key]="key(pane)"
1621
+ (pointerdown)="activate(pane)"
1622
+ (focusin)="activate(pane)"
1623
+ >
1624
+ @if (!hideHeader) {
1625
+ <div
1626
+ [class]="headerClass(pane)"
1627
+ (pointerdown)="startPaneDrag(pane, null, $event)"
1628
+ (dblclick)="toggleMaximized(pane)"
1629
+ >
1630
+ <span class="min-w-0 flex-1 truncate">{{ pane.header }}</span>
1631
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1632
+ </div>
1633
+ }
1634
+
1635
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto" [xuiDockContentOutlet]="pane.contentId"></div>
1636
+ </div>
1637
+ </ng-template>
1638
+
1639
+ <!-- ─── the pin / maximize / close cluster, shared by headers and tabs ── -->
1640
+ <ng-template #paneActionsTpl let-pane>
1641
+ <div class="flex shrink-0 items-center">
1642
+ @if (pane.allowPinning !== false && !pane.documentOnly) {
1643
+ <button
1644
+ type="button"
1645
+ [class]="actionClass()"
1646
+ [attr.aria-label]="(pane.isPinned === false ? 'Pin ' : 'Collapse ') + (pane.header || pane.contentId)"
1647
+ (click)="togglePinned(pane); $event.stopPropagation()"
1648
+ (pointerdown)="$event.stopPropagation()"
1649
+ >
1650
+ <ng-icon xui size="14px" name="matPushPinRound" [class]="pane.isPinned === false ? 'text-primary' : ''" />
1651
+ </button>
1652
+ }
1653
+ @if (pane.allowFloating !== false && !pane.documentOnly && !isFloating(pane)) {
1654
+ <button
1655
+ type="button"
1656
+ [class]="actionClass()"
1657
+ [attr.aria-label]="'Float ' + (pane.header || pane.contentId)"
1658
+ (click)="floatPane(pane); $event.stopPropagation()"
1659
+ (pointerdown)="$event.stopPropagation()"
1660
+ >
1661
+ <ng-icon xui size="14px" name="matOpenInNewRound" />
1662
+ </button>
1663
+ }
1664
+ @if (pane.allowMaximize !== false) {
1665
+ <button
1666
+ type="button"
1667
+ [class]="actionClass()"
1668
+ [attr.aria-label]="(pane.isMaximized ? 'Restore ' : 'Maximize ') + (pane.header || pane.contentId)"
1669
+ (click)="toggleMaximized(pane); $event.stopPropagation()"
1670
+ (pointerdown)="$event.stopPropagation()"
1671
+ >
1672
+ <ng-icon xui size="14px" [name]="pane.isMaximized ? 'matCloseFullscreenRound' : 'matOpenInFullRound'" />
1673
+ </button>
1674
+ }
1675
+ @if (pane.allowClose !== false) {
1676
+ <button
1677
+ type="button"
1678
+ [class]="actionClass()"
1679
+ [attr.aria-label]="'Close ' + (pane.header || pane.contentId)"
1680
+ (click)="closePane(pane); $event.stopPropagation()"
1681
+ (pointerdown)="$event.stopPropagation()"
1682
+ >
1683
+ <ng-icon xui size="14px" name="matCloseRound" />
1684
+ </button>
1685
+ }
1686
+ </div>
1687
+ </ng-template>
1688
+
1689
+ <!-- ─── a tab group: one strip, one visible body ─────────────────────── -->
1690
+ <ng-template #tabGroupTpl let-pane>
1691
+ <div [class]="frameClass(pane)" [attr.data-dock-key]="key(pane)">
1692
+ <div [class]="tabStripClass()" role="tablist">
1693
+ @for (tab of pinnedTabs(pane); track key(tab)) {
1694
+ <button
1695
+ type="button"
1696
+ role="tab"
1697
+ [class]="tabClass(pane, tab)"
1698
+ [attr.aria-selected]="tab === selectedTab(pane)"
1699
+ [tabindex]="tab === selectedTab(pane) ? 0 : -1"
1700
+ (click)="selectPane(tab)"
1701
+ (pointerdown)="startPaneDrag(tab, null, $event)"
1702
+ (dblclick)="toggleMaximized(tab)"
1703
+ (keydown)="onTabKeydown(pane, $event)"
1704
+ >
1705
+ <span class="min-w-0 truncate">{{ tab.header }}</span>
1706
+ @if (tab.allowClose !== false) {
1707
+ <!-- A nested <button> is invalid markup, so this affordance is
1708
+ pointer-only and hidden from assistive technology; the
1709
+ action cluster on the right carries the real close button. -->
1710
+ <span
1711
+ aria-hidden="true"
1712
+ [class]="tabCloseClass()"
1713
+ (click)="closePane(tab); $event.stopPropagation()"
1714
+ (pointerdown)="$event.stopPropagation()"
1715
+ >
1716
+ <ng-icon xui size="12px" name="matCloseRound" />
1717
+ </span>
1718
+ }
1719
+ </button>
1720
+ }
1721
+
1722
+ @if (selectedTab(pane); as tab) {
1723
+ <div class="ms-auto flex items-center ps-1">
1724
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: tab }" />
1725
+ </div>
1726
+ }
1727
+ </div>
1728
+
1729
+ @if (selectedTab(pane); as tab) {
1730
+ <div
1731
+ role="tabpanel"
1732
+ class="min-h-0 min-w-0 flex-1 overflow-auto"
1733
+ [xuiDockContentOutlet]="tab.contentId"
1734
+ (pointerdown)="activate(tab)"
1735
+ (focusin)="activate(tab)"
1736
+ ></div>
1737
+ }
1738
+ </div>
1739
+ </ng-template>
1740
+
1741
+ <!-- ─── a split pane: children along one axis, gutters in between ────── -->
1742
+ <ng-template #splitTpl let-pane>
1743
+ <div [class]="splitClass(pane)">
1744
+ @for (child of childrenOf(pane); track key(child); let i = $index, last = $last) {
1745
+ <div class="relative flex min-h-0 min-w-0" [style.flex]="flexOf(child)">
1746
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: child }" />
1747
+ </div>
1748
+
1749
+ @if (!last) {
1750
+ <!-- Drag or arrow-key the gutter to trade space between the panes
1751
+ either side of it. -->
1752
+ <div
1753
+ role="separator"
1754
+ tabindex="0"
1755
+ [class]="gutterClass(pane)"
1756
+ [attr.aria-orientation]="pane.orientation === 'vertical' ? 'horizontal' : 'vertical'"
1757
+ [attr.aria-label]="'Resize ' + headerOf(child)"
1758
+ (pointerdown)="startSplitterDrag(pane, i, $event)"
1759
+ (keydown)="onSplitterKeydown(pane, i, $event)"
1760
+ >
1761
+ <span [class]="gutterHandleClass(pane)"></span>
1762
+ </div>
1763
+ }
1764
+ }
1765
+ </div>
1766
+ </ng-template>
1767
+
1768
+ <!-- ─── the document host: the only home for documentOnly panes ─────── -->
1769
+ <ng-template #documentHostTpl let-pane>
1770
+ <div class="bg-surface-sunken flex min-h-0 min-w-0 flex-1" [attr.data-dock-key]="key(pane)">
1771
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: pane.rootPane }" />
1772
+ </div>
1773
+ </ng-template>
1774
+
1775
+ <ng-template #paneTpl let-pane>
1776
+ @switch (pane.type) {
1777
+ @case ('splitPane') {
1778
+ <ng-container [ngTemplateOutlet]="splitTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1779
+ }
1780
+ @case ('tabGroupPane') {
1781
+ <ng-container [ngTemplateOutlet]="tabGroupTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1782
+ }
1783
+ @case ('documentHost') {
1784
+ <ng-container [ngTemplateOutlet]="documentHostTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1785
+ }
1786
+ @default {
1787
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1788
+ }
1789
+ }
1790
+ </ng-template>
1791
+
1792
+ <!-- ─── the dock manager itself ─────────────────────────────────────── -->
1793
+ @if (maximized(); as pane) {
1794
+ <div class="absolute inset-0 z-30 flex">
1795
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1796
+ </div>
1797
+ } @else {
1798
+ @if (unpinned().top.length) {
1799
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'top' }" />
1800
+ }
1801
+
1802
+ <div class="flex min-h-0 min-w-0 flex-1">
1803
+ @if (unpinned().start.length) {
1804
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'start' }" />
1805
+ }
1806
+
1807
+ <div class="flex min-h-0 min-w-0 flex-1">
1808
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: layout().rootPane }" />
1809
+ </div>
1810
+
1811
+ @if (unpinned().end.length) {
1812
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'end' }" />
1813
+ }
1814
+ </div>
1815
+
1816
+ @if (unpinned().bottom.length) {
1817
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'bottom' }" />
1818
+ }
1819
+
1820
+ <!-- The fly-out an unpinned pane opens over the layout. -->
1821
+ @if (flyout(); as pane) {
1822
+ <div data-dock-flyout [class]="flyoutClass()" [style]="flyoutStyle(pane)">
1823
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1824
+ </div>
1825
+ }
1826
+
1827
+ @for (window of floatingPanes(); track key(window)) {
1828
+ <div
1829
+ [attr.data-dock-window]="key(window)"
1830
+ [class]="floatingClass()"
1831
+ [style]="floatingStyle(window)"
1832
+ (pointerdown)="bringToFront(window)"
1833
+ >
1834
+ <div
1835
+ [class]="titleBarClass()"
1836
+ (pointerdown)="startPaneDrag(floatingContent(window), window, $event)"
1837
+ (dblclick)="dockBack(window)"
1838
+ >
1839
+ <span class="min-w-0 flex-1 truncate">{{ floatingTitle(window) }}</span>
1840
+ <button
1841
+ type="button"
1842
+ [class]="actionClass()"
1843
+ aria-label="Dock window"
1844
+ (click)="dockBack(window); $event.stopPropagation()"
1845
+ (pointerdown)="$event.stopPropagation()"
1846
+ >
1847
+ <ng-icon xui size="14px" name="matDockRound" />
1848
+ </button>
1849
+
1850
+ <!-- A one-pane window shows that pane's own actions; the header they
1851
+ would normally live in is suppressed below. -->
1852
+ @if (singlePane(window); as pane) {
1853
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1854
+ } @else {
1855
+ <button
1856
+ type="button"
1857
+ [class]="actionClass()"
1858
+ aria-label="Close window"
1859
+ (click)="closeWindow(window); $event.stopPropagation()"
1860
+ (pointerdown)="$event.stopPropagation()"
1861
+ >
1862
+ <ng-icon xui size="14px" name="matCloseRound" />
1863
+ </button>
1864
+ }
1865
+ </div>
1866
+
1867
+ <div class="flex min-h-0 min-w-0 flex-1">
1868
+ @if (singlePane(window); as pane) {
1869
+ <ng-container
1870
+ [ngTemplateOutlet]="contentTpl"
1871
+ [ngTemplateOutletContext]="{ $implicit: pane, hideHeader: true }"
1872
+ />
1873
+ } @else {
1874
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: window }" />
1875
+ }
1876
+ </div>
1877
+
1878
+ @if (window.floatingResizable !== false) {
1879
+ @for (handle of RESIZE_HANDLES; track handle.id) {
1880
+ <div
1881
+ [class]="handle.class"
1882
+ [attr.aria-hidden]="true"
1883
+ (pointerdown)="startFloatingResize(window, handle.id, $event)"
1884
+ ></div>
1885
+ }
1886
+ }
1887
+ </div>
1888
+ }
1889
+ }
1890
+
1891
+ <!-- Where a drop would land. -->
1892
+ @if (dropRect(); as rect) {
1893
+ <div
1894
+ class="border-primary bg-primary/20 pointer-events-none absolute z-50 rounded-sm border-2"
1895
+ [style.left.px]="rect.left"
1896
+ [style.top.px]="rect.top"
1897
+ [style.width.px]="rect.width"
1898
+ [style.height.px]="rect.height"
1899
+ ></div>
1900
+ }
1901
+
1902
+ <!-- ─── the docking joystick and the outer ring ─────────────────────── -->
1903
+ @for (indicator of dockIndicators(); track indicator.key) {
1904
+ <div
1905
+ aria-hidden="true"
1906
+ [class]="indicatorClass(indicator)"
1907
+ [style.left.px]="indicator.left"
1908
+ [style.top.px]="indicator.top"
1909
+ [style.width.px]="INDICATOR_SIZE"
1910
+ [style.height.px]="INDICATOR_SIZE"
1911
+ >
1912
+ <span [class]="indicatorFillClass(indicator)"></span>
1913
+ </div>
1914
+ }
1915
+
1916
+ @if (dragGhost(); as ghost) {
1917
+ <div
1918
+ class="bg-surface-overlay border-border text-foreground pointer-events-none fixed z-60 rounded-md border px-2 py-1 text-xs shadow-lg"
1919
+ [style.left.px]="ghost.x + 12"
1920
+ [style.top.px]="ghost.y + 12"
1921
+ >
1922
+ {{ ghost.label }}
1923
+ </div>
1924
+ }
1925
+
1926
+ <!-- ─── an edge strip of collapsed panes ────────────────────────────── -->
1927
+ <ng-template #stripTpl let-location>
1928
+ <div data-dock-strip [class]="stripClass(location)">
1929
+ @for (pane of unpinnedAt(location); track key(pane)) {
1930
+ <button
1931
+ type="button"
1932
+ [class]="stripTabClass(location, pane)"
1933
+ [attr.aria-expanded]="pane === flyout()"
1934
+ (click)="toggleFlyout(pane)"
1935
+ >
1936
+ {{ pane.header || pane.contentId }}
1937
+ </button>
1938
+ }
1939
+ </div>
1940
+ </ng-template>
1941
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }, { kind: "directive", type: XuiIcon, selector: "ng-icon[xui]", inputs: ["class", "size", "color", "title"], exportAs: ["xuiIcon"] }, { kind: "directive", type: XuiDockContentOutlet, selector: "[xuiDockContentOutlet]", inputs: ["xuiDockContentOutlet"] }], viewProviders: [
1942
+ provideIcons({
1943
+ matCloseRound,
1944
+ matDockRound,
1945
+ matPushPinRound,
1946
+ matOpenInFullRound,
1947
+ matCloseFullscreenRound,
1948
+ matOpenInNewRound
1949
+ })
1950
+ ], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1951
+ }
1952
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiDockManager, decorators: [{
1953
+ type: Component,
1954
+ args: [{
1955
+ selector: 'xui-dock-manager',
1956
+ imports: [NgTemplateOutlet, NgIcon, XuiIcon, XuiDockContentOutlet],
1957
+ viewProviders: [
1958
+ provideIcons({
1959
+ matCloseRound,
1960
+ matDockRound,
1961
+ matPushPinRound,
1962
+ matOpenInFullRound,
1963
+ matCloseFullscreenRound,
1964
+ matOpenInNewRound
1965
+ })
1966
+ ],
1967
+ providers: [{ provide: XUI_DOCK_CONTENT_MOUNTER, useExisting: forwardRef((() => XuiDockManager)) }],
1968
+ template: `
1969
+ <!-- ─── one content pane: header chrome plus the body outlet ─────────── -->
1970
+ <ng-template #contentTpl let-pane let-hideHeader="hideHeader">
1971
+ <div
1972
+ [class]="frameClass(pane)"
1973
+ [attr.data-dock-key]="key(pane)"
1974
+ (pointerdown)="activate(pane)"
1975
+ (focusin)="activate(pane)"
1976
+ >
1977
+ @if (!hideHeader) {
1978
+ <div
1979
+ [class]="headerClass(pane)"
1980
+ (pointerdown)="startPaneDrag(pane, null, $event)"
1981
+ (dblclick)="toggleMaximized(pane)"
1982
+ >
1983
+ <span class="min-w-0 flex-1 truncate">{{ pane.header }}</span>
1984
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
1985
+ </div>
1986
+ }
1987
+
1988
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto" [xuiDockContentOutlet]="pane.contentId"></div>
1989
+ </div>
1990
+ </ng-template>
1991
+
1992
+ <!-- ─── the pin / maximize / close cluster, shared by headers and tabs ── -->
1993
+ <ng-template #paneActionsTpl let-pane>
1994
+ <div class="flex shrink-0 items-center">
1995
+ @if (pane.allowPinning !== false && !pane.documentOnly) {
1996
+ <button
1997
+ type="button"
1998
+ [class]="actionClass()"
1999
+ [attr.aria-label]="(pane.isPinned === false ? 'Pin ' : 'Collapse ') + (pane.header || pane.contentId)"
2000
+ (click)="togglePinned(pane); $event.stopPropagation()"
2001
+ (pointerdown)="$event.stopPropagation()"
2002
+ >
2003
+ <ng-icon xui size="14px" name="matPushPinRound" [class]="pane.isPinned === false ? 'text-primary' : ''" />
2004
+ </button>
2005
+ }
2006
+ @if (pane.allowFloating !== false && !pane.documentOnly && !isFloating(pane)) {
2007
+ <button
2008
+ type="button"
2009
+ [class]="actionClass()"
2010
+ [attr.aria-label]="'Float ' + (pane.header || pane.contentId)"
2011
+ (click)="floatPane(pane); $event.stopPropagation()"
2012
+ (pointerdown)="$event.stopPropagation()"
2013
+ >
2014
+ <ng-icon xui size="14px" name="matOpenInNewRound" />
2015
+ </button>
2016
+ }
2017
+ @if (pane.allowMaximize !== false) {
2018
+ <button
2019
+ type="button"
2020
+ [class]="actionClass()"
2021
+ [attr.aria-label]="(pane.isMaximized ? 'Restore ' : 'Maximize ') + (pane.header || pane.contentId)"
2022
+ (click)="toggleMaximized(pane); $event.stopPropagation()"
2023
+ (pointerdown)="$event.stopPropagation()"
2024
+ >
2025
+ <ng-icon xui size="14px" [name]="pane.isMaximized ? 'matCloseFullscreenRound' : 'matOpenInFullRound'" />
2026
+ </button>
2027
+ }
2028
+ @if (pane.allowClose !== false) {
2029
+ <button
2030
+ type="button"
2031
+ [class]="actionClass()"
2032
+ [attr.aria-label]="'Close ' + (pane.header || pane.contentId)"
2033
+ (click)="closePane(pane); $event.stopPropagation()"
2034
+ (pointerdown)="$event.stopPropagation()"
2035
+ >
2036
+ <ng-icon xui size="14px" name="matCloseRound" />
2037
+ </button>
2038
+ }
2039
+ </div>
2040
+ </ng-template>
2041
+
2042
+ <!-- ─── a tab group: one strip, one visible body ─────────────────────── -->
2043
+ <ng-template #tabGroupTpl let-pane>
2044
+ <div [class]="frameClass(pane)" [attr.data-dock-key]="key(pane)">
2045
+ <div [class]="tabStripClass()" role="tablist">
2046
+ @for (tab of pinnedTabs(pane); track key(tab)) {
2047
+ <button
2048
+ type="button"
2049
+ role="tab"
2050
+ [class]="tabClass(pane, tab)"
2051
+ [attr.aria-selected]="tab === selectedTab(pane)"
2052
+ [tabindex]="tab === selectedTab(pane) ? 0 : -1"
2053
+ (click)="selectPane(tab)"
2054
+ (pointerdown)="startPaneDrag(tab, null, $event)"
2055
+ (dblclick)="toggleMaximized(tab)"
2056
+ (keydown)="onTabKeydown(pane, $event)"
2057
+ >
2058
+ <span class="min-w-0 truncate">{{ tab.header }}</span>
2059
+ @if (tab.allowClose !== false) {
2060
+ <!-- A nested <button> is invalid markup, so this affordance is
2061
+ pointer-only and hidden from assistive technology; the
2062
+ action cluster on the right carries the real close button. -->
2063
+ <span
2064
+ aria-hidden="true"
2065
+ [class]="tabCloseClass()"
2066
+ (click)="closePane(tab); $event.stopPropagation()"
2067
+ (pointerdown)="$event.stopPropagation()"
2068
+ >
2069
+ <ng-icon xui size="12px" name="matCloseRound" />
2070
+ </span>
2071
+ }
2072
+ </button>
2073
+ }
2074
+
2075
+ @if (selectedTab(pane); as tab) {
2076
+ <div class="ms-auto flex items-center ps-1">
2077
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: tab }" />
2078
+ </div>
2079
+ }
2080
+ </div>
2081
+
2082
+ @if (selectedTab(pane); as tab) {
2083
+ <div
2084
+ role="tabpanel"
2085
+ class="min-h-0 min-w-0 flex-1 overflow-auto"
2086
+ [xuiDockContentOutlet]="tab.contentId"
2087
+ (pointerdown)="activate(tab)"
2088
+ (focusin)="activate(tab)"
2089
+ ></div>
2090
+ }
2091
+ </div>
2092
+ </ng-template>
2093
+
2094
+ <!-- ─── a split pane: children along one axis, gutters in between ────── -->
2095
+ <ng-template #splitTpl let-pane>
2096
+ <div [class]="splitClass(pane)">
2097
+ @for (child of childrenOf(pane); track key(child); let i = $index, last = $last) {
2098
+ <div class="relative flex min-h-0 min-w-0" [style.flex]="flexOf(child)">
2099
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: child }" />
2100
+ </div>
2101
+
2102
+ @if (!last) {
2103
+ <!-- Drag or arrow-key the gutter to trade space between the panes
2104
+ either side of it. -->
2105
+ <div
2106
+ role="separator"
2107
+ tabindex="0"
2108
+ [class]="gutterClass(pane)"
2109
+ [attr.aria-orientation]="pane.orientation === 'vertical' ? 'horizontal' : 'vertical'"
2110
+ [attr.aria-label]="'Resize ' + headerOf(child)"
2111
+ (pointerdown)="startSplitterDrag(pane, i, $event)"
2112
+ (keydown)="onSplitterKeydown(pane, i, $event)"
2113
+ >
2114
+ <span [class]="gutterHandleClass(pane)"></span>
2115
+ </div>
2116
+ }
2117
+ }
2118
+ </div>
2119
+ </ng-template>
2120
+
2121
+ <!-- ─── the document host: the only home for documentOnly panes ─────── -->
2122
+ <ng-template #documentHostTpl let-pane>
2123
+ <div class="bg-surface-sunken flex min-h-0 min-w-0 flex-1" [attr.data-dock-key]="key(pane)">
2124
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: pane.rootPane }" />
2125
+ </div>
2126
+ </ng-template>
2127
+
2128
+ <ng-template #paneTpl let-pane>
2129
+ @switch (pane.type) {
2130
+ @case ('splitPane') {
2131
+ <ng-container [ngTemplateOutlet]="splitTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2132
+ }
2133
+ @case ('tabGroupPane') {
2134
+ <ng-container [ngTemplateOutlet]="tabGroupTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2135
+ }
2136
+ @case ('documentHost') {
2137
+ <ng-container [ngTemplateOutlet]="documentHostTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2138
+ }
2139
+ @default {
2140
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2141
+ }
2142
+ }
2143
+ </ng-template>
2144
+
2145
+ <!-- ─── the dock manager itself ─────────────────────────────────────── -->
2146
+ @if (maximized(); as pane) {
2147
+ <div class="absolute inset-0 z-30 flex">
2148
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2149
+ </div>
2150
+ } @else {
2151
+ @if (unpinned().top.length) {
2152
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'top' }" />
2153
+ }
2154
+
2155
+ <div class="flex min-h-0 min-w-0 flex-1">
2156
+ @if (unpinned().start.length) {
2157
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'start' }" />
2158
+ }
2159
+
2160
+ <div class="flex min-h-0 min-w-0 flex-1">
2161
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: layout().rootPane }" />
2162
+ </div>
2163
+
2164
+ @if (unpinned().end.length) {
2165
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'end' }" />
2166
+ }
2167
+ </div>
2168
+
2169
+ @if (unpinned().bottom.length) {
2170
+ <ng-container [ngTemplateOutlet]="stripTpl" [ngTemplateOutletContext]="{ $implicit: 'bottom' }" />
2171
+ }
2172
+
2173
+ <!-- The fly-out an unpinned pane opens over the layout. -->
2174
+ @if (flyout(); as pane) {
2175
+ <div data-dock-flyout [class]="flyoutClass()" [style]="flyoutStyle(pane)">
2176
+ <ng-container [ngTemplateOutlet]="contentTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2177
+ </div>
2178
+ }
2179
+
2180
+ @for (window of floatingPanes(); track key(window)) {
2181
+ <div
2182
+ [attr.data-dock-window]="key(window)"
2183
+ [class]="floatingClass()"
2184
+ [style]="floatingStyle(window)"
2185
+ (pointerdown)="bringToFront(window)"
2186
+ >
2187
+ <div
2188
+ [class]="titleBarClass()"
2189
+ (pointerdown)="startPaneDrag(floatingContent(window), window, $event)"
2190
+ (dblclick)="dockBack(window)"
2191
+ >
2192
+ <span class="min-w-0 flex-1 truncate">{{ floatingTitle(window) }}</span>
2193
+ <button
2194
+ type="button"
2195
+ [class]="actionClass()"
2196
+ aria-label="Dock window"
2197
+ (click)="dockBack(window); $event.stopPropagation()"
2198
+ (pointerdown)="$event.stopPropagation()"
2199
+ >
2200
+ <ng-icon xui size="14px" name="matDockRound" />
2201
+ </button>
2202
+
2203
+ <!-- A one-pane window shows that pane's own actions; the header they
2204
+ would normally live in is suppressed below. -->
2205
+ @if (singlePane(window); as pane) {
2206
+ <ng-container [ngTemplateOutlet]="paneActionsTpl" [ngTemplateOutletContext]="{ $implicit: pane }" />
2207
+ } @else {
2208
+ <button
2209
+ type="button"
2210
+ [class]="actionClass()"
2211
+ aria-label="Close window"
2212
+ (click)="closeWindow(window); $event.stopPropagation()"
2213
+ (pointerdown)="$event.stopPropagation()"
2214
+ >
2215
+ <ng-icon xui size="14px" name="matCloseRound" />
2216
+ </button>
2217
+ }
2218
+ </div>
2219
+
2220
+ <div class="flex min-h-0 min-w-0 flex-1">
2221
+ @if (singlePane(window); as pane) {
2222
+ <ng-container
2223
+ [ngTemplateOutlet]="contentTpl"
2224
+ [ngTemplateOutletContext]="{ $implicit: pane, hideHeader: true }"
2225
+ />
2226
+ } @else {
2227
+ <ng-container [ngTemplateOutlet]="paneTpl" [ngTemplateOutletContext]="{ $implicit: window }" />
2228
+ }
2229
+ </div>
2230
+
2231
+ @if (window.floatingResizable !== false) {
2232
+ @for (handle of RESIZE_HANDLES; track handle.id) {
2233
+ <div
2234
+ [class]="handle.class"
2235
+ [attr.aria-hidden]="true"
2236
+ (pointerdown)="startFloatingResize(window, handle.id, $event)"
2237
+ ></div>
2238
+ }
2239
+ }
2240
+ </div>
2241
+ }
2242
+ }
2243
+
2244
+ <!-- Where a drop would land. -->
2245
+ @if (dropRect(); as rect) {
2246
+ <div
2247
+ class="border-primary bg-primary/20 pointer-events-none absolute z-50 rounded-sm border-2"
2248
+ [style.left.px]="rect.left"
2249
+ [style.top.px]="rect.top"
2250
+ [style.width.px]="rect.width"
2251
+ [style.height.px]="rect.height"
2252
+ ></div>
2253
+ }
2254
+
2255
+ <!-- ─── the docking joystick and the outer ring ─────────────────────── -->
2256
+ @for (indicator of dockIndicators(); track indicator.key) {
2257
+ <div
2258
+ aria-hidden="true"
2259
+ [class]="indicatorClass(indicator)"
2260
+ [style.left.px]="indicator.left"
2261
+ [style.top.px]="indicator.top"
2262
+ [style.width.px]="INDICATOR_SIZE"
2263
+ [style.height.px]="INDICATOR_SIZE"
2264
+ >
2265
+ <span [class]="indicatorFillClass(indicator)"></span>
2266
+ </div>
2267
+ }
2268
+
2269
+ @if (dragGhost(); as ghost) {
2270
+ <div
2271
+ class="bg-surface-overlay border-border text-foreground pointer-events-none fixed z-60 rounded-md border px-2 py-1 text-xs shadow-lg"
2272
+ [style.left.px]="ghost.x + 12"
2273
+ [style.top.px]="ghost.y + 12"
2274
+ >
2275
+ {{ ghost.label }}
2276
+ </div>
2277
+ }
2278
+
2279
+ <!-- ─── an edge strip of collapsed panes ────────────────────────────── -->
2280
+ <ng-template #stripTpl let-location>
2281
+ <div data-dock-strip [class]="stripClass(location)">
2282
+ @for (pane of unpinnedAt(location); track key(pane)) {
2283
+ <button
2284
+ type="button"
2285
+ [class]="stripTabClass(location, pane)"
2286
+ [attr.aria-expanded]="pane === flyout()"
2287
+ (click)="toggleFlyout(pane)"
2288
+ >
2289
+ {{ pane.header || pane.contentId }}
2290
+ </button>
2291
+ }
2292
+ </div>
2293
+ </ng-template>
2294
+ `,
2295
+ host: {
2296
+ '[class]': 'computedClass()',
2297
+ '(keydown.escape)': 'onEscape()'
2298
+ },
2299
+ changeDetection: ChangeDetectionStrategy.OnPush,
2300
+ encapsulation: ViewEncapsulation.None
2301
+ }]
2302
+ }], ctorParameters: () => [], propDecorators: { contents: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => XuiDockContent), { ...{ descendants: true }, isSignal: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: true }] }, { type: i0.Output, args: ["layoutChange"] }], paneClose: [{ type: i0.Output, args: ["paneClose"] }], activePaneChange: [{ type: i0.Output, args: ["activePaneChange"] }], panePinnedChange: [{ type: i0.Output, args: ["panePinnedChange"] }], paneMaximizedChange: [{ type: i0.Output, args: ["paneMaximizedChange"] }], paneFloatingChange: [{ type: i0.Output, args: ["paneFloatingChange"] }], paneDragStart: [{ type: i0.Output, args: ["paneDragStart"] }], paneDragEnd: [{ type: i0.Output, args: ["paneDragEnd"] }] } });
2303
+
2304
+ const XuiDockManagerImports = [XuiDockManager, XuiDockContent];
2305
+
2306
+ /**
2307
+ * Generated bundle index. Do not edit.
2308
+ */
2309
+
2310
+ export { XUI_DOCK_CONTENT_MOUNTER, XuiDockContent, XuiDockContentOutlet, XuiDockManager, XuiDockManagerImports, canDockInto, cloneDockLayout, collectContentPanes, defaultUnpinnedLocation, dockAncestors, dockChildren, dockPaneAt, dockPaneKey, dockRoots, findDockParent, findDocumentHost, findMaximizedPane, floatDockPane, insertDockPane, isContentPane, isDockPaneVisible, isDockRoot, isDocumentHost, isFloatingRoot, isInDocumentHost, isParentPane, isSplitPane, isTabGroupPane, normalizeDockLayout, removeDockPane, selectedTabPane, unpinnedDockPanes, visibleDockChildren, visitDockPanes };
2311
+ //# sourceMappingURL=xui-dock-manager.mjs.map