From 8f9d225c61b1d325ae555e285d4c4f76e553d2cf Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Sun, 28 Jan 2024 14:23:22 +0000 Subject: [PATCH] feat: window popout enhancements --- .../dockview/dockviewComponent.spec.ts | 16 ++ .../dockview-core/src/api/component.api.ts | 4 +- .../src/api/dockviewGroupPanelApi.ts | 13 +- .../dockview-core/src/api/dockviewPanelApi.ts | 11 +- .../src/dockview/dockviewComponent.ts | 167 +++++++++++------- .../src/dockview/dockviewPopoutGroupPanel.ts | 18 +- packages/dockview-core/src/popoutWindow.ts | 61 ++++--- packages/docs/package.json | 1 + .../popoutgroup-dockview/src/app.tsx | 39 ++-- .../popoutgroup-dockview/src/popover.tsx | 14 +- 10 files changed, 190 insertions(+), 154 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 15e38dd61..af7b18617 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -111,6 +111,13 @@ describe('dockviewComponent', () => { }); describe('memory leakage', () => { + beforeEach(() => { + window.open = () => fromPartial({ + addEventListener: jest.fn(), + close: jest.fn(), + }); + }); + test('event leakage', () => { Emitter.setLeakageMonitorEnabled(true); @@ -4415,6 +4422,15 @@ describe('dockviewComponent', () => { }); describe('popout group', () => { + beforeEach(() => { + jest.spyOn(window, 'open').mockReturnValue( + fromPartial({ + addEventListener: jest.fn(), + close: jest.fn(), + }) + ); + }); + test('that can remove a popout group', () => { const container = document.createElement('div'); diff --git a/packages/dockview-core/src/api/component.api.ts b/packages/dockview-core/src/api/component.api.ts index f57fa9fa4..49d82f98f 100644 --- a/packages/dockview-core/src/api/component.api.ts +++ b/packages/dockview-core/src/api/component.api.ts @@ -833,7 +833,7 @@ export class DockviewApi implements CommonApi { onDidOpen?: (event: { id: string; window: Window }) => void; onWillClose?: (event: { id: string; window: Window }) => void; } - ): void { - this.component.addPopoutGroup(item, options); + ): Promise { + return this.component.addPopoutGroup(item, options); } } diff --git a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts index 1a999301d..a5a8bb371 100644 --- a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts @@ -9,12 +9,9 @@ export interface DockviewGroupPanelApi extends GridviewPanelApi { readonly onDidLocationChange: Event; readonly location: DockviewGroupLocation; /** - * - * If you require the documents Window object you can call `document.defaultView`. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView + * If you require the Window object */ - getDocument(): Document; + getWindow(): Window; moveTo(options: { group?: DockviewGroupPanel; position?: Position }): void; maximize(): void; isMaximized(): boolean; @@ -49,10 +46,10 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { this.addDisposables(this._onDidLocationChange); } - getDocument(): Document { + getWindow(): Window { return this.location.type === 'popout' - ? this.location.getWindow().document - : window.document; + ? this.location.getWindow() + : window; } moveTo(options: { group?: DockviewGroupPanel; position?: Position }): void { diff --git a/packages/dockview-core/src/api/dockviewPanelApi.ts b/packages/dockview-core/src/api/dockviewPanelApi.ts index dffca9297..66772bd1d 100644 --- a/packages/dockview-core/src/api/dockviewPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewPanelApi.ts @@ -44,12 +44,9 @@ export interface DockviewPanelApi isMaximized(): boolean; exitMaximized(): void; /** - * - * If you require the documents Window object you can call `document.defaultView`. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView + * If you require the Window object */ - getDocument(): Document; + getWindow(): Window; } export class DockviewPanelApiImpl @@ -145,8 +142,8 @@ export class DockviewPanelApiImpl ); } - getDocument(): Document { - return this.group.api.getDocument(); + getWindow(): Window { + return this.group.api.getWindow(); } moveTo(options: { diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index e808bdb79..0f6ef22c0 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -12,7 +12,7 @@ import { } from '../dnd/droptarget'; import { tail, sequenceEquals, remove } from '../array'; import { DockviewPanel, IDockviewPanel } from './dockviewPanel'; -import { CompositeDisposable, Disposable } from '../lifecycle'; +import { CompositeDisposable, Disposable, IDisposable } from '../lifecycle'; import { Event, Emitter } from '../events'; import { Watermark } from './components/watermark/watermark'; import { @@ -74,7 +74,7 @@ const DEFAULT_ROOT_OVERLAY_MODEL: DroptargetOverlayModel = { size: { type: 'pixels', value: 20 }, }; -function getTheme(element: HTMLElement): string | undefined { +function getDockviewTheme(element: HTMLElement): string | undefined { function toClassList(element: HTMLElement) { const list: string[] = []; @@ -290,7 +290,7 @@ export interface IDockviewComponent extends IBaseGrid { onDidOpen?: (event: { id: string; window: Window }) => void; onWillClose?: (event: { id: string; window: Window }) => void; } - ): void; + ): Promise; } export class DockviewComponent @@ -332,7 +332,11 @@ export class DockviewComponent this._onDidActivePanelChange.event; private readonly _floatingGroups: DockviewFloatingGroupPanel[] = []; - private readonly _popoutGroups: DockviewPopoutGroupPanel[] = []; + private readonly _popoutGroups: { + window: PopoutWindow; + group: DockviewGroupPanel; + disposable: IDisposable; + }[] = []; private readonly _rootDropTarget: Droptarget; get orientation(): Orientation { @@ -413,7 +417,7 @@ export class DockviewComponent // iterate over a copy of the array since .dispose() mutates the original array for (const group of [...this._popoutGroups]) { - group.dispose(); + group.disposable.dispose(); } }) ); @@ -510,7 +514,7 @@ export class DockviewComponent this.updateWatermark(); } - addPopoutGroup( + async addPopoutGroup( item: DockviewPanel | DockviewGroupPanel, options?: { skipRemoveGroup?: boolean; @@ -519,72 +523,108 @@ export class DockviewComponent onDidOpen?: (event: { id: string; window: Window }) => void; onWillClose?: (event: { id: string; window: Window }) => void; } - ): void { - let group: DockviewGroupPanel; - let box: Box | undefined = options?.position; + ): Promise { + const theme = getDockviewTheme(this.gridview.element); - if (item instanceof DockviewPanel) { - group = this.createGroup(); - - this.removePanel(item, { - removeEmptyGroup: true, - skipDispose: true, - }); - - group.model.openPanel(item); - - if (!box) { - box = this.element.getBoundingClientRect(); - } - } else { - group = item; - - if (!box) { - box = group.element.getBoundingClientRect(); + const getBox: () => Box = () => { + if (options?.position) { + return options.position; } - const skip = - typeof options?.skipRemoveGroup === 'boolean' && - options.skipRemoveGroup; - - if (!skip) { - this.doRemoveGroup(item, { skipDispose: true }); + if (item instanceof DockviewGroupPanel) { + return item.element.getBoundingClientRect(); } - } - const theme = getTheme(this.gridview.element); + if (item.group) { + return item.group.element.getBoundingClientRect(); + } + return this.element.getBoundingClientRect(); + }; - const popoutWindow = new DockviewPopoutGroupPanel( - `${this.id}-${group.id}`, // globally unique within dockview - group, + const box: Box = getBox(); + + const groupId = + item instanceof DockviewGroupPanel + ? item.id + : this.getNextGroupId(); + + const _window = new PopoutWindow( + `${this.id}-${groupId}`, // globally unique within dockview + theme ?? '', { - className: theme ?? '', - popoutUrl: options?.popoutUrl ?? '/popout.html', - box: { - left: window.screenX + box.left, - top: window.screenY + box.top, - width: box.width, - height: box.height, - }, + url: options?.popoutUrl ?? '/popout.html', + left: window.screenX + box.left, + top: window.screenY + box.top, + width: box.width, + height: box.height, onDidOpen: options?.onDidOpen, onWillClose: options?.onWillClose, } ); - popoutWindow.addDisposables( - { - dispose: () => { - remove(this._popoutGroups, popoutWindow); - this.updateWatermark(); - }, - }, - popoutWindow.window.onDidClose(() => { - this.doAddGroup(group, [0]); + const disposables = new CompositeDisposable( + _window, + _window.onDidClose(() => { + disposables.dispose(); }) ); - this._popoutGroups.push(popoutWindow); - this.updateWatermark(); + const popoutContainer = await _window.open(); + + if (popoutContainer) { + let group: DockviewGroupPanel; + + if (item instanceof DockviewPanel) { + group = this.createGroup({ id: groupId }); + + this.removePanel(item, { + removeEmptyGroup: true, + skipDispose: true, + }); + + group.model.openPanel(item); + } else { + group = item; + + const skip = + typeof options?.skipRemoveGroup === 'boolean' && + options.skipRemoveGroup; + + if (!skip) { + this.doRemoveGroup(item, { skipDispose: true }); + } + } + + popoutContainer.appendChild(group.element); + + group.model.location = { + type: 'popout', + getWindow: () => _window.window!, + }; + + const value = { window: _window, group, disposable: disposables }; + + disposables.addDisposables( + { + dispose: () => { + group.model.location = { type: 'grid' }; + + remove(this._popoutGroups, value); + this.updateWatermark(); + }, + }, + _window.onDidClose(() => { + this.doAddGroup(group, [0]); + }) + ); + + this._popoutGroups.push(value); + this.updateWatermark(); + return true; + } else { + disposables.dispose(); + return false; + } } addFloatingGroup( @@ -1428,7 +1468,7 @@ export class DockviewComponent this._onDidRemoveGroup.fire(group); } - selectedGroup.dispose(); + selectedGroup.disposable.dispose(); if (!options?.skipActive && this._activeGroup === group) { const groups = Array.from(this._groups.values()); @@ -1595,7 +1635,7 @@ export class DockviewComponent if (!selectedPopoutGroup) { throw new Error('failed to find popout group'); } - selectedPopoutGroup.dispose(); + selectedPopoutGroup.disposable.dispose(); } } @@ -1629,6 +1669,15 @@ export class DockviewComponent } } + private getNextGroupId(): string { + let id = this.nextGroupId.next(); + while (this._groups.has(id)) { + id = this.nextGroupId.next(); + } + + return id; + } + createGroup(options?: GroupOptions): DockviewGroupPanel { if (!options) { options = {}; diff --git a/packages/dockview-core/src/dockview/dockviewPopoutGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewPopoutGroupPanel.ts index 858178280..3116b56d9 100644 --- a/packages/dockview-core/src/dockview/dockviewPopoutGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewPopoutGroupPanel.ts @@ -1,14 +1,12 @@ import { CompositeDisposable } from '../lifecycle'; import { PopoutWindow } from '../popoutWindow'; import { Box } from '../types'; -import { DockviewGroupPanel } from './dockviewGroupPanel'; export class DockviewPopoutGroupPanel extends CompositeDisposable { readonly window: PopoutWindow; constructor( readonly id: string, - readonly group: DockviewGroupPanel, private readonly options: { className: string; popoutUrl: string; @@ -29,23 +27,17 @@ export class DockviewPopoutGroupPanel extends CompositeDisposable { onWillClose: this.options.onWillClose, }); - group.model.location = { - type: 'popout', - getWindow: () => this.window.window!, - }; - this.addDisposables( this.window, - { - dispose: () => { - group.model.location = { type: 'grid' }; - }, - }, this.window.onDidClose(() => { this.dispose(); }) ); + } - this.window.open(group.element); + open(): Promise { + const didOpen = this.window.open(); + + return didOpen; } } diff --git a/packages/dockview-core/src/popoutWindow.ts b/packages/dockview-core/src/popoutWindow.ts index 3f1531cac..33d3a1434 100644 --- a/packages/dockview-core/src/popoutWindow.ts +++ b/packages/dockview-core/src/popoutWindow.ts @@ -66,7 +66,7 @@ export class PopoutWindow extends CompositeDisposable { } } - open(content: HTMLElement): void { + async open(): Promise { if (this._window) { throw new Error('instance of popout window is already open'); } @@ -88,9 +88,13 @@ export class PopoutWindow extends CompositeDisposable { const externalWindow = window.open(url, this.target, features); if (!externalWindow) { - return; + /** + * Popup blocked + */ + return null; } + const disposable = new CompositeDisposable(); this._window = { value: externalWindow, disposable }; @@ -104,36 +108,41 @@ export class PopoutWindow extends CompositeDisposable { * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event */ this.close(); - }) - ); - - externalWindow.addEventListener('load', () => { - /** - * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event - */ - - const externalDocument = externalWindow.document; - externalDocument.title = document.title; - - const container = this.createPopoutWindowContainer(); - container.classList.add(this.className); - container.appendChild(content); - - // externalDocument.body.replaceChildren(container); - externalDocument.body.appendChild(container); - externalDocument.body.classList.add(this.className); - - addStyles(externalDocument, window.document.styleSheets); - - externalWindow.addEventListener('beforeunload', () => { + }), + addDisposableWindowListener(externalWindow, 'beforeunload', () => { /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event */ this.close(); - }); + }) + ); + + const container = this.createPopoutWindowContainer(); + container.classList.add(this.className); + + this.options.onDidOpen?.({ + id: this.target, + window: externalWindow, }); - this.options.onDidOpen?.({ id: this.target, window: externalWindow }); + return new Promise((resolve) => { + externalWindow.addEventListener('load', () => { + /** + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event + */ + + const externalDocument = externalWindow.document; + externalDocument.title = document.title; + + // externalDocument.body.replaceChildren(container); + externalDocument.body.appendChild(container); + externalDocument.body.classList.add(this.className); + + addStyles(externalDocument, window.document.styleSheets); + + resolve(container); + }); + }); } private createPopoutWindowContainer(): HTMLElement { diff --git a/packages/docs/package.json b/packages/docs/package.json index efc9d4128..6f40f715e 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -34,6 +34,7 @@ "dockview": "^1.9.2", "prism-react-renderer": "^2.3.1", "react-dnd": "^16.0.1", + "react-laag": "^2.0.5", "recoil": "^0.7.7", "source-map-loader": "^4.0.2", "uuid": "^9.0.1" diff --git a/packages/docs/sandboxes/popoutgroup-dockview/src/app.tsx b/packages/docs/sandboxes/popoutgroup-dockview/src/app.tsx index 57fe7e8be..fbca7560d 100644 --- a/packages/docs/sandboxes/popoutgroup-dockview/src/app.tsx +++ b/packages/docs/sandboxes/popoutgroup-dockview/src/app.tsx @@ -6,49 +6,30 @@ import { IDockviewPanelProps, SerializedDockview, DockviewPanelApi, - DockviewGroupLocation, } from 'dockview'; import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import { Icon } from './utils'; import { PopoverMenu } from './popover'; -function usePopoutWindowContext(api: DockviewPanelApi): Window { - const [location, setLocation] = React.useState( - api.location - ); +function usePanelWindowObject(api: DockviewPanelApi): Window { + const [document, setDocument] = React.useState(api.getWindow()); React.useEffect(() => { const disposable = api.onDidLocationChange((event) => { - setLocation(event.location); + setDocument(api.getWindow()); }); return () => { disposable.dispose(); }; - }); + }, [api]); - const windowContext = React.useMemo(() => { - if (location.type === 'popout') { - return location.getWindow(); - } - return window; - }, [location]); - - return windowContext; + return document; } const components = { default: (props: IDockviewPanelProps<{ title: string }>) => { - const windowContext = usePopoutWindowContext(props.api); - - React.useEffect(() => { - setTimeout(() => { - const a = windowContext.document.createElement('div'); - a.className = 'aaa'; - windowContext.document.body.appendChild(a); - }, 5000); - }, [windowContext]); + const _window = usePanelWindowObject(props.api); const [reset, setReset] = React.useState(false); @@ -62,7 +43,7 @@ const components = { > - {!reset && } + {!reset && } {props.api.title} ); @@ -258,12 +239,12 @@ const LeftComponent = (props: IDockviewHeaderActionsProps) => { const RightComponent = (props: IDockviewHeaderActionsProps) => { const [popout, setPopout] = React.useState( - props.api.location === 'popout' + props.api.location.type === 'popout' ); React.useEffect(() => { const disposable = props.group.api.onDidLocationChange((event) => [ - setPopout(event.location === 'popout'), + setPopout(event.location.type === 'popout'), ]); return () => { diff --git a/packages/docs/sandboxes/popoutgroup-dockview/src/popover.tsx b/packages/docs/sandboxes/popoutgroup-dockview/src/popover.tsx index 3b0b8d1f4..9d9663443 100644 --- a/packages/docs/sandboxes/popoutgroup-dockview/src/popover.tsx +++ b/packages/docs/sandboxes/popoutgroup-dockview/src/popover.tsx @@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import * as React from 'react'; import { DockviewPanelApi } from 'dockview'; -export function PopoverMenu(props: { api: DockviewPanelApi }) { +export function PopoverMenu(props: { window: Window }) { const [isOpen, setOpen] = React.useState(false); // helper function to close the menu @@ -11,11 +11,6 @@ export function PopoverMenu(props: { api: DockviewPanelApi }) { setOpen(false); } - const _window = - props.api.location.type === 'popout' - ? props.api.location.getWindow() - : undefined; - const { renderLayer, triggerProps, layerProps, arrowProps } = useLayer({ isOpen, onOutsideClick: close, // close the menu when the user clicks outside @@ -26,15 +21,14 @@ export function PopoverMenu(props: { api: DockviewPanelApi }) { triggerOffset: 12, // keep some distance to the trigger containerOffset: 16, // give the menu some room to breath relative to the container arrowOffset: 16, // let the arrow have some room to breath also, - environment: _window, - container: _window + environment: props.window, + container: props.window ? () => { - const el = _window.document.body; + const el = props.window.document.body; Object.setPrototypeOf(el, HTMLElement.prototype); return el; } : undefined, - // container: props.window.document.body }); // Again, we're using framer-motion for the transition effect