Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/splitview/paneview.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { isFirefox } from '../../browser.js';
7
import { DataTransfers } from '../../dnd.js';
8
import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from '../../dom.js';
9
import { DomEmitter } from '../../event.js';
10
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
11
import { Gesture, EventType as TouchEventType } from '../../touch.js';
12
import { IBoundarySashes, Orientation } from '../sash/sash.js';
13
import { Color, RGBA } from '../../../common/color.js';
14
import { Emitter, Event } from '../../../common/event.js';
15
import { KeyCode } from '../../../common/keyCodes.js';
16
import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js';
17
import { ScrollEvent } from '../../../common/scrollable.js';
18
import './paneview.css';
19
import { localize } from '../../../../nls.js';
20
import { IView, Sizing, SplitView } from './splitview.js';
21
import { applyDragImage } from '../dnd/dnd.js';
22
23
export interface IPaneOptions {
24
minimumBodySize?: number;
25
maximumBodySize?: number;
26
expanded?: boolean;
27
orientation?: Orientation;
28
title: string;
29
titleDescription?: string;
30
}
31
32
export interface IPaneStyles {
33
readonly dropBackground: string | undefined;
34
readonly headerForeground: string | undefined;
35
readonly headerBackground: string | undefined;
36
readonly headerBorder: string | undefined;
37
readonly leftBorder: string | undefined;
38
}
39
40
/**
41
* A Pane is a structured SplitView view.
42
*
43
* WARNING: You must call `render()` after you construct it.
44
* It can't be done automatically at the end of the ctor
45
* because of the order of property initialization in TypeScript.
46
* Subclasses wouldn't be able to set own properties
47
* before the `render()` call, thus forbidding their use.
48
*/
49
export abstract class Pane extends Disposable implements IView {
50
51
private static readonly HEADER_SIZE = 22;
52
53
readonly element: HTMLElement;
54
private header: HTMLElement | undefined;
55
private body!: HTMLElement;
56
57
protected _expanded: boolean;
58
protected _orientation: Orientation;
59
60
private expandedSize: number | undefined = undefined;
61
private _headerVisible = true;
62
private _collapsible = true;
63
private _bodyRendered = false;
64
private _minimumBodySize: number;
65
private _maximumBodySize: number;
66
private _ariaHeaderLabel: string;
67
private styles: IPaneStyles = {
68
dropBackground: undefined,
69
headerBackground: undefined,
70
headerBorder: undefined,
71
headerForeground: undefined,
72
leftBorder: undefined
73
};
74
private animationTimer: number | undefined = undefined;
75
76
private readonly _onDidChange = this._register(new Emitter<number | undefined>());
77
readonly onDidChange: Event<number | undefined> = this._onDidChange.event;
78
79
private readonly _onDidChangeExpansionState = this._register(new Emitter<boolean>());
80
readonly onDidChangeExpansionState: Event<boolean> = this._onDidChangeExpansionState.event;
81
82
get ariaHeaderLabel(): string {
83
return this._ariaHeaderLabel;
84
}
85
86
set ariaHeaderLabel(newLabel: string) {
87
this._ariaHeaderLabel = newLabel;
88
this.header?.setAttribute('aria-label', this.ariaHeaderLabel);
89
}
90
91
get draggableElement(): HTMLElement | undefined {
92
return this.header;
93
}
94
95
get dropTargetElement(): HTMLElement {
96
return this.element;
97
}
98
99
get dropBackground(): string | undefined {
100
return this.styles.dropBackground;
101
}
102
103
get minimumBodySize(): number {
104
return this._minimumBodySize;
105
}
106
107
set minimumBodySize(size: number) {
108
this._minimumBodySize = size;
109
this._onDidChange.fire(undefined);
110
}
111
112
get maximumBodySize(): number {
113
return this._maximumBodySize;
114
}
115
116
set maximumBodySize(size: number) {
117
this._maximumBodySize = size;
118
this._onDidChange.fire(undefined);
119
}
120
121
private get headerSize(): number {
122
return this.headerVisible ? Pane.HEADER_SIZE : 0;
123
}
124
125
get minimumSize(): number {
126
const headerSize = this.headerSize;
127
const expanded = !this.headerVisible || this.isExpanded();
128
const minimumBodySize = expanded ? this.minimumBodySize : 0;
129
130
return headerSize + minimumBodySize;
131
}
132
133
get maximumSize(): number {
134
const headerSize = this.headerSize;
135
const expanded = !this.headerVisible || this.isExpanded();
136
const maximumBodySize = expanded ? this.maximumBodySize : 0;
137
138
return headerSize + maximumBodySize;
139
}
140
141
orthogonalSize: number = 0;
142
143
protected getAriaHeaderLabel(title: string): string {
144
return localize('viewSection', "{0} Section", title);
145
}
146
147
constructor(options: IPaneOptions) {
148
super();
149
this._expanded = typeof options.expanded === 'undefined' ? true : !!options.expanded;
150
this._orientation = typeof options.orientation === 'undefined' ? Orientation.VERTICAL : options.orientation;
151
this._ariaHeaderLabel = this.getAriaHeaderLabel(options.title);
152
this._minimumBodySize = typeof options.minimumBodySize === 'number' ? options.minimumBodySize : this._orientation === Orientation.HORIZONTAL ? 200 : 120;
153
this._maximumBodySize = typeof options.maximumBodySize === 'number' ? options.maximumBodySize : Number.POSITIVE_INFINITY;
154
155
this.element = $('.pane');
156
}
157
158
isExpanded(): boolean {
159
return this._expanded;
160
}
161
162
setExpanded(expanded: boolean): boolean {
163
if (!expanded && !this.collapsible) {
164
return false;
165
}
166
167
if (this._expanded === !!expanded) {
168
return false;
169
}
170
171
this.element?.classList.toggle('expanded', expanded);
172
173
this._expanded = !!expanded;
174
this.updateHeader();
175
176
if (expanded) {
177
if (!this._bodyRendered) {
178
this.renderBody(this.body);
179
this._bodyRendered = true;
180
}
181
182
if (typeof this.animationTimer === 'number') {
183
getWindow(this.element).clearTimeout(this.animationTimer);
184
}
185
append(this.element, this.body);
186
} else {
187
this.animationTimer = getWindow(this.element).setTimeout(() => {
188
this.body.remove();
189
}, 200);
190
}
191
192
this._onDidChangeExpansionState.fire(expanded);
193
this._onDidChange.fire(expanded ? this.expandedSize : undefined);
194
return true;
195
}
196
197
get headerVisible(): boolean {
198
return this._headerVisible;
199
}
200
201
set headerVisible(visible: boolean) {
202
if (this._headerVisible === !!visible) {
203
return;
204
}
205
206
this._headerVisible = !!visible;
207
this.updateHeader();
208
this._onDidChange.fire(undefined);
209
}
210
211
get collapsible(): boolean {
212
return this._collapsible;
213
}
214
215
set collapsible(collapsible: boolean) {
216
if (this._collapsible === !!collapsible) {
217
return;
218
}
219
220
this._collapsible = !!collapsible;
221
this.updateHeader();
222
}
223
224
get orientation(): Orientation {
225
return this._orientation;
226
}
227
228
set orientation(orientation: Orientation) {
229
if (this._orientation === orientation) {
230
return;
231
}
232
233
this._orientation = orientation;
234
235
if (this.element) {
236
this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL);
237
this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL);
238
}
239
240
if (this.header) {
241
this.updateHeader();
242
}
243
}
244
245
render(): void {
246
this.element.classList.toggle('expanded', this.isExpanded());
247
this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL);
248
this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL);
249
250
this.header = $('.pane-header');
251
append(this.element, this.header);
252
this.header.setAttribute('tabindex', '0');
253
// Use role button so the aria-expanded state gets read https://github.com/microsoft/vscode/issues/95996
254
this.header.setAttribute('role', 'button');
255
this.header.setAttribute('aria-label', this.ariaHeaderLabel);
256
this.renderHeader(this.header);
257
258
const focusTracker = trackFocus(this.header);
259
this._register(focusTracker);
260
this._register(focusTracker.onDidFocus(() => this.header?.classList.add('focused'), null));
261
this._register(focusTracker.onDidBlur(() => this.header?.classList.remove('focused'), null));
262
263
this.updateHeader();
264
265
const eventDisposables = this._register(new DisposableStore());
266
const onKeyDown = this._register(new DomEmitter(this.header, 'keydown'));
267
const onHeaderKeyDown = Event.map(onKeyDown.event, e => new StandardKeyboardEvent(e), eventDisposables);
268
269
this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space, eventDisposables)(() => this.setExpanded(!this.isExpanded()), null));
270
271
this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.LeftArrow, eventDisposables)(() => this.setExpanded(false), null));
272
273
this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.RightArrow, eventDisposables)(() => this.setExpanded(true), null));
274
275
this._register(Gesture.addTarget(this.header));
276
277
const header = this.header;
278
[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {
279
this._register(addDisposableListener(header, eventType, e => {
280
if (!e.defaultPrevented) {
281
this.setExpanded(!this.isExpanded());
282
}
283
}));
284
});
285
286
this.body = append(this.element, $('.pane-body'));
287
288
// Only render the body if it will be visible
289
// Otherwise, render it when the pane is expanded
290
if (!this._bodyRendered && this.isExpanded()) {
291
this.renderBody(this.body);
292
this._bodyRendered = true;
293
}
294
295
if (!this.isExpanded()) {
296
this.body.remove();
297
}
298
}
299
300
layout(size: number): void {
301
const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0;
302
303
const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size;
304
const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize;
305
306
if (this.isExpanded()) {
307
this.body.classList.toggle('wide', width >= 600);
308
this.layoutBody(height, width);
309
this.expandedSize = size;
310
}
311
}
312
313
style(styles: IPaneStyles): void {
314
this.styles = styles;
315
316
if (!this.header) {
317
return;
318
}
319
320
this.updateHeader();
321
}
322
323
protected updateHeader(): void {
324
if (!this.header) {
325
return;
326
}
327
const expanded = !this.headerVisible || this.isExpanded();
328
329
if (this.collapsible) {
330
this.header.setAttribute('tabindex', '0');
331
this.header.setAttribute('role', 'button');
332
} else {
333
this.header.removeAttribute('tabindex');
334
this.header.removeAttribute('role');
335
}
336
337
this.header.style.lineHeight = `${this.headerSize}px`;
338
this.header.classList.toggle('hidden', !this.headerVisible);
339
this.header.classList.toggle('expanded', expanded);
340
this.header.classList.toggle('not-collapsible', !this.collapsible);
341
this.header.setAttribute('aria-expanded', String(expanded));
342
343
this.header.style.color = this.collapsible ? this.styles.headerForeground ?? '' : '';
344
this.header.style.backgroundColor = (this.collapsible ? this.styles.headerBackground : 'transparent') ?? '';
345
this.header.style.borderTop = this.styles.headerBorder && this.orientation === Orientation.VERTICAL ? `1px solid ${this.styles.headerBorder}` : '';
346
this.element.style.borderLeft = this.styles.leftBorder && this.orientation === Orientation.HORIZONTAL ? `1px solid ${this.styles.leftBorder}` : '';
347
}
348
349
protected abstract renderHeader(container: HTMLElement): void;
350
protected abstract renderBody(container: HTMLElement): void;
351
protected abstract layoutBody(height: number, width: number): void;
352
}
353
354
interface IDndContext {
355
draggable: PaneDraggable | null;
356
}
357
358
class PaneDraggable extends Disposable {
359
360
private static readonly DefaultDragOverBackgroundColor = new Color(new RGBA(128, 128, 128, 0.5));
361
362
private dragOverCounter = 0; // see https://github.com/microsoft/vscode/issues/14470
363
364
private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>());
365
readonly onDidDrop = this._onDidDrop.event;
366
367
constructor(private pane: Pane, private dnd: IPaneDndController, private context: IDndContext) {
368
super();
369
370
pane.draggableElement!.draggable = true;
371
this._register(addDisposableListener(pane.draggableElement!, 'dragstart', e => this.onDragStart(e)));
372
this._register(addDisposableListener(pane.dropTargetElement, 'dragenter', e => this.onDragEnter(e)));
373
this._register(addDisposableListener(pane.dropTargetElement, 'dragleave', e => this.onDragLeave(e)));
374
this._register(addDisposableListener(pane.dropTargetElement, 'dragend', e => this.onDragEnd(e)));
375
this._register(addDisposableListener(pane.dropTargetElement, 'drop', e => this.onDrop(e)));
376
}
377
378
private onDragStart(e: DragEvent): void {
379
if (!this.dnd.canDrag(this.pane) || !e.dataTransfer) {
380
e.preventDefault();
381
e.stopPropagation();
382
return;
383
}
384
385
const label = this.pane.draggableElement?.textContent || '';
386
387
e.dataTransfer.effectAllowed = 'move';
388
389
if (isFirefox) {
390
// Firefox: requires to set a text data transfer to get going
391
e.dataTransfer?.setData(DataTransfers.TEXT, label);
392
}
393
394
applyDragImage(e, this.pane.element, label);
395
396
this.context.draggable = this;
397
}
398
399
private onDragEnter(e: DragEvent): void {
400
if (!this.context.draggable || this.context.draggable === this) {
401
return;
402
}
403
404
if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {
405
return;
406
}
407
408
this.dragOverCounter++;
409
this.render();
410
}
411
412
private onDragLeave(e: DragEvent): void {
413
if (!this.context.draggable || this.context.draggable === this) {
414
return;
415
}
416
417
if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {
418
return;
419
}
420
421
this.dragOverCounter--;
422
423
if (this.dragOverCounter === 0) {
424
this.render();
425
}
426
}
427
428
private onDragEnd(e: DragEvent): void {
429
if (!this.context.draggable) {
430
return;
431
}
432
433
this.dragOverCounter = 0;
434
this.render();
435
this.context.draggable = null;
436
}
437
438
private onDrop(e: DragEvent): void {
439
if (!this.context.draggable) {
440
return;
441
}
442
443
EventHelper.stop(e);
444
445
this.dragOverCounter = 0;
446
this.render();
447
448
if (this.dnd.canDrop(this.context.draggable.pane, this.pane) && this.context.draggable !== this) {
449
this._onDidDrop.fire({ from: this.context.draggable.pane, to: this.pane });
450
}
451
452
this.context.draggable = null;
453
}
454
455
private render(): void {
456
let backgroundColor: string | null = null;
457
458
if (this.dragOverCounter > 0) {
459
backgroundColor = this.pane.dropBackground ?? PaneDraggable.DefaultDragOverBackgroundColor.toString();
460
}
461
462
this.pane.dropTargetElement.style.backgroundColor = backgroundColor || '';
463
}
464
}
465
466
export interface IPaneDndController {
467
canDrag(pane: Pane): boolean;
468
canDrop(pane: Pane, overPane: Pane): boolean;
469
}
470
471
export class DefaultPaneDndController implements IPaneDndController {
472
473
canDrag(pane: Pane): boolean {
474
return true;
475
}
476
477
canDrop(pane: Pane, overPane: Pane): boolean {
478
return true;
479
}
480
}
481
482
export interface IPaneViewOptions {
483
dnd?: IPaneDndController;
484
orientation?: Orientation;
485
}
486
487
interface IPaneItem {
488
pane: Pane;
489
disposable: IDisposable;
490
}
491
492
export class PaneView extends Disposable {
493
494
private dnd: IPaneDndController | undefined;
495
private dndContext: IDndContext = { draggable: null };
496
readonly element: HTMLElement;
497
private paneItems: IPaneItem[] = [];
498
private orthogonalSize: number = 0;
499
private size: number = 0;
500
private splitview: SplitView;
501
private animationTimer: number | undefined = undefined;
502
503
private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>());
504
readonly onDidDrop: Event<{ from: Pane; to: Pane }> = this._onDidDrop.event;
505
506
orientation: Orientation;
507
private boundarySashes: IBoundarySashes | undefined;
508
readonly onDidSashChange: Event<number>;
509
readonly onDidSashReset: Event<number>;
510
readonly onDidScroll: Event<ScrollEvent>;
511
512
constructor(container: HTMLElement, options: IPaneViewOptions = {}) {
513
super();
514
515
this.dnd = options.dnd;
516
this.orientation = options.orientation ?? Orientation.VERTICAL;
517
this.element = append(container, $('.monaco-pane-view'));
518
this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation }));
519
this.onDidSashReset = this.splitview.onDidSashReset;
520
this.onDidSashChange = this.splitview.onDidSashChange;
521
this.onDidScroll = this.splitview.onDidScroll;
522
523
const eventDisposables = this._register(new DisposableStore());
524
const onKeyDown = this._register(new DomEmitter(this.element, 'keydown'));
525
const onHeaderKeyDown = Event.map(Event.filter(onKeyDown.event, e => isHTMLElement(e.target) && e.target.classList.contains('pane-header'), eventDisposables), e => new StandardKeyboardEvent(e), eventDisposables);
526
527
this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.UpArrow, eventDisposables)(() => this.focusPrevious()));
528
this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.DownArrow, eventDisposables)(() => this.focusNext()));
529
}
530
531
addPane(pane: Pane, size: number, index = this.splitview.length): void {
532
const disposables = new DisposableStore();
533
pane.onDidChangeExpansionState(this.setupAnimation, this, disposables);
534
535
const paneItem = { pane: pane, disposable: disposables };
536
this.paneItems.splice(index, 0, paneItem);
537
pane.orientation = this.orientation;
538
pane.orthogonalSize = this.orthogonalSize;
539
this.splitview.addView(pane, size, index);
540
541
if (this.dnd) {
542
const draggable = new PaneDraggable(pane, this.dnd, this.dndContext);
543
disposables.add(draggable);
544
disposables.add(draggable.onDidDrop(this._onDidDrop.fire, this._onDidDrop));
545
}
546
}
547
548
removePane(pane: Pane): void {
549
const index = this.paneItems.findIndex(item => item.pane === pane);
550
551
if (index === -1) {
552
return;
553
}
554
555
this.splitview.removeView(index, pane.isExpanded() ? Sizing.Distribute : undefined);
556
const paneItem = this.paneItems.splice(index, 1)[0];
557
paneItem.disposable.dispose();
558
}
559
560
movePane(from: Pane, to: Pane): void {
561
const fromIndex = this.paneItems.findIndex(item => item.pane === from);
562
const toIndex = this.paneItems.findIndex(item => item.pane === to);
563
564
if (fromIndex === -1 || toIndex === -1) {
565
return;
566
}
567
568
const [paneItem] = this.paneItems.splice(fromIndex, 1);
569
this.paneItems.splice(toIndex, 0, paneItem);
570
571
this.splitview.moveView(fromIndex, toIndex);
572
}
573
574
resizePane(pane: Pane, size: number): void {
575
const index = this.paneItems.findIndex(item => item.pane === pane);
576
577
if (index === -1) {
578
return;
579
}
580
581
this.splitview.resizeView(index, size);
582
}
583
584
getPaneSize(pane: Pane): number {
585
const index = this.paneItems.findIndex(item => item.pane === pane);
586
587
if (index === -1) {
588
return -1;
589
}
590
591
return this.splitview.getViewSize(index);
592
}
593
594
layout(height: number, width: number): void {
595
this.orthogonalSize = this.orientation === Orientation.VERTICAL ? width : height;
596
this.size = this.orientation === Orientation.HORIZONTAL ? width : height;
597
598
for (const paneItem of this.paneItems) {
599
paneItem.pane.orthogonalSize = this.orthogonalSize;
600
}
601
602
this.splitview.layout(this.size);
603
}
604
605
setBoundarySashes(sashes: IBoundarySashes) {
606
this.boundarySashes = sashes;
607
this.updateSplitviewOrthogonalSashes(sashes);
608
}
609
610
private updateSplitviewOrthogonalSashes(sashes: IBoundarySashes | undefined) {
611
if (this.orientation === Orientation.VERTICAL) {
612
this.splitview.orthogonalStartSash = sashes?.left;
613
this.splitview.orthogonalEndSash = sashes?.right;
614
} else {
615
this.splitview.orthogonalEndSash = sashes?.bottom;
616
}
617
}
618
619
flipOrientation(height: number, width: number): void {
620
this.orientation = this.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL;
621
const paneSizes = this.paneItems.map(pane => this.getPaneSize(pane.pane));
622
623
this.splitview.dispose();
624
clearNode(this.element);
625
626
this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation }));
627
this.updateSplitviewOrthogonalSashes(this.boundarySashes);
628
629
const newOrthogonalSize = this.orientation === Orientation.VERTICAL ? width : height;
630
const newSize = this.orientation === Orientation.HORIZONTAL ? width : height;
631
632
this.paneItems.forEach((pane, index) => {
633
pane.pane.orthogonalSize = newOrthogonalSize;
634
pane.pane.orientation = this.orientation;
635
636
const viewSize = this.size === 0 ? 0 : (newSize * paneSizes[index]) / this.size;
637
this.splitview.addView(pane.pane, viewSize, index);
638
});
639
640
this.size = newSize;
641
this.orthogonalSize = newOrthogonalSize;
642
643
this.splitview.layout(this.size);
644
}
645
646
private setupAnimation(): void {
647
if (typeof this.animationTimer === 'number') {
648
getWindow(this.element).clearTimeout(this.animationTimer);
649
}
650
651
this.element.classList.add('animated');
652
653
this.animationTimer = getWindow(this.element).setTimeout(() => {
654
this.animationTimer = undefined;
655
this.element.classList.remove('animated');
656
}, 200);
657
}
658
659
private getPaneHeaderElements(): HTMLElement[] {
660
return [...this.element.querySelectorAll('.pane-header')] as HTMLElement[];
661
}
662
663
private focusPrevious(): void {
664
const headers = this.getPaneHeaderElements();
665
const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement);
666
667
if (index === -1) {
668
return;
669
}
670
671
headers[Math.max(index - 1, 0)].focus();
672
}
673
674
private focusNext(): void {
675
const headers = this.getPaneHeaderElements();
676
const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement);
677
678
if (index === -1) {
679
return;
680
}
681
682
headers[Math.min(index + 1, headers.length - 1)].focus();
683
}
684
685
override dispose(): void {
686
super.dispose();
687
688
this.paneItems.forEach(i => i.disposable.dispose());
689
}
690
}
691
692