infamous
Version:
A CSS3D/WebGL UI library.
477 lines (395 loc) • 18.6 kB
JavaScript
/* global HTMLSlotElement */
import WebComponent from './WebComponent'
import HTMLNode from './HTMLNode'
import { observeChildren, /*getShadowRootVersion,*/ hasShadowDomV0,
hasShadowDomV1, getAncestorShadowRoot } from '../core/Utility'
import {
Mesh,
BoxGeometry,
MeshPhongMaterial,
Color,
NoBlending,
DoubleSide,
} from 'three'
var DeclarativeBase
const observers = new WeakMap
initDeclarativeBase()
export function initDeclarativeBase() {
if (DeclarativeBase) return
/**
* @implements {EventListener}
*/
DeclarativeBase = WebComponent.subclass('DeclarativeBase', ({ Super, Public, Private }) => ({
static: {
define(name) {
name = name || this.defaultElementName
customElements.define(name, this)
this._definedElementName = name
},
get definedElementName() {
return this._definedElementName || null
},
},
// We use this to Override HTMLElement.prototype.attachShadow in v1, and
// HTMLElement.prototype.createShadowRoot in v0, so that we can make the
// connection between parent and child on the imperative side when the HTML side
// is using shadow roots.
//
// TODO finish ShadowDOM compatibility!
attachShadow(options, _method) {
_method = _method || 'attachShadow'
if ( !( this instanceof DeclarativeBase ) )
return Super( this )[ _method ]( options )
// In v0, shadow roots can be replaced, but in v1 calling attachShadow
// on an element that already has a root throws. So, we can set this to
// true, and if the try-catch passes then we know we have a v0 root and
// that the root was just replaced.
const oldRoot = this.shadowRoot
let root = null
try {
root = Super( this )[ _method ]( options )
}
catch (e) { throw e }
const privateThis = Private(this)
privateThis._hasShadowRoot = true
if (oldRoot) {
Private(this)._onV0ShadowRootReplaced( oldRoot )
}
const observer = observeChildren(
root,
privateThis._shadowRootChildAdded.bind(privateThis),
privateThis._shadowRootChildRemoved.bind(privateThis)
)
observers.set(root, observer)
const {children} = this
for (let l=children.length, i=0; i<l; i+=1) {
if (!(children[i] instanceof DeclarativeBase)) continue
Private(children[i])._isPossiblyDistributed = true
}
return root
},
createShadowRoot() {
this.attachShadow( undefined, 'createShadowRoot' )
},
childConnectedCallback(child) {
// mirror the DOM connections in the imperative API's virtual scene graph.
if (child instanceof HTMLNode) {
if (Private(this)._hasShadowRoot) Private(child)._isPossiblyDistributed = true
// If ImperativeBase#add was called first, child's
// `parent` will already be set, so prevent recursion.
if (child.parent) return
this.add(child)
}
else if (
hasShadowDomV0
&& child instanceof HTMLContentElement
&&
//getShadowRootVersion(
getAncestorShadowRoot(this)
//) == 'v0'
) {
// observe <content> elements.
}
else if (
hasShadowDomV1
&& child instanceof HTMLSlotElement
&&
//getShadowRootVersion(
getAncestorShadowRoot(this)
//) == 'v1'
) {
child.addEventListener('slotchange', this)
Private(this)._handleDistributedChildren(child)
}
else { // if non-library content was added (div, img, etc).
// TODO: replace this check with a more general one that
// detects if anything is visible including from styling, not
// just content. Perhaps make a specific API for defining that
// a node should have DOM content, to make it clear.
if ( this instanceof HTMLNode && !this.isDOMNode && (
( !( child instanceof Text ) && !( child instanceof Comment ) ) ||
( child instanceof Text && child.textContent.trim().length > 0 )
) ) {
Private(this)._possiblyCreateDOMPlane()
}
}
},
childDisconnectedCallback(child) {
// mirror the connection in the imperative API's virtual scene graph.
if (child instanceof HTMLNode) {
Private(child)._isPossiblyDistributed = false
// If ImperativeBase#remove was called first, child's
// `parent` will already be null, so prevent recursion.
if (!child.parent) return
this.remove(child)
}
else if (
hasShadowDomV0
&& child instanceof HTMLContentElement
&&
//getShadowRootVersion(
getAncestorShadowRoot(this)
//) == 'v0'
) {
// unobserve <content> element
}
else if (
hasShadowDomV1
&& child instanceof HTMLSlotElement
&&
//getShadowRootVersion(
getAncestorShadowRoot(this)
//) == 'v1'
) {
child.removeEventListener('slotchange', this)
Private(this)._handleDistributedChildren(child)
Private(this)._slotElementsAssignedNodes.delete(child)
}
else { // if non-library content was removed (div, img, etc).
if ( this instanceof HTMLNode && !this.isDOMNode && (
( !( child instanceof Text ) && !( child instanceof Comment ) ) ||
( child instanceof Text && child.textContent.trim().length > 0 )
) ) {
Private(this)._possiblyDestroyDOMPlane()
}
}
},
// Traverses a tree while considering ShadowDOM disribution.
traverse(isShadowChild) {
console.log(isShadowChild ? 'distributedNode:' : 'node:', this)
// in the future, the user will be use a pure-JS API with no HTML
// DOM API.
const hasHtmlApi = true
const {children} = this
for (let l=children.length, i=0; i<l; i+=1) {
// skip nodes that are possiblyDistributed, i.e. they have a parent
// that has a ShadowRoot.
if (!hasHtmlApi || !Private(children[i])._isPossiblyDistributed)
children[i].traverse()
}
const shadowChildren = Private(this)._shadowChildren
if (hasHtmlApi && shadowChildren) {
for (let l=shadowChildren.length, i=0; i<l; i+=1)
shadowChildren[i].traverse(true)
}
},
// TODO: make setAttribute accept non-string values.
setAttribute(attr, value) {
//if (this.tagName.toLowerCase() == 'motor-scene')
//console.log('setting attribute', arguments[1])
Super(this).setAttribute(attr, value)
},
get threeDOMPlane() {
return Private(this)._threeDOMPlane
},
private: {
// true if this node has a shadow root (even if it is "closed", see
// attachShadow method above). Once true always true because shadow
// roots cannot be removed.
_hasShadowRoot: false,
// True when this node has a parent that has a shadow root. When
// using the HTML API, Imperative API can look at this to determine
// whether to render this node or not, in the case of WebGL.
_isPossiblyDistributed: false,
// A map of the slot elements that are children of this node and
// their last-known assigned nodes. When a slotchange happens while
// this node is in a shadow root and has a slot child, we can
// detect what the difference is between the last known and the new
// assignments, and notate the new distribution of child nodes. See
// issue #40 for background on why we do this.
_slotElementsAssignedNodes: new WeakMap,
// If this node is distributed into a shadow tree, this will
// reference the parent of the <slot> or <content> element.
// Basically, this node will render as a child of that parent node
// in the flat tree.
_shadowParent: null,
// If this element has a child <slot> or <content> element while in
// a shadow root, then this will be a Set of the nodes distributed
// into the <slot> or <content>, and those nodes render relatively
// to this node in the flat tree. We instantiate this later, only
// when/if needed.
_shadowChildren: null,
// If this HTMLNode needs to be visible (f.e. it has non-library
// HTML children like div, span, img, etc), then we store here a
// reference to a WebGL plane that is aligned with the DOM element
// in order to achieve "mixed mode" features like the DOM element
// intersecting with WebGL meshes.
_threeDOMPlane: null,
_nonLibraryElementCount: 0,
_shadowRootChildAdded(child) {
// NOTE Logic here is similar to childConnectedCallback
if (child instanceof DeclarativeBase) {
Public(this).add(child)
}
else if (
hasShadowDomV0
&& child instanceof HTMLContentElement
) {
// observe <content> elements.
}
else if (
hasShadowDomV1
&& child instanceof HTMLSlotElement
) {
child.addEventListener('slotchange', this)
this._handleDistributedChildren(child)
}
},
_shadowRootChildRemoved(child) {
// NOTE Logic here is similar to childDisconnectedCallback
if (child instanceof DeclarativeBase) {
Public(this).remove(child)
}
else if (
hasShadowDomV0
&& child instanceof HTMLContentElement
) {
// unobserve <content> element
}
else if (
hasShadowDomV1
&& child instanceof HTMLSlotElement
) {
child.removeEventListener('slotchange', this)
this._handleDistributedChildren(child)
this._slotElementsAssignedNodes.delete(child)
}
},
// This method is part of the EventListener interface.
handleEvent(event) {
if (event.type == 'slotchange') {
const slot = event.target
this._handleDistributedChildren(slot)
}
},
_handleDistributedChildren(slot) {
const diff = this._getDistributedChildDifference(slot)
const {added} = diff
for (let l=added.length, i=0; i<l; i+=1) {
const addedNode = added[i]
if (!(addedNode instanceof DeclarativeBase)) continue
// Keep track of the final distribution of a node.
//
// If the given slot is assigned to another
// slot, then this logic will run again for the next slot on
// that next slot's slotchange, so we remove the distributed
// node from the previous shadowParent and add it to the next
// one. If we don't do this, then the distributed node will
// exist in multiple shadowChildren lists when there is a
// chain of assigned slots. For more info, see
// https://github.com/w3c/webcomponents/issues/611
const shadowParent = addedNode._shadowParent
if (shadowParent && shadowParent._shadowChildren) {
const shadowChildren = shadowParent._shadowChildren
shadowChildren.splice(shadowChildren.indexOf(addedNode), 1)
if (!shadowChildren.length)
shadowParent._shadowChildren = null
}
// The node is now distributed to `this` element.
addedNode._shadowParent = this
if (!this._shadowChildren) this._shadowChildren = []
this._shadowChildren.add(addedNode)
}
const {removed} = diff
for (let l=removed.length, i=0; i<l; i+=1) {
const removedNode = removed[i]
if (!(removedNode instanceof DeclarativeBase)) continue
removedNode._shadowParent = null
this._shadowChildren.delete(removedNode)
if (!this._shadowChildren.size) this._shadowChildren = null
}
},
_getDistributedChildDifference(slot) {
let previousNodes
if (this._slotElementsAssignedNodes.has(slot))
previousNodes = this._slotElementsAssignedNodes.get(slot)
else
previousNodes = []
const newNodes = slot.assignedNodes({flatten: true})
// save the newNodes to be used as the previousNodes for next time.
this._slotElementsAssignedNodes.set(slot, newNodes)
const diff = {
removed: [],
}
for (let i=0, l=previousNodes.length; i<l; i+=1) {
const oldNode = previousNodes[i]
const newIndex = newNodes.indexOf(oldNode)
// if it exists in the previousNodes but not the newNodes, then
// the node was removed.
if (!(newIndex >= 0)) {
diff.removed.push(oldNode)
}
// otherwise the node wasn't added or removed.
else {
newNodes.splice(i, 1)
}
}
// Remaining nodes in newNodes must have been added.
diff.added = newNodes
return diff
},
_possiblyCreateDOMPlane() {
if ( !this._nonLibraryElementCount ) this._createDOMPlane()
this._nonLibraryElementCount++
},
_possiblyDestroyDOMPlane() {
this._nonLibraryElementCount--
if ( !this._nonLibraryElementCount ) this._destroyDOMPlane()
},
_createDOMPlane() {
// We have to use a BoxGeometry instead of a
// PlaneGeometry because Three.js is not capable of
// casting shadows from Planes, at least until we find
// another way. Unfortunately, this increases polygon
// count by a factor of 6. See issue
// https://github.com/mrdoob/three.js/issues/9315
const geometry = this._createDOMPlaneGeometry()
// TODO PERFORMANCE we can re-use a single material for
// all the DOM planes rather than a new material per
// plane.
const material = new MeshPhongMaterial({
opacity : 0.5,
color : new Color( 0x111111 ),
blending: NoBlending,
//side : DoubleSide,
})
const mesh = this._threeDOMPlane = new Mesh( geometry, material )
mesh.castShadow = true
mesh.receiveShadow = true
Public(this).threeObject3d.add(mesh)
Public(this).on('sizechange', this._updateDOMPlaneOnSizeChange, this)
},
_updateDOMPlaneOnSizeChange({ x, y, z }) {
// TODO PERFORMANCE, destroying and creating a whole new geometry is
// wasteful, but it works for now. Improve this.
this._threeDOMPlane.geometry.dispose()
this._threeDOMPlane.geometry = this._createDOMPlaneGeometry()
},
_destroyDOMPlane() {
const publicThis = Public(this)
publicThis.threeObject3d.remove(this._threeDOMPlane)
this._threeDOMPlane.geometry.dispose()
this._threeDOMPlane.material.dispose()
this._threeDOMPlane = null
publicThis.off('sizechange', this._updateDOMPlaneOnSizeChange)
},
_createDOMPlaneGeometry() {
const publicThis = Public(this)
return new BoxGeometry( publicThis._calculatedSize.x, publicThis._calculatedSize.y, 1 )
},
_onV0ShadowRootReplaced( oldRoot ) {
observers.get(oldRoot).disconnect()
observers.delete(oldRoot)
const {childNodes} = oldRoot
for (let l=childNodes.length, i=0; i<l; i+=1) {
const child = childNodes[i]
if (!(child instanceof DeclarativeBase)) continue
// We should disconnect the imperative connection (f.e. so it is not
// rendered in WebGL)
Public(this).remove(child, true)
}
},
},
}))
}
export {DeclarativeBase as default}