feat: window popout enhancements

This commit is contained in:
mathuo 2024-01-28 14:23:22 +00:00
parent 6274708acb
commit 8f9d225c61
No known key found for this signature in database
GPG Key ID: C6EEDEFD6CA07281
10 changed files with 190 additions and 154 deletions

View File

@ -111,6 +111,13 @@ describe('dockviewComponent', () => {
});
describe('memory leakage', () => {
beforeEach(() => {
window.open = () => fromPartial<Window>({
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<Window>({
addEventListener: jest.fn(),
close: jest.fn(),
})
);
});
test('that can remove a popout group', () => {
const container = document.createElement('div');

View File

@ -833,7 +833,7 @@ export class DockviewApi implements CommonApi<SerializedDockview> {
onDidOpen?: (event: { id: string; window: Window }) => void;
onWillClose?: (event: { id: string; window: Window }) => void;
}
): void {
this.component.addPopoutGroup(item, options);
): Promise<boolean> {
return this.component.addPopoutGroup(item, options);
}
}

View File

@ -9,12 +9,9 @@ export interface DockviewGroupPanelApi extends GridviewPanelApi {
readonly onDidLocationChange: Event<DockviewGroupPanelFloatingChangeEvent>;
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 {

View File

@ -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: {

View File

@ -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<DockviewGroupPanel> {
onDidOpen?: (event: { id: string; window: Window }) => void;
onWillClose?: (event: { id: string; window: Window }) => void;
}
): void;
): Promise<boolean>;
}
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<boolean> {
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 = {};

View File

@ -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<HTMLElement | null> {
const didOpen = this.window.open();
return didOpen;
}
}

View File

@ -66,7 +66,7 @@ export class PopoutWindow extends CompositeDisposable {
}
}
open(content: HTMLElement): void {
async open(): Promise<HTMLElement | null> {
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<HTMLElement | null>((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 {

View File

@ -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"

View File

@ -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<DockviewGroupLocation>(
api.location
);
function usePanelWindowObject(api: DockviewPanelApi): Window {
const [document, setDocument] = React.useState<Window>(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<boolean>(false);
@ -62,7 +43,7 @@ const components = {
>
<button
onClick={() => {
console.log(windowContext);
console.log(_window);
setReset(true);
setTimeout(() => {
setReset(false);
@ -71,7 +52,7 @@ const components = {
>
Print
</button>
{!reset && <PopoverMenu api={props.api} />}
{!reset && <PopoverMenu window={_window} />}
{props.api.title}
</div>
);
@ -258,12 +239,12 @@ const LeftComponent = (props: IDockviewHeaderActionsProps) => {
const RightComponent = (props: IDockviewHeaderActionsProps) => {
const [popout, setPopout] = React.useState<boolean>(
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 () => {

View File

@ -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