From 9b1c366ce5d4630e7f0b6e75f867b2850ac98d37 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:25:35 +0000 Subject: [PATCH 1/2] feat: gready rendering --- .../__tests__/overlayRenderContainer.spec.ts | 8 + .../dockview-core/src/api/dockviewPanelApi.ts | 2 +- .../src/dockview/components/panel/content.ts | 12 +- .../src/dockview/dockviewComponent.scss | 2 +- .../src/dockview/dockviewComponent.ts | 21 ++- .../src/dockview/dockviewPanel.ts | 2 +- .../dockview-core/src/dockview/options.ts | 2 +- packages/dockview-core/src/dockview/types.ts | 2 +- packages/dockview-core/src/index.ts | 2 +- ...tainer.scss => overlayReadyContainer.scss} | 0 .../src/overlayRenderContainer.ts | 150 ++++++++++++++++++ 11 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts rename packages/dockview-core/src/{dockview/components/greadyReadyContainer.scss => overlayReadyContainer.scss} (100%) create mode 100644 packages/dockview-core/src/overlayRenderContainer.ts diff --git a/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts b/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts new file mode 100644 index 000000000..d366f83c8 --- /dev/null +++ b/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts @@ -0,0 +1,8 @@ +import { OverlayRenderContainer } from '../overlayRenderContainer'; + +describe('overlayRenderContainer', () => { + test('abc', () => { + const el = document.createElement('div'); + const cut = new OverlayRenderContainer(el); + }); +}); diff --git a/packages/dockview-core/src/api/dockviewPanelApi.ts b/packages/dockview-core/src/api/dockviewPanelApi.ts index b6eb47706..89bad6ee3 100644 --- a/packages/dockview-core/src/api/dockviewPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewPanelApi.ts @@ -5,7 +5,7 @@ import { MutableDisposable } from '../lifecycle'; import { DockviewPanel, IDockviewPanel } 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; diff --git a/packages/dockview-core/src/dockview/components/panel/content.ts b/packages/dockview-core/src/dockview/components/panel/content.ts index 19f01d1b5..1876ae90e 100644 --- a/packages/dockview-core/src/dockview/components/panel/content.ts +++ b/packages/dockview-core/src/dockview/components/panel/content.ts @@ -58,12 +58,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) => { @@ -125,7 +119,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( @@ -142,7 +136,7 @@ export class ContentContainer this._element.removeChild(panel.view.content.element); } container = - this.accessor.greadyRenderContainer.setReferenceContentContainer( + this.accessor.overlayRenderContainer.setReferenceContentContainer( panel, this ); @@ -201,7 +195,7 @@ export class ContentContainer switch (renderer) { case 'always': container = - this.accessor.greadyRenderContainer.setReferenceContentContainer( + this.accessor.overlayRenderContainer.setReferenceContentContainer( panel, this ); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.scss b/packages/dockview-core/src/dockview/dockviewComponent.scss index 3bcfb7a57..54a0e290d 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -11,7 +11,7 @@ z-index: 1; } - .dv-gready-render-container { + .dv-overlay-render-container { position: relative; } } diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 41325fb3c..5b19ca298 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -56,9 +56,18 @@ import { TabDragEvent, } from './components/titlebar/tabsContainer'; import { - GreadyRenderContainer, + OverlayRenderContainer, DockviewPanelRenderer, +<<<<<<< Updated upstream } from './components/greadyRenderContainer'; +======= +} from '../overlayRenderContainer'; +import { DockviewPopoutGroupPanel } from './dockviewPopoutGroupPanel'; +import { + DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE, + DEFAULT_FLOATING_GROUP_POSITION, +} from '../constants'; +>>>>>>> Stashed changes const DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE = 100; @@ -249,7 +258,7 @@ export class DockviewComponent private _options: Exclude; private watermark: IWatermarkRenderer | null = null; - readonly greadyRenderContainer: GreadyRenderContainer; + readonly overlayRenderContainer: OverlayRenderContainer; private readonly _onWillDragPanel = new Emitter(); readonly onWillDragPanel: Event = this._onWillDragPanel.event; @@ -319,16 +328,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, @@ -1059,7 +1068,7 @@ export class DockviewComponent group.model.removePanel(panel); if (!options.skipDispose) { - this.greadyRenderContainer.remove(panel); + this.overlayRenderContainer.remove(panel); panel.dispose(); } diff --git a/packages/dockview-core/src/dockview/dockviewPanel.ts b/packages/dockview-core/src/dockview/dockviewPanel.ts index da990c58e..8c01b08c6 100644 --- a/packages/dockview-core/src/dockview/dockviewPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewPanel.ts @@ -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; diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 97587f1a6..422d3ac58 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -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; diff --git a/packages/dockview-core/src/dockview/types.ts b/packages/dockview-core/src/dockview/types.ts index 8afab7040..28cbcc557 100644 --- a/packages/dockview-core/src/dockview/types.ts +++ b/packages/dockview-core/src/dockview/types.ts @@ -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, diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index 7e9b1205c..b3fda3fa7 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -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, diff --git a/packages/dockview-core/src/dockview/components/greadyReadyContainer.scss b/packages/dockview-core/src/overlayReadyContainer.scss similarity index 100% rename from packages/dockview-core/src/dockview/components/greadyReadyContainer.scss rename to packages/dockview-core/src/overlayReadyContainer.scss diff --git a/packages/dockview-core/src/overlayRenderContainer.ts b/packages/dockview-core/src/overlayRenderContainer.ts new file mode 100644 index 000000000..2eb0a650a --- /dev/null +++ b/packages/dockview-core/src/overlayRenderContainer.ts @@ -0,0 +1,150 @@ +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 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. + */ + focusContainer.style.display = event.isVisible ? '' : 'none'; + }), + panel.api.onDidDimensionsChange(() => { + 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. + */ + resize(); + }); + + this.map[panel.api.id].disposable = disposable; + + return focusContainer; + } +} From 10256672b4243dc210520c3972e37570c82f0705 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Wed, 3 Jan 2024 19:38:18 +0000 Subject: [PATCH 2/2] test: add tests --- package.json | 2 +- .../src/__tests__/__test_utils__/utils.ts | 19 +++ .../__tests__/overlayRenderContainer.spec.ts | 145 +++++++++++++++++- .../src/overlayRenderContainer.ts | 16 +- 4 files changed, 175 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 1370fa761..ddcaa6a9e 100644 --- a/package.json +++ b/package.json @@ -71,4 +71,4 @@ "engines": { "node": ">=18.0" } -} \ No newline at end of file +} diff --git a/packages/dockview-core/src/__tests__/__test_utils__/utils.ts b/packages/dockview-core/src/__tests__/__test_utils__/utils.ts index 8204d797e..ac4daa88d 100644 --- a/packages/dockview-core/src/__tests__/__test_utils__/utils.ts +++ b/packages/dockview-core/src/__tests__/__test_utils__/utils.ts @@ -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 extends object + ? { -readonly [K in keyof T]: Writable } + : T; + export function setMockRefElement(node: Partial): 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 { + return new Promise((resolve) => resolve()); +} diff --git a/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts b/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts index d366f83c8..9df8e1680 100644 --- a/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts +++ b/packages/dockview-core/src/__tests__/overlayRenderContainer.spec.ts @@ -1,8 +1,145 @@ -import { OverlayRenderContainer } from '../overlayRenderContainer'; +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('abc', () => { - const el = document.createElement('div'); - const cut = new OverlayRenderContainer(el); + 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(); + const onDidDimensionsChange = new Emitter(); + + const panel = fromPartial({ + api: { + id: 'test_panel_id', + onDidVisibilityChange: onDidVisibilityChange.event, + onDidDimensionsChange: onDidDimensionsChange.event, + isVisible: true, + }, + view: { + content: { + element: panelContentEl, + }, + }, + group: { + api: { + location: 'grid', + }, + }, + }); + + const dropTarget = fromPartial({}); + + const refContainerEl = document.createElement('div'); + + const referenceContainer: IRenderable = { + element: refContainerEl, + dropTarget, + }; + + (parentContainer as jest.Mocked).getBoundingClientRect = + jest + .fn() + .mockReturnValueOnce( + fromPartial({ + left: 100, + top: 200, + width: 1000, + height: 500, + }) + ) + .mockReturnValueOnce( + fromPartial({ + left: 101, + top: 201, + width: 1000, + height: 500, + }) + ) + .mockReturnValueOnce( + fromPartial({ + left: 100, + top: 200, + width: 1000, + height: 500, + }) + ); + + (refContainerEl as jest.Mocked).getBoundingClientRect = + jest + .fn() + .mockReturnValueOnce( + fromPartial({ + left: 150, + top: 300, + width: 100, + height: 200, + }) + ) + .mockReturnValueOnce( + fromPartial({ + left: 150, + top: 300, + width: 101, + height: 201, + }) + ) + .mockReturnValueOnce( + fromPartial({ + 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).api.isVisible = false; + onDidVisibilityChange.fire({}); + expect(container.style.display).toBe('none'); + expect(refContainerEl.getBoundingClientRect).toHaveBeenCalledTimes(2); + + (panel as Writable).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); }); }); diff --git a/packages/dockview-core/src/overlayRenderContainer.ts b/packages/dockview-core/src/overlayRenderContainer.ts index 2eb0a650a..cee392cb2 100644 --- a/packages/dockview-core/src/overlayRenderContainer.ts +++ b/packages/dockview-core/src/overlayRenderContainer.ts @@ -89,6 +89,14 @@ export class OverlayRenderContainer extends CompositeDisposable { ); }; + 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 @@ -117,9 +125,13 @@ export class OverlayRenderContainer extends CompositeDisposable { * the content is still maintained within the DOM hence DOM specific attributes * such as scroll position are maintained when next made visible. */ - focusContainer.style.display = event.isVisible ? '' : 'none'; + visibilityChanged(); }), panel.api.onDidDimensionsChange(() => { + if (!panel.api.isVisible) { + return; + } + resize(); }), { @@ -140,7 +152,7 @@ export class OverlayRenderContainer extends CompositeDisposable { * calling the first resize as other size-altering events may still occur before * the end of the stack-frame. */ - resize(); + visibilityChanged(); }); this.map[panel.api.id].disposable = disposable;