Skip to content

Commit

Permalink
Add breadcrumbs to tabbar
Browse files Browse the repository at this point in the history
Signed-off-by: Colin Grant <colin.grant@ericsson.com>
  • Loading branch information
colin-grant-work committed Aug 17, 2021
1 parent 6317927 commit 1242a57
Show file tree
Hide file tree
Showing 28 changed files with 478 additions and 260 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
[1.17.0 Milestone](https://github.com/eclipse-theia/theia/milestone/23)

- [core] modified handling of toolbar items for `ViewContainer`s to handle `onDidChange` correctly. [#9798](https://github.com/eclipse-theia/theia/pull/9798)
- [core, outline-view, file-system, workspace] added breadcrumbs contribution points and renderers to `core` and contributions to other packages. [#9920](https://github.com/eclipse-theia/theia/pull/9920)

<a name="breaking_changes_1.17.0">[Breaking Changes:](#breaking_changes_1.17.0)</a>

- [core] registering toolbar items for commands that explicitly target a `ViewContainer` rather than a child widget may not behave as expected. Such registrations should be made in the `ViewContainer` by overriding the `updateToolbarItems` method and using the `registerToolbarItem` utility. See the modifications to the `scm` and `vsx-registry` packages in the PR for examples. [#9798](https://github.com/eclipse-theia/theia/pull/9798)
- [vsx-registry] `VSXExtensionsContribution` no longer implements `TabBarToolbarContribution` and is not bound as such. Extensions of the class that expect such behavior should reimplement it with caution. See caveats in PR. [#9798](https://github.com/eclipse-theia/theia/pull/9798)
- [core] `handleExpansionToggleDblClickEvent` in `TreeWidget` can no longer be overridden. Instead, `doHandleExpansionToggleDblClickEvent` can be overridden. [#9877](https://github.com/eclipse-theia/theia/pull/9877)
- [core] added `BreadcrumbsRendererFactory` to constructor arguments of `DockPanelRenderer` and `ToolbarAwareTabBar`. [#9920](https://github.com/eclipse-theia/theia/pull/9920)

## v1.16.0 - 7/29/2021

Expand Down
69 changes: 45 additions & 24 deletions packages/core/src/browser/breadcrumbs/breadcrumb-popup-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,59 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable, postConstruct } from '../../../shared/inversify';
import { Emitter, Event } from '../../common';
import { Disposable, DisposableCollection } from '../../common/disposable';
import { Coordinate } from '../context-menu-renderer';
import { RendererHost } from '../widgets/react-renderer';
import { Breadcrumbs } from './breadcrumbs';

export interface BreadcrumbPopupContainerFactory {
(parent: HTMLElement, breadcrumbId: string, position: Coordinate): BreadcrumbPopupContainer;
}
export const BreadcrumbPopupContainerFactory = Symbol('BreadcrumbPopupContainerFactory');

export type BreadcrumbID = string;
export const BreadcrumbID = Symbol('BreadcrumbID');

/**
* This class creates a popup container at the given position
* so that contributions can attach their HTML elements
* as childs of `BreadcrumbPopupContainer#container`.
* as children of `BreadcrumbPopupContainer#container`.
*
* - `dispose()` is called on blur or on hit on escape
*/
@injectable()
export class BreadcrumbPopupContainer implements Disposable {
@inject(RendererHost) protected readonly parent: RendererHost;
@inject(BreadcrumbID) public readonly breadcrumbId: BreadcrumbID;
@inject(Coordinate) protected readonly position: Coordinate;

protected toDispose: DisposableCollection = new DisposableCollection();
protected onDidDisposeEmitter = new Emitter<void>();
get onDidDispose(): Event<void> {
return this.onDidDisposeEmitter.event;
}

readonly container: HTMLElement;
public isOpen: boolean;
protected _container: HTMLElement;
get container(): HTMLElement {
return this._container;
}

constructor(
protected readonly parent: HTMLElement,
public readonly breadcrumbId: string,
position: { x: number, y: number }
) {
this.container = this.createPopupDiv(position);
protected _isOpen: boolean;
get isOpen(): boolean {
return this._isOpen;
}

@postConstruct()
protected init(): void {
this._container = this.createPopupDiv(this.position);
document.addEventListener('keyup', this.escFunction);
this.container.focus();
this.isOpen = true;
this._container.focus();
this._isOpen = true;
}

protected createPopupDiv(position: { x: number, y: number }): HTMLDivElement {
protected createPopupDiv(position: Coordinate): HTMLDivElement {
const result = window.document.createElement('div');
result.className = Breadcrumbs.Styles.BREADCRUMB_POPUP;
result.style.left = `${position.x}px`;
Expand All @@ -62,36 +86,33 @@ export class BreadcrumbPopupContainer implements Disposable {
// This is a click on the breadcrumb that has openend this popup.
// We do not close this popup here but let the click event of the breadcrumb handle this instead
// because it needs to know that this popup is open to decide if it just closes this popup or
// also open a new popup.
// also opens a new popup.
return;
}
if (this.container.contains(event.relatedTarget)) {
if (this._container.contains(event.relatedTarget)) {
// A child element gets focus. Set the focus to the container again.
// Otherwise the popup would not be closed when elements outside the popup get the focus.
// A popup content should not relay on getting a focus.
this.container.focus();
this._container.focus();
return;
}
}
this.dispose();
}
};

protected escFunction = (event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Esc') {
this.dispose();
}
}
};

dispose(): void {
this.onDidDisposeEmitter.fire();
this.toDispose.dispose();
if (this.parent.contains(this.container)) {
this.parent.removeChild(this.container);
if (this.parent.contains(this._container)) {
this.parent.removeChild(this._container);
}
this.isOpen = false;
this._isOpen = false;
document.removeEventListener('keyup', this.escFunction);
}

addDisposable(disposable: Disposable | undefined): void {
if (disposable) { this.toDispose.push(disposable); }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import URI from '../../common/uri';
import { Breadcrumb } from './breadcrumb';
import { Disposable } from '../../common';
import { Disposable, MaybePromise } from '../../common';

export const BreadcrumbsContribution = Symbol('BreadcrumbsContribution');
export interface BreadcrumbsContribution {
Expand All @@ -27,14 +27,14 @@ export interface BreadcrumbsContribution {
readonly type: symbol;

/**
* The priority of this breadcrumbs contribution. Contributions with lower priority are rendered first.
* The priority of this breadcrumbs contribution. Contributions are rendered left to right in order of ascending priority.
*/
readonly priority: number;

/**
* Computes breadcrumbs for a given URI.
*/
computeBreadcrumbs(uri: URI): Promise<Breadcrumb[]>;
computeBreadcrumbs(uri: URI): MaybePromise<Breadcrumb[]>;

/**
* Attaches the breadcrumb popup content for the given breadcrumb as child to the given parent.
Expand Down
122 changes: 82 additions & 40 deletions packages/core/src/browser/breadcrumbs/breadcrumbs-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ import { BreadcrumbsService } from './breadcrumbs-service';
import { BreadcrumbRenderer } from './breadcrumb-renderer';
import PerfectScrollbar from 'perfect-scrollbar';
import URI from '../../common/uri';
import { Emitter, Event } from '../../common';
import { BreadcrumbPopupContainer } from './breadcrumb-popup-container';
import { DisposableCollection } from '../../common/disposable';
import { CorePreferences } from '../core-preferences';
import { Coordinate } from '../context-menu-renderer';

export const BreadcrumbsURI = Symbol('BreadcrumbsURI');
interface Cancelable {
canceled: boolean;
}

@injectable()
export class BreadcrumbsRenderer extends ReactRenderer {
Expand All @@ -41,22 +45,36 @@ export class BreadcrumbsRenderer extends ReactRenderer {
@inject(CorePreferences)
protected readonly corePreferences: CorePreferences;

private breadcrumbs: Breadcrumb[] = [];

private popup: BreadcrumbPopupContainer | undefined;
protected readonly onDidChangeActiveStateEmitter = new Emitter<boolean>();
get onDidChangeActiveState(): Event<boolean> {
return this.onDidChangeActiveStateEmitter.event;
}

private scrollbar: PerfectScrollbar | undefined;
protected uri: URI | undefined;
protected breadcrumbs: Breadcrumb[] = [];
protected popup: BreadcrumbPopupContainer | undefined;
protected scrollbar: PerfectScrollbar | undefined;
protected toDispose: DisposableCollection = new DisposableCollection();

private toDispose: DisposableCollection = new DisposableCollection();
get active(): boolean {
return !!this.breadcrumbs.length;
}

constructor(
@inject(BreadcrumbsURI) readonly uri: URI
) { super(); }
protected refreshCancellationMarker: Cancelable = { canceled: true };

@postConstruct()
init(): void {
this.toDispose.push(this.breadcrumbsService.onDidChangeBreadcrumbs(uri => { if (this.uri.toString() === uri.toString()) { this.refresh(); } }));
this.toDispose.push(this.corePreferences.onPreferenceChanged(_ => this.refresh()));
this.toDispose.push(this.onDidChangeActiveStateEmitter);
this.toDispose.push(this.breadcrumbsService.onDidChangeBreadcrumbs(uri => {
if (this.uri?.isEqual(uri)) {
this.refresh(uri);
}
}));
this.toDispose.push(this.corePreferences.onPreferenceChanged(change => {
if (change.preferenceName === 'breadcrumbs.enabled') {
this.refresh(this.uri);
}
}));
}

dispose(): void {
Expand All @@ -69,38 +87,62 @@ export class BreadcrumbsRenderer extends ReactRenderer {
}
}

async refresh(): Promise<void> {
if (this.corePreferences['breadcrumbs.enabled']) {
this.breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(this.uri);
async refresh(uri?: URI): Promise<void> {
this.refreshCancellationMarker.canceled = true;
const currentCallCanceled = { canceled: false };
this.refreshCancellationMarker = currentCallCanceled;
let breadcrumbs: Breadcrumb[];
if (uri && this.corePreferences['breadcrumbs.enabled']) {
breadcrumbs = await this.breadcrumbsService.getBreadcrumbs(uri);
} else {
this.breadcrumbs = [];
breadcrumbs = [];
}
if (currentCallCanceled.canceled) {
return;
}

this.uri = uri;
const wasActive = this.active;
this.breadcrumbs = breadcrumbs;
const isActive = this.active;
if (wasActive !== isActive) {
this.onDidChangeActiveStateEmitter.fire(isActive);
}

this.update();
}

protected update(): void {
this.render();

if (!this.scrollbar) {
if (this.host.firstChild) {
this.scrollbar = new PerfectScrollbar(this.host.firstChild as HTMLElement, {
handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'],
useBothWheelAxes: true,
scrollXMarginOffset: 4,
suppressScrollY: true
});
}
this.createScrollbar();
} else {
this.scrollbar.update();
}
this.scrollToEnd();
}

private scrollToEnd(): void {
protected createScrollbar(): void {
if (this.host.firstChild) {
this.scrollbar = new PerfectScrollbar(this.host.firstChild as HTMLElement, {
handlers: ['drag-thumb', 'keyboard', 'wheel', 'touch'],
useBothWheelAxes: true,
scrollXMarginOffset: 4,
suppressScrollY: true
});
}
}

protected scrollToEnd(): void {
if (this.host.firstChild) {
const breadcrumbsHtmlElement = (this.host.firstChild as HTMLElement);
breadcrumbsHtmlElement.scrollLeft = breadcrumbsHtmlElement.scrollWidth;
}
}

protected doRender(): React.ReactNode {
return <ul key={'ul'} className={Breadcrumbs.Styles.BREADCRUMBS}>{this.renderBreadcrumbs()}</ul>;
return <ul className={Breadcrumbs.Styles.BREADCRUMBS}>{this.renderBreadcrumbs()}</ul>;
}

protected renderBreadcrumbs(): React.ReactNode {
Expand All @@ -111,26 +153,25 @@ export class BreadcrumbsRenderer extends ReactRenderer {
event.stopPropagation();
event.preventDefault();
let openPopup = true;
if (this.popup) {
if (this.popup.isOpen) {
this.popup.dispose();
if (this.popup?.isOpen) {
this.popup.dispose();

// There is a popup open. If the popup is the popup that belongs to the currently clicked breadcrumb
// just close the popup. When another breadcrumb was clicked open the new popup immediately.
openPopup = !(this.popup.breadcrumbId === breadcrumb.id);
}
// There is a popup open. If the popup is the popup that belongs to the currently clicked breadcrumb
// just close the popup. If another breadcrumb was clicked, open the new popup immediately.
openPopup = this.popup.breadcrumbId !== breadcrumb.id;
} else {
this.popup = undefined;
}
if (openPopup) {
if (event.nativeEvent.target && event.nativeEvent.target instanceof HTMLElement) {
const breadcrumbsHtmlElement = BreadcrumbsRenderer.findParentBreadcrumbsHtmlElement(event.nativeEvent.target as HTMLElement);
if (breadcrumbsHtmlElement && breadcrumbsHtmlElement.parentElement && breadcrumbsHtmlElement.parentElement.lastElementChild) {
const position: { x: number, y: number } = BreadcrumbsRenderer.determinePopupAnchor(event.nativeEvent) || event.nativeEvent;
const position: Coordinate = BreadcrumbsRenderer.determinePopupAnchor(event.nativeEvent) || event.nativeEvent;
this.breadcrumbsService.openPopup(breadcrumb, position).then(popup => { this.popup = popup; });
}
}
}
}
};
}

export namespace BreadcrumbsRenderer {
Expand Down Expand Up @@ -159,7 +200,7 @@ export namespace BreadcrumbsRenderer {
if (child.classList.contains(cssClass)) {
return child;
} else {
if (child.parentElement !== null) {
if (child.parentElement) {
return findParentHtmlElement(child.parentElement, cssClass);
}
}
Expand All @@ -171,21 +212,22 @@ export namespace BreadcrumbsRenderer {
* It finds the parent HTML element with CSS class `Breadcrumbs.Styles.BREADCRUMB_ITEM` of event's target element
* and return the bottom left corner of this element.
*/
export function determinePopupAnchor(event: MouseEvent): { x: number, y: number } | undefined {
if (event.target === null || !(event.target instanceof HTMLElement)) {
export function determinePopupAnchor(event: MouseEvent): Coordinate | undefined {
if (!(event.target instanceof HTMLElement)) {
return undefined;
}
const itemHtmlElement = findParentItemHtmlElement(event.target);
if (itemHtmlElement) {
const { left, bottom } = itemHtmlElement.getBoundingClientRect();
return {
x: itemHtmlElement.getBoundingClientRect().left,
y: itemHtmlElement.getBoundingClientRect().bottom
x: left,
y: bottom,
};
}
}
}

export const BreadcrumbsRendererFactory = Symbol('BreadcrumbsRendererFactory');
export interface BreadcrumbsRendererFactory {
(uri: URI): BreadcrumbsRenderer;
(): BreadcrumbsRenderer;
}
Loading

0 comments on commit 1242a57

Please sign in to comment.