Merge pull request #421 from mathuo/397-gready-rendering-mode

feat: gready rendering
This commit is contained in:
mathuo 2024-01-04 19:53:32 +00:00 committed by GitHub
commit 4b616c5578
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 342 additions and 25 deletions

View File

@ -1,5 +1,14 @@
import * as React from 'react';
/**
* useful utility type to erase readonly signatures for testing purposes
*
* @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#readonly-mapped-type-modifiers-and-readonly-arrays
*/
export type Writable<T> = T extends object
? { -readonly [K in keyof T]: Writable<T[K]> }
: T;
export function setMockRefElement(node: Partial<HTMLElement>): void {
const mockRef = {
get current() {
@ -25,3 +34,13 @@ export function createOffsetDragOverEvent(params: {
Object.defineProperty(event, 'clientY', { get: () => params.clientY });
return event;
}
/**
* `jest.runAllTicks` doesn't seem to exhaust all events in the micro-task queue so
* as a **hacky** alternative we'll wait for an empty Promise to complete which runs
* on the micro-task queue so will force a run-to-completion emptying the queue
* of any pending micro-task
*/
export function exhaustMicrotaskQueue(): Promise<void> {
return new Promise<void>((resolve) => resolve());
}

View File

@ -0,0 +1,145 @@
import { Droptarget } from '../dnd/droptarget';
import { IDockviewPanel } from '../dockview/dockviewPanel';
import { Emitter } from '../events';
import { IRenderable, OverlayRenderContainer } from '../overlayRenderContainer';
import { fromPartial } from '@total-typescript/shoehorn';
import { Writable, exhaustMicrotaskQueue } from './__test_utils__/utils';
describe('overlayRenderContainer', () => {
test('add a view that is not currently in the DOM', async () => {
const parentContainer = document.createElement('div');
const cut = new OverlayRenderContainer(parentContainer);
const panelContentEl = document.createElement('div');
const onDidVisibilityChange = new Emitter<any>();
const onDidDimensionsChange = new Emitter<any>();
const panel = fromPartial<IDockviewPanel>({
api: {
id: 'test_panel_id',
onDidVisibilityChange: onDidVisibilityChange.event,
onDidDimensionsChange: onDidDimensionsChange.event,
isVisible: true,
},
view: {
content: {
element: panelContentEl,
},
},
group: {
api: {
location: 'grid',
},
},
});
const dropTarget = fromPartial<Droptarget>({});
const refContainerEl = document.createElement('div');
const referenceContainer: IRenderable = {
element: refContainerEl,
dropTarget,
};
(parentContainer as jest.Mocked<HTMLDivElement>).getBoundingClientRect =
jest
.fn<DOMRect, []>()
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 100,
top: 200,
width: 1000,
height: 500,
})
)
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 101,
top: 201,
width: 1000,
height: 500,
})
)
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 100,
top: 200,
width: 1000,
height: 500,
})
);
(refContainerEl as jest.Mocked<HTMLDivElement>).getBoundingClientRect =
jest
.fn<DOMRect, []>()
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 150,
top: 300,
width: 100,
height: 200,
})
)
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 150,
top: 300,
width: 101,
height: 201,
})
)
.mockReturnValueOnce(
fromPartial<DOMRect>({
left: 150,
top: 300,
width: 100,
height: 200,
})
);
const container = cut.setReferenceContentContainer(
panel,
referenceContainer
);
await exhaustMicrotaskQueue();
expect(panelContentEl.parentElement).toBe(container);
expect(container.parentElement).toBe(parentContainer);
expect(container.style.display).toBe('');
expect(container.style.left).toBe('50px');
expect(container.style.top).toBe('100px');
expect(container.style.width).toBe('100px');
expect(container.style.height).toBe('200px');
expect(refContainerEl.getBoundingClientRect).toHaveBeenCalledTimes(1);
onDidDimensionsChange.fire({});
expect(container.style.display).toBe('');
expect(container.style.left).toBe('49px');
expect(container.style.top).toBe('99px');
expect(container.style.width).toBe('101px');
expect(container.style.height).toBe('201px');
expect(refContainerEl.getBoundingClientRect).toHaveBeenCalledTimes(2);
(panel as Writable<IDockviewPanel>).api.isVisible = false;
onDidVisibilityChange.fire({});
expect(container.style.display).toBe('none');
expect(refContainerEl.getBoundingClientRect).toHaveBeenCalledTimes(2);
(panel as Writable<IDockviewPanel>).api.isVisible = true;
onDidVisibilityChange.fire({});
expect(container.style.display).toBe('');
expect(container.style.left).toBe('50px');
expect(container.style.top).toBe('100px');
expect(container.style.width).toBe('100px');
expect(container.style.height).toBe('200px');
expect(refContainerEl.getBoundingClientRect).toHaveBeenCalledTimes(3);
});
});

View File

