diff --git a/src/framework/components/scrollbar/component.js b/src/framework/components/scrollbar/component.js index 26623836e97..406008f24f6 100644 --- a/src/framework/components/scrollbar/component.js +++ b/src/framework/components/scrollbar/component.js @@ -9,7 +9,6 @@ import { ElementDragHelper } from '../element/element-drag-helper.js'; /** * @import { EventHandle } from '../../../core/event-handle.js' * @import { Entity } from '../../entity.js' - * @import { ScrollbarComponentSystem } from './system.js' */ /** @@ -69,6 +68,15 @@ class ScrollbarComponent extends Component { */ static EVENT_SETVALUE = 'set:value'; + /** @private */ + _orientation = ORIENTATION_HORIZONTAL; + + /** @private */ + _value = 0; + + /** @private */ + _handleSize = 0; + /** * @type {Entity|null} * @private @@ -87,35 +95,6 @@ class ScrollbarComponent extends Component { */ _evtHandleEntityChanges = []; - /** - * Create a new ScrollbarComponent. - * - * @param {ScrollbarComponentSystem} system - The ComponentSystem that created this Component. - * @param {Entity} entity - The Entity that this Component is attached to. - */ - constructor(system, entity) { - super(system, entity); - this._toggleLifecycleListeners('on'); - } - - /** - * Sets the enabled state of the component. - * - * @type {boolean} - */ - set enabled(arg) { - this._setValue('enabled', arg); - } - - /** - * Gets the enabled state of the component. - * - * @type {boolean} - */ - get enabled() { - return this.data.enabled; - } - /** * Sets whether the scrollbar moves horizontally or vertically. Can be: * @@ -127,7 +106,15 @@ class ScrollbarComponent extends Component { * @type {number} */ set orientation(arg) { - this._setValue('orientation', arg); + if (this._orientation === arg) { + return; + } + + this._orientation = arg; + + if (this._handleEntity?.element) { + this._handleEntity.element[this._getOppositeDimension()] = 0; + } } /** @@ -136,7 +123,7 @@ class ScrollbarComponent extends Component { * @type {number} */ get orientation() { - return this.data.orientation; + return this._orientation; } /** @@ -145,7 +132,11 @@ class ScrollbarComponent extends Component { * @type {number} */ set value(arg) { - this._setValue('value', arg); + if (Math.abs(arg - this._value) > 1e-5) { + this._value = math.clamp(arg, 0, 1); + this._updateHandlePositionAndSize(); + this.fire('set:value', this._value); + } } /** @@ -154,7 +145,7 @@ class ScrollbarComponent extends Component { * @type {number} */ get value() { - return this.data.value; + return this._value; } /** @@ -165,7 +156,10 @@ class ScrollbarComponent extends Component { * @type {number} */ set handleSize(arg) { - this._setValue('handleSize', arg); + if (Math.abs(arg - this._handleSize) > 1e-5) { + this._handleSize = math.clamp(arg, 0, 1); + this._updateHandlePositionAndSize(); + } } /** @@ -174,7 +168,7 @@ class ScrollbarComponent extends Component { * @type {number} */ get handleSize() { - return this.data.handleSize; + return this._handleSize; } /** @@ -208,12 +202,6 @@ class ScrollbarComponent extends Component { if (this._handleEntity) { this._handleEntitySubscribe(); } - - if (this._handleEntity) { - this.data.handleEntity = this._handleEntity.getGuid(); - } else if (isString && arg) { - this.data.handleEntity = arg; - } } /** @@ -225,26 +213,6 @@ class ScrollbarComponent extends Component { return this._handleEntity; } - /** @ignore */ - _setValue(name, value) { - const data = this.data; - const oldValue = data[name]; - data[name] = value; - this.fire('set', name, oldValue, value); - } - - /** - * @param {string} onOrOff - 'on' or 'off'. - * @private - */ - _toggleLifecycleListeners(onOrOff) { - this[onOrOff]('set_value', this._onSetValue, this); - this[onOrOff]('set_handleSize', this._onSetHandleSize, this); - this[onOrOff]('set_orientation', this._onSetOrientation, this); - - // TODO Handle scrollwheel events - } - _handleEntitySubscribe() { this._evtHandleEntityElementAdd = this._handleEntity.on('element:add', this._onHandleElementGain, this); @@ -299,31 +267,10 @@ class ScrollbarComponent extends Component { } } - _onSetValue(name, oldValue, newValue) { - if (Math.abs(newValue - oldValue) > 1e-5) { - this.data.value = math.clamp(newValue, 0, 1); - this._updateHandlePositionAndSize(); - this.fire('set:value', this.data.value); - } - } - - _onSetHandleSize(name, oldValue, newValue) { - if (Math.abs(newValue - oldValue) > 1e-5) { - this.data.handleSize = math.clamp(newValue, 0, 1); - this._updateHandlePositionAndSize(); - } - } - _onSetHandleAlignment() { this._updateHandlePositionAndSize(); } - _onSetOrientation(name, oldValue, newValue) { - if (newValue !== oldValue && this._handleEntity?.element) { - this._handleEntity.element[this._getOppositeDimension()] = 0; - } - } - _updateHandlePositionAndSize() { const handleEntity = this._handleEntity; const handleElement = handleEntity?.element; @@ -353,34 +300,34 @@ class ScrollbarComponent extends Component { _getTrackLength() { if (this.entity.element) { - return this.orientation === ORIENTATION_HORIZONTAL ? this.entity.element.calculatedWidth : this.entity.element.calculatedHeight; + return this._orientation === ORIENTATION_HORIZONTAL ? this.entity.element.calculatedWidth : this.entity.element.calculatedHeight; } return 0; } _getHandleLength() { - return this._getTrackLength() * this.handleSize; + return this._getTrackLength() * this._handleSize; } _getHandlePosition() { - return this._scrollValueToHandlePosition(this.value); + return this._scrollValueToHandlePosition(this._value); } _getSign() { - return this.orientation === ORIENTATION_HORIZONTAL ? 1 : -1; + return this._orientation === ORIENTATION_HORIZONTAL ? 1 : -1; } _getAxis() { - return this.orientation === ORIENTATION_HORIZONTAL ? 'x' : 'y'; + return this._orientation === ORIENTATION_HORIZONTAL ? 'x' : 'y'; } _getDimension() { - return this.orientation === ORIENTATION_HORIZONTAL ? 'width' : 'height'; + return this._orientation === ORIENTATION_HORIZONTAL ? 'width' : 'height'; } _getOppositeDimension() { - return this.orientation === ORIENTATION_HORIZONTAL ? 'height' : 'width'; + return this._orientation === ORIENTATION_HORIZONTAL ? 'height' : 'width'; } _destroyDragHelper() { @@ -405,7 +352,6 @@ class ScrollbarComponent extends Component { onRemove() { this._destroyDragHelper(); - this._toggleLifecycleListeners('off'); } resolveDuplicatedEntityReferenceProperties(oldScrollbar, duplicatedIdsMap) { diff --git a/src/framework/components/scrollbar/data.js b/src/framework/components/scrollbar/data.js index cfd8e80efe9..a491396b230 100644 --- a/src/framework/components/scrollbar/data.js +++ b/src/framework/components/scrollbar/data.js @@ -1,21 +1,5 @@ -import { ORIENTATION_HORIZONTAL } from '../../../scene/constants.js'; - -/** - * @import { Entity } from '../../../framework/entity' - */ - class ScrollbarComponentData { enabled = true; - - orientation = ORIENTATION_HORIZONTAL; - - value = 0; - - /** @type {number} */ - handleSize = 0; - - /** @type {Entity|null} */ - handleEntity = null; } export { ScrollbarComponentData }; diff --git a/src/framework/components/scrollbar/system.js b/src/framework/components/scrollbar/system.js index 1abf56a1b03..75ac50dc86b 100644 --- a/src/framework/components/scrollbar/system.js +++ b/src/framework/components/scrollbar/system.js @@ -1,3 +1,4 @@ +import { Component } from '../component.js'; import { ComponentSystem } from '../system.js'; import { ScrollbarComponent } from './component.js'; import { ScrollbarComponentData } from './data.js'; @@ -6,12 +7,9 @@ import { ScrollbarComponentData } from './data.js'; * @import { AppBase } from '../../app-base.js' */ -const _schema = [ - { name: 'enabled', type: 'boolean' }, - { name: 'orientation', type: 'number' }, - { name: 'value', type: 'number' }, - { name: 'handleSize', type: 'number' } -]; +const _schema = ['enabled']; + +const _properties = ['orientation', 'value', 'handleSize', 'handleEntity']; /** * Manages creation of {@link ScrollbarComponent}s. @@ -40,8 +38,25 @@ class ScrollbarComponentSystem extends ComponentSystem { } initializeComponentData(component, data, properties) { + for (let i = 0; i < _properties.length; i++) { + const property = _properties[i]; + if (data.hasOwnProperty(property)) { + component[property] = data[property]; + } + } + super.initializeComponentData(component, data, _schema); - component.handleEntity = data.handleEntity; + } + + cloneComponent(entity, clone) { + const c = entity.scrollbar; + return this.addComponent(clone, { + enabled: c.enabled, + orientation: c.orientation, + value: c.value, + handleSize: c.handleSize, + handleEntity: c.handleEntity + }); } _onAddComponent(entity) { @@ -53,4 +68,6 @@ class ScrollbarComponentSystem extends ComponentSystem { } } +Component._buildAccessors(ScrollbarComponent.prototype, _schema); + export { ScrollbarComponentSystem }; diff --git a/test/framework/components/scrollbar/component.test.mjs b/test/framework/components/scrollbar/component.test.mjs new file mode 100644 index 00000000000..52ca1378372 --- /dev/null +++ b/test/framework/components/scrollbar/component.test.mjs @@ -0,0 +1,302 @@ +import { expect } from 'chai'; + +import { ELEMENTTYPE_IMAGE } from '../../../../src/framework/components/element/constants.js'; +import { Entity } from '../../../../src/framework/entity.js'; +import { ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL } from '../../../../src/scene/constants.js'; +import { createApp } from '../../../app.mjs'; +import { jsdomSetup, jsdomTeardown } from '../../../jsdom.mjs'; + +describe('ScrollbarComponent', function () { + let app; + + beforeEach(function () { + jsdomSetup(); + app = createApp(); + }); + + afterEach(function () { + app?.destroy(); + app = null; + jsdomTeardown(); + }); + + describe('#addComponent', function () { + + it('creates a component with sensible defaults', function () { + const e = new Entity(); + e.addComponent('scrollbar'); + + expect(e.scrollbar).to.exist; + expect(e.scrollbar.enabled).to.equal(true); + expect(e.scrollbar.orientation).to.equal(ORIENTATION_HORIZONTAL); + expect(e.scrollbar.value).to.equal(0); + expect(e.scrollbar.handleSize).to.equal(0); + expect(e.scrollbar.handleEntity).to.equal(null); + }); + + it('round-trips every property passed via the data argument', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const e = new Entity(); + e.addChild(handle); + e.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + e.addComponent('scrollbar', { + enabled: false, + orientation: ORIENTATION_VERTICAL, + value: 0.4, + handleSize: 0.3, + handleEntity: handle + }); + + expect(e.scrollbar.enabled).to.equal(false); + expect(e.scrollbar.orientation).to.equal(ORIENTATION_VERTICAL); + expect(e.scrollbar.value).to.be.closeTo(0.4, 1e-6); + expect(e.scrollbar.handleSize).to.be.closeTo(0.3, 1e-6); + expect(e.scrollbar.handleEntity).to.equal(handle); + }); + + }); + + describe('#value', function () { + + it('clamps writes outside [0, 1]', function () { + const e = new Entity(); + e.addComponent('scrollbar'); + + e.scrollbar.value = -1; + expect(e.scrollbar.value).to.equal(0); + + e.scrollbar.value = 2; + expect(e.scrollbar.value).to.equal(1); + }); + + it('fires set:value with the clamped value when it changes', function () { + const e = new Entity(); + e.addComponent('scrollbar'); + + const captured = []; + e.scrollbar.on('set:value', (v) => { + captured.push(v); + }); + + e.scrollbar.value = 1.5; + expect(captured).to.deep.equal([1]); + + e.scrollbar.value = -0.25; + expect(captured).to.deep.equal([1, 0]); + }); + + it('does not fire set:value when the change is below 1e-5', function () { + const e = new Entity(); + e.addComponent('scrollbar', { value: 0.5 }); + + let fired = 0; + e.scrollbar.on('set:value', () => { + fired++; + }); + + // sub-epsilon delta should be ignored + e.scrollbar.value = 0.5 + 1e-7; + expect(fired).to.equal(0); + + // setting to (effectively) the current value should also be a no-op + e.scrollbar.value = 0.5; + expect(fired).to.equal(0); + }); + + }); + + describe('#handleSize', function () { + + it('clamps writes outside [0, 1]', function () { + const e = new Entity(); + e.addComponent('scrollbar'); + + e.scrollbar.handleSize = -2; + expect(e.scrollbar.handleSize).to.equal(0); + + e.scrollbar.handleSize = 5; + expect(e.scrollbar.handleSize).to.equal(1); + }); + + it('ignores writes below the 1e-5 epsilon', function () { + const e = new Entity(); + e.addComponent('scrollbar', { handleSize: 0.5 }); + + // sub-epsilon delta should leave handleSize effectively unchanged + e.scrollbar.handleSize = 0.5 + 1e-7; + expect(e.scrollbar.handleSize).to.be.closeTo(0.5, 1e-5); + }); + + }); + + describe('#orientation', function () { + + it('zeroes the opposite dimension on the handle element when orientation changes', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE, width: 50, height: 50 }); + + const e = new Entity(); + e.addChild(handle); + e.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + e.addComponent('scrollbar', { handleEntity: handle, orientation: ORIENTATION_HORIZONTAL }); + + expect(e.scrollbar.orientation).to.equal(ORIENTATION_HORIZONTAL); + + // switching to vertical should clear the handle element's width (the opposite of vertical) + e.scrollbar.orientation = ORIENTATION_VERTICAL; + + expect(e.scrollbar.orientation).to.equal(ORIENTATION_VERTICAL); + expect(handle.element.width).to.equal(0); + }); + + it('is a no-op when the orientation is unchanged', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE, width: 50, height: 50 }); + + const e = new Entity(); + e.addChild(handle); + e.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + e.addComponent('scrollbar', { handleEntity: handle, orientation: ORIENTATION_HORIZONTAL }); + + const widthBefore = handle.element.width; + const heightBefore = handle.element.height; + + e.scrollbar.orientation = ORIENTATION_HORIZONTAL; + + expect(handle.element.width).to.equal(widthBefore); + expect(handle.element.height).to.equal(heightBefore); + }); + + }); + + describe('#handleEntity', function () { + + it('accepts an Entity reference', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const e = new Entity(); + e.addComponent('scrollbar'); + + e.scrollbar.handleEntity = handle; + + expect(e.scrollbar.handleEntity).to.equal(handle); + }); + + it('accepts a GUID string and resolves via app.getEntityFromIndex', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + const handleGuid = handle.getGuid(); + + const e = new Entity(); + e.addComponent('scrollbar'); + + e.scrollbar.handleEntity = handleGuid; + + expect(e.scrollbar.handleEntity).to.equal(handle); + }); + + it('accepts null', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const e = new Entity(); + e.addComponent('scrollbar', { handleEntity: handle }); + + e.scrollbar.handleEntity = null; + + expect(e.scrollbar.handleEntity).to.equal(null); + }); + + it('unsubscribes from the previous handle entity when reassigned', function () { + const handle1 = new Entity(); + handle1.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const handle2 = new Entity(); + handle2.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const e = new Entity(); + e.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + e.addComponent('scrollbar', { handleEntity: handle1 }); + + // scrollbar should be listening to handle1's element:add + expect(handle1.hasEvent('element:add')).to.equal(true); + expect(handle2.hasEvent('element:add')).to.equal(false); + + e.scrollbar.handleEntity = handle2; + + expect(e.scrollbar.handleEntity).to.equal(handle2); + expect(handle1.hasEvent('element:add')).to.equal(false); + expect(handle2.hasEvent('element:add')).to.equal(true); + }); + + }); + + describe('#cloneComponent', function () { + + it('clones every scalar property', function () { + const e = new Entity(); + e.addComponent('scrollbar', { + enabled: false, + orientation: ORIENTATION_VERTICAL, + value: 0.4, + handleSize: 0.3 + }); + + const clone = e.clone(); + const c = clone.scrollbar; + + expect(c).to.exist; + expect(c.enabled).to.equal(false); + expect(c.orientation).to.equal(ORIENTATION_VERTICAL); + expect(c.value).to.be.closeTo(0.4, 1e-6); + expect(c.handleSize).to.be.closeTo(0.3, 1e-6); + }); + + it('remaps handleEntity to the cloned child via the duplicated ids map', function () { + const handle = new Entity('handle'); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const e = new Entity('parent'); + e.addChild(handle); + e.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + e.addComponent('scrollbar', { handleEntity: handle }); + + const clone = e.clone(); + const cloneHandle = clone.findByName('handle'); + + expect(cloneHandle).to.exist; + expect(cloneHandle).to.not.equal(handle); + expect(clone.scrollbar.handleEntity).to.equal(cloneHandle); + }); + + }); + + describe('resolveDuplicatedEntityReferenceProperties', function () { + + it('remaps the handle entity through duplicatedIdsMap', function () { + const handle = new Entity(); + handle.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const replacement = new Entity(); + replacement.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + + const source = new Entity(); + source.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + source.addComponent('scrollbar', { handleEntity: handle }); + + const target = new Entity(); + target.addComponent('element', { type: ELEMENTTYPE_IMAGE }); + target.addComponent('scrollbar'); + + const map = { [handle.getGuid()]: replacement }; + target.scrollbar.resolveDuplicatedEntityReferenceProperties(source.scrollbar, map); + + expect(target.scrollbar.handleEntity).to.equal(replacement); + }); + + }); + +});