/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; this.MarqueeWidget = class { constructor(shadowRoot) { this.shadowRoot = shadowRoot; this.element = shadowRoot.host; this.document = this.element.ownerDocument; this.window = this.document.defaultView; // This needed for behavior=alternate, in order to know in which of the two // directions we're going. this.dirsign = 1; this._currentLoop = this.element.loop; this.animation = null; this._restartScheduled = null; } onsetup() { // White-space isn't allowed because a marquee could be // inside 'white-space: pre' this.shadowRoot.innerHTML = ``; this._mutationObserver = new this.window.MutationObserver(aMutations => this._mutationActor(aMutations) ); this._mutationObserver.observe(this.element, { attributes: true, attributeOldValue: true, attributeFilter: ["loop", "direction", "behavior"], }); // init needs to be run after the page has loaded in order to calculate // the correct height/width if (this.document.readyState == "complete") { this.init(); } else { this.window.addEventListener("load", this, { once: true }); } this.shadowRoot.addEventListener("marquee-start", this); this.shadowRoot.addEventListener("marquee-stop", this); } teardown() { this.doStop(); this._mutationObserver.disconnect(); this.window.removeEventListener("load", this); this.shadowRoot.removeEventListener("marquee-start", this); this.shadowRoot.removeEventListener("marquee-stop", this); this.shadowRoot.replaceChildren(); } handleEvent(aEvent) { if (!aEvent.isTrusted) { return; } switch (aEvent.type) { case "load": this.init(); break; case "marquee-start": this.doStart(); break; case "marquee-stop": this.doStop(); break; case "finish": this._animationFinished(); break; } } _animationFinished() { let behavior = this.element.behavior; let shouldLoop = this._currentLoop > 1 || (this._currentLoop == -1 && behavior != "slide"); if (shouldLoop) { if (this._currentLoop > 0) { this._currentLoop--; } if (behavior == "alternate") { this.dirsign = -this.dirsign; } this.doStop(); this.doStart(); } } get scrollDelayWithTruespeed() { if (this.element.scrollDelay < 60 && !this.element.trueSpeed) { return 60; } return this.element.scrollDelay; } get slot() { return this.shadowRoot.lastChild; } /** * Computes CSS-derived values needed to compute the transform of the * contents. * * In particular, it measures the auto width and height of the contents, * and the effective width and height of the marquee itself, along with its * css directionality (which affects the effective direction). */ getMetrics() { let slot = this.slot; slot.style.width = "max-content"; let slotCS = this.window.getComputedStyle(slot); let marqueeCS = this.window.getComputedStyle(this.element); let contentWidth = parseFloat(slotCS.width) || 0; let contentHeight = parseFloat(slotCS.height) || 0; let marqueeWidth = parseFloat(marqueeCS.width) || 0; let marqueeHeight = parseFloat(marqueeCS.height) || 0; slot.style.width = ""; return { contentWidth, contentHeight, marqueeWidth, marqueeHeight, cssDirection: marqueeCS.direction, }; } /** * Gets the layout metrics from getMetrics(), and returns an object * describing the start, end, and axis of the animation for the given marquee * behavior and direction. */ getTransformParameters({ contentWidth, contentHeight, marqueeWidth, marqueeHeight, cssDirection, }) { const innerWidth = marqueeWidth - contentWidth; const innerHeight = marqueeHeight - contentHeight; const dir = this.element.direction; let start = 0; let end = 0; const axis = dir == "up" || dir == "down" ? "y" : "x"; switch (this.element.behavior) { case "alternate": switch (dir) { case "up": case "down": { if (innerHeight >= 0) { start = innerHeight; end = 0; } else { start = 0; end = innerHeight; } if (dir == "down") { [start, end] = [end, start]; } if (this.dirsign == -1) { [start, end] = [end, start]; } break; } case "right": case "left": default: { if (innerWidth >= 0) { start = innerWidth; end = 0; } else { start = 0; end = innerWidth; } if (dir == "right") { [start, end] = [end, start]; } if (cssDirection == "rtl") { [start, end] = [end, start]; } if (this.dirsign == -1) { [start, end] = [end, start]; } break; } } break; case "slide": switch (dir) { case "up": { start = marqueeHeight; end = 0; break; } case "down": { start = -contentHeight; end = innerHeight; break; } case "right": default: { let isRight = dir == "right"; if (cssDirection == "rtl") { isRight = !isRight; } if (isRight) { start = -contentWidth; end = innerWidth; } else { start = marqueeWidth; end = 0; } break; } } break; case "scroll": default: switch (dir) { case "up": case "down": { start = marqueeHeight; end = -contentHeight; if (dir == "down") { [start, end] = [end, start]; } break; } case "right": case "left": default: { start = marqueeWidth; end = -contentWidth; if (dir == "right") { [start, end] = [end, start]; } if (cssDirection == "rtl") { [start, end] = [end, start]; } break; } } break; } return { start, end, axis }; } /** * Measures the marquee contents, and starts the marquee animation if needed. * The translate animation is applied to the element. * Bouncing and looping is implemented in the finish event handler for the * given animation (see _animationFinished()). */ doStart() { if (this.animation) { return; } let scrollAmount = this.element.scrollAmount; if (!scrollAmount) { return; } let metrics = this.getMetrics(); let { axis, start, end } = this.getTransformParameters(metrics); let duration = (Math.abs(end - start) * this.scrollDelayWithTruespeed) / scrollAmount; let startValue = start + "px"; let endValue = end + "px"; if (axis == "y") { startValue = "0 " + startValue; endValue = "0 " + endValue; } // NOTE(emilio): It seems tempting to use `iterations` here, but doing so // wouldn't be great because this uses current layout values (via // getMetrics()), so sizes wouldn't update. This way we update once per // animation iteration. // // fill: forwards is needed so that behavior=slide doesn't jump back to the // start after the animation finishes. this.animation = this.slot.animate( { translate: [startValue, endValue], }, { duration, easing: "linear", fill: "forwards", } ); this.animation.addEventListener("finish", this, { once: true }); } doStop() { if (!this.animation) { return; } if (this._restartScheduled) { this.window.cancelAnimationFrame(this._restartScheduled); this._restartScheduled = null; } this.animation.removeEventListener("finish", this); this.animation.cancel(); this.animation = null; } init() { this.element.stop(); this.doStart(); } _mutationActor(aMutations) { while (aMutations.length) { let mutation = aMutations.shift(); let attrName = mutation.attributeName.toLowerCase(); let oldValue = mutation.oldValue; let newValue = this.element.getAttribute(attrName); if (oldValue == newValue) { continue; } if (attrName == "loop") { this._currentLoop = this.element.loop; } if (attrName == "direction" || attrName == "behavior") { this._scheduleRestartIfNeeded(); } } } // Schedule a restart with the new parameters if we're running. _scheduleRestartIfNeeded() { if (!this.animation || this._restartScheduled != null) { return; } this._restartScheduled = this.window.requestAnimationFrame(() => { if (this.animation) { this.doStop(); this.doStart(); } }); } };