@ -5,7 +5,7 @@ import { MutableDisposable } from '../lifecycle';
import { DockviewPanel } from '../dockview/dockviewPanel';
import { DockviewComponent } from '../dockview/dockviewComponent';
import { Position } from '../dnd/droptarget';
import { DockviewPanelRenderer } from '../dockview/components/greadyRenderContainer';
import { DockviewPanelRenderer } from '../overlayRenderContainer';
export interface TitleEvent {
readonly title: string;

View File

@ -56,12 +56,6 @@ export class ContentContainer
this.addDisposables(this._onDidFocus, this._onDidBlur);
// for hosted containers
// 1) register a drop target on the host
// 2) register window dragStart events to disable pointer events
// 3) register dragEnd events
// 4) register mouseMove events (if no buttons are present we take this as a dragEnd event)
this.dropTarget = new Droptarget(this.element, {
acceptedTargetZones: ['top', 'bottom', 'left', 'right', 'center'],
canDisplayOverlay: (event, position) => {
@ -127,7 +121,7 @@ export class ContentContainer
switch (panel.api.renderer) {
case 'onlyWhenVisibile':
this.accessor.greadyRenderContainer.remove(panel);
this.accessor.overlayRenderContainer.remove(panel);
if (isActive) {
if (this.panel) {
this._element.appendChild(
@ -144,7 +138,7 @@ export class ContentContainer
this._element.removeChild(panel.view.content.element);
}
container =
this.accessor.greadyRenderContainer.setReferenceContentContainer(
this.accessor.overlayRenderContainer.setReferenceContentContainer(
panel,
this
);
@ -203,7 +197,7 @@ export class ContentContainer
switch (renderer) {
case 'always':
container =
this.accessor.greadyRenderContainer.setReferenceContentContainer(
this.accessor.overlayRenderContainer.setReferenceContentContainer(
panel,
this
);

View File

@ -11,7 +11,7 @@
z-index: 1;
}
.dv-gready-render-container {
.dv-overlay-render-container {
position: relative;
}
}

View File

@ -53,15 +53,12 @@ import {
TabDragEvent,
} from './components/titlebar/tabsContainer';
import { Box } from '../types';
import {
GreadyRenderContainer,
DockviewPanelRenderer,
} from './components/greadyRenderContainer';
import { DockviewPopoutGroupPanel } from './dockviewPopoutGroupPanel';
import {
DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE,
DEFAULT_FLOATING_GROUP_POSITION,
} from '../constants';
import { DockviewPanelRenderer, OverlayRenderContainer } from '../overlayRenderContainer';
function getTheme(element: HTMLElement): string | undefined {
function toClassList(element: HTMLElement) {
@ -289,7 +286,7 @@ export class DockviewComponent
private _options: Exclude<DockviewComponentOptions, 'orientation'>;
private watermark: IWatermarkRenderer | null = null;
readonly greadyRenderContainer: GreadyRenderContainer;
readonly overlayRenderContainer: OverlayRenderContainer;
private readonly _onWillDragPanel = new Emitter<TabDragEvent>();
readonly onWillDragPanel: Event<TabDragEvent> = this._onWillDragPanel.event;
@ -360,16 +357,16 @@ export class DockviewComponent
});
const gready = document.createElement('div');
gready.className = 'dv-gready-render-container';
gready.className = 'dv-overlay-render-container';
this.gridview.element.appendChild(gready);
this.greadyRenderContainer = new GreadyRenderContainer(gready);
this.overlayRenderContainer = new OverlayRenderContainer(gready);
toggleClass(this.gridview.element, 'dv-dockview', true);
toggleClass(this.element, 'dv-debug', !!options.debug);
this.addDisposables(
this.greadyRenderContainer,
this.overlayRenderContainer,
this._onWillDragPanel,
this._onWillDragGroup,
this._onDidActivePanelChange,
@ -1200,7 +1197,7 @@ export class DockviewComponent
group.model.removePanel(panel);
if (!options.skipDispose) {
this.greadyRenderContainer.remove(panel);
this.overlayRenderContainer.remove(panel);
panel.dispose();
}

View File

@ -9,7 +9,7 @@ import { CompositeDisposable, IDisposable } from '../lifecycle';
import { IPanel, PanelUpdateEvent, Parameters } from '../panel/types';
import { IDockviewPanelModel } from './dockviewPanelModel';
import { DockviewComponent } from './dockviewComponent';
import { DockviewPanelRenderer } from './components/greadyRenderContainer';
import { DockviewPanelRenderer } from '../overlayRenderContainer';
export interface IDockviewPanel extends IDisposable, IPanel {
readonly view: IDockviewPanelModel;

View File

@ -20,7 +20,7 @@ import {
FrameworkFactory,
} from '../panel/componentFactory';
import { DockviewGroupPanelApi } from '../api/dockviewGroupPanelApi';
import { DockviewPanelRenderer } from './components/greadyRenderContainer';
import { DockviewPanelRenderer } from '../overlayRenderContainer';
export interface IHeaderActionsRenderer extends IDisposable {
readonly element: HTMLElement;

View File

@ -5,7 +5,7 @@ import { DockviewApi } from '../api/component.api';
import { Event } from '../events';
import { Optional } from '../types';
import { DockviewGroupPanel, IDockviewGroupPanel } from './dockviewGroupPanel';
import { DockviewPanelRenderer } from './components/greadyRenderContainer';
import { DockviewPanelRenderer } from '../overlayRenderContainer';
export enum DockviewDropTargets {
Tab,

View File

@ -49,7 +49,7 @@ export * from './splitview/splitviewPanel';
export * from './paneview/paneviewPanel';
export * from './dockview/types';
export { DockviewPanelRenderer } from './dockview/components/greadyRenderContainer';
export { DockviewPanelRenderer } from './overlayRenderContainer';
export {
Position,

View File

@ -0,0 +1,162 @@
import { DragAndDropObserver } from './dnd/dnd';
import { Droptarget } from './dnd/droptarget';
import { getDomNodePagePosition, toggleClass } from './dom';
import { CompositeDisposable, Disposable, IDisposable } from './lifecycle';
import { IDockviewPanel } from './dockview/dockviewPanel';
export type DockviewPanelRenderer = 'onlyWhenVisibile' | 'always';
export interface IRenderable {
readonly element: HTMLElement;
readonly dropTarget: Droptarget;
}
function createFocusableElement(): HTMLDivElement {
const element = document.createElement('div');
element.tabIndex = -1;
return element;
}
export class OverlayRenderContainer extends CompositeDisposable {
private readonly map: Record<
string,
{ disposable: IDisposable; element: HTMLElement }
> = {};
get allIds(): string[] {
return Object.keys(this.map);
}
constructor(private readonly element: HTMLElement) {
super();
this.addDisposables({
dispose: () => {
for (const value of Object.values(this.map)) {
value.disposable.dispose();
}
},
});
}
remove(panel: IDockviewPanel): boolean {
if (this.map[panel.api.id]) {
this.map[panel.api.id].disposable.dispose();
delete this.map[panel.api.id];
return true;
}
return false;
}
setReferenceContentContainer(
panel: IDockviewPanel,
referenceContainer: IRenderable
): HTMLElement {
if (!this.map[panel.api.id]) {
const element = createFocusableElement();
element.className = 'dv-render-overlay';
this.map[panel.api.id] = {
disposable: Disposable.NONE,
element,
};
}
this.map[panel.api.id]?.disposable.dispose();
const focusContainer = this.map[panel.api.id].element;
if (panel.view.content.element.parentElement !== focusContainer) {
focusContainer.appendChild(panel.view.content.element);
}
if (focusContainer.parentElement !== this.element) {
this.element.appendChild(focusContainer);
}
const resize = () => {
// TODO propagate position to avoid getDomNodePagePosition calls, possible performance bottleneck?
const box = getDomNodePagePosition(referenceContainer.element);
const box2 = getDomNodePagePosition(this.element);
focusContainer.style.left = `${box.left - box2.left}px`;
focusContainer.style.top = `${box.top - box2.top}px`;
focusContainer.style.width = `${box.width}px`;
focusContainer.style.height = `${box.height}px`;
toggleClass(
focusContainer,
'dv-render-overlay-float',
panel.group.api.location === 'floating'
);
};
const visibilityChanged = () => {
if (panel.api.isVisible) {
resize();
}
focusContainer.style.display = panel.api.isVisible ? '' : 'none';
};
const disposable = new CompositeDisposable(
/**
* since container is positioned absoutely we must explicitly forward
* the dnd events for the expect behaviours to continue to occur in terms of dnd
*/
new DragAndDropObserver(focusContainer, {
onDragEnd: (e) => {
referenceContainer.dropTarget.dnd.onDragEnd(e);
},
onDragEnter: (e) => {
referenceContainer.dropTarget.dnd.onDragEnter(e);
},
onDragLeave: (e) => {
referenceContainer.dropTarget.dnd.onDragLeave(e);
},
onDrop: (e) => {
referenceContainer.dropTarget.dnd.onDrop(e);
},
onDragOver: (e) => {
referenceContainer.dropTarget.dnd.onDragOver(e);
},
}),
panel.api.onDidVisibilityChange((event) => {
/**
* Control the visibility of the content, however even when not visible (display: none)
* the content is still maintained within the DOM hence DOM specific attributes
* such as scroll position are maintained when next made visible.
*/
visibilityChanged();
}),
panel.api.onDidDimensionsChange(() => {
if (!panel.api.isVisible) {
return;
}
resize();
}),
{
dispose: () => {
focusContainer.removeChild(panel.view.content.element);
this.element.removeChild(focusContainer);
},
}
);
queueMicrotask(() => {
if (this.isDisposed) {
return;
}
/**
* wait until everything has finished in the current stack-frame call before
* calling the first resize as other size-altering events may still occur before
* the end of the stack-frame.
*/
visibilityChanged();
});
this.map[panel.api.id].disposable = disposable;
return focusContainer;
}
}