Path: blob/main/src/vs/base/browser/ui/splitview/paneview.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { isFirefox } from '../../browser.js';6import { DataTransfers } from '../../dnd.js';7import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from '../../dom.js';8import { DomEmitter } from '../../event.js';9import { StandardKeyboardEvent } from '../../keyboardEvent.js';10import { Gesture, EventType as TouchEventType } from '../../touch.js';11import { IBoundarySashes, Orientation } from '../sash/sash.js';12import { Color, RGBA } from '../../../common/color.js';13import { Emitter, Event } from '../../../common/event.js';14import { KeyCode } from '../../../common/keyCodes.js';15import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js';16import { ScrollEvent } from '../../../common/scrollable.js';17import './paneview.css';18import { localize } from '../../../../nls.js';19import { IView, Sizing, SplitView } from './splitview.js';20import { applyDragImage } from '../dnd/dnd.js';2122export interface IPaneOptions {23minimumBodySize?: number;24maximumBodySize?: number;25expanded?: boolean;26orientation?: Orientation;27title: string;28titleDescription?: string;29}3031export interface IPaneStyles {32readonly dropBackground: string | undefined;33readonly headerForeground: string | undefined;34readonly headerBackground: string | undefined;35readonly headerBorder: string | undefined;36readonly leftBorder: string | undefined;37}3839/**40* A Pane is a structured SplitView view.41*42* WARNING: You must call `render()` after you construct it.43* It can't be done automatically at the end of the ctor44* because of the order of property initialization in TypeScript.45* Subclasses wouldn't be able to set own properties46* before the `render()` call, thus forbidding their use.47*/48export abstract class Pane extends Disposable implements IView {4950private static readonly HEADER_SIZE = 22;5152readonly element: HTMLElement;53private header: HTMLElement | undefined;54private body!: HTMLElement;5556protected _expanded: boolean;57protected _orientation: Orientation;5859private expandedSize: number | undefined = undefined;60private _headerVisible = true;61private _collapsible = true;62private _bodyRendered = false;63private _minimumBodySize: number;64private _maximumBodySize: number;65private _ariaHeaderLabel: string;66private styles: IPaneStyles = {67dropBackground: undefined,68headerBackground: undefined,69headerBorder: undefined,70headerForeground: undefined,71leftBorder: undefined72};73private animationTimer: number | undefined = undefined;7475private readonly _onDidChange = this._register(new Emitter<number | undefined>());76readonly onDidChange: Event<number | undefined> = this._onDidChange.event;7778private readonly _onDidChangeExpansionState = this._register(new Emitter<boolean>());79readonly onDidChangeExpansionState: Event<boolean> = this._onDidChangeExpansionState.event;8081get ariaHeaderLabel(): string {82return this._ariaHeaderLabel;83}8485set ariaHeaderLabel(newLabel: string) {86this._ariaHeaderLabel = newLabel;87this.header?.setAttribute('aria-label', this.ariaHeaderLabel);88}8990get draggableElement(): HTMLElement | undefined {91return this.header;92}9394get dropTargetElement(): HTMLElement {95return this.element;96}9798get dropBackground(): string | undefined {99return this.styles.dropBackground;100}101102get minimumBodySize(): number {103return this._minimumBodySize;104}105106set minimumBodySize(size: number) {107this._minimumBodySize = size;108this._onDidChange.fire(undefined);109}110111get maximumBodySize(): number {112return this._maximumBodySize;113}114115set maximumBodySize(size: number) {116this._maximumBodySize = size;117this._onDidChange.fire(undefined);118}119120private get headerSize(): number {121return this.headerVisible ? Pane.HEADER_SIZE : 0;122}123124get minimumSize(): number {125const headerSize = this.headerSize;126const expanded = !this.headerVisible || this.isExpanded();127const minimumBodySize = expanded ? this.minimumBodySize : 0;128129return headerSize + minimumBodySize;130}131132get maximumSize(): number {133const headerSize = this.headerSize;134const expanded = !this.headerVisible || this.isExpanded();135const maximumBodySize = expanded ? this.maximumBodySize : 0;136137return headerSize + maximumBodySize;138}139140orthogonalSize: number = 0;141142protected getAriaHeaderLabel(title: string): string {143return localize('viewSection', "{0} Section", title);144}145146constructor(options: IPaneOptions) {147super();148this._expanded = typeof options.expanded === 'undefined' ? true : !!options.expanded;149this._orientation = typeof options.orientation === 'undefined' ? Orientation.VERTICAL : options.orientation;150this._ariaHeaderLabel = this.getAriaHeaderLabel(options.title);151this._minimumBodySize = typeof options.minimumBodySize === 'number' ? options.minimumBodySize : this._orientation === Orientation.HORIZONTAL ? 200 : 120;152this._maximumBodySize = typeof options.maximumBodySize === 'number' ? options.maximumBodySize : Number.POSITIVE_INFINITY;153154this.element = $('.pane');155}156157isExpanded(): boolean {158return this._expanded;159}160161setExpanded(expanded: boolean): boolean {162if (!expanded && !this.collapsible) {163return false;164}165166if (this._expanded === !!expanded) {167return false;168}169170this.element?.classList.toggle('expanded', expanded);171172this._expanded = !!expanded;173this.updateHeader();174175if (expanded) {176if (!this._bodyRendered) {177this.renderBody(this.body);178this._bodyRendered = true;179}180181if (typeof this.animationTimer === 'number') {182getWindow(this.element).clearTimeout(this.animationTimer);183}184append(this.element, this.body);185} else {186this.animationTimer = getWindow(this.element).setTimeout(() => {187this.body.remove();188}, 200);189}190191this._onDidChangeExpansionState.fire(expanded);192this._onDidChange.fire(expanded ? this.expandedSize : undefined);193return true;194}195196get headerVisible(): boolean {197return this._headerVisible;198}199200set headerVisible(visible: boolean) {201if (this._headerVisible === !!visible) {202return;203}204205this._headerVisible = !!visible;206this.updateHeader();207this._onDidChange.fire(undefined);208}209210get collapsible(): boolean {211return this._collapsible;212}213214set collapsible(collapsible: boolean) {215if (this._collapsible === !!collapsible) {216return;217}218219this._collapsible = !!collapsible;220this.updateHeader();221}222223get orientation(): Orientation {224return this._orientation;225}226227set orientation(orientation: Orientation) {228if (this._orientation === orientation) {229return;230}231232this._orientation = orientation;233234if (this.element) {235this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL);236this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL);237}238239if (this.header) {240this.updateHeader();241}242}243244render(): void {245this.element.classList.toggle('expanded', this.isExpanded());246this.element.classList.toggle('horizontal', this.orientation === Orientation.HORIZONTAL);247this.element.classList.toggle('vertical', this.orientation === Orientation.VERTICAL);248249this.header = $('.pane-header');250append(this.element, this.header);251this.header.setAttribute('tabindex', '0');252// Use role button so the aria-expanded state gets read https://github.com/microsoft/vscode/issues/95996253this.header.setAttribute('role', 'button');254this.header.setAttribute('aria-label', this.ariaHeaderLabel);255this.renderHeader(this.header);256257const focusTracker = trackFocus(this.header);258this._register(focusTracker);259this._register(focusTracker.onDidFocus(() => this.header?.classList.add('focused'), null));260this._register(focusTracker.onDidBlur(() => this.header?.classList.remove('focused'), null));261262this.updateHeader();263264const eventDisposables = this._register(new DisposableStore());265const onKeyDown = this._register(new DomEmitter(this.header, 'keydown'));266const onHeaderKeyDown = Event.map(onKeyDown.event, e => new StandardKeyboardEvent(e), eventDisposables);267268this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.Enter || e.keyCode === KeyCode.Space, eventDisposables)(() => this.setExpanded(!this.isExpanded()), null));269270this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.LeftArrow, eventDisposables)(() => this.setExpanded(false), null));271272this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.RightArrow, eventDisposables)(() => this.setExpanded(true), null));273274this._register(Gesture.addTarget(this.header));275276const header = this.header;277[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {278this._register(addDisposableListener(header, eventType, e => {279if (!e.defaultPrevented) {280this.setExpanded(!this.isExpanded());281}282}));283});284285this.body = append(this.element, $('.pane-body'));286287// Only render the body if it will be visible288// Otherwise, render it when the pane is expanded289if (!this._bodyRendered && this.isExpanded()) {290this.renderBody(this.body);291this._bodyRendered = true;292}293294if (!this.isExpanded()) {295this.body.remove();296}297}298299layout(size: number): void {300const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0;301302const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size;303const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize;304305if (this.isExpanded()) {306this.body.classList.toggle('wide', width >= 600);307this.layoutBody(height, width);308this.expandedSize = size;309}310}311312style(styles: IPaneStyles): void {313this.styles = styles;314315if (!this.header) {316return;317}318319this.updateHeader();320}321322protected updateHeader(): void {323if (!this.header) {324return;325}326const expanded = !this.headerVisible || this.isExpanded();327328if (this.collapsible) {329this.header.setAttribute('tabindex', '0');330this.header.setAttribute('role', 'button');331} else {332this.header.removeAttribute('tabindex');333this.header.removeAttribute('role');334}335336this.header.style.lineHeight = `${this.headerSize}px`;337this.header.classList.toggle('hidden', !this.headerVisible);338this.header.classList.toggle('expanded', expanded);339this.header.classList.toggle('not-collapsible', !this.collapsible);340this.header.setAttribute('aria-expanded', String(expanded));341342this.header.style.color = this.collapsible ? this.styles.headerForeground ?? '' : '';343this.header.style.backgroundColor = (this.collapsible ? this.styles.headerBackground : 'transparent') ?? '';344this.header.style.borderTop = this.styles.headerBorder && this.orientation === Orientation.VERTICAL ? `1px solid ${this.styles.headerBorder}` : '';345this.element.style.borderLeft = this.styles.leftBorder && this.orientation === Orientation.HORIZONTAL ? `1px solid ${this.styles.leftBorder}` : '';346}347348protected abstract renderHeader(container: HTMLElement): void;349protected abstract renderBody(container: HTMLElement): void;350protected abstract layoutBody(height: number, width: number): void;351}352353interface IDndContext {354draggable: PaneDraggable | null;355}356357class PaneDraggable extends Disposable {358359private static readonly DefaultDragOverBackgroundColor = new Color(new RGBA(128, 128, 128, 0.5));360361private dragOverCounter = 0; // see https://github.com/microsoft/vscode/issues/14470362363private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>());364readonly onDidDrop = this._onDidDrop.event;365366constructor(private pane: Pane, private dnd: IPaneDndController, private context: IDndContext) {367super();368369pane.draggableElement!.draggable = true;370this._register(addDisposableListener(pane.draggableElement!, 'dragstart', e => this.onDragStart(e)));371this._register(addDisposableListener(pane.dropTargetElement, 'dragenter', e => this.onDragEnter(e)));372this._register(addDisposableListener(pane.dropTargetElement, 'dragleave', e => this.onDragLeave(e)));373this._register(addDisposableListener(pane.dropTargetElement, 'dragend', e => this.onDragEnd(e)));374this._register(addDisposableListener(pane.dropTargetElement, 'drop', e => this.onDrop(e)));375}376377private onDragStart(e: DragEvent): void {378if (!this.dnd.canDrag(this.pane) || !e.dataTransfer) {379e.preventDefault();380e.stopPropagation();381return;382}383384const label = this.pane.draggableElement?.textContent || '';385386e.dataTransfer.effectAllowed = 'move';387388if (isFirefox) {389// Firefox: requires to set a text data transfer to get going390e.dataTransfer?.setData(DataTransfers.TEXT, label);391}392393applyDragImage(e, this.pane.element, label);394395this.context.draggable = this;396}397398private onDragEnter(e: DragEvent): void {399if (!this.context.draggable || this.context.draggable === this) {400return;401}402403if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {404return;405}406407this.dragOverCounter++;408this.render();409}410411private onDragLeave(e: DragEvent): void {412if (!this.context.draggable || this.context.draggable === this) {413return;414}415416if (!this.dnd.canDrop(this.context.draggable.pane, this.pane)) {417return;418}419420this.dragOverCounter--;421422if (this.dragOverCounter === 0) {423this.render();424}425}426427private onDragEnd(e: DragEvent): void {428if (!this.context.draggable) {429return;430}431432this.dragOverCounter = 0;433this.render();434this.context.draggable = null;435}436437private onDrop(e: DragEvent): void {438if (!this.context.draggable) {439return;440}441442EventHelper.stop(e);443444this.dragOverCounter = 0;445this.render();446447if (this.dnd.canDrop(this.context.draggable.pane, this.pane) && this.context.draggable !== this) {448this._onDidDrop.fire({ from: this.context.draggable.pane, to: this.pane });449}450451this.context.draggable = null;452}453454private render(): void {455let backgroundColor: string | null = null;456457if (this.dragOverCounter > 0) {458backgroundColor = this.pane.dropBackground ?? PaneDraggable.DefaultDragOverBackgroundColor.toString();459}460461this.pane.dropTargetElement.style.backgroundColor = backgroundColor || '';462}463}464465export interface IPaneDndController {466canDrag(pane: Pane): boolean;467canDrop(pane: Pane, overPane: Pane): boolean;468}469470export class DefaultPaneDndController implements IPaneDndController {471472canDrag(pane: Pane): boolean {473return true;474}475476canDrop(pane: Pane, overPane: Pane): boolean {477return true;478}479}480481export interface IPaneViewOptions {482dnd?: IPaneDndController;483orientation?: Orientation;484}485486interface IPaneItem {487pane: Pane;488disposable: IDisposable;489}490491export class PaneView extends Disposable {492493private dnd: IPaneDndController | undefined;494private dndContext: IDndContext = { draggable: null };495readonly element: HTMLElement;496private paneItems: IPaneItem[] = [];497private orthogonalSize: number = 0;498private size: number = 0;499private splitview: SplitView;500private animationTimer: number | undefined = undefined;501502private _onDidDrop = this._register(new Emitter<{ from: Pane; to: Pane }>());503readonly onDidDrop: Event<{ from: Pane; to: Pane }> = this._onDidDrop.event;504505orientation: Orientation;506private boundarySashes: IBoundarySashes | undefined;507readonly onDidSashChange: Event<number>;508readonly onDidSashReset: Event<number>;509readonly onDidScroll: Event<ScrollEvent>;510511constructor(container: HTMLElement, options: IPaneViewOptions = {}) {512super();513514this.dnd = options.dnd;515this.orientation = options.orientation ?? Orientation.VERTICAL;516this.element = append(container, $('.monaco-pane-view'));517this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation }));518this.onDidSashReset = this.splitview.onDidSashReset;519this.onDidSashChange = this.splitview.onDidSashChange;520this.onDidScroll = this.splitview.onDidScroll;521522const eventDisposables = this._register(new DisposableStore());523const onKeyDown = this._register(new DomEmitter(this.element, 'keydown'));524const onHeaderKeyDown = Event.map(Event.filter(onKeyDown.event, e => isHTMLElement(e.target) && e.target.classList.contains('pane-header'), eventDisposables), e => new StandardKeyboardEvent(e), eventDisposables);525526this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.UpArrow, eventDisposables)(() => this.focusPrevious()));527this._register(Event.filter(onHeaderKeyDown, e => e.keyCode === KeyCode.DownArrow, eventDisposables)(() => this.focusNext()));528}529530addPane(pane: Pane, size: number, index = this.splitview.length): void {531const disposables = new DisposableStore();532pane.onDidChangeExpansionState(this.setupAnimation, this, disposables);533534const paneItem = { pane: pane, disposable: disposables };535this.paneItems.splice(index, 0, paneItem);536pane.orientation = this.orientation;537pane.orthogonalSize = this.orthogonalSize;538this.splitview.addView(pane, size, index);539540if (this.dnd) {541const draggable = new PaneDraggable(pane, this.dnd, this.dndContext);542disposables.add(draggable);543disposables.add(draggable.onDidDrop(this._onDidDrop.fire, this._onDidDrop));544}545}546547removePane(pane: Pane): void {548const index = this.paneItems.findIndex(item => item.pane === pane);549550if (index === -1) {551return;552}553554this.splitview.removeView(index, pane.isExpanded() ? Sizing.Distribute : undefined);555const paneItem = this.paneItems.splice(index, 1)[0];556paneItem.disposable.dispose();557}558559movePane(from: Pane, to: Pane): void {560const fromIndex = this.paneItems.findIndex(item => item.pane === from);561const toIndex = this.paneItems.findIndex(item => item.pane === to);562563if (fromIndex === -1 || toIndex === -1) {564return;565}566567const [paneItem] = this.paneItems.splice(fromIndex, 1);568this.paneItems.splice(toIndex, 0, paneItem);569570this.splitview.moveView(fromIndex, toIndex);571}572573resizePane(pane: Pane, size: number): void {574const index = this.paneItems.findIndex(item => item.pane === pane);575576if (index === -1) {577return;578}579580this.splitview.resizeView(index, size);581}582583getPaneSize(pane: Pane): number {584const index = this.paneItems.findIndex(item => item.pane === pane);585586if (index === -1) {587return -1;588}589590return this.splitview.getViewSize(index);591}592593layout(height: number, width: number): void {594this.orthogonalSize = this.orientation === Orientation.VERTICAL ? width : height;595this.size = this.orientation === Orientation.HORIZONTAL ? width : height;596597for (const paneItem of this.paneItems) {598paneItem.pane.orthogonalSize = this.orthogonalSize;599}600601this.splitview.layout(this.size);602}603604setBoundarySashes(sashes: IBoundarySashes) {605this.boundarySashes = sashes;606this.updateSplitviewOrthogonalSashes(sashes);607}608609private updateSplitviewOrthogonalSashes(sashes: IBoundarySashes | undefined) {610if (this.orientation === Orientation.VERTICAL) {611this.splitview.orthogonalStartSash = sashes?.left;612this.splitview.orthogonalEndSash = sashes?.right;613} else {614this.splitview.orthogonalEndSash = sashes?.bottom;615}616}617618flipOrientation(height: number, width: number): void {619this.orientation = this.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL;620const paneSizes = this.paneItems.map(pane => this.getPaneSize(pane.pane));621622this.splitview.dispose();623clearNode(this.element);624625this.splitview = this._register(new SplitView(this.element, { orientation: this.orientation }));626this.updateSplitviewOrthogonalSashes(this.boundarySashes);627628const newOrthogonalSize = this.orientation === Orientation.VERTICAL ? width : height;629const newSize = this.orientation === Orientation.HORIZONTAL ? width : height;630631this.paneItems.forEach((pane, index) => {632pane.pane.orthogonalSize = newOrthogonalSize;633pane.pane.orientation = this.orientation;634635const viewSize = this.size === 0 ? 0 : (newSize * paneSizes[index]) / this.size;636this.splitview.addView(pane.pane, viewSize, index);637});638639this.size = newSize;640this.orthogonalSize = newOrthogonalSize;641642this.splitview.layout(this.size);643}644645private setupAnimation(): void {646if (typeof this.animationTimer === 'number') {647getWindow(this.element).clearTimeout(this.animationTimer);648}649650this.element.classList.add('animated');651652this.animationTimer = getWindow(this.element).setTimeout(() => {653this.animationTimer = undefined;654this.element.classList.remove('animated');655}, 200);656}657658private getPaneHeaderElements(): HTMLElement[] {659return [...this.element.querySelectorAll('.pane-header')] as HTMLElement[];660}661662private focusPrevious(): void {663const headers = this.getPaneHeaderElements();664const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement);665666if (index === -1) {667return;668}669670headers[Math.max(index - 1, 0)].focus();671}672673private focusNext(): void {674const headers = this.getPaneHeaderElements();675const index = headers.indexOf(this.element.ownerDocument.activeElement as HTMLElement);676677if (index === -1) {678return;679}680681headers[Math.min(index + 1, headers.length - 1)].focus();682}683684override dispose(): void {685super.dispose();686687this.paneItems.forEach(i => i.disposable.dispose());688}689}690691692