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
}