Skip to content

Commit

Permalink
Backport PR jupyterlab#432: Avoid menu layout trashing by moving DOM …
Browse files Browse the repository at this point in the history
…queries

* Move/cache DOM queries to prevent menu layout trashing

The DOM queries will execute before subsequent DOM modifications
to avoid layout trashing when opening or switching menus.

* Use simpler transient cache, move update requests

(cherry picked from commit 55e87ca)
  • Loading branch information
krassowski committed Oct 10, 2022
1 parent f6f3364 commit e938ee1
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 47 deletions.
5 changes: 4 additions & 1 deletion packages/virtualdom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,7 +1349,10 @@ namespace Private {

// Handle the simplest case of in-place text update first.
if (oldVNode.type === 'text' && newVNode.type === 'text') {
currElem!.textContent = newVNode.content;
// Avoid spurious updates for performance.
if (currElem!.textContent !== newVNode.content) {
currElem!.textContent = newVNode.content;
}
currElem = currElem!.nextSibling;
continue;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/widgets/src/contextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export class ContextMenu {
* position indicated by the event.
*/
open(event: MouseEvent): boolean {
// Prior to any DOM modifications update the window data.
Menu.saveWindowData();

// Clear the current contents of the context menu.
this.menu.clearItems();

Expand Down
106 changes: 78 additions & 28 deletions packages/widgets/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import {

import { Widget } from './widget';

interface IWindowData {
pageXOffset: number;
pageYOffset: number;
clientWidth: number;
clientHeight: number;
}

/**
* A widget which displays items as a canonical menu.
*/
Expand Down Expand Up @@ -829,6 +836,9 @@ export class Menu extends Widget {
return;
}

// Prior to any DOM modifications save window data
Menu.saveWindowData();

// Ensure the current child menu is closed.
this._closeChildMenu();

Expand Down Expand Up @@ -911,6 +921,19 @@ export class Menu extends Widget {
}
}

/**
* Save window data used for menu positioning in transient cache.
*
* In order to avoid layout trashing it is recommended to invoke this
* method immediately prior to opening the menu and any DOM modifications
* (like closing previously visible menu, or adding a class to menu widget).
*
* The transient cache will be released upon `open()` call.
*/
static saveWindowData(): void {
Private.saveWindowData();
}

private _childIndex = -1;
private _activeIndex = -1;
private _openTimerID = 0;
Expand Down Expand Up @@ -1439,6 +1462,32 @@ namespace Private {
*/
export const SUBMENU_OVERLAP = 3;

let transientWindowDataCache: IWindowData | null = null;
let transientCacheCounter: number = 0;

function getWindowData(): IWindowData {
// if transient cache is in use, take one from it
if (transientCacheCounter > 0) {
transientCacheCounter--;
return transientWindowDataCache!;
}
return _getWindowData();
}

/**
* Store window data in transient cache.
*
* The transient cache will be released upon `getWindowData()` call.
* If this function is called multiple times, the cache will be
* retained until as many calls to `getWindowData()` were made.
*
* Note: should be called before any DOM modifications.
*/
export function saveWindowData(): void {
transientWindowDataCache = _getWindowData();
transientCacheCounter++;
}

/**
* Create the DOM node for a menu.
*/
Expand Down Expand Up @@ -1541,6 +1590,15 @@ namespace Private {
return result;
}

function _getWindowData(): IWindowData {
return {
pageXOffset: window.pageXOffset,
pageYOffset: window.pageYOffset,
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight
};
}

/**
* Open a menu as a root menu at the target location.
*/
Expand All @@ -1551,15 +1609,16 @@ namespace Private {
forceX: boolean,
forceY: boolean
): void {
// Get the current position and size of the main viewport.
const windowData = getWindowData();
let px = windowData.pageXOffset;
let py = windowData.pageYOffset;
let cw = windowData.clientWidth;
let ch = windowData.clientHeight;

// Ensure the menu is updated before attaching and measuring.
MessageLoop.sendMessage(menu, Widget.Msg.UpdateRequest);

// Get the current position and size of the main viewport.
let px = window.pageXOffset;
let py = window.pageYOffset;
let cw = document.documentElement.clientWidth;
let ch = document.documentElement.clientHeight;

// Compute the maximum allowed height for the menu.
let maxHeight = ch - (forceY ? y : 0);

Expand All @@ -1568,11 +1627,7 @@ namespace Private {
let style = node.style;

// Clear the menu geometry and prepare it for measuring.
style.top = '';
style.left = '';
style.width = '';
style.height = '';
style.visibility = 'hidden';
style.opacity = '0';
style.maxHeight = `${maxHeight}px`;

// Attach the menu to the document.
Expand All @@ -1596,26 +1651,26 @@ namespace Private {
}

// Update the position of the menu to the computed position.
style.top = `${Math.max(0, y)}px`;
style.left = `${Math.max(0, x)}px`;
style.transform = `translate(${Math.max(0, x)}px, ${Math.max(0, y)}px`;

// Finally, make the menu visible on the screen.
style.visibility = '';
style.opacity = '1';
}

/**
* Open a menu as a submenu using an item node for positioning.
*/
export function openSubmenu(submenu: Menu, itemNode: HTMLElement): void {
// Get the current position and size of the main viewport.
const windowData = getWindowData();
let px = windowData.pageXOffset;
let py = windowData.pageYOffset;
let cw = windowData.clientWidth;
let ch = windowData.clientHeight;

// Ensure the menu is updated before opening.
MessageLoop.sendMessage(submenu, Widget.Msg.UpdateRequest);

// Get the current position and size of the main viewport.
let px = window.pageXOffset;
let py = window.pageYOffset;
let cw = document.documentElement.clientWidth;
let ch = document.documentElement.clientHeight;

// Compute the maximum allowed height for the menu.
let maxHeight = ch;

Expand All @@ -1624,11 +1679,7 @@ namespace Private {
let style = node.style;

// Clear the menu geometry and prepare it for measuring.
style.top = '';
style.left = '';
style.width = '';
style.height = '';
style.visibility = 'hidden';
style.opacity = '0';
style.maxHeight = `${maxHeight}px`;

// Attach the menu to the document.
Expand Down Expand Up @@ -1660,11 +1711,10 @@ namespace Private {
}

// Update the position of the menu to the computed position.
style.top = `${Math.max(0, y)}px`;
style.left = `${Math.max(0, x)}px`;
style.transform = `translate(${Math.max(0, x)}px, ${Math.max(0, y)}px`;

// Finally, make the menu visible on the screen.
style.visibility = '';
style.opacity = '1';
}

/**
Expand Down
68 changes: 58 additions & 10 deletions packages/widgets/src/menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,11 @@ export class MenuBar extends Widget {
this._closeChildMenu();
this.activeIndex = index;
} else {
const position = this._positionForMenu(index);
Menu.saveWindowData();
// Begin DOM modifications.
this.activeIndex = index;
this._openChildMenu();
this._openChildMenu(position);
}
}

Expand All @@ -549,15 +552,39 @@ export class MenuBar extends Widget {
return;
}

// Get position for the new menu >before< updating active index.
const position = this._positionForMenu(index);

// Before any modification, update window data.
Menu.saveWindowData();

// Begin DOM modifications.

// Update the active index to the hovered item.
this.activeIndex = index;

// Open the new menu if a menu is already open.
if (this._childMenu) {
this._openChildMenu();
this._openChildMenu(position);
}
}

/**
* Find initial position for the menu based on menubar item position.
*
* NOTE: this should be called before updating active index to avoid
* an additional layout and style invalidation as changing active
* index modifies DOM.
*/
private _positionForMenu(index: number): Private.IPosition {
let itemNode = this.contentNode.children[index];
let { left, bottom } = (itemNode as HTMLElement).getBoundingClientRect();
return {
top: bottom,
left
};
}

/**
* Handle the `'mouseleave'` event for the menu bar.
*/
Expand All @@ -574,7 +601,7 @@ export class MenuBar extends Widget {
* If a different child menu is already open, it will be closed,
* even if there is no active menu.
*/
private _openChildMenu(): void {
private _openChildMenu(options: { left?: number; top?: number } = {}): void {
// If there is no active menu, close the current menu.
let newMenu = this.activeMenu;
if (!newMenu) {
Expand All @@ -595,23 +622,30 @@ export class MenuBar extends Widget {
if (oldMenu) {
oldMenu.close();
} else {
this.addClass('lm-mod-active');
/* <DEPRECATED> */
this.addClass('p-mod-active');
/* </DEPRECATED> */
document.addEventListener('mousedown', this, true);
}

// Ensure the menu bar is updated and look up the item node.
MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
let itemNode = this.contentNode.children[this._activeIndex];

// Get the positioning data for the new menu.
let { left, bottom } = (itemNode as HTMLElement).getBoundingClientRect();
let { left, top } = options;
if (typeof left === 'undefined' || typeof top === 'undefined') {
({ left, top } = this._positionForMenu(this._activeIndex));
}
// Begin DOM modifications

if (!oldMenu) {
// Continue setup for new menu
this.addClass('lm-mod-active');
/* <DEPRECATED> */
this.addClass('p-mod-active');
/* </DEPRECATED> */
}

// Open the new menu at the computed location.
if (newMenu.items.length > 0) {
newMenu.open(left, bottom, this._forceItemsPosition);
newMenu.open(left, top, this._forceItemsPosition);
}
}

Expand Down Expand Up @@ -961,6 +995,20 @@ namespace Private {
return node;
}

/**
* Position for the menu relative to top-left screen corner.
*/
export interface IPosition {
/**
* Pixels right from screen origin.
*/
left: number;
/**
* Pixels down from screen origin.
*/
top: number;
}

/**
* The results of a mnemonic search.
*/
Expand Down
17 changes: 9 additions & 8 deletions packages/widgets/tests/src/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,30 +558,31 @@ describe('@lumino/widgets', () => {
it('should open the menu at the specified location', () => {
menu.addItem({ command: 'test' });
menu.open(10, 10);
expect(menu.node.style.left).to.equal('10px');
expect(menu.node.style.top).to.equal('10px');
expect(menu.node.style.transform).to.equal('translate(10px, 10px)');
});

it('should be adjusted to fit naturally on the screen', () => {
menu.addItem({ command: 'test' });
menu.open(-10, 10000);
expect(menu.node.style.left).to.equal('0px');
expect(menu.node.style.top).to.not.equal('10000px');
expect(menu.node.style.transform.indexOf('translate(0px, ')).to.equal(
0
);
expect(menu.node.style.transform.indexOf(', 10000px)')).to.equal(-1);
});

it('should accept flags to force the location', () => {
menu.addItem({ command: 'test' });
menu.open(10000, 10000, { forceX: true, forceY: true });
expect(menu.node.style.left).to.equal('10000px');
expect(menu.node.style.top).to.equal('10000px');
expect(menu.node.style.transform).to.equal(
'translate(10000px, 10000px)'
);
});

it('should bail if already attached', () => {
menu.addItem({ command: 'test' });
menu.open(10, 10);
menu.open(100, 100);
expect(menu.node.style.left).to.equal('10px');
expect(menu.node.style.top).to.equal('10px');
expect(menu.node.style.transform).to.equal('translate(10px, 10px)');
});
});

Expand Down
1 change: 1 addition & 0 deletions review/api/widgets.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ export class Menu extends Widget {
removeItemAt(index: number): void;
readonly renderer: Menu.IRenderer;
readonly rootMenu: Menu;
static saveWindowData(): void;
triggerActiveItem(): void;
}

Expand Down

0 comments on commit e938ee1

Please sign in to comment.