diff --git a/packages/virtualdom/package.json b/packages/virtualdom/package.json index 70b4340f3..b3840f0cb 100644 --- a/packages/virtualdom/package.json +++ b/packages/virtualdom/package.json @@ -36,6 +36,9 @@ "docs": "typedoc --options tdoptions.json src", "test": "npm run test:firefox", "test:chrome": "cd tests && karma start --browsers=Chrome", + "test:chrome-headless": "cd tests && karma start --browsers=ChromeHeadless", + "test:debug": "cd tests && karma start --browsers=Chrome --singleRun=false --debug=true --browserNoActivityTimeout=10000000 --browserDisconnectTimeout=10000000", + "test:debug:chrome-headless": "cd tests && karma start --browsers=ChromeHeadless --singleRun=false --debug=true --browserNoActivityTimeout=10000000 --browserDisconnectTimeout=10000000", "test:firefox": "cd tests && karma start --browsers=Firefox", "test:ie": "cd tests && karma start --browsers=IE", "watch": "tsc --build --watch" diff --git a/packages/virtualdom/src/index.ts b/packages/virtualdom/src/index.ts index b344fcefb..49f70ba81 100644 --- a/packages/virtualdom/src/index.ts +++ b/packages/virtualdom/src/index.ts @@ -750,11 +750,75 @@ class VirtualElement { } +/** + * A "pass thru" virtual node whose children are managed by a render and an + * unrender callback. The intent of this flavor of virtual node is to make + * it easy to blend other kinds of virtualdom (eg React) into Phosphor's + * virtualdom. + * + * #### Notes + * User code will not typically create a `VirtualElementPass` node directly. + * Instead, the `hpass()` function will be used to create an element tree. + */ +export +class VirtualElementPass{ + /** + * The type of the node. + * + * This value can be used as a type guard for discriminating the + * `VirtualNode` union type. + */ + readonly type: 'passthru' = 'passthru'; + + /** + * Construct a new virtual element pass thru node. + * + * @param tag - the tag of the parent element of this node. Once the parent + * element is rendered, it will be passed as an argument to + * renderer.render + * + * @param attrs - attributes that will assigned to the + * parent element + * + * @param renderer - an object with render and unrender + * functions, each of which should take a single argument of type + * HTMLElement and return nothing. If null, the parent element + * will be rendered barren without any children. + */ + constructor(readonly tag: string, readonly attrs: ElementAttrs, readonly renderer: VirtualElementPass.IRenderer | null) {} + + render(host: HTMLElement): void { + // skip actual render if renderer is null + if (this.renderer) { + this.renderer.render(host); + } + } + + unrender(host: HTMLElement): void { + // skip actual unrender if renderer is null + if (this.renderer) { + this.renderer.unrender(host); + } + } +} + + +/** + * The namespace for the VirtualElementPass class statics. + */ +export namespace VirtualElementPass { + export type IRenderer = { + render: (host: HTMLElement) => void, + unrender: (host: HTMLElement) => void + }; +} + + /** * A type alias for a general virtual node. */ export -type VirtualNode = VirtualElement | VirtualText; +type VirtualNode = VirtualElement | VirtualElementPass | VirtualText; /** @@ -792,6 +856,8 @@ export function h(tag: string): VirtualElement { children.push(arg); } else if (arg instanceof VirtualElement) { children.push(arg); + } else if (arg instanceof VirtualElementPass) { + children.push(arg); } else if (arg instanceof Array) { extend(children, arg); } else if (i === 1 && arg && typeof arg === 'object') { @@ -808,6 +874,8 @@ export function h(tag: string): VirtualElement { array.push(child); } else if (child instanceof VirtualElement) { array.push(child); + } else if (child instanceof VirtualElementPass) { + array.push(child); } } } @@ -934,6 +1002,41 @@ namespace h { } +/** + * Create a new "pass thru" virtual element node. + * + * @param tag - The tag name for the parent element. + * + * @param attrs - The attributes for the parent element, if any. + * + * @param renderer - an object with render and unrender functions, if any. + * + * @returns A new "pass thru" virtual element node for the given parameters. + * + */ +export function hpass(tag: string, renderer?: VirtualElementPass.IRenderer): VirtualElementPass; +export function hpass(tag: string, attrs: ElementAttrs, renderer?: VirtualElementPass.IRenderer): VirtualElementPass; +export function hpass(tag: string): VirtualElementPass { + let attrs: ElementAttrs = {}; + let renderer: VirtualElementPass.IRenderer | null = null; + + if (arguments.length === 2) { + const arg = arguments[1]; + + if ("render" in arg && "unrender" in arg) { + renderer = arg; + } else { + attrs = arg; + } + } else if (arguments.length === 3) { + attrs = arguments[1]; + renderer = arguments[2]; + } + + return new VirtualElementPass(tag, attrs, renderer); +} + + /** * The namespace for the virtual DOM rendering functions. */ @@ -952,8 +1055,10 @@ namespace VirtualDOM { * * If virtual diffing is desired, use the `render` function instead. */ - export - function realize(node: VirtualElement): HTMLElement { + export function realize(node: VirtualText): Text; + export function realize(node: VirtualElement): HTMLElement; + export function realize(node: VirtualElementPass): HTMLElement; + export function realize(node: VirtualNode): HTMLElement | Text { return Private.createDOMNode(node); } @@ -990,14 +1095,12 @@ namespace Private { /** * A weak mapping of host element to virtual DOM content. */ - export - const hostMap = new WeakMap>(); + export const hostMap = new WeakMap>(); /** * Cast a content value to a content array. */ - export - function asContentArray(value: VirtualNode | ReadonlyArray | null): ReadonlyArray { + export function asContentArray(value: VirtualNode | ReadonlyArray | null): ReadonlyArray { if (!value) { return []; } @@ -1010,32 +1113,42 @@ namespace Private { /** * Create a new DOM element for a virtual node. */ - export - function createDOMNode(node: VirtualText): Text; - export - function createDOMNode(node: VirtualElement): HTMLElement; - export - function createDOMNode(node: VirtualNode): HTMLElement | Text; - export - function createDOMNode(node: VirtualNode): HTMLElement | Text { - // Create a text node for a virtual text node. - if (node.type === 'text') { - return document.createTextNode(node.content); - } + export function createDOMNode(node: VirtualText): Text; + export function createDOMNode(node: VirtualElement): HTMLElement; + export function createDOMNode(node: VirtualElementPass): HTMLElement; + export function createDOMNode(node: VirtualNode): HTMLElement | Text; + export function createDOMNode(node: VirtualNode, host: HTMLElement | null): HTMLElement | Text; + export function createDOMNode(node: VirtualNode, host: HTMLElement | null, before: Node | null): HTMLElement | Text; + export function createDOMNode(node: VirtualNode): HTMLElement | Text { + let host = arguments[1] || null; + const before = arguments[2] || null; + + if (host) { + host.insertBefore(createDOMNode(node), before); + } else { + // Create a text node for a virtual text node. + if (node.type === 'text') { + return document.createTextNode(node.content); + } - // Create the HTML element with the specified tag. - let element = document.createElement(node.tag); + // Create the HTML element with the specified tag. + host = document.createElement(node.tag); - // Add the attributes for the new element. - addAttrs(element, node.attrs); + // Add the attributes for the new element. + addAttrs(host, node.attrs); - // Recursively populate the element with child content. - for (let i = 0, n = node.children.length; i < n; ++i) { - element.appendChild(createDOMNode(node.children[i])); + if (node.type === 'passthru') { + node.render(host); + return host; + } + + // Recursively populate the element with child content. + for (let i = 0, n = node.children.length; i < n; ++i) { + createDOMNode(node.children[i], host); + } } - // Return the populated element. - return element; + return host; } /** @@ -1044,8 +1157,7 @@ namespace Private { * This is the core "diff" algorithm. There is no explicit "patch" * phase. The host is patched at each step as the diff progresses. */ - export - function updateContent(host: HTMLElement, oldContent: ReadonlyArray, newContent: ReadonlyArray): void { + export function updateContent(host: HTMLElement, oldContent: ReadonlyArray, newContent: ReadonlyArray): void { // Bail early if the content is identical. if (oldContent === newContent) { return; @@ -1068,7 +1180,7 @@ namespace Private { // If the old content is exhausted, create a new node. if (i >= oldCopy.length) { - host.appendChild(createDOMNode(newContent[i])); + createDOMNode(newContent[i], host); continue; } @@ -1089,11 +1201,19 @@ namespace Private { continue; } - // If the old or new node is a text node, the other node is now - // known to be an element node, so create and insert a new node. - if (oldVNode.type === 'text' || newVNode.type === 'text') { + // Handle the case of passthru update. + if (oldVNode.type === 'passthru' && newVNode.type === 'passthru') { + newVNode.render(currElem as HTMLElement); + currElem = currElem!.nextSibling; + continue; + } + + // If the types of the old and new nodes differ, + // create and insert a new node. + if (oldVNode.type === 'text' || newVNode.type === 'text' || + oldVNode.type === 'passthru' || newVNode.type === 'passthru') { ArrayExt.insert(oldCopy, i, newVNode); - host.insertBefore(createDOMNode(newVNode), currElem); + createDOMNode(newVNode, host, currElem); continue; } @@ -1126,14 +1246,14 @@ namespace Private { let oldKey = oldVNode.attrs.key; if (oldKey && oldKey !== newKey) { ArrayExt.insert(oldCopy, i, newVNode); - host.insertBefore(createDOMNode(newVNode), currElem); + createDOMNode(newVNode, host, currElem); continue; } // If the tags are different, create a new node. if (oldVNode.tag !== newVNode.tag) { ArrayExt.insert(oldCopy, i, newVNode); - host.insertBefore(createDOMNode(newVNode), currElem); + createDOMNode(newVNode, host, currElem); continue; } @@ -1149,9 +1269,32 @@ namespace Private { currElem = currElem!.nextSibling; } + // Cleanup stale DOM + removeContent(host, oldCopy, newCount, true); + } + + /** + * Handle cleanup of stale vdom and its associated DOM. Stale nodes are + * traversed recursively and any needed explicit cleanup is carried out ( + * in particular, the unrender callback of VirtualElementPass nodes). The + * stale children of the top level node are removed using removeChild. + */ + function removeContent(host: HTMLElement, oldContent: ReadonlyArray, newCount: number, _sentinel = false) { // Dispose of the old nodes pushed to the end of the host. - for (let i = oldCopy.length - newCount; i > 0; --i) { - host.removeChild(host.lastChild!); + for (let i = oldContent.length - 1; i >= newCount; --i) { + const oldNode = oldContent[i]; + const child = (_sentinel ? host.lastChild : host.childNodes[i]) as HTMLElement; + + // recursively clean up host children + if (oldNode.type === 'text') {} else if (oldNode.type === 'passthru') { + oldNode.unrender(child!); + } else { + removeContent(child!, oldNode.children, 0); + } + + if (_sentinel) { + host.removeChild(child!); + } } } diff --git a/packages/virtualdom/tests/src/index.spec.ts b/packages/virtualdom/tests/src/index.spec.ts index 80a48b65c..15aa4c440 100644 --- a/packages/virtualdom/tests/src/index.spec.ts +++ b/packages/virtualdom/tests/src/index.spec.ts @@ -12,7 +12,7 @@ import { } from 'chai'; import { - VirtualDOM, VirtualElement, VirtualText, h + VirtualDOM, VirtualElement, VirtualElementPass, VirtualText, h, hpass } from '@lumino/virtualdom'; @@ -100,6 +100,61 @@ describe('@lumino/virtualdom', () => { }); + describe('VirtualElementPass', () => { + let mockRenderer = { + render: (host: HTMLElement) => {}, + unrender: (host: HTMLElement) =>{} + }; + + describe('#constructor()', () => { + + it('should create a virtual element node', () => { + let vnode = new VirtualElementPass('div', {}, mockRenderer); + expect(vnode).to.be.an.instanceof(VirtualElementPass); + }); + + }); + + describe('#type', () => { + + it('should be `element`', () => { + let vnode = new VirtualElementPass('div', {}, mockRenderer); + expect(vnode.type).to.equal('passthru'); + }); + + }); + + describe('#tag', () => { + + it('should be the element tag name', () => { + let vnode = new VirtualElementPass('img', {}, mockRenderer); + expect(vnode.tag).to.equal('img'); + }); + + }); + + describe('#attrs', () => { + + it('should be the element attrs', () => { + let attrs = { className: 'baz' }; + let vnode = new VirtualElementPass('img', attrs, mockRenderer); + expect(vnode.attrs).to.deep.equal(attrs); + }); + + }); + + describe('#renderer', () => { + + it('should be the element children renderer', () => { + let vnode = new VirtualElementPass('div', {}, mockRenderer); + expect(vnode.renderer!.render).to.equal(mockRenderer.render); + expect(vnode.renderer!.unrender).to.equal(mockRenderer.unrender); + }); + + }); + + }); + describe('h()', () => { it('should create a new virtual element node', () => { @@ -290,8 +345,63 @@ describe('@lumino/virtualdom', () => { }); - describe('VirtualDOM', () => { + describe('hpass()', () => { + let tag = 'div'; + let attrs = { className: 'baz' }; + let mockRenderer = { + render: (host: HTMLElement) => {}, + unrender: (host: HTMLElement) =>{} + }; + + it('should create a new virtual element passthru node', () => { + let vnode = hpass( + tag, + attrs, + mockRenderer + ); + expect(vnode).to.be.an.instanceof(VirtualElementPass); + expect(vnode.tag).to.equal(tag); + expect(vnode.attrs).to.deep.equal(attrs); + expect(vnode.renderer!.render).to.equal(mockRenderer.render); + expect(vnode.renderer!.unrender).to.equal(mockRenderer.unrender); + }); + + it('should create a passthru vnode without attrs', () => { + let vnode = hpass( + 'div', + mockRenderer + ); + expect(vnode).to.be.an.instanceof(VirtualElementPass); + expect(vnode.tag).to.equal('div'); + expect(vnode.attrs).to.deep.equal({}); + expect(vnode.renderer!.render).to.equal(mockRenderer.render); + expect(vnode.renderer!.unrender).to.equal(mockRenderer.unrender); + }); + + it('should create a passthru vnode without renderer', () => { + let vnode = hpass( + 'div', + attrs + ); + expect(vnode).to.be.an.instanceof(VirtualElementPass); + expect(vnode.tag).to.equal(tag); + expect(vnode.attrs).to.deep.equal(attrs); + expect(vnode.renderer).to.equal(null); + }); + + it('should create a passthru vnode without attrs or renderer', () => { + let vnode = hpass( + 'div' + ); + expect(vnode).to.be.an.instanceof(VirtualElementPass); + expect(vnode.tag).to.equal('div'); + expect(vnode.attrs).to.deep.equal({}); + expect(vnode.renderer).to.equal(null); + }); + }); + + describe('VirtualDOM', () => { describe('realize()', () => { it('should create a real DOM node from a virtual DOM node', () => { @@ -423,4 +533,67 @@ describe('@lumino/virtualdom', () => { }); + describe('VirtualDOM passthru', () => { + const rendererClosure = (record: any = {}) => { + return { + render: (host: HTMLElement) => { + const renderNode = document.createElement('div'); + renderNode.className = 'p-render'; + host.appendChild(renderNode); + record.child = renderNode; + }, + unrender: (host: HTMLElement) => { + host.removeChild(host.lastChild as HTMLElement); + record.cleanedUp = true; + } + } + }; + + describe('realize()', () => { + it('should realize successfully', () => { + let node = VirtualDOM.realize(hpass('span', rendererClosure())); + expect(node.tagName.toLowerCase()).to.equal('span'); + expect(node.children[0].tagName.toLowerCase()).to.equal('div'); + expect(node.children[0].className).to.equal('p-render'); + }); + + }); + + describe('render()', () => { + it('should render successfully at top of tree', () => { + let host = document.createElement('div'); + + VirtualDOM.render(hpass('span', rendererClosure()), host); + expect(host.children[0].tagName.toLowerCase()).to.equal('span'); + expect(host.children[0].children[0].tagName.toLowerCase()).to.equal('div'); + expect(host.children[0].children[0].className).to.equal('p-render'); + }); + + it('should render child node', () => { + let host = document.createElement('div'); + let record: any = {child: undefined, cleanedUp: false}; + + let children = [h.a(), h.span(), h.div(h.div(), hpass('span', rendererClosure(record)), h.div())]; + VirtualDOM.render(children, host); + expect(host.children[2].children[1].children[0]).to.equal(record.child); + expect(host.children[2].children[1].children[0].className).to.equal('p-render'); + }); + + it('should cleanup child node', () => { + let host = document.createElement('div'); + let record: any = {child: undefined, cleanedUp: false}; + + // first pass, render the hpass children + let children0 = [h.a(), h.span(), h.div(h.div(), hpass('span', rendererClosure(record)), h.div())]; + VirtualDOM.render(children0, host); + + // second pass, explicitly unrender the hpass children + let children1 = [h.a(), h.span(), h.label()]; + VirtualDOM.render(children1, host); + expect(record.cleanedUp).to.equal(true); + }); + }); + + }); + }); diff --git a/packages/virtualdom/tests/tsconfig.json b/packages/virtualdom/tests/tsconfig.json index c29f24523..c949f6f00 100644 --- a/packages/virtualdom/tests/tsconfig.json +++ b/packages/virtualdom/tests/tsconfig.json @@ -9,6 +9,7 @@ "moduleResolution": "node", "target": "ES5", "outDir": "build", + "sourceMap": true, "lib": [ "ES5", "DOM" diff --git a/packages/virtualdom/tests/webpack.config.js b/packages/virtualdom/tests/webpack.config.js index f2cc2b433..35e4b0c16 100644 --- a/packages/virtualdom/tests/webpack.config.js +++ b/packages/virtualdom/tests/webpack.config.js @@ -4,5 +4,6 @@ module.exports = { entry: './build/index.spec.js', output: { filename: './build/bundle.test.js' - } -} + }, + devtool: 'inline-source-map' +}; diff --git a/packages/virtualdom/tsconfig.json b/packages/virtualdom/tsconfig.json index 5292ac820..e6d2749ca 100644 --- a/packages/virtualdom/tsconfig.json +++ b/packages/virtualdom/tsconfig.json @@ -15,6 +15,7 @@ "ES2015.Collection", "DOM" ], + "sourceMap": true, "types": [], "rootDir": "src" }, diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 989408679..8510f19bd 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -37,8 +37,13 @@ "clean:test": "rimraf tests/build", "docs": "typedoc --options tdoptions.json src", "test": "npm run test:firefox", + "test:debug": "npm run test:debug:firefox", "test:chrome": "cd tests && karma start --browsers=Chrome", + "test:chrome-headless": "cd tests && karma start --browsers=ChromeHeadless", "test:firefox": "cd tests && karma start --browsers=Firefox", + "test:debug:chrome": "cd tests && karma start --browsers=Chrome --singleRun=false --debug=true --browserNoActivityTimeout=10000000 --browserDisconnectTimeout=10000000", + "test:debug:chrome-headless": "cd tests && karma start --browsers=ChromeHeadless --singleRun=false --debug=true --browserNoActivityTimeout=10000000 --browserDisconnectTimeout=10000000", + "test:debug:firefox": "cd tests && karma start --browsers=Firefox --singleRun=false --debug=true --browserNoActivityTimeout=10000000 --browserDisconnectTimeout=10000000", "test:ie": "cd tests && karma start --browsers=IE", "watch": "tsc --build --watch" }, diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 6325b0245..7c53dc29a 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -32,7 +32,7 @@ import { } from '@lumino/signaling'; import { - ElementDataset, ElementInlineStyle, VirtualDOM, VirtualElement, h + ElementDataset, ElementInlineStyle, VirtualDOM, VirtualElement, VirtualElementPass, h, hpass } from '@lumino/virtualdom'; import { @@ -1339,9 +1339,15 @@ namespace TabBar { * * @returns A virtual element representing the tab icon. */ - renderIcon(data: IRenderData): VirtualElement { + renderIcon(data: IRenderData): VirtualElement | VirtualElementPass { + const { title } = data; let className = this.createIconClass(data); - return h.div({ className }, data.title.iconLabel); + + if (title.iconRenderer) { + return hpass('div', title.iconRenderer); + } else { + return h.div({className}, data.title.iconLabel); + } } /** diff --git a/packages/widgets/src/title.ts b/packages/widgets/src/title.ts index ee78d7a5c..d0f233c58 100644 --- a/packages/widgets/src/title.ts +++ b/packages/widgets/src/title.ts @@ -11,6 +11,8 @@ import { ISignal, Signal } from '@lumino/signaling'; +import { VirtualElementPass } from "@lumino/virtualdom"; + /** * An object which holds data related to an object's title. @@ -44,6 +46,9 @@ class Title { if (options.iconLabel !== undefined) { this._iconLabel = options.iconLabel; } + if (options.iconRenderer !== undefined) { + this._iconRenderer = options.iconRenderer; + } if (options.caption !== undefined) { this._caption = options.caption; } @@ -172,6 +177,30 @@ class Title { this._changed.emit(undefined); } + /** + * Get the icon renderer for the title. + * + * #### Notes + * The default value is undefined. + */ + get iconRenderer(): VirtualElementPass.IRenderer { + return this._iconRenderer; + } + + /** + * Set the icon renderer for the title. + * + * #### Notes + * A renderer is an object that supplies a render and unrender function. + */ + set iconRenderer(value: VirtualElementPass.IRenderer) { + if (this._iconRenderer === value) { + return; + } + this._iconRenderer = value; + this._changed.emit(undefined); + } + /** * Get the caption for the title. * @@ -270,6 +299,7 @@ class Title { private _mnemonic = -1; private _iconClass = ''; private _iconLabel = ''; + private _iconRenderer: VirtualElementPass.IRenderer; private _className = ''; private _closable = false; private _dataset: Title.Dataset; @@ -323,6 +353,12 @@ namespace Title { */ iconLabel?: string; + /** + * An object that supplies render and unrender functions used + * to create and cleanup the icon of the title. + */ + iconRenderer?: VirtualElementPass.IRenderer; + /** * The caption for the title. */ diff --git a/packages/widgets/tests/src/menubar.spec.ts b/packages/widgets/tests/src/menubar.spec.ts index b637e1b37..17dd56d05 100644 --- a/packages/widgets/tests/src/menubar.spec.ts +++ b/packages/widgets/tests/src/menubar.spec.ts @@ -32,7 +32,7 @@ import { } from '@lumino/messaging'; import { - VirtualDOM + VirtualDOM, VirtualElement } from '@lumino/virtualdom'; import { @@ -873,7 +873,7 @@ describe('@lumino/widgets', () => { data.title.mnemonic = 1; let label = renderer.formatLabel(data); expect((label as any)[0]).to.equal('f'); - let node = VirtualDOM.realize((label as any)[1]); + let node = VirtualDOM.realize(((label as any)[1]) as VirtualElement); expect(node.className).to.contain('p-MenuBar-itemMnemonic'); expect(node.textContent).to.equal('o'); expect((label as any)[2]).to.equal('o'); diff --git a/packages/widgets/tests/src/tabbar.spec.ts b/packages/widgets/tests/src/tabbar.spec.ts index 7938ca14d..cd02f31c0 100644 --- a/packages/widgets/tests/src/tabbar.spec.ts +++ b/packages/widgets/tests/src/tabbar.spec.ts @@ -28,7 +28,7 @@ import { } from '@lumino/widgets'; import { - VirtualDOM + VirtualDOM, VirtualElement } from '@lumino/virtualdom'; @@ -1310,7 +1310,7 @@ describe('@lumino/widgets', () => { it('should render the icon element for a tab', () => { let renderer = new TabBar.Renderer(); let vNode = renderer.renderIcon({ title, current: true, zIndex: 1 }); - let node = VirtualDOM.realize(vNode); + let node = VirtualDOM.realize(vNode as VirtualElement); expect(node.className).to.contain('p-TabBar-tabIcon'); expect(node.classList.contains(title.icon)).to.equal(true); });