@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.
- package/LICENSE +201 -0
- package/README.md +217 -0
- package/fesm2022/xui-dock-manager.mjs +2311 -0
- package/fesm2022/xui-dock-manager.mjs.map +1 -0
- package/package.json +50 -0
- package/types/xui-dock-manager.d.ts +571 -0
|
@@ -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
|