diff --git a/packages/lume/docs/examples/autolayout-imperative.md b/packages/lume/docs/examples/autolayout-imperative.md index 76b94382b..c779c20ea 100644 --- a/packages/lume/docs/examples/autolayout-imperative.md +++ b/packages/lume/docs/examples/autolayout-imperative.md @@ -38,13 +38,13 @@ webgl: true, }) - scene.mount(document.body) + document.body.append(scene) const ambientLight = new AmbientLight().set({ intensity: 0.1, }) - scene.add(ambientLight) + scene.append(ambientLight) const pointLight = new PointLight().set({ color: "white", @@ -54,7 +54,7 @@ intensity: "0.5", }) - scene.add(pointLight) + scene.append(pointLight) const sphere = new Sphere().set({ size: [10, 10, 10], @@ -66,7 +66,7 @@ }) sphere.setAttribute('has', 'basic-material') - pointLight.add(sphere) + pointLight.append(sphere) const vfl1 = \` //viewport aspect-ratio:3/1 max-height:300 @@ -131,7 +131,7 @@ child5.textContent = text layout.addToLayout(child5, 'child5') - scene.add(layout); // add layout to the scene + scene.append(layout); layout.size = (x,y,z,t) => [ 600+200*Math.sin(t/1000), 400+200*Math.sin(t/1000), z ] diff --git a/packages/lume/docs/examples/ripple-flip.md b/packages/lume/docs/examples/ripple-flip.md index ece5c7f07..b71cd6397 100644 --- a/packages/lume/docs/examples/ripple-flip.md +++ b/packages/lume/docs/examples/ripple-flip.md @@ -61,7 +61,7 @@ perspective: 800 }) - scene.mount('body') + document.body.append(scene) const gridSizeX = 13 const gridSizeY = 13 @@ -75,7 +75,7 @@ position: {z: -600}, }) - scene.add(grid) + scene.append(grid) console.log('grid size', grid.calculatedSize) @@ -100,7 +100,7 @@ node.style.background = ''+mainColor.clone().darken(10) node.style.border = '1px solid ' + mainColor.clone().darken(35) - grid.add(node) + grid.append(node) } } diff --git a/packages/lume/docs/guide/install.md b/packages/lume/docs/guide/install.md index feb8e53c6..85822c569 100644 --- a/packages/lume/docs/guide/install.md +++ b/packages/lume/docs/guide/install.md @@ -302,8 +302,8 @@ code: }) node.style.background = 'cornflowerblue' - scene.add(node) - scene.mount(document.body) + scene.append(node) + document.body.append(scene) node.rotation = (x, y, z) => [x, y + 1, z] // The code outputs these elements to the DOM: @@ -367,8 +367,8 @@ code: }) node.style.background = 'cornflowerblue' - scene.add(node) - scene.mount(document.body) + scene.append(node) + document.body.append(scene) node.rotation = (x, y, z) => [x, y + 1, z] // The code outputs these elements to the DOM: diff --git a/packages/lume/docs/workflows.md b/packages/lume/docs/workflows.md index b285320fd..64136dff4 100644 --- a/packages/lume/docs/workflows.md +++ b/packages/lume/docs/workflows.md @@ -131,8 +131,8 @@ const node = new Node({ node.innerHTML = '
Hello 3D
' -scene.add(node) -scene.mount(document.body) +scene.append(node) +document.body.append(scene) node.rotation = (x, y, z) => [x, ++y, z] ``` diff --git a/packages/lume/examples/basic-shadow-dom.html b/packages/lume/examples/basic-shadow-dom.html index dac80c8df..9220028d2 100644 --- a/packages/lume/examples/basic-shadow-dom.html +++ b/packages/lume/examples/basic-shadow-dom.html @@ -1,7 +1,39 @@ + + + - ObjModel + ShadowDOM @@ -9,10 +41,9 @@ - - - - + - - - - - - - - - - + + + + diff --git a/packages/lume/examples/shimmer-cube/index.html b/packages/lume/examples/shimmer-cube/index.html index 52bedaadf..9cd516ee1 100644 --- a/packages/lume/examples/shimmer-cube/index.html +++ b/packages/lume/examples/shimmer-cube/index.html @@ -13,14 +13,16 @@ - - + + + + diff --git a/packages/lume/examples/shimmer-cube/shimmer-cube.js b/packages/lume/examples/shimmer-cube/shimmer-cube.js index 9792a84b5..a0adaf0e7 100644 --- a/packages/lume/examples/shimmer-cube/shimmer-cube.js +++ b/packages/lume/examples/shimmer-cube/shimmer-cube.js @@ -34,6 +34,7 @@ const ShimmerSurface = element('shimmer-surface')( background-size: 100% 100%; width: 400%; height: 400%; + /* TODO Report Chrome bug: the animation has to be disabled then re-enabled in devtools to make the yellow gradient appear. */ animation: ShimmerEffect 1.8s cubic-bezier(0.75, 0.000, 0.25, 1.000) infinite; } ` @@ -74,7 +75,7 @@ const ShimmerCube = element('shimmer-cube')( > - + ${cubeFaceOrientations.map( orientation => html` `, )} + ` // root.querySelector('lume-box').three.material.opacity = 0.2 diff --git a/packages/lume/src/behaviors/MeshBehavior.ts b/packages/lume/src/behaviors/MeshBehavior.ts index c1992311f..1920af502 100644 --- a/packages/lume/src/behaviors/MeshBehavior.ts +++ b/packages/lume/src/behaviors/MeshBehavior.ts @@ -1,5 +1,3 @@ -import {BoxGeometry} from 'three/src/geometries/BoxGeometry.js' -import {MeshPhongMaterial} from 'three/src/materials/MeshPhongMaterial.js' import {RenderableBehavior} from './RenderableBehavior.js' import {Mesh} from '../meshes/Mesh.js' import {Points} from '../meshes/Points.js' @@ -38,11 +36,7 @@ export abstract class MeshBehavior extends RenderableBehavior { unloadGL() { if (!super.unloadGL()) return false - // if the behavior is being disconnected, but the element still has GL - // mode (.three), then leave the element with a default mesh GL - // component to be rendered. - if (this.element.three) this.#setDefaultComponent(this.element, this.type) - else this.#disposeMeshComponent(this.element, this.type) + this.#disposeMeshComponent(this.type) this.element.needsUpdate() return true @@ -50,9 +44,9 @@ export abstract class MeshBehavior extends RenderableBehavior { resetMeshComponent() { // TODO We might have to defer so that calculatedSize is already calculated - // (note, resetMeshComponent is only called when the size prop has + // (note, this method is called when the size or sizeMode prop of subclasses has // changed) - this.#setMeshComponent(this.element, this.type, this._createComponent()) + this.#setMeshComponent(this.type, this._createComponent()) this.element.needsUpdate() } @@ -70,34 +64,17 @@ export abstract class MeshBehavior extends RenderableBehavior { // TODO // #initialSize: null, - #disposeMeshComponent(element: Mesh | Points, name: 'geometry' | 'material') { + #disposeMeshComponent(name: 'geometry' | 'material') { // TODO handle material arrays - if (element.three[name]) (element.three[name] as Geometry | Material).dispose() + if (this.element.three[name]) (this.element.three[name] as Geometry | Material).dispose() } - #setMeshComponent( - element: Mesh | Points, - name: 'geometry' | 'material', - newComponent: BufferGeometry | Geometry | Material, - ) { - this.#disposeMeshComponent(element, name) + #setMeshComponent(name: 'geometry' | 'material', newComponent: BufferGeometry | Geometry | Material) { + this.#disposeMeshComponent(name) // the following type casting is not type safe, but shows what we intend // (we can't type this sort of JavaScript in TypeScript) - element.three[name as 'geometry'] = newComponent as Geometry + this.element.three[name as 'geometry'] = newComponent as Geometry // or element.three[name as 'material'] = newComponent as Material } - - #setDefaultComponent(element: Mesh | Points, name: 'geometry' | 'material') { - this.#setMeshComponent(element, name, this.#makeDefaultComponent(element, name)) - } - - #makeDefaultComponent(element: Mesh | Points, name: 'geometry' | 'material'): Geometry | Material { - switch (name) { - case 'geometry': - return new BoxGeometry(element.calculatedSize.x, element.calculatedSize.y, element.calculatedSize.z) - case 'material': - return new MeshPhongMaterial({color: 0xff6600}) - } - } } diff --git a/packages/lume/src/cameras/PerspectiveCamera.ts b/packages/lume/src/cameras/PerspectiveCamera.ts index 5eac8584e..d2366da8c 100644 --- a/packages/lume/src/cameras/PerspectiveCamera.ts +++ b/packages/lume/src/cameras/PerspectiveCamera.ts @@ -105,9 +105,6 @@ export class PerspectiveCamera extends Node { disconnectedCallback() { super.disconnectedCallback() - // TODO we want to call this in the upcoming - // unmountedCallback, but for now it's harmless but - // will run unnecessary logic. #150 this.#setSceneCamera('unset') this.#lastKnownScene = null } @@ -116,9 +113,6 @@ export class PerspectiveCamera extends Node { #setSceneCamera(unset?: 'unset') { if (unset) { - // TODO: unset might be triggered before the scene was mounted, so - // there might not be a last known scene. We won't need this check - // when we add unmountedCallback. #150 if (this.#lastKnownScene) this.#lastKnownScene._removeCamera(this) } else { if (!this.scene || !this.isConnected) return diff --git a/packages/lume/src/core/DeclarativeBase.ts b/packages/lume/src/core/DeclarativeBase.ts index 33686d7b2..9ff3936c0 100644 --- a/packages/lume/src/core/DeclarativeBase.ts +++ b/packages/lume/src/core/DeclarativeBase.ts @@ -1,5 +1,5 @@ import {Element as LumeElement} from '@lume/element' -import {observeChildren} from '../core/utils.js' +import {defer, observeChildren} from '../core/utils.js' import {WithChildren} from './WithChildren.js' import {DefaultBehaviors} from '../behaviors/DefaultBehaviors.js' @@ -9,8 +9,8 @@ export type ConnectionType = 'root' | 'slot' | 'actual' const observers = new WeakMap() -// using isNode instead of instanceof HTMLNode to avoid runtime reference, -// thus prevent circular dependency between this module and HTMLNode +// We're using isNode instead of instanceof HtmlNode to avoid a runtime reference HtmlNode here, +// thus prevent a circular dependency between this module and HtmlNode // TODO consolidate with one in ImperativeBase function isNode(n: any): n is HtmlNode { return n.isNode @@ -37,12 +37,21 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) super() } - // We use this to Override HTMLElement.prototype.attachShadow in v1 so - // that we can make the connection between parent and child on the - // imperative side when the HTML side is using shadow roots. + // COMPOSED TREE TRACKING: + // Overriding HTMLElement.prototype.attachShadow here is part of our + // implementation for tracking the composed tree and connecting THREE + // objects in the same structure as the DOM composed tree so that it will + // render as expected when end users compose elements with ShadowDOM and + // slots. attachShadow(options: ShadowRootInit): ShadowRoot { const root = super.attachShadow(options) + // Skip ShadowRoot observation for Scene instances. Only Scene actual + // children or distributed children are considered in the LUME scene + // graph because Scene's ShadowRoot already exists and serves in the + // rendering implementation and is not the user's. + if (this.isScene) return root + this.__shadowRoot = root const observer = observeChildren( @@ -60,7 +69,7 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) for (const child of children) { if (!(child instanceof DeclarativeBase)) continue child.__isPossiblyDistributedToShadowRoot = true - this.__childUncomposedCallback(child, 'slot') + this.__triggerChildUncomposedCallback(child, 'actual') } return root @@ -73,10 +82,6 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) isNode = false childConnectedCallback(child: HTMLElement) { - // TODO Another case to handle is default slot content: when there - // are no nodes distributed to a slot, then connect the - // element's children to the parent. - // This code handles two cases: the element has a ShadowRoot // ("composed children" are children of the ShadowRoot), or it has a // child ("composed children" are nodes that may be @@ -89,7 +94,7 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) if (!this.isScene && this.__shadowRoot) { child.__isPossiblyDistributedToShadowRoot = true - // We don't call #childComposedCallback here because that + // We don't call childComposedCallback here because that // will be called indirectly due to a slotchange event on a // element if the added child will be distributed to // a slot. @@ -99,25 +104,47 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) // regular parent-child composition (no distribution, no // children of a ShadowRoot). - this.__childComposedCallback(child, 'actual') + this.__triggerChildComposedCallback(child, 'actual') } } else if (child instanceof HTMLSlotElement) { - if (!this.__slots) this.__slots = [] - this.__slots.push(child) + // COMPOSED TREE TRACKING: Detecting slots here is part of composed + // tree tracking (detecting when a child is distributed to an element). child.addEventListener('slotchange', this.__onChildSlotChange) - // TODO do we need #handleDistributedChildren for initial - // slotted nodes? Or does `slotchange` conver that? Also, does - // `slotchange` fire for distributed slots? Or do we need to - // also look at assigned nodes of distributed slots in the - // initial #handleDistributedChildren call? - this.__handleDistributedChildren(child /*, true*/) + // XXX Do we need __handleDistributedChildren for initial slotted + // nodes? The answer seems to be "yes, sometimes". When slots are + // appended, their slotchange events will fire. However, this + // `childConnectedCallback` is fired later from when a child is + // actually connected, in a MutationObserver task. Because of this, + // an appended slot's slotchange event *may* have already fired, + // and we will not have had the chance to add a slotchange event + // handler yet, therefore we need to fire + // __handleDistributedChildren here to handle that missed + // opportunity. + // + // Also we need to defer() here because otherwise, this + // childConnectedCallback will fire once for when a child is + // connected into the light DOM and run the logic in the `if + // (isNode(child))` branch *after* childConnectedCallback is fired + // and executes this __handleDistributedChildren call for a shadow + // DOM slot, and in that case the distribution will not be detected + // (why is that?). By deferring, this __handleDistributedChildren + // call correctly happens *after* the above `if (isNode(child))` + // branch and then things will work as expected. This is all due to + // using MutationObserver, which fires event in a later task than + // when child connections actually happen. + // + // TODO ^, Can we make WithChildren call this callback right when + // children are added, synchronously? If so then we could rely on + // a slot's slotchange event upon it being connected without having + // to call __handleDistributedChildren here (which means also not + // having to use defer for anything). + defer(() => this.__handleDistributedChildren(child)) } } childDisconnectedCallback(child: HTMLElement) { - // mirror the connection in the imperative API's virtual scene graph. if (isNode(child)) { if (!this.isScene && this.__shadowRoot) { child.__isPossiblyDistributedToShadowRoot = false @@ -125,204 +152,229 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) // If there's no shadow root, call the // childUncomposedCallback with connection type "actual". // This is effectively similar to childDisconnectedCallback. - this.__childUncomposedCallback(child, 'actual') + this.__triggerChildUncomposedCallback(child, 'actual') } } else if (child instanceof HTMLSlotElement) { - child.removeEventListener('slotchange', this.__onChildSlotChange) + // COMPOSED TREE TRACKING: + child.removeEventListener('slotchange', this.__onChildSlotChange, {capture: true}) - this.__slots!.splice(this.__slots!.indexOf(child), 1) - if (!this.__slots!.length) this.__slots = undefined this.__handleDistributedChildren(child) this.__previousSlotAssignedNodes.delete(child) } } - // TODO use this to detect when we should render only to WebGL in a - // non-DOM environment. - get hasHtmlApi() { - // @prod-prune - if (this instanceof HTMLElement) return true - return false - } - - // Traverses a tree while considering ShadowDOM disribution. - // - // This isn't used for anything at the moment. It was going to be used - // to traverse the composed tree and render using our own WebGL - // renderer, but at the moment we're using Three.js nodes and composing - // them in the structured of the composed tree, then Three.js handles - // the traversal for rendering the WebGL. - traverseComposed(cb: (n: Node) => void) { - // In the future, the user will be use a pure-JS API with no HTML - // DOM API. - const hasHtmlApi = this.hasHtmlApi - - const {children} = this - for (let l = children.length, i = 0; i < l; i += 1) { - const child = children[i] - - if (!(child instanceof DeclarativeBase)) continue - - // skip nodes that are possiblyDistributed, i.e. they have a parent - // that has a ShadowRoot. - if (!hasHtmlApi || !child.__isPossiblyDistributedToShadowRoot) child.traverseComposed(cb) - - cb(child) - } - - const distributedChildren = this.__distributedChildren - if (hasHtmlApi && distributedChildren) { - for (const shadowChild of distributedChildren) { - shadowChild.traverseComposed(cb) - cb(shadowChild) - } - } - } - // TODO: make setAttribute accept non-string values. setAttribute(attr: string, value: any) { super.setAttribute(attr, value) } - get _hasShadowRoot() { + // TODO move the following ShadowDOM stuff into a more generic place like LUME Element. + + // COMPOSED TREE TRACKING: + get _hasShadowRoot(): boolean { return !!this.__shadowRoot } - get _isPossiblyDistributedToShadowRoot() { + // COMPOSED TREE TRACKING: + get _isPossiblyDistributedToShadowRoot(): boolean { return this.__isPossiblyDistributedToShadowRoot } - get _shadowRootParent() { + // COMPOSED TREE TRACKING: + get _shadowRootParent(): DeclarativeBase | null { return this.__shadowRootParent } - get _distributedParent() { + get _shadowRootChildren(): DeclarativeBase[] { + if (!this.__shadowRoot) return [] + + return Array.from(this.__shadowRoot.children).filter((n): n is DeclarativeBase => n instanceof DeclarativeBase) + } + + // COMPOSED TREE TRACKING: Elements that are distributed to a slot that is + // child of a ShadowRoot of this element. + get _distributedShadowRootChildren(): DeclarativeBase[] { + const result: DeclarativeBase[] = [] + + for (const child of Array.from(this.__shadowRoot?.children || [])) { + if (child instanceof HTMLSlotElement && !child.assignedSlot) { + for (const distributed of child.assignedElements({flatten: true})) { + if (isNode(distributed)) result.push(distributed) + } + } + } + + return result + } + + // COMPOSED TREE TRACKING: + get _distributedParent(): DeclarativeBase | null { return this.__distributedParent } - get _distributedChildren() { + // COMPOSED TREE TRACKING: + get _distributedChildren(): DeclarativeBase[] | null { return this.__distributedChildren ? [...this.__distributedChildren] : null } - // The composed parent is the parent that this node renders relative + // COMPOSED TREE TRACKING: The composed parent is the parent that this element renders relative // to in the flat tree (composed tree). get _composedParent(): HTMLElement | DeclarativeBase | null { - return this.__distributedParent || this.__shadowRootParent || this.parentElement + const parent = this.__distributedParent || this.__shadowRootParent || this.parentElement + + if (parent instanceof HTMLSlotElement) { + // If this element is a child of a element (i.e. this + // element is a slot's default content), then return null if the + // slot has anything slotted to it in which case default content + // does not participate in the composed tree. + if (parent.assignedElements({flatten: true}).length) return null + else return parent.parentElement + } + + return parent } - // Composed children are the children that render relative to this - // node in the flat tree (composed tree), whether as children of a + // COMPOSED TREE TRACKING: Composed children are the children that render relative to this + // element in the flat tree (composed tree), whether as children of a // shadow root, or distributed children (assigned nodes) of a // element. get _composedChildren(): DeclarativeBase[] { - if (this.__shadowRoot) { - // We only care about DeclarativeBase nodes. + if (!this.isScene && this.__shadowRoot) { // TODO move this composed stuff to a separate class that has - // no limitation on which types of noeds it observes, then use + // no limitation on which types of nodes it observes, then use // it here and apply the restriction. - return [...Array.prototype.filter.call(this.__shadowRoot.children, n => n instanceof DeclarativeBase)] + return [...this._distributedShadowRootChildren, ...this._shadowRootChildren] } else { return [ - ...(this.__distributedChildren || []), // TODO perhaps use slot.assignedNodes instead? - ...(Array.from(this.children).filter(n => n instanceof DeclarativeBase) as DeclarativeBase[]), + ...(this.__distributedChildren || []), // TODO perhaps use slot.assignedElements instead? + // We only care about DeclarativeBase nodes. + ...Array.from(this.children).filter((n): n is DeclarativeBase => n instanceof DeclarativeBase), ] } } - // This node's shadow root, if any. This always points to the shadow + // COMPOSED TREE TRACKING: This element's shadow root, if any. This always points to the shadow // root, even if it is a closed root, unlike the public shadowRoot // property. __shadowRoot?: ShadowRoot - // All elements of this node, if any. - __slots?: HTMLSlotElement[] - - // 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. + // COMPOSED TREE TRACKING: True when this element has a parent that has a shadow root. __isPossiblyDistributedToShadowRoot = false - __prevAssignedNodes?: WeakMap + __prevAssignedNodes?: WeakMap - // 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. + // COMPOSED TREE TRACKING: + // A map of the slot elements that are children of this element and + // their last-known assigned elements. When a slotchange happens while + // this element is in a shadow root and has a slot child, we can + // detect what the difference is between the last known assigned elements and the new + // ones. get __previousSlotAssignedNodes() { if (!this.__prevAssignedNodes) this.__prevAssignedNodes = new WeakMap() return this.__prevAssignedNodes } - // If this node is distributed into a shadow tree, this will - // reference the parent of the element where this node is - // distributed to. Basically, this node will render as a child of - // that parent node in the flat tree (composed tree). + // COMPOSED TREE TRACKING: + // If this element is distributed into a shadow tree, this will + // reference the parent element of the element where this element is + // distributed to. This element will render as a child of + // that parent element in the flat tree (composed tree). __distributedParent: DeclarativeBase | null = null - // If this node is a top-level child of a shadow root, then this + // COMPOSED TREE TRACKING: + // If this element is a top-level child of a shadow root, then this // points to the parent of the shadow root. The shadow root parent - // is the node that this node renders relative to in the flat tree + // is the element that this element renders relative to in the flat tree // (composed tree). __shadowRootParent: DeclarativeBase | null = null - __isComposed = false - + // COMPOSED TREE TRACKING: // If this element has a child element while in // a shadow root, then this will be a Set of the nodes distributed // into the , and those nodes render relatively - // to this node in the flat tree. We instantiate this later, only + // to this element in the flat tree. We instantiate this later, only // when/if needed. __distributedChildren?: Set + // COMPOSED TREE TRACKING: Called when a child is added to the ShadowRoot of this element. + // This does not run for Scene instances, which already have a root for their rendering implementation. __shadowRootChildAdded(child: HTMLElement) { // NOTE Logic here is similar to childConnectedCallback if (child instanceof DeclarativeBase) { child.__shadowRootParent = this - this.__childComposedCallback(child, 'root') + this.__triggerChildComposedCallback(child, 'root') } else if (child instanceof HTMLSlotElement) { child.addEventListener('slotchange', this.__onChildSlotChange) - this.__handleDistributedChildren(child /*, true*/) + this.__handleDistributedChildren(child) } } + // COMPOSED TREE TRACKING: Called when a child is removed from the ShadowRoot of this element. + // This does not run for Scene instances, which already have a root for their rendering implementation. __shadowRootChildRemoved(child: HTMLElement) { // NOTE Logic here is similar to childDisconnectedCallback if (child instanceof DeclarativeBase) { child.__shadowRootParent = null - this.__childUncomposedCallback(child, 'root') + this.__triggerChildUncomposedCallback(child, 'root') } else if (child instanceof HTMLSlotElement) { - child.removeEventListener('slotchange', this.__onChildSlotChange) + child.removeEventListener('slotchange', this.__onChildSlotChange, {capture: true}) this.__handleDistributedChildren(child) this.__previousSlotAssignedNodes.delete(child) } } - __onChildSlotChange = (event: Event) => { - const slot = event.target as HTMLSlotElement // must be a element, if the event is slotchange - this.__handleDistributedChildren(slot) + // COMPOSED TREE TRACKING: Called when a slot child of this element emits a slotchange event. + // TODO we need an @lazy decorator instead of making this a getter manually. + get __onChildSlotChange(): (event: Event) => void { + if (this.__onChildSlotChange__) return this.__onChildSlotChange__ + + this.__onChildSlotChange__ = (event: Event) => { + // event.currentTarget is the slot that this event handler is on, + // while event.target is always the slot from the ancestor-most + // tree if that slot is assigned to this slot or another slot that + // ultimate distributes to this slot. + const slot = event.currentTarget as HTMLSlotElement + + this.__handleDistributedChildren(slot) + } + + return this.__onChildSlotChange__ } + __onChildSlotChange__?: (event: Event) => void + + // COMPOSED TREE TRACKING: Life cycle methods for use by subclasses to run + // logic when children are composed or uncomposed to them in the composed + // tree. + // TODO: enable composition tracking only if a sublass instance has one of + // these methods in place, otherwise don't waste the resources. childComposedCallback?(child: Element, connectionType: ConnectionType): void childUncomposedCallback?(child: Element, connectionType: ConnectionType): void - __childComposedCallback(child: DeclarativeBase, connectionType: ConnectionType) { - if (child.__isComposed) return - child.__isComposed = true + __triggerChildComposedCallback(child: DeclarativeBase, connectionType: ConnectionType) { + if (!this.childComposedCallback) return - this.childComposedCallback && this.childComposedCallback(child, connectionType) - } + const isUpgraded = child.matches(':defined') - __childUncomposedCallback(child: DeclarativeBase, connectionType: ConnectionType) { - if (!child.__isComposed) return - child.__isComposed = false + if (isUpgraded) { + this.childComposedCallback(child, connectionType) + } else { + customElements.whenDefined(child.tagName.toLowerCase()).then(() => { + this.childComposedCallback!(child, connectionType) + }) + } + } + __triggerChildUncomposedCallback(child: DeclarativeBase, connectionType: ConnectionType) { this.childUncomposedCallback && this.childUncomposedCallback(child, connectionType) } + // COMPOSED TREE TRACKING: This is called in certain cases when distributed + // children may have changed, f.e. when a slot was added to this element, or + // when a child slot of this element has had assigned nodes changed + // (slotchange). __handleDistributedChildren(slot: HTMLSlotElement) { const diff = this.__getDistributedChildDifference(slot) @@ -356,7 +408,7 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) if (!this.__distributedChildren) this.__distributedChildren = new Set() this.__distributedChildren.add(addedNode) - this.__childComposedCallback(addedNode, 'slot') + this.__triggerChildComposedCallback(addedNode, 'slot') } const {removed} = diff @@ -369,17 +421,29 @@ export class DeclarativeBase extends DefaultBehaviors(WithChildren(LumeElement)) this.__distributedChildren!.delete(removedNode) if (!this.__distributedChildren!.size) this.__distributedChildren = undefined - this.__childUncomposedCallback(removedNode, 'slot') + this.__triggerChildUncomposedCallback(removedNode, 'slot') } } + // COMPOSED TREE TRACKING: Get the difference between the last assigned + // elements and current assigned elements of a child slot of this element. __getDistributedChildDifference(slot: HTMLSlotElement) { - let previousNodes = this.__previousSlotAssignedNodes.get(slot) ?? [] - - const newNodes = slot.assignedNodes({flatten: true}) - - // save the newNodes to be used as the previousNodes for next time. - this.__previousSlotAssignedNodes.set(slot, newNodes) + const previousNodes = this.__previousSlotAssignedNodes.get(slot) ?? [] + + // If this slot is assigned to another slot, then we don't consider any + // of the slot's assigned nodes as being distributed to the current element, + // because instead they are distributed to an element further down in the + // composed tree where this slot is assigned to. + // Special case for Scenes: we don't care if slot children of a Scene + // distribute to a deeper slot, because a Scene's ShadowRoot is for the rendering + // implementation and not the user's distribution, so we only want to detect + // elements slotted directly to the Scene in that case. + const newNodes = !this.isScene && slot.assignedSlot ? [] : slot.assignedElements({flatten: true}) + + // Save the newNodes to be used as the previousNodes for next time + // (clone it so the following in-place modification doesn't ruin any + // assumptions in the next round). + this.__previousSlotAssignedNodes.set(slot, [...newNodes]) const diff: {added: Node[]; removed: Node[]} = { added: newNodes, diff --git a/packages/lume/src/core/HtmlScene.ts b/packages/lume/src/core/HtmlScene.ts index 214001f69..f1ff75adf 100644 --- a/packages/lume/src/core/HtmlScene.ts +++ b/packages/lume/src/core/HtmlScene.ts @@ -97,26 +97,6 @@ export class HtmlScene extends ImperativeBase { ` - // from Scene - // TODO PossiblyScene type, or perhaps a mixin that can be applied to the - // Scene class to make it gain the HTML interface - _mounted = false - mount?(f?: string | Element | null): void - unmount?(): void - - connectedCallback() { - super.connectedCallback() - - // When the HTMLScene gets addded to the DOM, make it be "mounted". - if (!this._mounted) this.mount!(this.parentNode as Element) - } - - disconnectedCallback() { - super.disconnectedCallback() - - this.unmount!() - } - // WebGLRendererThree appends its content into here. _glLayer: HTMLDivElement | null = null diff --git a/packages/lume/src/core/ImperativeBase.ts b/packages/lume/src/core/ImperativeBase.ts index 7dd264bde..71dad6d07 100644 --- a/packages/lume/src/core/ImperativeBase.ts +++ b/packages/lume/src/core/ImperativeBase.ts @@ -69,10 +69,6 @@ export type BaseAttributes = TransformableAttributes // TODO switch to @element('element-name', false) and use defineElement in html/index.ts @element export class ImperativeBase extends Settable(Transformable) { - // we don't need this, keep for backward compatibility (mainly - // all my demos at trusktr.io). - imperativeCounterpart = this - // TODO re-organize variables like isScene and isNode, so they come from // one place. f.e. isScene is currently also used in DeclarativeBase. @@ -191,14 +187,6 @@ export class ImperativeBase extends Settable(Transformable) { super.connectedCallback() this._stopFns.push( - autorun(() => { - this.rotation - this._updateRotation() - }), - autorun(() => { - this.scale - this._updateScale() - }), autorun(() => { this.sizeMode this.size @@ -218,9 +206,11 @@ export class ImperativeBase extends Settable(Transformable) { }) }), autorun(() => { - if (!this.parent) return + const composedParent = this.composedSceneGraphParent + + if (!composedParent) return - this.parent.calculatedSize + composedParent.calculatedSize untrack(() => { if ( @@ -272,28 +262,20 @@ export class ImperativeBase extends Settable(Transformable) { } /** - * Called whenever a node is connected, but this is called with + * Called whenever a node is connected. This is called with * a connectionType that tells us how the node is connected * (relative to the "flat tree" or "composed tree"). * - * @param {"root" | "slot" | "actual"} connectionType - If the - * value is "root", then the child was connected as a child of a - * shadow root of the current node. If the value is "slot", then - * the child was distributed to the current node via a slot. If - * the value is "actual", then the child was connect to the - * current node as a regular child ("actual" is the same as - * childConnectedCallback). + * @param {"root" | "slot" | "actual"} connectionType - If the value is + * "root", then the child was connected as a child of a shadow root of the + * current node. If the value is "slot", then the child was distributed to + * the current node via a slot. If the value is "actual", then the + * child was connected to the current node as a regular child + * (childComposedCallback with "actual" being passed in is essentially the + * same as childConnectedCallback). */ - childComposedCallback(child: Element, connectionType: ConnectionType): void { + childComposedCallback(child: Element, _connectionType: ConnectionType): void { if (child instanceof ImperativeBase) { - // If ImperativeBase#add was called first, child's - // `parent` will already be set, so prevent recursion. - if (!child.parent) { - // mirror the DOM connections in the imperative API's virtual scene graph. - const __updateDOMConnection = connectionType === 'actual' - this.add(child, __updateDOMConnection) - } - // Calculate sizing because proportional size might depend on // the new parent. child._calcSize() @@ -303,18 +285,8 @@ export class ImperativeBase extends Settable(Transformable) { } } - childUncomposedCallback(child: Element, connectionType: ConnectionType): void { - if (child instanceof ImperativeBase) { - // If ImperativeBase#removeNode was called first, child's - // `parent` will already be null, so prevent recursion. - if (child.parent) { - // mirror the connection in the imperative API's virtual scene graph. - const __updateDOMConnection = connectionType === 'actual' - this.removeNode(child, __updateDOMConnection) - } - - this.__possiblyUnloadThree(child) - } + childUncomposedCallback(child: Element, _connectionType: ConnectionType): void { + if (child instanceof ImperativeBase) this.__possiblyUnloadThree(child) } /** @@ -326,15 +298,14 @@ export class ImperativeBase extends Settable(Transformable) { // This traverses recursively upward at first, then the value is cached on // subsequent reads. - // NOTE: this._scene is initally null. - - const parent = this.parent - // const parent = this.parent || this._composedParent + const parent = this.composedSceneGraphParent // if already cached, return it. Or if no parent, return it (it'll be null). // Additionally, Scenes have this._scene already set to themselves. + // NOTE: this._scene is initally null. if (this._scene || !parent) return this._scene! + // @prod-prune if (!(parent instanceof ImperativeBase)) throw new Error('Expected instance of ImperativeBase') // if the parent node already has a ref to the scene, use that. @@ -355,62 +326,23 @@ export class ImperativeBase extends Settable(Transformable) { } /** - * This overrides the `parent` property of the `TreeNode` class to restrict - * parents to being `ImperativeBase` (`Node` or `Scene`) instances. + * Overrides [`TreeNode.lumeParent`](./TreeNode?id=lumeparent) to assert + * that parents are `ImperativeBase` (`Node` or `Scene`) instances. */ - // This override serves mainly to change the type of `parent` for + // This override serves to change the type of `lumeParent` for // subclasses of ImperativeBase. // Nodes (f.e. Mesh, Sphere, etc) and Scenes should always have parents - // that are Nodes or Scenes (at least for now). The overridden add() - // method below enforces this. + // that are Nodes or Scenes (at least for now). // @prod-prune - get parent(): ImperativeBase | null { - const parent = super.parent + override get lumeParent(): ImperativeBase | null { + const parent = super.lumeParent + // @prod-prune if (parent && !(parent instanceof ImperativeBase)) throw new TypeError('Parent must be type ImperativeBase.') return parent } - /** - * @override - */ - add(childNode: ImperativeBase, /* private */ __updateDOMConnection = true): this { - if (!(childNode instanceof ImperativeBase)) return this - - // We cannot add Scenes to Nodes, for now. - if (isScene(childNode)) { - return this - - // TODO Figure how to handle nested scenes. We were throwing - // this error, but it has been harmless not to throw in the - // existing demos. - // throw new TypeError(` - // A Scene cannot be added to another Node or Scene (at - // least for now). To place a Scene in a Node, just mount - // a new Scene onto an HTMLNode with Scene.mount(). - // `) - } - - super.add(childNode) - - // FIXME remove the type cast here and modify it so it is - // DOM-agnostic for when we run thsi in a non-DOM environment. - if (__updateDOMConnection) this._elementOperations.connectChildElement(childNode as unknown as HTMLElement) - - return this - } - - removeNode(childNode: ImperativeBase, /* private */ __updateDOMConnection = true): this { - if (!isNode(childNode)) return this - - super.removeNode(childNode) - - if (__updateDOMConnection) this._elementOperations.disconnectChildElement(childNode) - - return this - } - /** * @method needsUpdate - Schedules a rendering update for the element. Usually you don't need to call this when using the outer APIs. * @@ -434,14 +366,17 @@ export class ImperativeBase extends Settable(Transformable) { // if (!this.scene || !this.isConnected) return // TODO make sure we render when connected into a tree with a scene - this._willBeRendered = true + // TODO, we already call Motor.setNodeToBeRendered(node), so instead + // of having a __willBeRendered property, we can have a + // Motor.nodeWillBeRendered(node) method. + this.__willBeRendered = true Motor.setNodeToBeRendered(this) } _glLoaded = false @reactive _cssLoaded = false - _willBeRendered = false + __willBeRendered = false get _elementOperations(): ElementOperations { if (!elOps.has(this)) elOps.set(this, new ElementOperations(this)) @@ -481,75 +416,34 @@ export class ImperativeBase extends Settable(Transformable) { } _connectThree(): void { - if ( - this._isPossiblyDistributedToShadowRoot && - // check parent isn't a Scene because Scenes always - // have shadow roots, and we treat distribution into - // the Scene shacow root different than with all - // other Nodes. - this.parent !== this.scene - ) { - if (this._distributedParent) { - // TODO make sure this check works. - // @prod-prune - // if (!(this._distributedParent instanceof ImperativeBase)) - // throw new Error('expected _distributedParent to be ImperativeBase') - - ;(this._distributedParent as ImperativeBase).three.add(this.three) - } - } else if (this._shadowRootParent) { - // TODO make sure this check works. - // @prod-prune - // if (!(this._shadowRootParent instanceof ImperativeBase)) - // throw new Error('expected _distributedParent to be ImperativeBase') - - ;(this._shadowRootParent as ImperativeBase).three.add(this.three) - } else { - // TODO make sure this check works. - // @prod-prune - // TODO instanceof check doesn't work here. Investigate Symbol.hasInstance feature in Mixin. - // if (!(this.parent instanceof ImperativeBase)) throw new Error('expected parent to be ImperativeBase') - - this.parent && (this.parent as ImperativeBase).three.add(this.three) - } - + this.composedSceneGraphParent?.three.add(this.three) this.needsUpdate() } _connectThreeCSS(): void { - // @ts-ignore - if ( - this._isPossiblyDistributedToShadowRoot && - // check parent isn't a Scene because Scenes always - // have shadow roots, and we treat distribution into - // the Scene shacow root different than with all - // other Nodes. - this.parent !== this.scene - ) { - if (this._distributedParent) { - // TODO make sure this check works. - // @prod-prune - // if (!(this._distributedParent instanceof ImperativeBase)) - // throw new Error('Expected _distributedParent to be a LUME Node.') - - ;(this._distributedParent as ImperativeBase).threeCSS.add(this.threeCSS) - } - } else if (this._shadowRootParent) { - // TODO make sure this check works. - // @prod-prune - // if (!(this._shadowRootParent instanceof ImperativeBase)) - // throw new Error('Expected _distributedParent to be a LUME Node.') - - ;(this._shadowRootParent as ImperativeBase).threeCSS.add(this.threeCSS) - } else { - // TODO make sure this check works. - // @prod-prune - // if (!(this.parent instanceof ImperativeBase)) throw new Error('Expected parent to be a LUME Node.') + this.composedSceneGraphParent?.threeCSS.add(this.threeCSS) + this.needsUpdate() + } - this.parent && (this.parent as ImperativeBase).threeCSS.add(this.threeCSS) - } + get composedLumeParent(): ImperativeBase | null { + const result = super.composedLumeParent + if (!(result instanceof ImperativeBase)) return null + return result + } - this.needsUpdate() + get composedSceneGraphParent(): ImperativeBase | null { + // check if lumeParent is a Scene because Scenes always have shadow + // roots as part of their implementation (users will not be adding + // shadow roots to them), and we treat distribution into a Scene shadow + // root different than with all other Nodes (users can add shadow roots + // to those). Otherwise _distributedParent for a lume-node that is + // child of a lume-scene will be a non-LUME element that is inside of + // the lume-scene's ShadowRoot, and things will not work in that case + // because the top-level Node elements will seem to not be composed to + // any Scene element. + if (this.lumeParent?.isScene) return this.lumeParent + + return this.composedLumeParent } _glStopFns: StopFunction[] = [] @@ -562,17 +456,9 @@ export class ImperativeBase extends Settable(Transformable) { this._glLoaded = true // we don't let Three update local matrices automatically, we do - // it ourselves in Transformable._calculateMatrix and - // Transformable._calculateWorldMatricesInSubtree + // it ourselves in _calculateMatrix and _calculateWorldMatricesInSubtree this.three.matrixAutoUpdate = false - // NOTE, this.parent works here because _loadGL - // is called by childConnectedCallback (or when - // distributed to a shadow root) at which point a child - // is already upgraded and thus has this.parent - // API ready. Only a Scene has no parent. - // - // this.parent && this.parent.three.add(this.three) this._connectThree() this.needsUpdate() @@ -606,17 +492,10 @@ export class ImperativeBase extends Settable(Transformable) { if (this._cssLoaded) return false this._cssLoaded = true - // we don't let Three update local matrices automatically, we do - // it ourselves in Transformable._calculateMatrix and - // Transformable._calculateWorldMatricesInSubtree + // We don't let Three update local matrices automatically, we do + // it ourselves in _calculateMatrix and _calculateWorldMatricesInSubtree. this.threeCSS.matrixAutoUpdate = false - // NOTE, this.parent works here because _loadCSS - // is called by childConnectedCallback (or when - // distributed to a shadow root) at which point a child - // is already upgraded and thus has this.parent - // API ready. Only a Scene has no parent. - // this.parent && this.parent.threeCSS.add(this.threeCSS) this._connectThreeCSS() this.needsUpdate() @@ -659,8 +538,6 @@ export class ImperativeBase extends Settable(Transformable) { this.emit(Events.GL_LOAD, this) }) - - for (const child of this.subnodes) (child as ImperativeBase)._triggerLoadGL() } _triggerUnloadGL(): void { @@ -673,7 +550,6 @@ export class ImperativeBase extends Settable(Transformable) { if (!this._loadCSS()) return this.emit(Events.CSS_LOAD, this) - for (const child of this.subnodes) (child as ImperativeBase)._triggerLoadCSS() } _triggerUnloadCSS(): void { @@ -708,11 +584,6 @@ export class ImperativeBase extends Settable(Transformable) { threeJsPostAdjustment[1] = size.y / 2 threeJsPostAdjustment[2] = size.z / 2 - // TODO If a Scene has a `parent`, it is not mounted directly into a - // regular DOM element but rather it is child of a Node. In this - // case we don't want the scene size to be based on observed size - // of a regular DOM element, but relative to a parent Node just - // like for all other Nodes. const parentSize = this._getParentSize() // THREE-COORDS-TO-DOM-COORDS @@ -830,7 +701,8 @@ export class ImperativeBase extends Settable(Transformable) { // in _calculateMatrix so that it has the same effect. this.three.rotation.set(-toRadians(x), toRadians(y), -toRadians(z)) - const childOfScene = this.parent?.isScene + // @ts-ignore duck typing with use of isScene + const childOfScene = this.composedSceneGraphParent?.isScene // TODO write a comment as to why we needed the childOfScne check to // alternate rotation directions here. It's been a while, I forgot @@ -848,15 +720,26 @@ export class ImperativeBase extends Settable(Transformable) { this.threeCSS.scale.set(x, y, z) } - _calculateWorldMatricesInSubtree(): void { + updateWorldMatrices(): void { this.three.updateMatrixWorld() this.threeCSS.updateMatrixWorld() this.emit('worldMatrixUpdate') } - /** This is called by Motor on each update before the GL or CSS renderers will re-render. */ - // TODO rename "render" to "update". "render" is more for the renderer classes. - _render(_timestamp: number, _deltaTime: number): void { + /** + * This is called by Motor on each update before the GL or CSS renderers + * will re-render. This ultimately fires as a response to updating any of a + * node's reactive properties. It does not fire repeatedly, it only fires + * as a response to modifying any of a node's properties/attributes + * (modifying a property enqueues a render task which calls update). This + * is called only once per browser animation frame (essentially "batched"). + * You can modify many properties, then this will finally fire once in the + * next animation frame. + */ + update(_timestamp: number, _deltaTime: number): void { + this._updateRotation() + this._updateScale() + // TODO: only run this when necessary (f.e. not if only opacity // changed, only if position/align/mountPoint changed, etc) this._calculateMatrix() @@ -869,19 +752,15 @@ export class ImperativeBase extends Settable(Transformable) { } // This method is used by Motor._renderNodes(). - _getNearestAncestorThatShouldBeRendered(): ImperativeBase | false { - let parent = this.parent - - while (parent) { - // TODO it'd be nice to have a way to prune away runtime type checks in prod mode. - // @prod-prune - if (!(parent instanceof ImperativeBase)) throw new Error('expected ImperativeBase') + getNearestAncestorThatShouldBeRendered(): ImperativeBase | null { + let composedParent = this.composedSceneGraphParent - if (parent._willBeRendered) return parent - parent = parent.parent + while (composedParent) { + if (composedParent.__willBeRendered) return composedParent + composedParent = composedParent.composedSceneGraphParent } - return false + return null } } diff --git a/packages/lume/src/core/Motor.ts b/packages/lume/src/core/Motor.ts index 110b3cfe1..0ee9ba8e2 100644 --- a/packages/lume/src/core/Motor.ts +++ b/packages/lume/src/core/Motor.ts @@ -150,12 +150,12 @@ class _Motor { // read this.scene which would then set this.scene. if (!node.scene) continue - node._render(timestamp, deltaTime) + node.update(timestamp, deltaTime) // if there is no ancestor of the current node that should be // rendered, then the current node is a root node of a subtree // that needs to be updated - if (!node._getNearestAncestorThatShouldBeRendered() && !this.#treesToUpdate.includes(node)) { + if (!node.getNearestAncestorThatShouldBeRendered() && !this.#treesToUpdate.includes(node)) { this.#treesToUpdate.push(node) } @@ -167,7 +167,7 @@ class _Motor { // Update world matrices of the subtrees. const treesToUpdate = this.#treesToUpdate for (let i = 0, l = treesToUpdate.length; i < l; i += 1) { - treesToUpdate[i]._calculateWorldMatricesInSubtree() + treesToUpdate[i].updateWorldMatrices() } treesToUpdate.length = 0 @@ -180,7 +180,7 @@ class _Motor { const nodesToUpdate = this.#nodesToUpdate for (let i = 0, l = nodesToUpdate.length; i < l; i += 1) { - nodesToUpdate[i]._willBeRendered = false + nodesToUpdate[i].__willBeRendered = false } nodesToUpdate.length = 0 } diff --git a/packages/lume/src/core/Node.test.ts b/packages/lume/src/core/Node.test.ts index 3902f5fd6..30a42dedd 100644 --- a/packages/lume/src/core/Node.test.ts +++ b/packages/lume/src/core/Node.test.ts @@ -9,10 +9,10 @@ describe('Node', () => { const body = document.body afterEach(() => { - scene.unmount() + scene.remove() body.innerHTML = '' scene = new Scene() - scene.mount(body) + body.append(scene) }) it('default values', async () => { @@ -52,7 +52,7 @@ describe('Node', () => { it('element is an instance of Node, created with `new`', async () => { const n = new Node() - scene.add(n) + scene.append(n) expect(n instanceof Node).toBe(true) // expect(n.constructor.name).toBe('Node') // Not reliable, minification can mangle the names, or decorators can inject constructors with differing names. @@ -65,7 +65,7 @@ describe('Node', () => { // TODO: is there a better way than casting the result of createElement? const n = document.createElement('lume-node') as Node - scene.add(n) + scene.append(n) expect(n instanceof Node).toBe(true) // expect(n.constructor.name).toBe('Node') // Not reliable, minification can mangle the names, or decorators can inject constructors with differing names. diff --git a/packages/lume/src/core/Node.ts b/packages/lume/src/core/Node.ts index e9257782b..09c47bbc1 100644 --- a/packages/lume/src/core/Node.ts +++ b/packages/lume/src/core/Node.ts @@ -142,14 +142,61 @@ export class Node extends HtmlInterface { // The `parent` property can already be set if this instance is // already in the DOM and wwhile being upgraded into a custom // element. - // TODO Remove this after we make it lazy and deferred this to a + // TODO Remove this after we make _calcSize lazy and deferred to a // render task. - if (this.parent) { + if (this.composedLumeParent) { this._calcSize() this.needsUpdate() } } + get composedLumeChildren(): Node[] { + const result: Node[] = [] + for (const child of super.composedLumeChildren) if (isNode(child)) result.push(child) + return result + } + + /** + * @method traverseSceneGraph - This traverses the the composed tree of + * LUME 3D elements (the scene graph) including this element, in pre-order. It skips non-LUME elements. + * @param {(node: Node) => void} visitor - A function called for each + * LUME node in the scene graph (the composed tree). + * @param {boolean} waitForUpgrade - Defaults to `false`. If `true`, + * the traversal will wait for custom elements to be defined (with + * customElements.whenDefined) before traversing to them. + * @returns {void | Promise} - If `waitForUpgrade` is `false`, + * the traversal will complete synchronously, and the return value will be + * `undefined`. If `waitForUpgrade` is `true`, then traversal completes + * asynchronously as soon as all custom elements are defined, and a Promise is + * returned so that it is possible to wait for the traversal to complete. + */ + traverseSceneGraph(visitor: (node: Node) => void, waitForUpgrade = false): Promise | void { + visitor(this) + + if (!waitForUpgrade) { + for (const child of this.composedLumeChildren) child.traverseSceneGraph(visitor, waitForUpgrade) + return + } + + // if waitForUpgrade is true, we make a promise chain so that + // traversal order is still the same as when waitForUpgrade is false. + let promise: Promise = Promise.resolve() + + for (const child of this.composedLumeChildren) { + const isUpgraded = child.matches(':defined') + + if (isUpgraded) { + promise = promise!.then(() => child.traverseSceneGraph(visitor, waitForUpgrade)) + } else { + promise = promise! + .then(() => customElements.whenDefined(child.tagName.toLowerCase())) + .then(() => child.traverseSceneGraph(visitor, waitForUpgrade)) + } + } + + return promise + } + _loadCSS() { if (!super._loadCSS()) return false @@ -202,3 +249,7 @@ declare module '@lume/element' { } } } + +function isNode(n: any): n is Node { + return n.isNode +} diff --git a/packages/lume/src/core/ResizeObserver.ts b/packages/lume/src/core/ResizeObserver.ts index 4b6edaa87..46565d23d 100644 --- a/packages/lume/src/core/ResizeObserver.ts +++ b/packages/lume/src/core/ResizeObserver.ts @@ -3,7 +3,6 @@ import RO from 'resize-observer-polyfill/dist/ResizeObserver.es.js' import {getGlobal} from '../utils/getGlobal.js' -// TODO make sure this does not polyfill unless needed. export function possiblyPolyfillResizeObserver() { if (typeof ResizeObserver !== 'undefined') return getGlobal().ResizeObserver = RO diff --git a/packages/lume/src/core/Scene.ts b/packages/lume/src/core/Scene.ts index dbdc4ac97..11995465f 100644 --- a/packages/lume/src/core/Scene.ts +++ b/packages/lume/src/core/Scene.ts @@ -13,18 +13,18 @@ import {FogExp2} from 'three/src/scenes/FogExp2.js' import {WebglRendererThree, ShadowMapTypeString} from '../renderers/WebglRendererThree.js' import {Css3dRendererThree} from '../renderers/Css3dRendererThree.js' import {HtmlScene as HTMLInterface} from './HtmlScene.js' -import {documentBody, thro, trim} from './utils.js' +import {defer, thro} from './utils.js' import {possiblyPolyfillResizeObserver} from './ResizeObserver.js' import {isDisposable} from '../utils/three.js' import {Motor} from './Motor.js' -import type {ImperativeBase} from './ImperativeBase.js' +import type {DeclarativeBase} from './DeclarativeBase.js' import type {TColor} from '../utils/three.js' import type {PerspectiveCamera} from '../cameras/PerspectiveCamera.js' import type {XYZValuesObject} from '../xyz-values/XYZValues.js' import type {Sizeable} from './Sizeable.js' import type {SizeableAttributes} from './Sizeable.js' -import type {TreeNode} from './TreeNode.js' +import type {Node} from './Node.js' export type SceneAttributes = // Don't expost TransformableAttributes here for now (although they exist). What should modifying those on a Scene do? @@ -361,84 +361,27 @@ export class Scene extends HTMLInterface { // "literal". this._elementOperations.shouldRender = true - // size of the element where the Scene is mounted - // NOTE: z size is always 0, since native DOM elements are always flat. - this._elementParentSize = {x: 0, y: 0, z: 0} - this._createDefaultCamera() this._calcSize() this.needsUpdate() } - drawScene() { - this.#glRenderer && this.#glRenderer.drawScene(this) - this.#cssRenderer && this.#cssRenderer.drawScene(this) - } - - /** - * @method mount - Mount the scene into the given target. - * - * @param {string|HTMLElement} [mountPoint=document.body] If a string selector is provided, - * the mount point will be selected from the DOM. If an HTMLElement is - * provided, that will be the mount point. If no mount point is provided, - * the scene will be mounted into document.body (possibly waiting for the body to - * exist if it does not yet exist). - */ - async mount(mountPoint?: string | HTMLElement) { - let _mountPoint: string | Element | null | undefined = mountPoint + // size of the element where the Scene is mounted + // NOTE: z size is always 0, since native DOM elements are always flat. + _elementParentSize: XYZValuesObject = {x: 0, y: 0, z: 0} - // if no mountPoint was provided, just mount onto the element. - if (_mountPoint === undefined) { - if (!document.body) await documentBody() - _mountPoint = document.body - } - - // if the user supplied a selector, mount there. - else if (typeof _mountPoint === 'string') { - const selector = _mountPoint - - _mountPoint = document.querySelector(selector) - if (!_mountPoint && document.readyState === 'loading') { - // maybe the element wasn't parsed yet, check again when the - // document is ready. - await documentReady() - _mountPoint = document.querySelector(selector) - } - } - - // At this point we should have an actual mount point (the user may have passed it in) - if (!(_mountPoint instanceof HTMLElement || _mountPoint instanceof ShadowRoot)) { - throw new Error( - trim(` - Invalid mount point specified in Scene.mount() call - (${_mountPoint}). Pass a selector or an HTMLElement. Not - passing any argument will cause the Scene to be mounted - to the . - `), - ) + static css = /*css*/ ` + ${HTMLInterface.css} + .vrButton { + color: black; + border-color: black; } + ` - // The user can mount to a new location without calling unmount - // first. Call it automatically in that case. - if (this._mounted) this.unmount() - - if (_mountPoint !== this.parentNode) _mountPoint.appendChild(this) - - this._mounted = true - } - - /** - * @method unmount - Unmount the scene from it's mount point. Use this when you are done using a scene. - */ - // TODO we can remove this. Use standard DOM APIs like `remove()` and - // replace use of `_mounted` with the standard `isConnected` property. - unmount() { - if (!this._mounted) return - - if (this.parentNode) this.parentNode.removeChild(this) - - this._mounted = false + drawScene() { + this.#glRenderer && this.#glRenderer.drawScene(this) + this.#cssRenderer && this.#cssRenderer.drawScene(this) } connectedCallback() { @@ -523,8 +466,19 @@ export class Scene extends HTMLInterface { this.#stopParentSizeObservation() } - _mounted = false - _elementParentSize: XYZValuesObject + static observedAttributes = ['slot'] + + attributeChangedCallback(name: string, oldV: string | null, newV: string | null) { + super.attributeChangedCallback!(name, oldV, newV) + + if (name === 'slot') { + defer(() => { + throw new Error( + 'Assigning a to a slot is not currently supported and may not work as expected. Instead, wrap the in another element like a
, then assign the wrapper to the slot.', + ) + }) + } + } makeThreeObject3d() { return new ThreeScene() @@ -534,6 +488,52 @@ export class Scene extends HTMLInterface { return new ThreeScene() } + get composedLumeChildren(): Node[] { + const result: Node[] = [] + for (const child of super.composedLumeChildren) if (isNode(child)) result.push(child) + return result + } + + /** + * @method traverseSceneGraph - This traverses the the composed tree of + * LUME 3D elements (the scene graph) not including this element, in pre-order. It skips non-LUME elements. + * @param {(node: Node) => void} visitor - A function called for each + * LUME node in the scene graph (the composed tree). + * @param {boolean} waitForUpgrade - Defaults to `false`. If `true`, + * the traversal will wait for custom elements to be defined (with + * customElements.whenDefined) before traversing to them. + * @returns {void | Promise} - If `waitForUpgrade` is `false`, + * the traversal will complete synchronously, and the return value will be + * `undefined`. If `waitForUpgrade` is `true`, then traversal completes + * asynchronously once all custom elements are defined, and a Promise is + * returned so that it is possible to wait for the traversal to complete. + */ + traverseSceneGraph(visitor: (node: Node) => void, waitForUpgrade = false): Promise | void { + if (!waitForUpgrade) { + for (const child of this.composedLumeChildren) child.traverseSceneGraph(visitor, waitForUpgrade) + + return + } + + // if waitForUpgrade is true, we make a promise chain so that + // traversal order is still the same as when waitForUpgrade is false. + let promise: Promise = Promise.resolve() + + for (const child of this.composedLumeChildren) { + const isUpgraded = child.matches(':defined') + + if (isUpgraded) { + promise = promise!.then(() => child.traverseSceneGraph(visitor, waitForUpgrade)) + } else { + promise = promise! + .then(() => customElements.whenDefined(child.tagName.toLowerCase())) + .then(() => child.traverseSceneGraph(visitor, waitForUpgrade)) + } + } + + return promise + } + _createDefaultCamera() { // Use untrack so this method is non-reactive. untrack(() => { @@ -596,7 +596,7 @@ export class Scene extends HTMLInterface { /** @override */ _getParentSize(): XYZValuesObject { - return this.parent ? (this.parent as Sizeable).calculatedSize : this._elementParentSize + return this.composedLumeParent ? (this.composedLumeParent as Sizeable).calculatedSize : this._elementParentSize } // For now, use the same program (with shaders) for all objects. @@ -607,8 +607,6 @@ export class Scene extends HTMLInterface { // maybe keep this in sceneState in WebGLRendererThree if (!super._loadGL()) return false - this._composedChildren - // We don't let Three update any matrices, we supply our own world // matrices. this.three.autoUpdate = false @@ -693,24 +691,11 @@ export class Scene extends HTMLInterface { }), ) - this.traverse((node: TreeNode) => { - // skip `this`, we already handled it above - if (node === this) return - - if (isImperativeBase(node)) node._triggerLoadGL() - }) + this.traverseSceneGraph((node: Node) => node._triggerLoadGL(), true) return true } - static css = /*css*/ ` - ${HTMLInterface.css} - .vrButton { - color: black; - border-color: black; - } - ` - _unloadGL() { if (!super._unloadGL()) return false @@ -719,12 +704,7 @@ export class Scene extends HTMLInterface { this.#glRenderer = null } - this.traverse((node: TreeNode) => { - // skip `this`, we already handled it above - if (node === this) return - - if (isImperativeBase(node)) node._triggerUnloadGL() - }) + this.traverseSceneGraph((node: Node) => node._triggerUnloadGL()) // Not all things are loaded in _loadGL (they may be loaded // depending on property/attribute values), but all things, if any, should @@ -742,12 +722,7 @@ export class Scene extends HTMLInterface { this.#cssRenderer = this.#getCSSRenderer('three') - this.traverse((node: TreeNode) => { - // skip `this`, we already handled it above - if (node === this) return - - if (isImperativeBase(node)) node._loadCSS() - }) + this.traverseSceneGraph((node: Node) => node._loadCSS(), true) return true } @@ -760,12 +735,7 @@ export class Scene extends HTMLInterface { this.#cssRenderer = null } - this.traverse((node: TreeNode) => { - // skip `this`, we already handled it above - if (node === this) return - - if (isImperativeBase(node)) node._unloadCSS() - }) + this.traverseSceneGraph((node: Node) => node._unloadCSS()) return true } @@ -821,7 +791,6 @@ export class Scene extends HTMLInterface { #parentSize: XYZValuesObject = {x: 0, y: 0, z: 0} - // HTM-API #startOrStopParentSizeObservation() { if ( // If we will be rendering something... @@ -840,15 +809,22 @@ export class Scene extends HTMLInterface { #resizeObserver: ResizeObserver | null = null - // observe size changes on the scene element. - // HTM-API + // observe size changes on the scene's parent. #startParentSizeObservation() { - const parent = - this.parentNode instanceof HTMLElement - ? this.parentNode - : this.parentNode instanceof ShadowRoot - ? this.parentNode.host - : thro('A Scene can only be child of an HTMLElement or ShadowRoot (and f.e. not an SVGElement).') + const parentError = 'A Scene can only be child of HTMLElement or ShadowRoot (f.e. not an SVGElement).' + + // TODO SLOTS use of _composedParent here won't fully work until we + // detect composed parent changes when the parent is not one of our own + // elements (f.e. when a scene is distributed via slot to a div). The only way + // to do this without polling is with a combination of monkey patching + // attachShadow and using MutationObserver in all trees to observe slot + // elements for slotchange. + // https://github.com/WICG/webcomponents/issues/941 + const parent = this._composedParent + + // This shouldn't be possible. + // @prod-prune + if (!parent) thro(parentError) // TODO use a single ResizeObserver for all scenes. @@ -893,7 +869,6 @@ export class Scene extends HTMLInterface { this.#resizeObserver.observe(parent) } - // HTM-API #stopParentSizeObservation() { this.#resizeObserver?.disconnect() this.#resizeObserver = null @@ -901,11 +876,10 @@ export class Scene extends HTMLInterface { // NOTE, the Z dimension of a scene doesn't matter, it's a flat plane, so // we haven't taken that into consideration here. - // HTM-API #checkSize(x: number, y: number) { const parentSize = this.#parentSize - // if we have a size change, emit parentsizechange + // if we have a size change if (parentSize.x != x || parentSize.y != y) { parentSize.x = x parentSize.y = y @@ -914,7 +888,6 @@ export class Scene extends HTMLInterface { } } - // HTM-API #onElementParentSizeChange(newSize: XYZValuesObject) { this._elementParentSize = newSize // TODO #66 defer _calcSize to an animation frame (via needsUpdate), @@ -925,13 +898,6 @@ export class Scene extends HTMLInterface { } } -function isImperativeBase(_n: TreeNode): _n is ImperativeBase { - // TODO make sure instanceof works. For all intents and purposes, we assume - // to always have an ImperativeNode where we use this. - // return n instanceof ImperativeBase - return true -} - import type {ElementAttributes} from '@lume/element' declare module '@lume/element' { @@ -948,14 +914,8 @@ declare global { } } -function documentReady() { - if (document.readyState === 'loading') { - return new Promise(resolve => { - document.addEventListener('DOMContentLoaded', () => resolve()) - }) - } +type FogMode = 'none' | 'linear' | 'expo2' - return Promise.resolve() +function isNode(n: DeclarativeBase): n is Node { + return n.isNode } - -type FogMode = 'none' | 'linear' | 'expo2' diff --git a/packages/lume/src/core/Sizeable.ts b/packages/lume/src/core/Sizeable.ts index aae75e561..d81763e5b 100644 --- a/packages/lume/src/core/Sizeable.ts +++ b/packages/lume/src/core/Sizeable.ts @@ -169,24 +169,18 @@ export class Sizeable extends TreeNode { this._stopFns.length = 0 } - get _renderParent(): Sizeable | null { - if (this.hasHtmlApi) { - return this._composedParent as Sizeable | null - } else { - return this.parent as Sizeable | null - } + get composedLumeParent(): Sizeable | null { + const result = super._composedParent + if (!(result instanceof Sizeable)) return null + return result } - get _renderChildren() { - if (this.hasHtmlApi) { - return this._composedChildren - } else { - return this.subnodes - } + get composedLumeChildren(): Sizeable[] { + return super._composedChildren as Sizeable[] } _getParentSize() { - return (this._renderParent && calculatedSize.get(this._renderParent)?.get()) ?? {x: 0, y: 0, z: 0} + return (this.composedLumeParent && calculatedSize.get(this.composedLumeParent)?.get()) ?? {x: 0, y: 0, z: 0} } _calcSize() { diff --git a/packages/lume/src/core/TreeNode.test.ts b/packages/lume/src/core/TreeNode.test.ts index ec9ddd8c7..7c56e2bbb 100644 --- a/packages/lume/src/core/TreeNode.test.ts +++ b/packages/lume/src/core/TreeNode.test.ts @@ -11,127 +11,79 @@ describe('TreeNode', () => { const t = new TreeNode() - expect(t.subnodes).toEqual([]) - expect(t.parent).toBe(null) - expect(t.childCount).toBe(0) + expect(t.lumeChildren).toEqual([]) + expect(t.lumeParent).toBe(null) + expect(t.lumeChildCount).toBe(0) }) - it('.add', () => { + // The below tests are essentially a placeholder for when the day comes to + // ensure that some DOM-like APIs work in non-DOM environments (f.e. we're + // planning to bind to OpenGL in Node.js, or in AssemblyScript, without a + // DOM). + + it('.append(single)', () => { const t = new TreeNode() const a = new TreeNode() const b = new TreeNode() - t.add(a) + t.append(a) // Adding an already-added node is a no-op. - expect(() => t.add(a)).not.toThrow() + expect(() => t.append(a)).not.toThrow() - expect(t.subnodes).toEqual([a]) + expect(t.lumeChildren).toEqual([a]) - t.add(b) + t.append(b) - expect(t.subnodes).toEqual([a, b]) + expect(t.lumeChildren).toEqual([a, b]) }) - it('.addChildren', () => { + it('.append(...multiple)', () => { const t = new TreeNode() const a = new TreeNode() const b = new TreeNode() const c = new TreeNode() - t.addChildren([b, c]) + t.append(b, c) // If children are re-added, it's a no-op. - expect(() => t.addChildren([b, c])).not.toThrow() + expect(() => t.append(b, c)).not.toThrow() - expect(t.childCount).toBe(2) - expect(t.subnodes).toEqual([b, c]) + expect(t.lumeChildCount).toBe(2) + expect(t.lumeChildren).toEqual([b, c]) - t.addChildren([a]) - expect(() => t.addChildren([a, b])).not.toThrow() + t.append(a) + expect(() => t.append(a, b)).not.toThrow() - expect(t.childCount).toBe(3) - expect(t.subnodes).toEqual([b, c, a]) + expect(t.lumeChildCount).toBe(3) + expect(t.lumeChildren).toEqual([b, c, a]) }) - it('.removeNode', () => { + it('.removeChild', () => { const t = new TreeNode() const a = new TreeNode() const b = new TreeNode() const c = new TreeNode() - t.addChildren([b, a, c]) - - expect(t.childCount).toBe(3) - expect(t.subnodes).toEqual([b, a, c]) - - t.removeNode(b) - expect(() => t.removeNode(b)).toThrowError(ReferenceError, 'childNode is not a child of this parent.') - - expect(t.childCount).toBe(2) - expect(t.subnodes).toEqual([a, c]) - - t.removeNode(a) - - expect(t.childCount).toBe(1) - expect(t.subnodes).toEqual([c]) - - t.removeNode(c) - - expect(t.childCount).toBe(0) - expect(t.subnodes).toEqual([]) - }) - - it('.removeChildren', () => { - const t = new TreeNode() - const a = new TreeNode() - const b = new TreeNode() - const c = new TreeNode() - const d = new TreeNode() + t.append(b, a, c) - t.addChildren([b, a, c, d]) + expect(t.lumeChildCount).toBe(3) + expect(t.lumeChildren).toEqual([b, a, c]) - expect(t.childCount).toBe(4) - expect(t.subnodes).toEqual([b, a, c, d]) + t.removeChild(b) + expect(() => t.removeChild(b)).toThrowError(ReferenceError, 'childNode is not a child of this parent.') - t.removeChildren([c]) - expect(() => t.removeChildren([c])).toThrowError(ReferenceError, 'childNode is not a child of this parent.') + expect(t.lumeChildCount).toBe(2) + expect(t.lumeChildren).toEqual([a, c]) - expect(t.childCount).toBe(3) - expect(t.subnodes).toEqual([b, a, d]) + t.removeChild(a) - t.removeChildren([b, d]) + expect(t.lumeChildCount).toBe(1) + expect(t.lumeChildren).toEqual([c]) - expect(t.childCount).toBe(1) - expect(t.subnodes).toEqual([a]) - - t.removeChildren([a]) - - expect(t.childCount).toBe(0) - expect(t.subnodes).toEqual([]) - }) - - it('.removeAllChildren', () => { - const t = new TreeNode() - const a = new TreeNode() - const b = new TreeNode() - const c = new TreeNode() - const d = new TreeNode() - - t.addChildren([b, a, c, d]) - - expect(t.childCount).toBe(4) - expect(t.subnodes).toEqual([b, a, c, d]) - - t.removeAllChildren() - expect(() => t.removeAllChildren()).toThrowError(ReferenceError, 'This node has no children.') - - expect(t.childCount).toBe(0) - expect(t.subnodes).toEqual([]) - }) + t.removeChild(c) - xit('lifecycle callbacks', () => { - // TODO - expect(false).toBe(true) + expect(t.lumeChildCount).toBe(0) + expect(t.lumeChildren).toEqual([]) }) }) diff --git a/packages/lume/src/core/TreeNode.ts b/packages/lume/src/core/TreeNode.ts index 9701659c5..b70f8370b 100644 --- a/packages/lume/src/core/TreeNode.ts +++ b/packages/lume/src/core/TreeNode.ts @@ -1,7 +1,6 @@ import {reactive} from '@lume/element' import {Eventful} from '@lume/eventful' import {DeclarativeBase} from './DeclarativeBase.js' -import {defer} from './utils.js' /** * @class TreeNode - The `TreeNode` class represents objects that are connected @@ -12,183 +11,36 @@ import {defer} from './utils.js' */ @reactive export class TreeNode extends Eventful(DeclarativeBase) { - constructor() { - super() - - // If we're already in the DOM, let's set up the tree state right away. - // @ts-ignore - this.parentNode?.add?.(this) - } - - @reactive __parent: TreeNode | null = null - - __children: TreeNode[] = [] - /** * @readonly - * @property {TreeNode | null} parent - The parent of the current TreeNode. - * Each node in a tree can have only one parent. `null` if no parent when not connected into a tree. + * @property {TreeNode | null} lumeParent - The LUME-specific parent of the + * current TreeNode. Each node in a tree can have only one parent. This is + * `null` if there is no parent when not connected into a tree, or if the + * parentElement while connected into a tree is not as LUME 3D element. */ - get parent() { - // In case we're in the DOM when this is called and the parent has - // the TreeNode API, immediately set up our tree state so that APIs - // depending on .parent (f.e. before childComposedCallback fires the - // .add method) don't receive a deceitful null value. - // @ts-ignore - if (!this.__parent) this.parentNode?.add?.(this) - - return this.__parent + get lumeParent(): TreeNode | null { + if (this.parentElement instanceof TreeNode) return this.parentElement + return null } /** - * @property {TreeNode[]} subnodes - An array of this TreeNode's - * children. This returns a clone of the internal child array, so - * modifying the cloned array directly does not effect the state of the - * TreeNode. Use [TreeNode.add(child)](#addchild) and - * [TreeNode.removeNode(child)](#removenode) to modify a TreeNode's - * list of children. - * This is named `subnodes` to avoid conflict with HTML's `Element.children` property. * @readonly + * @property {TreeNode[]} lumeChildren - An array of this element's + * LUME-specific children. This returns a new static array each time, so + * and modifying this array directly does not effect the state of the + * TreeNode. Use [TreeNode.append(child)](#append) and + * [TreeNode.removeChild(child)](#removechild) to modify a TreeNode's + * actual children. */ - get subnodes() { - // return a new array, so that the user modifying it doesn't affect - // this node's actual children. - return [...this.__children] - } - - __isConnected = false - - /** @readonly */ - get isConnected(): boolean { - if (this instanceof Element) { - // TODO Report this to TypeScript - // @ts-ignore TS doesn't know that super.isConnected would work here. - return super.isConnected - } - - // @ts-ignore - return this.__isConnected - } - - /** - * @method add - Add a child node to this TreeNode. - * @param {TreeNode} childNode - The child node to add. - * @returns {this} - */ - add(childNode: TreeNode): this { - // @prod-prune - if (!(childNode instanceof TreeNode)) - throw new TypeError('TreeNode.add() expects the childNode argument to be a TreeNode instance.') - - if (childNode.__parent === this) return this - - if (childNode.__parent) childNode.__parent.removeNode(childNode) - - childNode.__parent = this - - if (!this.__children) this.__children = [] - this.__children.push(childNode) - - childNode.__isConnected = true - - // TODO avoid deferring. We may need this now that we switched from - // WithUpdate to reactive props. - defer(() => { - childNode.connected() - this.childConnected(childNode) - }) - - return this - } - - /** - * @method addChildren - Add all the child nodes in the given array to this node. - * @param {Array} nodes - The nodes to add. - * @returns {this} - */ - addChildren(nodes: TreeNode[]) { - nodes.forEach(node => this.add(node)) - return this - } - - /** - * @method removeNode - Remove a child node from this node. - * @param {TreeNode} childNode - The node to remove. - * @returns {this} - */ - removeNode(childNode: TreeNode): this { - if (!(childNode instanceof TreeNode)) { - throw new Error(` - TreeNode.remove expects the childNode argument to be an - instance of TreeNode. There should only be TreeNodes in the - tree. - `) - } - - if (childNode.__parent !== this) throw new ReferenceError('childNode is not a child of this parent.') - - childNode.__parent = null - this.__children.splice(this.__children.indexOf(childNode), 1) - - childNode.__isConnected = false - - // TODO avoid deferring. We may need this now that we switched from - // WithUpdate to reactive props. - defer(() => { - childNode.disconnected() - this.childDisconnected(childNode) - }) - - return this - } - - /** - * @method removeChildren - Remove all the child nodes in the given array from this node. - * @param {Array} nodes - The nodes to remove. - * @returns {this} - */ - removeChildren(nodes: TreeNode[]) { - for (let i = nodes.length - 1; i >= 0; i -= 1) { - this.removeNode(nodes[i]) - } - return this - } - - /** - * @method removeAllChildren - Remove all children. - * @returns {this} - */ - removeAllChildren() { - if (!this.__children.length) throw new ReferenceError('This node has no children.') - this.removeChildren(this.__children) - return this + get lumeChildren(): TreeNode[] { + return Array.prototype.filter.call(this.children, c => c instanceof TreeNode) as TreeNode[] } /** * @readonly - * @property {number} childCount - How many children this TreeNode has. + * @property {number} lumeChildCount - The number of children this TreeNode has. */ - get childCount() { - return this.__children.length - } - - // generic life cycle methods - connected() {} - disconnected() {} - childConnected(_child: TreeNode) {} - childDisconnected(_child: TreeNode) {} - - /** - * @method traverse - Traverse this node and it's tree of subnodes in pre-order. - * @param {(n: TreeNode) => void} fn - A callback called on each node, - * receiving as first arg the current node in the traversal. - */ - traverse(fn: (n: TreeNode) => void) { - fn(this) - - const children = this.__children - for (let i = 0, l = children.length; i < l; i++) { - children[i].traverse(fn) - } + get lumeChildCount(): number { + return this.lumeChildren.length } } diff --git a/packages/lume/src/layouts/AutoLayoutNode.ts b/packages/lume/src/layouts/AutoLayoutNode.ts index f53bf16a0..4900a5069 100644 --- a/packages/lume/src/layouts/AutoLayoutNode.ts +++ b/packages/lume/src/layouts/AutoLayoutNode.ts @@ -95,15 +95,23 @@ export class AutoLayoutNode extends Node { #autoLayoutView?: any | undefined - childConnected(child: Node) { - super.childConnected(child) + childConnectedCallback(child: Node) { + // @prod-prune + if (!(child instanceof Node)) + throw new Error('Child elements of AutoLayoutNode must be instances of LUME.Node.') + + super.childConnectedCallback(child) if (!this.#autoLayoutView) return this.#checkNodes() } - childDisconnected(child: Node) { - super.childDisconnected(child) + childDisconnectedCallback(child: Node) { + // @prod-prune + if (!(child instanceof Node)) + throw new Error('Child elements of AutoLayoutNode must be instances of LUME.Node.') + + super.childDisconnectedCallback(child) if (!this.#autoLayoutView) return const _idToNode = this.#idToNode @@ -175,7 +183,7 @@ export class AutoLayoutNode extends Node { */ addToLayout(child: Node, id: string) { // PORTED - this.add(child) // PORTED + this.append(child) // PORTED // TODO instead of handling nodes here, we should handle them in // childComposedCallback, to support ShadowDOM. if (id) this.#idToNode[id] = child @@ -193,7 +201,7 @@ export class AutoLayoutNode extends Node { removeFromLayout(child: Node, id: string) { // PORTED if (child && id) { - this.removeNode(child) // PORTED + this.removeChild(child) // PORTED delete this.#idToNode[id] } else if (child) { for (id in this.#idToNode) { @@ -202,9 +210,9 @@ export class AutoLayoutNode extends Node { break } } - this.removeNode(child) // PORTED + this.removeChild(child) // PORTED } else if (id) { - this.removeNode(this.#idToNode[id]) // PORTED + this.removeChild(this.#idToNode[id]) // PORTED delete this.#idToNode[id] } this.reflowLayout() diff --git a/packages/lume/src/layouts/Cube.ts b/packages/lume/src/layouts/Cube.ts index 3a26d1916..9f0ce8059 100644 --- a/packages/lume/src/layouts/Cube.ts +++ b/packages/lume/src/layouts/Cube.ts @@ -56,7 +56,7 @@ export class CubeLayout extends Node { this.sides.push(side) - rotator.add(side) + rotator.append(side) // TODO: make a new GenericSync-like thing based on Famous? //const sync = new GenericSync(['mouse','touch']); @@ -72,7 +72,7 @@ export class CubeLayout extends Node { side.getPosition().z = this.getSize().x / 2 - this.add(rotator) + this.append(rotator) } /** @@ -85,7 +85,7 @@ export class CubeLayout extends Node { setContent(content: Node[]) { for (let index = 0; index < 6; index += 1) { //this.cubeSideNodes[index].set(null); // TODO: how do we erase previous content? - this.sides[index].add(content[index]) + this.sides[index].append(content[index]) } return this }