diff --git a/src/framework/components/scroll-view/component.js b/src/framework/components/scroll-view/component.js index 9b93d410663..9b04a884cc0 100644 --- a/src/framework/components/scroll-view/component.js +++ b/src/framework/components/scroll-view/component.js @@ -3,7 +3,9 @@ import { math } from '../../../core/math/math.js'; import { Vec2 } from '../../../core/math/vec2.js'; import { Vec3 } from '../../../core/math/vec3.js'; import { ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL } from '../../../scene/constants.js'; -import { EntityReference } from '../../utils/entity-reference.js'; + +import { GraphNode } from '../../../scene/graph-node.js'; + import { ElementDragHelper } from '../element/element-drag-helper.js'; import { SCROLL_MODE_BOUNCE, SCROLL_MODE_CLAMP, SCROLL_MODE_INFINITE, SCROLLBAR_VISIBILITY_SHOW_ALWAYS, SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED } from './constants.js'; import { Component } from '../component.js'; @@ -13,6 +15,7 @@ import { EVENT_MOUSEWHEEL } from '../../../platform/input/constants.js'; * @import { Entity } from '../../entity.js' * @import { ScrollViewComponentData } from './data.js' * @import { ScrollViewComponentSystem } from './system.js' + * @import { EventHandle } from '../../../core/event-handle.js' */ const _tempScrollValue = new Vec2(); @@ -38,6 +41,102 @@ class ScrollViewComponent extends Component { */ static EVENT_SETSCROLL = 'set:scroll'; + /** + * @type {Entity|null} + * @private + */ + _viewportEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _contentEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _horizontalScrollbarEntity = null; + + /** + * @type {Entity|null} + * @private + */ + _verticalScrollbarEntity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtViewportElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtViewportResize = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentEntityElementAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentElementRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtContentResize = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHorizontalScrollbarValue = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarAdd = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarRemove = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtVerticalScrollbarValue = null; + /** * Create a new ScrollViewComponent. * @@ -47,27 +146,9 @@ class ScrollViewComponent extends Component { constructor(system, entity) { super(system, entity); - this._viewportReference = new EntityReference(this, 'viewportEntity', { - 'element#gain': this._onViewportElementGain, - 'element#resize': this._onSetContentOrViewportSize - }); - - this._contentReference = new EntityReference(this, 'contentEntity', { - 'element#gain': this._onContentElementGain, - 'element#lose': this._onContentElementLose, - 'element#resize': this._onSetContentOrViewportSize - }); - this._scrollbarUpdateFlags = {}; - this._scrollbarReferences = {}; - this._scrollbarReferences[ORIENTATION_HORIZONTAL] = new EntityReference(this, 'horizontalScrollbarEntity', { - 'scrollbar#set:value': this._onSetHorizontalScrollbarValue, - 'scrollbar#gain': this._onHorizontalScrollbarGain - }); - this._scrollbarReferences[ORIENTATION_VERTICAL] = new EntityReference(this, 'verticalScrollbarEntity', { - 'scrollbar#set:value': this._onSetVerticalScrollbarValue, - 'scrollbar#gain': this._onVerticalScrollbarGain - }); + + this._scrollbarEntities = {}; this._prevContentSizes = {}; this._prevContentSizes[ORIENTATION_HORIZONTAL] = null; @@ -80,7 +161,7 @@ class ScrollViewComponent extends Component { this._disabledContentInput = false; this._disabledContentInputEntities = []; - this._toggleLifecycleListeners('on', system); + this._toggleLifecycleListeners('on'); this._toggleElementListeners('on'); } @@ -301,76 +382,196 @@ class ScrollViewComponent extends Component { * Sets the entity to be used as the masked viewport area, within which the content will scroll. * This entity must have an ElementGroup component. * - * @type {Entity} + * @type {Entity|string|null} */ set viewportEntity(arg) { - this._setValue('viewportEntity', arg); + if (this._viewportEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._viewportEntity && isString && this._viewportEntity.getGuid() === arg) { + return; + } + + if (this._viewportEntity) { + this._viewportEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._viewportEntity = arg; + } else if (isString) { + this._viewportEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._viewportEntity = null; + } + + if (this._viewportEntity) { + this._viewportEntitySubscribe(); + } + + if (this._viewportEntity) { + this.data.viewportEntity = this._viewportEntity.getGuid(); + } else if (isString && arg) { + this.data.viewportEntity = arg; + } } /** * Gets the entity to be used as the masked viewport area, within which the content will scroll. * - * @type {Entity} + * @type {Entity|null} */ get viewportEntity() { - return this.data.viewportEntity; + return this._viewportEntity; } /** * Sets the entity which contains the scrolling content itself. This entity must have an * {@link ElementComponent}. * - * @type {Entity} + * @type {Entity|string|null} */ set contentEntity(arg) { - this._setValue('contentEntity', arg); + if (this._contentEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._contentEntity && isString && this._contentEntity.getGuid() === arg) { + return; + } + + if (this._contentEntity) { + this._contentEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._contentEntity = arg; + } else if (isString) { + this._contentEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._contentEntity = null; + } + + if (this._contentEntity) { + this._contentEntitySubscribe(); + } + + if (this._contentEntity) { + this.data.contentEntity = this._contentEntity.getGuid(); + } else if (isString && arg) { + this.data.contentEntity = arg; + } } /** * Gets the entity which contains the scrolling content itself. * - * @type {Entity} + * @type {Entity|null} */ get contentEntity() { - return this.data.contentEntity; + return this._contentEntity; } /** * Sets the entity to be used as the horizontal scrollbar. This entity must have a * {@link ScrollbarComponent}. * - * @type {Entity} + * @type {Entity|string|null} */ set horizontalScrollbarEntity(arg) { - this._setValue('horizontalScrollbarEntity', arg); + if (this._horizontalScrollbarEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._horizontalScrollbarEntity && isString && this._horizontalScrollbarEntity.getGuid() === arg) { + return; + } + + if (this._horizontalScrollbarEntity) { + this._horizontalScrollbarEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._horizontalScrollbarEntity = arg; + } else if (isString) { + this._horizontalScrollbarEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._horizontalScrollbarEntity = null; + } + + this._scrollbarEntities[ORIENTATION_HORIZONTAL] = this._horizontalScrollbarEntity; + + if (this._horizontalScrollbarEntity) { + this._horizontalScrollbarEntitySubscribe(); + } + + if (this._horizontalScrollbarEntity) { + this.data.horizontalScrollbarEntity = this._horizontalScrollbarEntity.getGuid(); + } else if (isString && arg) { + this.data.horizontalScrollbarEntity = arg; + } } /** * Gets the entity to be used as the horizontal scrollbar. * - * @type {Entity} + * @type {Entity|null} */ get horizontalScrollbarEntity() { - return this.data.horizontalScrollbarEntity; + return this._horizontalScrollbarEntity; } /** * Sets the entity to be used as the vertical scrollbar. This entity must have a * {@link ScrollbarComponent}. * - * @type {Entity} + * @type {Entity|string|null} */ set verticalScrollbarEntity(arg) { - this._setValue('verticalScrollbarEntity', arg); + if (this._verticalScrollbarEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._verticalScrollbarEntity && isString && this._verticalScrollbarEntity.getGuid() === arg) { + return; + } + + if (this._verticalScrollbarEntity) { + this._verticalScrollbarEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._verticalScrollbarEntity = arg; + } else if (isString) { + this._verticalScrollbarEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._verticalScrollbarEntity = null; + } + + this._scrollbarEntities[ORIENTATION_VERTICAL] = this._verticalScrollbarEntity; + + if (this._verticalScrollbarEntity) { + this._verticalScrollbarEntitySubscribe(); + } + + if (this._verticalScrollbarEntity) { + this.data.verticalScrollbarEntity = this._verticalScrollbarEntity.getGuid(); + } else if (isString && arg) { + this.data.verticalScrollbarEntity = arg; + } } /** * Gets the entity to be used as the vertical scrollbar. * - * @type {Entity} + * @type {Entity|null} */ get verticalScrollbarEntity() { - return this.data.verticalScrollbarEntity; + return this._verticalScrollbarEntity; } /** @@ -405,12 +606,11 @@ class ScrollViewComponent extends Component { * created this Component. * @private */ - _toggleLifecycleListeners(onOrOff, system) { + _toggleLifecycleListeners(onOrOff) { this[onOrOff]('set_horizontal', this._onSetHorizontalScrollingEnabled, this); this[onOrOff]('set_vertical', this._onSetVerticalScrollingEnabled, this); - system.app.systems.element[onOrOff]('add', this._onElementComponentAdd, this); - system.app.systems.element[onOrOff]('beforeremove', this._onElementComponentRemove, this); + this.entity[onOrOff]('element:add', this._onElementComponentAdd, this); } /** @@ -423,7 +623,7 @@ class ScrollViewComponent extends Component { return; } - this.entity.element[onOrOff]('resize', this._onSetContentOrViewportSize, this); + this.entity.element[onOrOff]('resize', this._syncAll, this); this.entity.element[onOrOff](EVENT_MOUSEWHEEL, this._onMouseWheel, this); this._hasElementListeners = onOrOff === 'on'; @@ -431,24 +631,92 @@ class ScrollViewComponent extends Component { } _onElementComponentAdd(entity) { - if (this.entity === entity) { - this._toggleElementListeners('on'); - } + this._evtElementRemove = this.entity.element.once('beforeremove', this._onElementComponentRemove, this); + this._toggleElementListeners('on'); } _onElementComponentRemove(entity) { - if (this.entity === entity) { - this._toggleElementListeners('off'); + this._evtElementRemove?.off(); + this._evtElementRemove = null; + this._toggleElementListeners('off'); + } + + _viewportEntitySubscribe() { + this._evtViewportEntityElementAdd = this._viewportEntity.on('element:add', this._onViewportElementGain, this); + + if (this._viewportEntity.element) { + this._onViewportElementGain(); + } + } + + _viewportEntityUnsubscribe() { + this._evtViewportEntityElementAdd?.off(); + this._evtViewportEntityElementAdd = null; + + if (this._viewportEntity?.element) { + this._onViewportElementLose(); } } + _viewportEntityElementSubscribe() { + const element = this._viewportEntity.element; + this._evtViewportElementRemove = element.once('beforeremove', this._onViewportElementLose, this); + this._evtViewportResize = element.on('resize', this._syncAll, this); + } + + _viewportEntityElementUnsubscribe() { + this._evtViewportElementRemove?.off(); + this._evtViewportElementRemove = null; + + this._evtViewportResize?.off(); + this._evtViewportResize = null; + } + _onViewportElementGain() { + this._viewportEntityElementSubscribe(); this._syncAll(); } + _onViewportElementLose() { + this._viewportEntityElementUnsubscribe(); + } + + _contentEntitySubscribe() { + this._evtContentEntityElementAdd = this._contentEntity.on('element:add', this._onContentElementGain, this); + + if (this._contentEntity.element) { + this._onContentElementGain(); + } + } + + _contentEntityUnsubscribe() { + this._evtContentEntityElementAdd?.off(); + this._evtContentEntityElementAdd = null; + + if (this._contentEntity?.element) { + this._onContentElementLose(); + } + } + + _contentEntityElementSubscribe() { + const element = this._contentEntity.element; + this._evtContentElementRemove = element.once('beforeremove', this._onContentElementLose, this); + this._evtContentResize = element.on('resize', this._syncAll, this); + } + + _contentEntityElementUnsubscribe() { + this._evtContentElementRemove?.off(); + this._evtContentElementRemove = null; + + this._evtContentResize?.off(); + this._evtContentResize = null; + } + _onContentElementGain() { + this._contentEntityElementSubscribe(); this._destroyDragHelper(); - this._contentDragHelper = new ElementDragHelper(this._contentReference.entity.element); + + this._contentDragHelper = new ElementDragHelper(this._contentEntity.element); this._contentDragHelper.on('drag:start', this._onContentDragStart, this); this._contentDragHelper.on('drag:end', this._onContentDragEnd, this); this._contentDragHelper.on('drag:move', this._onContentDragMove, this); @@ -460,12 +728,13 @@ class ScrollViewComponent extends Component { } _onContentElementLose() { + this._contentEntityElementUnsubscribe(); this._destroyDragHelper(); } _onContentDragStart() { - if (this._contentReference.entity && this.enabled && this.entity.enabled) { - this._dragStartPosition.copy(this._contentReference.entity.getLocalPosition()); + if (this._contentEntity && this.enabled && this.entity.enabled) { + this._dragStartPosition.copy(this._contentEntity.getLocalPosition()); } } @@ -475,7 +744,7 @@ class ScrollViewComponent extends Component { } _onContentDragMove(position) { - if (this._contentReference.entity && this.enabled && this.entity.enabled) { + if (this._contentEntity && this.enabled && this.entity.enabled) { this._wasDragged = true; this._setScrollFromContentPosition(position); this._setVelocityFromContentPositionDelta(position); @@ -494,8 +763,38 @@ class ScrollViewComponent extends Component { } } - _onSetContentOrViewportSize() { - this._syncAll(); + _horizontalScrollbarEntitySubscribe() { + this._evtHorizontalScrollbarAdd = this._horizontalScrollbarEntity.on('scrollbar:add', this._onHorizontalScrollbarGain, this); + + if (this._horizontalScrollbarEntity.scrollbar) { + this._onHorizontalScrollbarGain(); + } + } + + _verticalScrollbarEntitySubscribe() { + this._evtVerticalScrollbarAdd = this._verticalScrollbarEntity.on('scrollbar:add', this._onVerticalScrollbarGain, this); + + if (this._verticalScrollbarEntity.scrollbar) { + this._onVerticalScrollbarGain(); + } + } + + _horizontalScrollbarEntityUnsubscribe() { + this._evtHorizontalScrollbarAdd?.off(); + this._evtHorizontalScrollbarAdd = null; + + if (this._horizontalScrollbarEntity.scrollbar) { + this._onHorizontalScrollbarLose(); + } + } + + _verticalScrollbarEntityUnsubscribe() { + this._evtVerticalScrollbarAdd?.off(); + this._evtVerticalScrollbarAdd = null; + + if (this._verticalScrollbarEntity.scrollbar) { + this._onVerticalScrollbarLose(); + } } _onSetHorizontalScrollbarValue(scrollValueX) { @@ -510,22 +809,46 @@ class ScrollViewComponent extends Component { } } - _onSetHorizontalScrollingEnabled() { + _onHorizontalScrollbarGain() { + const scrollbar = this._horizontalScrollbarEntity?.scrollbar; + this._evtHorizontalScrollbarRemove = scrollbar.on('beforeremove', this._onHorizontalScrollbarLose, this); + this._evtHorizontalScrollbarValue = scrollbar.on('set:value', this._onSetHorizontalScrollbarValue, this); + this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); + this._syncScrollbarPosition(ORIENTATION_HORIZONTAL); } - _onSetVerticalScrollingEnabled() { + _onVerticalScrollbarGain() { + const scrollbar = this._verticalScrollbarEntity?.scrollbar; + this._evtVerticalScrollbarRemove = scrollbar.on('beforeremove', this._onVerticalScrollbarLose, this); + this._evtVerticalScrollbarValue = scrollbar.on('set:value', this._onSetVerticalScrollbarValue, this); + this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); + this._syncScrollbarPosition(ORIENTATION_VERTICAL); } - _onHorizontalScrollbarGain() { + _onHorizontalScrollbarLose() { + this._evtHorizontalScrollbarRemove?.off(); + this._evtHorizontalScrollbarRemove = null; + + this._evtHorizontalScrollbarValue?.off(); + this._evtHorizontalScrollbarValue = null; + } + + _onVerticalScrollbarLose() { + this._evtVerticalScrollbarRemove?.off(); + this._evtVerticalScrollbarRemove = null; + + this._evtVerticalScrollbarValue?.off(); + this._evtVerticalScrollbarValue = null; + } + + _onSetHorizontalScrollingEnabled() { this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); - this._syncScrollbarPosition(ORIENTATION_HORIZONTAL); } - _onVerticalScrollbarGain() { + _onSetVerticalScrollingEnabled() { this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); - this._syncScrollbarPosition(ORIENTATION_VERTICAL); } _onSetScroll(x, y, resetVelocity) { @@ -591,75 +914,79 @@ class ScrollViewComponent extends Component { } _syncContentPosition(orientation) { + if (!this._contentEntity) { + return; + } + const axis = this._getAxis(orientation); const sign = this._getSign(orientation); - const contentEntity = this._contentReference.entity; - - if (contentEntity) { - const prevContentSize = this._prevContentSizes[orientation]; - const currContentSize = this._getContentSize(orientation); - - // If the content size has changed, adjust the scroll value so that the content will - // stay in the same place from the user's perspective. - if (prevContentSize !== null && Math.abs(prevContentSize - currContentSize) > 1e-4) { - const prevMaxOffset = this._getMaxOffset(orientation, prevContentSize); - const currMaxOffset = this._getMaxOffset(orientation, currContentSize); - if (currMaxOffset === 0) { - this._scroll[axis] = 1; - } else { - this._scroll[axis] = math.clamp((this._scroll[axis] * prevMaxOffset) / currMaxOffset, 0, 1); - } + + const prevContentSize = this._prevContentSizes[orientation]; + const currContentSize = this._getContentSize(orientation); + + // If the content size has changed, adjust the scroll value so that the content will + // stay in the same place from the user's perspective. + if (prevContentSize !== null && Math.abs(prevContentSize - currContentSize) > 1e-4) { + const prevMaxOffset = this._getMaxOffset(orientation, prevContentSize); + const currMaxOffset = this._getMaxOffset(orientation, currContentSize); + if (currMaxOffset === 0) { + this._scroll[axis] = 1; + } else { + this._scroll[axis] = math.clamp((this._scroll[axis] * prevMaxOffset) / currMaxOffset, 0, 1); } + } - const offset = this._scroll[axis] * this._getMaxOffset(orientation); - const contentPosition = contentEntity.getLocalPosition(); - contentPosition[axis] = offset * sign; + const offset = this._scroll[axis] * this._getMaxOffset(orientation); + const contentPosition = this._contentEntity.getLocalPosition(); + contentPosition[axis] = offset * sign; - contentEntity.setLocalPosition(contentPosition); + this._contentEntity.setLocalPosition(contentPosition); - this._prevContentSizes[orientation] = currContentSize; - } + this._prevContentSizes[orientation] = currContentSize; } _syncScrollbarPosition(orientation) { + const scrollbarEntity = this._scrollbarEntities[orientation]; + if (!scrollbarEntity?.scrollbar) { + return; + } + const axis = this._getAxis(orientation); - const scrollbarEntity = this._scrollbarReferences[orientation].entity; - if (scrollbarEntity && scrollbarEntity.scrollbar) { - // Setting the value of the scrollbar will fire a 'set:value' event, which in turn - // will call the _onSetHorizontalScrollbarValue/_onSetVerticalScrollbarValue handlers - // and cause a cycle. To avoid this we keep track of the fact that we're in the process - // of updating the scrollbar value. - this._scrollbarUpdateFlags[orientation] = true; - scrollbarEntity.scrollbar.value = this._scroll[axis]; - scrollbarEntity.scrollbar.handleSize = this._getScrollbarHandleSize(axis, orientation); - this._scrollbarUpdateFlags[orientation] = false; - } + // Setting the value of the scrollbar will fire a 'set:value' event, which in turn + // will call the _onSetHorizontalScrollbarValue/_onSetVerticalScrollbarValue handlers + // and cause a cycle. To avoid this we keep track of the fact that we're in the process + // of updating the scrollbar value. + this._scrollbarUpdateFlags[orientation] = true; + scrollbarEntity.scrollbar.value = this._scroll[axis]; + scrollbarEntity.scrollbar.handleSize = this._getScrollbarHandleSize(axis, orientation); + this._scrollbarUpdateFlags[orientation] = false; } // Toggles the scrollbar entities themselves to be enabled/disabled based // on whether the user has enabled horizontal/vertical scrolling on the // scroll view. _syncScrollbarEnabledState(orientation) { - const entity = this._scrollbarReferences[orientation].entity; + const entity = this._scrollbarEntities[orientation]; + if (!entity) { + return; + } - if (entity) { - const isScrollingEnabled = this._getScrollingEnabled(orientation); - const requestedVisibility = this._getScrollbarVisibility(orientation); + const isScrollingEnabled = this._getScrollingEnabled(orientation); + const requestedVisibility = this._getScrollbarVisibility(orientation); - switch (requestedVisibility) { - case SCROLLBAR_VISIBILITY_SHOW_ALWAYS: - entity.enabled = isScrollingEnabled; - return; + switch (requestedVisibility) { + case SCROLLBAR_VISIBILITY_SHOW_ALWAYS: + entity.enabled = isScrollingEnabled; + return; - case SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED: - entity.enabled = isScrollingEnabled && this._contentIsLargerThanViewport(orientation); - return; + case SCROLLBAR_VISIBILITY_SHOW_WHEN_REQUIRED: + entity.enabled = isScrollingEnabled && this._contentIsLargerThanViewport(orientation); + return; - default: - console.warn(`Unhandled scrollbar visibility:${requestedVisibility}`); - entity.enabled = isScrollingEnabled; - } + default: + console.warn(`Unhandled scrollbar visibility:${requestedVisibility}`); + entity.enabled = isScrollingEnabled; } } @@ -722,16 +1049,16 @@ class ScrollViewComponent extends Component { } _getViewportSize(orientation) { - return this._getSize(orientation, this._viewportReference); + return this._getSize(orientation, this._viewportEntity); } _getContentSize(orientation) { - return this._getSize(orientation, this._contentReference); + return this._getSize(orientation, this._contentEntity); } - _getSize(orientation, entityReference) { - if (entityReference.entity && entityReference.entity.element) { - return entityReference.entity.element[this._getCalculatedDimension(orientation)]; + _getSize(orientation, entity) { + if (entity?.element) { + return entity.element[this._getCalculatedDimension(orientation)]; } return 0; @@ -778,7 +1105,7 @@ class ScrollViewComponent extends Component { } onUpdate() { - if (this._contentReference.entity) { + if (this._contentEntity) { this._updateVelocity(); this._syncScrollbarEnabledState(ORIENTATION_HORIZONTAL); this._syncScrollbarEnabledState(ORIENTATION_VERTICAL); @@ -798,10 +1125,10 @@ class ScrollViewComponent extends Component { } if (Math.abs(this._velocity.x) > 1e-4 || Math.abs(this._velocity.y) > 1e-4) { - const position = this._contentReference.entity.getLocalPosition(); + const position = this._contentEntity.getLocalPosition(); position.x += this._velocity.x; position.y += this._velocity.y; - this._contentReference.entity.setLocalPosition(position); + this._contentEntity.setLocalPosition(position); this._setScrollFromContentPosition(position); } @@ -889,12 +1216,12 @@ class ScrollViewComponent extends Component { } _setScrollbarComponentsEnabled(enabled) { - if (this._scrollbarReferences[ORIENTATION_HORIZONTAL].hasComponent('scrollbar')) { - this._scrollbarReferences[ORIENTATION_HORIZONTAL].entity.scrollbar.enabled = enabled; + if (this._horizontalScrollbarEntity?.scrollbar) { + this._horizontalScrollbarEntity.scrollbar.enabled = enabled; } - if (this._scrollbarReferences[ORIENTATION_VERTICAL].hasComponent('scrollbar')) { - this._scrollbarReferences[ORIENTATION_VERTICAL].entity.scrollbar.enabled = enabled; + if (this._verticalScrollbarEntity?.scrollbar) { + this._verticalScrollbarEntity.scrollbar.enabled = enabled; } } @@ -905,19 +1232,21 @@ class ScrollViewComponent extends Component { } _onMouseWheel(event) { - if (this.useMouseWheel) { - const wheelEvent = event.event; + if (!this.useMouseWheel || !this._contentEntity?.element) { + return; + } - // wheelEvent's delta variables are screen space, so they need to be normalized first - const normalizedDeltaX = (wheelEvent.deltaX / this._contentReference.entity.element.calculatedWidth) * this.mouseWheelSensitivity.x; - const normalizedDeltaY = (wheelEvent.deltaY / this._contentReference.entity.element.calculatedHeight) * this.mouseWheelSensitivity.y; + const wheelEvent = event.event; - // update scroll positions, clamping to [0, maxScrollValue] to always prevent over-shooting - const scrollX = math.clamp(this._scroll.x + normalizedDeltaX, 0, this._getMaxScrollValue(ORIENTATION_HORIZONTAL)); - const scrollY = math.clamp(this._scroll.y + normalizedDeltaY, 0, this._getMaxScrollValue(ORIENTATION_VERTICAL)); + // wheelEvent's delta variables are screen space, so they need to be normalized first + const normalizedDeltaX = (wheelEvent.deltaX / this._contentEntity.element.calculatedWidth) * this.mouseWheelSensitivity.x; + const normalizedDeltaY = (wheelEvent.deltaY / this._contentEntity.element.calculatedHeight) * this.mouseWheelSensitivity.y; - this.scroll = new Vec2(scrollX, scrollY); - } + // update scroll positions, clamping to [0, maxScrollValue] to always prevent over-shooting + const scrollX = math.clamp(this._scroll.x + normalizedDeltaX, 0, this._getMaxScrollValue(ORIENTATION_HORIZONTAL)); + const scrollY = math.clamp(this._scroll.y + normalizedDeltaY, 0, this._getMaxScrollValue(ORIENTATION_VERTICAL)); + + this.scroll = new Vec2(scrollX, scrollY); } // re-enable useInput flag on any descendant that was disabled @@ -946,10 +1275,9 @@ class ScrollViewComponent extends Component { } }; - const contentEntity = this._contentReference.entity; - if (contentEntity) { + if (this._contentEntity) { // disable input recursively for all children of the content entity - const children = contentEntity.children; + const children = this._contentEntity.children; for (let i = 0, l = children.length; i < l; i++) { _disableInput(children[i]); } @@ -959,10 +1287,6 @@ class ScrollViewComponent extends Component { } onEnable() { - this._viewportReference.onParentComponentEnable(); - this._contentReference.onParentComponentEnable(); - this._scrollbarReferences[ORIENTATION_HORIZONTAL].onParentComponentEnable(); - this._scrollbarReferences[ORIENTATION_VERTICAL].onParentComponentEnable(); this._setScrollbarComponentsEnabled(true); this._setContentDraggingEnabled(true); @@ -975,10 +1299,25 @@ class ScrollViewComponent extends Component { } onRemove() { - this._toggleLifecycleListeners('off', this.system); + this._toggleLifecycleListeners('off'); this._toggleElementListeners('off'); this._destroyDragHelper(); } + + resolveDuplicatedEntityReferenceProperties(oldScrollView, duplicatedIdsMap) { + if (oldScrollView.viewportEntity) { + this.viewportEntity = duplicatedIdsMap[oldScrollView.viewportEntity.getGuid()]; + } + if (oldScrollView.contentEntity) { + this.contentEntity = duplicatedIdsMap[oldScrollView.contentEntity.getGuid()]; + } + if (oldScrollView.horizontalScrollbarEntity) { + this.horizontalScrollbarEntity = duplicatedIdsMap[oldScrollView.horizontalScrollbarEntity.getGuid()]; + } + if (oldScrollView.verticalScrollbarEntity) { + this.verticalScrollbarEntity = duplicatedIdsMap[oldScrollView.verticalScrollbarEntity.getGuid()]; + } + } } export { ScrollViewComponent }; diff --git a/src/framework/components/scroll-view/data.js b/src/framework/components/scroll-view/data.js index 0cdff8a13e3..408a7a18043 100644 --- a/src/framework/components/scroll-view/data.js +++ b/src/framework/components/scroll-view/data.js @@ -31,22 +31,22 @@ class ScrollViewComponentData { mouseWheelSensitivity = new Vec2(1, 1); /** @type {number} */ - horizontalScrollbarVisibility; + horizontalScrollbarVisibility = 0; /** @type {number} */ - verticalScrollbarVisibility; + verticalScrollbarVisibility = 0; - /** @type {Entity} */ - viewportEntity; + /** @type {Entity|null} */ + viewportEntity = null; - /** @type {Entity} */ - contentEntity; + /** @type {Entity|null} */ + contentEntity = null; - /** @type {Entity} */ - horizontalScrollbarEntity; + /** @type {Entity|null} */ + horizontalScrollbarEntity = null; - /** @type {Entity} */ - verticalScrollbarEntity; + /** @type {Entity|null} */ + verticalScrollbarEntity = null; } export { ScrollViewComponentData }; diff --git a/src/framework/components/scroll-view/system.js b/src/framework/components/scroll-view/system.js index 2a74ab2beea..df3dd7f4d47 100644 --- a/src/framework/components/scroll-view/system.js +++ b/src/framework/components/scroll-view/system.js @@ -18,11 +18,7 @@ const _schema = [ { name: 'useMouseWheel', type: 'boolean' }, { name: 'mouseWheelSensitivity', type: 'vec2' }, { name: 'horizontalScrollbarVisibility', type: 'number' }, - { name: 'verticalScrollbarVisibility', type: 'number' }, - { name: 'viewportEntity', type: 'entity' }, - { name: 'contentEntity', type: 'entity' }, - { name: 'horizontalScrollbarEntity', type: 'entity' }, - { name: 'verticalScrollbarEntity', type: 'entity' } + { name: 'verticalScrollbarVisibility', type: 'number' } ]; const DEFAULT_DRAG_THRESHOLD = 10; @@ -66,6 +62,11 @@ class ScrollViewComponentSystem extends ComponentSystem { } super.initializeComponentData(component, data, _schema); + + component.viewportEntity = data.viewportEntity; + component.contentEntity = data.contentEntity; + component.horizontalScrollbarEntity = data.horizontalScrollbarEntity; + component.verticalScrollbarEntity = data.verticalScrollbarEntity; } onUpdate(dt) { diff --git a/src/framework/components/scrollbar/component.js b/src/framework/components/scrollbar/component.js index 0e4f63f2cbf..20ec31ca2ce 100644 --- a/src/framework/components/scrollbar/component.js +++ b/src/framework/components/scrollbar/component.js @@ -1,10 +1,13 @@ import { math } from '../../../core/math/math.js'; import { ORIENTATION_HORIZONTAL } from '../../../scene/constants.js'; + +import { GraphNode } from '../../../scene/graph-node.js'; + import { Component } from '../component.js'; import { ElementDragHelper } from '../element/element-drag-helper.js'; -import { EntityReference } from '../../utils/entity-reference.js'; /** + * @import { EventHandle } from '../../../core/event-handle.js' * @import { Entity } from '../../entity.js' * @import { ScrollbarComponentData } from './data.js' * @import { ScrollbarComponentSystem } from './system.js' @@ -29,6 +32,24 @@ class ScrollbarComponent extends Component { */ static EVENT_SETVALUE = 'set:value'; + /** + * @type {Entity|null} + * @private + */ + _handleEntity = null; + + /** + * @type {EventHandle|null} + * @private + */ + _evtHandleEntityElementAdd = null; + + /** + * @type {EventHandle[]} + * @private + */ + _evtHandleEntityChanges = []; + /** * Create a new ScrollbarComponent. * @@ -37,15 +58,6 @@ class ScrollbarComponent extends Component { */ constructor(system, entity) { super(system, entity); - - this._handleReference = new EntityReference(this, 'handleEntity', { - 'element#gain': this._onHandleElementGain, - 'element#lose': this._onHandleElementLose, - 'element#set:anchor': this._onSetHandleAlignment, - 'element#set:margin': this._onSetHandleAlignment, - 'element#set:pivot': this._onSetHandleAlignment - }); - this._toggleLifecycleListeners('on'); } @@ -142,19 +154,48 @@ class ScrollbarComponent extends Component { * Sets the entity to be used as the scrollbar handle. This entity must have a * {@link ScrollbarComponent}. * - * @type {Entity} + * @type {Entity|string|null} */ set handleEntity(arg) { - this._setValue('handleEntity', arg); + if (this._handleEntity === arg) { + return; + } + + const isString = typeof arg === 'string'; + if (this._handleEntity && isString && this._handleEntity.getGuid() === arg) { + return; + } + + if (this._handleEntity) { + this._handleEntityUnsubscribe(); + } + + if (arg instanceof GraphNode) { + this._handleEntity = arg; + } else if (isString) { + this._handleEntity = this.system.app.getEntityFromIndex(arg) || null; + } else { + this._handleEntity = null; + } + + if (this._handleEntity) { + this._handleEntitySubscribe(); + } + + if (this._handleEntity) { + this.data.handleEntity = this._handleEntity.getGuid(); + } else if (isString && arg) { + this.data.handleEntity = arg; + } } /** * Gets the entity to be used as the scrollbar handle. * - * @type {Entity} + * @type {Entity|null} */ get handleEntity() { - return this.data.handleEntity; + return this._handleEntity; } /** @ignore */ @@ -177,20 +218,56 @@ class ScrollbarComponent extends Component { // TODO Handle scrollwheel events } + _handleEntitySubscribe() { + this._evtHandleEntityElementAdd = this._handleEntity.on('element:add', this._onHandleElementGain, this); + + if (this._handleEntity.element) { + this._onHandleElementGain(); + } + } + + _handleEntityUnsubscribe() { + this._evtHandleEntityElementAdd?.off(); + this._evtHandleEntityElementAdd = null; + + if (this._handleEntity?.element) { + this._onHandleElementLose(); + } + } + + _handleEntityElementSubscribe() { + const element = this._handleEntity.element; + + const handles = this._evtHandleEntityChanges; + handles.push(element.once('beforeremove', this._onHandleElementLose, this)); + handles.push(element.on('set:anchor', this._onSetHandleAlignment, this)); + handles.push(element.on('set:margin', this._onSetHandleAlignment, this)); + handles.push(element.on('set:pivot', this._onSetHandleAlignment, this)); + } + + _handleEntityElementUnsubscribe() { + for (let i = 0; i < this._evtHandleEntityChanges.length; i++) { + this._evtHandleEntityChanges[i].off(); + } + this._evtHandleEntityChanges.length = 0; + } + _onHandleElementGain() { + this._handleEntityElementSubscribe(); this._destroyDragHelper(); - this._handleDragHelper = new ElementDragHelper(this._handleReference.entity.element, this._getAxis()); + this._handleDragHelper = new ElementDragHelper(this._handleEntity.element, this._getAxis()); this._handleDragHelper.on('drag:move', this._onHandleDrag, this); this._updateHandlePositionAndSize(); } _onHandleElementLose() { + this._handleEntityElementUnsubscribe(); this._destroyDragHelper(); } _onHandleDrag(position) { - if (this._handleReference.entity && this.enabled && this.entity.enabled) { + if (this._handleEntity && this.enabled && this.entity.enabled) { this.value = this._handlePositionToScrollValue(position[this._getAxis()]); } } @@ -215,19 +292,19 @@ class ScrollbarComponent extends Component { } _onSetOrientation(name, oldValue, newValue) { - if (newValue !== oldValue && this._handleReference.hasComponent('element')) { - this._handleReference.entity.element[this._getOppositeDimension()] = 0; + if (newValue !== oldValue && this._handleEntity?.element) { + this._handleEntity.element[this._getOppositeDimension()] = 0; } } _updateHandlePositionAndSize() { - const handleEntity = this._handleReference.entity; - const handleElement = handleEntity && handleEntity.element; + const handleEntity = this._handleEntity; + const handleElement = handleEntity?.element; if (handleEntity) { const position = handleEntity.getLocalPosition(); position[this._getAxis()] = this._getHandlePosition(); - this._handleReference.entity.setLocalPosition(position); + handleEntity.setLocalPosition(position); } if (handleElement) { @@ -292,7 +369,6 @@ class ScrollbarComponent extends Component { } onEnable() { - this._handleReference.onParentComponentEnable(); this._setHandleDraggingEnabled(true); } @@ -304,6 +380,12 @@ class ScrollbarComponent extends Component { this._destroyDragHelper(); this._toggleLifecycleListeners('off'); } + + resolveDuplicatedEntityReferenceProperties(oldScrollbar, duplicatedIdsMap) { + if (oldScrollbar.handleEntity) { + this.handleEntity = duplicatedIdsMap[oldScrollbar.handleEntity.getGuid()]; + } + } } export { ScrollbarComponent }; diff --git a/src/framework/components/scrollbar/data.js b/src/framework/components/scrollbar/data.js index 81c274eaa97..6d921b8cef4 100644 --- a/src/framework/components/scrollbar/data.js +++ b/src/framework/components/scrollbar/data.js @@ -12,10 +12,10 @@ class ScrollbarComponentData { value = 0; /** @type {number} */ - handleSize; + handleSize = 0; /** @type {Entity} */ - handleEntity; + handleEntity = null; } export { ScrollbarComponentData }; diff --git a/src/framework/components/scrollbar/system.js b/src/framework/components/scrollbar/system.js index 3be71f2602a..1abf56a1b03 100644 --- a/src/framework/components/scrollbar/system.js +++ b/src/framework/components/scrollbar/system.js @@ -10,8 +10,7 @@ const _schema = [ { name: 'enabled', type: 'boolean' }, { name: 'orientation', type: 'number' }, { name: 'value', type: 'number' }, - { name: 'handleSize', type: 'number' }, - { name: 'handleEntity', type: 'entity' } + { name: 'handleSize', type: 'number' } ]; /** @@ -36,11 +35,17 @@ class ScrollbarComponentSystem extends ComponentSystem { this.schema = _schema; + this.on('add', this._onAddComponent, this); this.on('beforeremove', this._onRemoveComponent, this); } initializeComponentData(component, data, properties) { super.initializeComponentData(component, data, _schema); + component.handleEntity = data.handleEntity; + } + + _onAddComponent(entity) { + entity.fire('scrollbar:add'); } _onRemoveComponent(entity, component) { diff --git a/src/framework/entity.js b/src/framework/entity.js index 5d75b917372..5676c5a8120 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -759,6 +759,16 @@ function resolveDuplicatedEntityReferenceProperties(oldSubtreeRoot, oldEntity, n newEntity.button.resolveDuplicatedEntityReferenceProperties(components.button, duplicatedIdsMap); } + // Handle entity scrollview attributes + if (components.scrollview) { + newEntity.scrollview.resolveDuplicatedEntityReferenceProperties(components.scrollview, duplicatedIdsMap); + } + + // Handle entity scrollbar attributes + if (components.scrollbar) { + newEntity.scrollbar.resolveDuplicatedEntityReferenceProperties(components.scrollbar, duplicatedIdsMap); + } + // Handle entity anim attributes if (components.anim) { newEntity.anim.resolveDuplicatedEntityReferenceProperties(components.anim, duplicatedIdsMap); diff --git a/src/framework/utils/entity-reference.js b/src/framework/utils/entity-reference.js deleted file mode 100644 index c58a34d4ebf..00000000000 --- a/src/framework/utils/entity-reference.js +++ /dev/null @@ -1,422 +0,0 @@ -import { Component } from '../components/component.js'; -import { Entity } from '../entity.js'; - -import { EventHandler } from '../../core/event-handler.js'; - -/** - * An EntityReference can be used in scenarios where a component has one or more properties that - * refer to entities in the scene graph. Using an EntityReference simplifies the job of dealing - * with the presence or non-presence of the underlying entity and its components, especially when - * it comes to dealing with the runtime addition or removal of components, and addition/removal of - * associated event listeners. - * - * ## Usage Scenario ## - * - * Imagine that you're creating a Checkbox component, which has a reference to an entity - * representing the checkmark/tickmark that is rendered in the Checkbox. The reference is modeled - * as an entity guid property on the Checkbox component, called simply 'checkmark'. We have to - * implement a basic piece of functionality whereby when the 'checkmark' entity reference is set, - * the Checkbox component must toggle the tint of an ImageElementComponent present on the checkmark - * entity to indicate whether the Checkbox is currently in the active or inactive state. - * - * Without using an EntityReference, the Checkbox component must implement some or all of the - * following: - * - * - Listen for its 'checkmark' property being set to a valid guid, and retrieve a reference to the - * entity associated with this guid whenever it changes (i.e. via `app.root.findByGuid()`). - * - Once a valid entity is received, check to see whether it has already has an - * ImageElementComponent or not: - * - If it has one, proceed to set the tint of the ImageElementComponent based on whether the - * Checkbox is currently active or inactive. - * - If it doesn't have one, add a listener to wait for the addition of an ImageElementComponent, - * and then apply the tint once one becomes present. - * - If the checkmark entity is then reassigned (such as if the user reassigns the field in the - * editor, or if this is done at runtime via a script), a well-behaved Checkbox component must - * also undo the tinting so that no lasting effect is applied to the old entity. - * - If the checkmark entity's ImageElementComponent is removed and then another - * ImageElementComponent is added, the Checkbox component must handle this in order to re-apply - * the tint. - * - To prevent memory leaks, the Checkbox component must also make sure to correctly remove - * listeners in each of the following scenarios: - * - Destruction of the Checkbox component. - * - Reassignment of the checkmark entity. - * - Removal of the ImageElementComponent. - * - It must also be careful not to double-add listeners in any of the above code paths, to avoid - * various forms of undesirable behavior. - * - * If the Checkbox component becomes more complicated and has multiple entity reference properties, - * all of the above must be done correctly for each entity. Similarly, if it depends on multiple - * different component types being present on the entities it has references to, it must correctly - * handle the presence and non-presence of each of these components in the various possible - * sequences of addition and removal. In addition to generating a lot of boilerplate, it's also - * very easy for subtle mistakes to be made that lead to memory leaks, null reference errors or - * visual bugs. - * - * By using an EntityReference, all of the above can be reduced to the following: - * - * ```javascript - * function CheckboxComponent() { - * this._checkmarkReference = new pc.EntityReference(this, 'checkmark', { - * 'element#gain': this._onCheckmarkImageElementGain, - * 'element#lose': this._onCheckmarkImageElementLose - * }); - * } - * ``` - * - * Using the above code snippet, the `_onCheckmarkImageElementGain()` listener will be called - * in either of the following scenarios: - * - * 1. A checkmark entity is assigned and already has an ElementComponent. - * 2. A checkmark entity is assigned that does not have an ElementComponent, but one is added - * later. - * - * Similarly, the `_onCheckmarkImageElementLose()` listener will be called in either of the - * following scenarios: - * - * 1. An ElementComponent is removed from the checkmark entity. - * 2. The checkmark entity is re-assigned (i.e. to another entity), or nullified. In this - * scenario the callback will only be called if the entity actually had an ElementComponent. - * - * ## Event String Format ## - * - * The event string (i.e. "element#gain" in the above examples) is of the format - * `sourceName#eventName`, and is defined as follows: - * - * - `sourceName`: May be any component name, or the special string "entity", which refers to the - * entity itself. - * - `eventName`: May be the name of any event dispatched by the relevant component or entity, as - * well as the special strings "gain" or "lose". - * - * Some examples are as follows: - * - * ```javascript - * "entity#destroy" // Called when the entity managed by the entity reference is destroyed. - * "element#set:width" // Called when the width of an ElementComponent is set. - * ``` - * - * When the entity reference changes to another entity (or null) the set:entity event is fired. - * - * ## Ownership and Destruction ## - * - * The lifetime of an ElementReference is tied to the parent component that instantiated it. This - * coupling is indicated by the provision of the `this` keyword to the ElementReference's - * constructor in the above examples (i.e. `new pc.EntityReference(this, ...`). - * - * Any event listeners managed by the ElementReference are automatically cleaned up when the parent - * component is removed or the parent component's entity is destroyed – as such you should never - * have to worry about dangling listeners. - * - * Additionally, any callbacks listed in the event config will automatically be called in the scope - * of the parent component – you should never have to worry about manually calling - * `Function.bind()`. - * - * @ignore - */ -class EntityReference extends EventHandler { - /** - * Helper class used for managing component properties that represent entity references. - * - * @param {Component} parentComponent - A reference to the parent component that owns this - * entity reference. - * @param {string} entityPropertyName - The name of the component property that contains the - * entity guid. - * @param {Object} [eventConfig] - A map of event listener configurations. - */ - constructor(parentComponent, entityPropertyName, eventConfig) { - super(); - - if (!parentComponent || !(parentComponent instanceof Component)) { - throw new Error('The parentComponent argument is required and must be a Component'); - } else if (!entityPropertyName || typeof entityPropertyName !== 'string') { - throw new Error('The propertyName argument is required and must be a string'); - } else if (eventConfig && typeof eventConfig !== 'object') { - throw new Error('If provided, the eventConfig argument must be an object'); - } - - this._parentComponent = parentComponent; - this._entityPropertyName = entityPropertyName; - this._entity = null; - this._app = parentComponent.system.app; - - this._configureEventListeners(eventConfig || {}, { - 'entity#destroy': this._onEntityDestroy - }); - this._toggleLifecycleListeners('on'); - } - - _configureEventListeners(externalEventConfig, internalEventConfig) { - const externalEventListenerConfigs = this._parseEventListenerConfig(externalEventConfig, 'external', this._parentComponent); - const internalEventListenerConfigs = this._parseEventListenerConfig(internalEventConfig, 'internal', this); - - this._eventListenerConfigs = externalEventListenerConfigs.concat(internalEventListenerConfigs); - this._listenerStatusFlags = {}; - this._gainListeners = {}; - this._loseListeners = {}; - } - - _parseEventListenerConfig(eventConfig, prefix, scope) { - return Object.keys(eventConfig).map((listenerDescription, index) => { - const listenerDescriptionParts = listenerDescription.split('#'); - const sourceName = listenerDescriptionParts[0]; - const eventName = listenerDescriptionParts[1]; - const callback = eventConfig[listenerDescription]; - - if (listenerDescriptionParts.length !== 2 || - typeof sourceName !== 'string' || sourceName.length === 0 || - typeof eventName !== 'string' || eventName.length === 0) { - throw new Error(`Invalid event listener description: \`${listenerDescription}\``); - } - - if (typeof callback !== 'function') { - throw new Error(`Invalid or missing callback for event listener \`${listenerDescription}\``); - } - - return { - id: `${prefix}_${index}_${listenerDescription}`, - sourceName: sourceName, - eventName: eventName, - callback: callback, - scope: scope - }; - }, this); - } - - _toggleLifecycleListeners(onOrOff) { - this._parentComponent[onOrOff](`set_${this._entityPropertyName}`, this._onSetEntity, this); - this._parentComponent.system[onOrOff]('beforeremove', this._onParentComponentRemove, this); - - this._app.systems[onOrOff]('postPostInitialize', this._updateEntityReference, this); - this._app[onOrOff]('tools:sceneloaded', this._onSceneLoaded, this); - - // For any event listeners that relate to the gain/loss of a component, register - // listeners that will forward the add/remove component events - const allComponentSystems = []; - - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - const config = this._eventListenerConfigs[i]; - const componentSystem = this._app.systems[config.sourceName]; - - if (componentSystem) { - if (allComponentSystems.indexOf(componentSystem) === -1) { - allComponentSystems.push(componentSystem); - } - - if (componentSystem && config.eventName === 'gain') { - this._gainListeners[config.sourceName] = config; - } - - if (componentSystem && config.eventName === 'lose') { - this._loseListeners[config.sourceName] = config; - } - } - } - - for (let i = 0; i < allComponentSystems.length; ++i) { - allComponentSystems[i][onOrOff]('add', this._onComponentAdd, this); - allComponentSystems[i][onOrOff]('beforeremove', this._onComponentRemove, this); - } - } - - _onSetEntity(name, oldValue, newValue) { - if (newValue instanceof Entity) { - this._updateEntityReference(); - } else { - if (newValue !== null && newValue !== undefined && typeof newValue !== 'string') { - console.warn(`Entity field \`${this._entityPropertyName}\` was set to unexpected type '${typeof newValue}'`); - return; - } - - if (oldValue !== newValue) { - this._updateEntityReference(); - } - } - } - - /** - * Must be called from the parent component's onEnable() method in order for entity references - * to be correctly resolved when {@link Entity#clone} is called. - * - * @private - */ - onParentComponentEnable() { - // When an entity is cloned via the JS API, we won't be able to resolve the - // entity reference until the cloned entity has been added to the scene graph. - // We can detect this by waiting for the parent component to be enabled, in the - // specific case where we haven't yet been able to resolve an entity reference. - if (!this._entity) { - this._updateEntityReference(); - } - } - - // When running within the editor, postInitialize is fired before the scene graph - // has been fully constructed. As such we use the special tools:sceneloaded event - // in order to know when the graph is ready to traverse. - _onSceneLoaded() { - this._updateEntityReference(); - } - - _updateEntityReference() { - let nextEntityGuid = this._parentComponent.data[this._entityPropertyName]; - let nextEntity; - - if (nextEntityGuid instanceof Entity) { - // if value is set to a Entity itself replace value with the GUID - nextEntity = nextEntityGuid; - nextEntityGuid = nextEntity.getGuid(); - this._parentComponent.data[this._entityPropertyName] = nextEntityGuid; - } else { - const root = this._parentComponent.system.app.root; - const isOnSceneGraph = this._parentComponent.entity.isDescendantOf(root); - - nextEntity = (isOnSceneGraph && nextEntityGuid) ? root.findByGuid(nextEntityGuid) : null; - } - - const hasChanged = this._entity !== nextEntity; - - if (hasChanged) { - if (this._entity) { - this._onBeforeEntityChange(); - } - - this._entity = nextEntity; - - if (this._entity) { - this._onAfterEntityChange(); - } - - this.fire('set:entity', this._entity); - } - } - - _onBeforeEntityChange() { - this._toggleEntityListeners('off'); - this._callAllGainOrLoseListeners(this._loseListeners); - } - - _onAfterEntityChange() { - this._toggleEntityListeners('on'); - this._callAllGainOrLoseListeners(this._gainListeners); - } - - _onComponentAdd(entity, component) { - const componentName = component.system.id; - - if (entity === this._entity) { - this._callGainOrLoseListener(componentName, this._gainListeners); - this._toggleComponentListeners('on', componentName); - } - } - - _onComponentRemove(entity, component) { - const componentName = component.system.id; - - if (entity === this._entity) { - this._callGainOrLoseListener(componentName, this._loseListeners); - this._toggleComponentListeners('off', componentName, true); - } - } - - _callAllGainOrLoseListeners(listenerMap) { - for (const componentName in this._entity.c) { - this._callGainOrLoseListener(componentName, listenerMap); - } - } - - _callGainOrLoseListener(componentName, listenerMap) { - if (this._entity.c.hasOwnProperty(componentName) && listenerMap[componentName]) { - const config = listenerMap[componentName]; - config.callback.call(config.scope); - } - } - - _toggleEntityListeners(onOrOff, isDestroying) { - if (this._entity) { - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - this._safeToggleListener(onOrOff, this._eventListenerConfigs[i], isDestroying); - } - } - } - - _toggleComponentListeners(onOrOff, componentName, isDestroying) { - for (let i = 0; i < this._eventListenerConfigs.length; ++i) { - const config = this._eventListenerConfigs[i]; - - if (config.sourceName === componentName) { - this._safeToggleListener(onOrOff, config, isDestroying); - } - } - } - - _safeToggleListener(onOrOff, config, isDestroying) { - const isAdding = (onOrOff === 'on'); - - // Prevent duplicate listeners - if (isAdding && this._listenerStatusFlags[config.id]) { - return; - } - - const source = this._getEventSource(config.sourceName, isDestroying); - - if (source) { - source[onOrOff](config.eventName, config.callback, config.scope); - this._listenerStatusFlags[config.id] = isAdding; - } - } - - _getEventSource(sourceName, isDestroying) { - // The 'entity' source name is a special case - we just want to return - // a reference to the entity itself. For all other cases the source name - // should refer to a component. - if (sourceName === 'entity') { - return this._entity; - } - - const component = this._entity[sourceName]; - - if (component) { - return component; - } - - if (!isDestroying) { - console.warn(`Entity has no component with name ${sourceName}`); - } - - return null; - } - - _onEntityDestroy(entity) { - if (this._entity === entity) { - this._toggleEntityListeners('off', true); - this._entity = null; - } - } - - _onParentComponentRemove(entity, component) { - if (component === this._parentComponent) { - this._toggleLifecycleListeners('off'); - this._toggleEntityListeners('off', true); - } - } - - /** - * Convenience method indicating whether the entity exists and has a component of the provided - * type. - * - * @param {string} componentName - Name of the component. - * @returns {boolean} True if the entity exists and has a component of the provided type. - */ - hasComponent(componentName) { - return (this._entity && this._entity.c) ? !!this._entity.c[componentName] : false; - } - - /** - * A reference to the entity, if present. - * - * @type {Entity} - */ - get entity() { - return this._entity; - } -} - -export { EntityReference }; diff --git a/src/index.js b/src/index.js index 65a82da4884..190fa471fd2 100644 --- a/src/index.js +++ b/src/index.js @@ -232,7 +232,6 @@ export { ElementComponent } from './framework/components/element/component.js'; export { ElementComponentSystem } from './framework/components/element/system.js'; export { ElementDragHelper } from './framework/components/element/element-drag-helper.js'; export { Entity } from './framework/entity.js'; -export { EntityReference } from './framework/utils/entity-reference.js'; export { GSplatComponent } from './framework/components/gsplat/component.js'; export { GSplatComponentSystem } from './framework/components/gsplat/system.js'; export { ImageElement } from './framework/components/element/image-element.js'; diff --git a/test/framework/utils/entity-reference.test.mjs b/test/framework/utils/entity-reference.test.mjs deleted file mode 100644 index 03dd09e6227..00000000000 --- a/test/framework/utils/entity-reference.test.mjs +++ /dev/null @@ -1,443 +0,0 @@ -import { expect } from 'chai'; -import { restore, spy, stub } from 'sinon'; - -import { Entity } from '../../../src/framework/entity.js'; -import { EntityReference } from '../../../src/framework/utils/entity-reference.js'; -import { createApp } from '../../app.mjs'; -import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; -import { DummyComponentSystem } from '../test-component/system.mjs'; - -/** - * @import { Application } from '../../../../src/framework/application.js' - * @import { Component } from '../../../../src/framework/components/component.js' - */ - -describe('EntityReference', function () { - /** @type {Application} */ - let app; - /** @type {Entity} */ - let testEntity; - /** @type {Component} */ - let testComponent; - /** @type {Entity} */ - let otherEntity1; - /** @type {Entity} */ - let otherEntity2; - - beforeEach(function () { - jsdomSetup(); - app = createApp(); - - app.systems.add(new DummyComponentSystem(app)); - - testEntity = new Entity('testEntity', app); - testComponent = testEntity.addComponent('dummy', {}); - - otherEntity1 = new Entity('otherEntity1', app); - otherEntity1.addComponent('dummy', {}); - otherEntity2 = new Entity('otherEntity2', app); - - app.root.addChild(testEntity); - app.root.addChild(otherEntity1); - app.root.addChild(otherEntity2); - }); - - afterEach(function () { - restore(); - app?.destroy(); - app = null; - jsdomTeardown(); - }); - - // Assertion helpers that rely on checking some private state. Usually I wouldn't do - // this, but given that we're checking such a stable part of the API (_callbacks has - // been present since 2011) I think it's preferable to adding public methods to the - // Events class that are only required for tests. Also it's critical that listener - // addition and removal is implemented correctly by EntityReference in order to avoid - // memory leaks, so the benefits as significant. - function getTotalEventListeners(entity) { - let total = 0; - - for (const callbacks of entity._callbacks.values()) { - total += callbacks.length; - } - - return total; - } - - function getNumListenersForEvent(entity, eventName) { - return entity._callbacks.get(eventName)?.length || 0; - } - - it('provides a reference to the entity once the guid is populated', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - expect(reference.entity).to.equal(null); - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - }); - - it('does not attempt to resolve the entity reference if the parent component is not on the scene graph yet', function () { - app.root.removeChild(testEntity); - - spy(app.root, 'findByGuid'); - - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(reference.entity).to.equal(null); - expect(app.root.findByGuid.callCount).to.equal(0); - }); - - it('resolves the entity reference when onParentComponentEnable() is called', function () { - app.root.removeChild(testEntity); - - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(null); - - app.root.addChild(testEntity); - reference.onParentComponentEnable(); - - expect(reference.entity).to.equal(otherEntity1); - }); - - it('nullifies the reference when the guid is nullified', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - - testComponent.myEntity1 = null; - expect(reference.entity).to.equal(null); - }); - - it('nullifies the reference when the referenced entity is destroyed', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(reference.entity).to.equal(otherEntity1); - - otherEntity1.destroy(); - expect(reference.entity).to.equal(null); - }); - - it('removes all entity and component listeners when the guid is reassigned', function () { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - - testComponent.myEntity1 = otherEntity2.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - }); - - it('removes all entity and component listeners when the parent component is removed', function () { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(2); - - testEntity.removeComponent('dummy'); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(0); - }); - - it('removes all entity and component listeners when the parent component\'s entity is destroyed', function () { - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': stub(), - 'dummy#bar': stub() - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - expect(getTotalEventListeners(otherEntity1)).to.equal(2); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(1); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(2); - - testEntity.destroy(); - expect(getTotalEventListeners(otherEntity1)).to.equal(0); - expect(getNumListenersForEvent(otherEntity1.dummy, 'bar')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'add')).to.equal(0); - expect(getNumListenersForEvent(app.systems.dummy, 'beforeremove')).to.equal(0); - }); - - it('fires component gain events when a guid is first assigned, if the referenced entity already has the component', function () { - const gainListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#gain': gainListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(gainListener.callCount).to.equal(1); - }); - - it('fires component gain events once a component is added', function () { - const gainListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity2', { - 'dummy#gain': gainListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity2 = otherEntity2.getGuid(); - - expect(gainListener.callCount).to.equal(0); - - otherEntity2.addComponent('dummy', {}); - - expect(gainListener.callCount).to.equal(1); - }); - - it('fires component lose and gain events when a component is removed and re-added', function () { - const gainListener = stub(); - const loseListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#gain': gainListener, - 'dummy#lose': loseListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(gainListener.callCount).to.equal(1); - expect(loseListener.callCount).to.equal(0); - - otherEntity1.removeComponent('dummy'); - - expect(gainListener.callCount).to.equal(1); - expect(loseListener.callCount).to.equal(1); - - otherEntity1.addComponent('dummy', {}); - - expect(gainListener.callCount).to.equal(2); - expect(loseListener.callCount).to.equal(1); - }); - - it('fires component lose events when the guid is reassigned, but only for component types that the entity had', function () { - const dummyLoseListener = stub(); - const lightLoseListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#lose': dummyLoseListener, - 'light#lose': lightLoseListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(dummyLoseListener.callCount).to.equal(0); - expect(lightLoseListener.callCount).to.equal(0); - - testComponent.myEntity1 = null; - - expect(dummyLoseListener.callCount).to.equal(1); - expect(lightLoseListener.callCount).to.equal(0); - }); - - it('forwards any events dispatched by a component', function () { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#foo': fooListener, - 'dummy#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - otherEntity1.dummy.fire('foo', 'a', 'b'); - expect(fooListener.callCount).to.equal(1); - expect(fooListener.getCall(0).args[0]).to.equal('a'); - expect(fooListener.getCall(0).args[1]).to.equal('b'); - expect(barListener.callCount).to.equal(0); - - otherEntity1.dummy.fire('bar', 'c', 'd'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - expect(barListener.getCall(0).args[0]).to.equal('c'); - expect(barListener.getCall(0).args[1]).to.equal('d'); - }); - - it('correctly handles component event forwarding across component removal and subsequent re-addition', function () { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'dummy#foo': fooListener, - 'dummy#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - const oldDummyComponent = otherEntity1.dummy; - - otherEntity1.removeComponent('dummy'); - - oldDummyComponent.fire('foo'); - oldDummyComponent.fire('bar'); - expect(fooListener.callCount).to.equal(0); - expect(barListener.callCount).to.equal(0); - - const newDummyComponent = otherEntity1.addComponent('dummy'); - - newDummyComponent.fire('foo'); - newDummyComponent.fire('bar'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - }); - - it('forwards any events dispatched by the entity', function () { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': fooListener, - 'entity#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - otherEntity1.fire('foo', 'a', 'b'); - expect(fooListener.callCount).to.equal(1); - expect(fooListener.getCall(0).args[0]).to.equal('a'); - expect(fooListener.getCall(0).args[1]).to.equal('b'); - expect(barListener.callCount).to.equal(0); - - otherEntity1.fire('bar', 'c', 'd'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - expect(barListener.getCall(0).args[0]).to.equal('c'); - expect(barListener.getCall(0).args[1]).to.equal('d'); - }); - - it('correctly handles entity event forwarding across entity nullification and subsequent reassignment', function () { - const fooListener = stub(); - const barListener = stub(); - - const reference = new EntityReference(testComponent, 'myEntity1', { - 'entity#foo': fooListener, - 'entity#bar': barListener - }); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - - testComponent.myEntity1 = null; - - otherEntity1.fire('foo'); - otherEntity1.fire('bar'); - expect(fooListener.callCount).to.equal(0); - expect(barListener.callCount).to.equal(0); - - testComponent.myEntity1 = otherEntity2.getGuid(); - - otherEntity2.fire('foo'); - otherEntity2.fire('bar'); - expect(fooListener.callCount).to.equal(1); - expect(barListener.callCount).to.equal(1); - }); - - it('validates the event map', function () { - function testEventMap(eventMap) { - const reference = new EntityReference(testComponent, 'myEntity1', eventMap); - expect(reference).to.be.ok; - } - - const callback = stub(); - - expect(() => { - testEventMap({ 'foo': callback }); - }).to.throw('Invalid event listener description: `foo`'); - - expect(() => { - testEventMap({ 'foo#': callback }); - }).to.throw('Invalid event listener description: `foo#`'); - - expect(() => { - testEventMap({ '#foo': callback }); - }).to.throw('Invalid event listener description: `#foo`'); - - expect(() => { - testEventMap({ 'foo#bar': null }); - }).to.throw('Invalid or missing callback for event listener `foo#bar`'); - }); - - it('logs a warning if the entity property is set to anything other than a string, undefined or null', function () { - stub(console, 'warn'); - - const reference = new EntityReference(testComponent, 'myEntity1'); - expect(reference).to.be.ok; - - testComponent.myEntity1 = otherEntity1.getGuid(); - testComponent.myEntity1 = null; - testComponent.myEntity1 = undefined; - - expect(console.warn.callCount).to.equal(0); - - testComponent.myEntity1 = {}; - - expect(console.warn.callCount).to.equal(1); - expect(console.warn.getCall(0).args[0]).to.equal('Entity field `myEntity1` was set to unexpected type \'object\''); - }); - - it('set reference to a Entity instead of guid, converts property to guid', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1; - - expect(testComponent.myEntity1).to.equal(otherEntity1.getGuid(), 'Component property converted to guid'); - expect(reference.entity).to.equal(otherEntity1); - }); - - it('set reference to a Entity that is not in hierarchy, converts property to guid', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - const entity = new Entity(); - testComponent.myEntity1 = entity; - - expect(testComponent.myEntity1).to.equal(entity.getGuid(), 'Component property converted to guid'); - expect(reference.entity).to.equal(entity); - }); - - it('hasComponent() returns false if the entity is not present', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - - expect(reference.hasComponent('dummy')).to.equal(false); - }); - - it('hasComponent() returns false if the entity is present but does not have a component of the provided type', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - otherEntity1.removeComponent('dummy'); - - expect(reference.hasComponent('dummy')).to.equal(false); - }); - - it('hasComponent() returns true if the entity is present and has a component of the provided type', function () { - const reference = new EntityReference(testComponent, 'myEntity1'); - testComponent.myEntity1 = otherEntity1.getGuid(); - - expect(reference.hasComponent('dummy')).to.equal(true); - }); - -});