class OutfitViewer extends HTMLElement { #internals; #isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround) constructor() { super(); this.#internals = this.attachInternals(); // for CSS `:state()` } connectedCallback() { // Set up listener for bubbled hasanimationschange events from layers this.addEventListener("hasanimationschange", (e) => { // Only handle events from outfit-layer children, not from ourselves if (e.target === this) return; this.#updateHasAnimations(); }); // Watch for new layers being added and apply the current playing state const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.tagName === "OUTFIT-LAYER") { // Apply current playing state to the new layer if (this.#internals.states.has("playing")) { node.play(); } else { node.pause(); } } } } }); observer.observe(this, { childList: true }); // The `` is connected to the DOM right before its // children are. So, to engage with the children, wait a tick! setTimeout(() => this.#connectToChildren(), 0); } #connectToChildren() { // Read initial playing state from cookie and initialize const isPlayingFromCookie = this.#getIsPlayingCookie(); // Initialize the boolean before calling #setIsPlaying // (We set it to the opposite first so #setIsPlaying detects a change) this.#isPlaying = !isPlayingFromCookie; this.#setIsPlaying(isPlayingFromCookie); // Check initial animation state this.#updateHasAnimations(); } #updateHasAnimations() { // Check if any layer has animations const hasAnimations = this.querySelector("outfit-layer:state(has-animations)") !== null; // Check if state actually changed const hadAnimations = this.#internals.states.has("has-animations"); if (hasAnimations === hadAnimations) { return; // No change, skip } // Update internal state if (hasAnimations) { this.#internals.states.add("has-animations"); } else { this.#internals.states.delete("has-animations"); } // Dispatch event so external components can react this.dispatchEvent( new CustomEvent("hasanimationschange", { detail: { hasAnimations }, bubbles: true, }), ); } /** * Public API: Start playing animations */ play() { this.#setIsPlaying(true); } /** * Public API: Pause animations */ pause() { this.#setIsPlaying(false); } /** * Public API: Check if currently playing */ get isPlaying() { return this.#isPlaying; } #setIsPlaying(isPlaying) { // Skip if already in this state if (this.#isPlaying === isPlaying) { return; } // Update internal state (both boolean and CustomStateSet for CSS) this.#isPlaying = isPlaying; if (isPlaying) { this.#internals.states.add("playing"); for (const layer of this.querySelectorAll("outfit-layer")) { layer.play(); } } else { this.#internals.states.delete("playing"); for (const layer of this.querySelectorAll("outfit-layer")) { layer.pause(); } } // Persist to cookie this.#setIsPlayingCookie(isPlaying); // Dispatch event so external components can react this.dispatchEvent( new CustomEvent("playingchange", { detail: { isPlaying }, bubbles: true, }), ); } #getIsPlayingCookie() { const cookie = document.cookie .split("; ") .find((row) => row.startsWith("DTIOutfitViewerIsPlaying=")); if (cookie) { return cookie.split("=")[1] === "true"; } return true; // Default to playing } #setIsPlayingCookie(isPlaying) { const thirtyDays = 60 * 60 * 24 * 30; const value = isPlaying ? "true" : "false"; document.cookie = `DTIOutfitViewerIsPlaying=${value};max-age=${thirtyDays}`; } } class OutfitLayer extends HTMLElement { #internals; #pendingMessage = null; // Queue one message to send when iframe loads constructor() { super(); this.#internals = this.attachInternals(); // An starts in the loading state, and then might very // quickly decide it's not after `#connectToChildren`. This is to prevent a // flash of *non*-loading state, when a new layer loads in. (e.g. In the // time between our parent loading, which shows the loading // spinner; and us being marked `:state(loading)`, which shows the loading // spinner; we don't want the loading spinner to do its usual *immediate* // total fade-out; then have to fade back in again, on the usual delay.) this.#setStatus("loading"); } connectedCallback() { setTimeout(() => this.#connectToChildren(), 0); } disconnectedCallback() { // When this `` leaves the DOM, stop listening for iframe // messages, if we were. window.removeEventListener("message", this.#onMessage); } play() { this.#sendMessageToIframe({ type: "play" }); } pause() { this.#sendMessageToIframe({ type: "pause" }); } #connectToChildren() { const image = this.querySelector("img"); const iframe = this.querySelector("iframe"); if (image) { // If this is an image layer, track its loading state by listening // to the load/error events, and initialize based on whether it's // already `complete` (which it can be if it loaded from cache). this.#setStatus(image.complete ? "loaded" : "loading"); image.addEventListener("load", () => this.#setStatus("loaded")); image.addEventListener("error", () => this.#setStatus("error")); } else if (iframe) { this.iframe = iframe; // Initialize status to `loading`, and asynchronously request a // status message from the iframe if it managed to load before this // triggers (impressive, but I think I've seen it happen!). Then, // wait for messages or error events from the iframe to update // status further if needed. this.#setStatus("loading"); this.#sendMessageToIframe({ type: "requestStatus" }); window.addEventListener("message", (m) => this.#onMessage(m)); this.iframe.addEventListener("error", () => this.#setStatus("error")); // If there was a pending play/pause message, send it now if (this.#pendingMessage) { this.#sendMessageToIframe(this.#pendingMessage); this.#pendingMessage = null; } } else { console.warn(` contained no image or iframe: `, this); } } #onMessage({ source, data }) { // Ignore messages that aren't from *our* frame. if (source !== this.iframe.contentWindow) { return; } // Validate the incoming status message, then set our status to match. if (data.type === "status") { if (data.status === "loaded") { this.#setStatus("loaded"); this.#setHasAnimations(data.hasAnimations); } else if (data.status === "error") { this.#setStatus("error"); } else { throw new Error( ` got unexpected status: ` + JSON.stringify(data.status), ); } } else { throw new Error( ` got unexpected message: ` + JSON.stringify(data), ); } } /** * Set the status value that the CSS `:state()` selector will match. * For example, when loading, `:state(loading)` matches this element. */ #setStatus(newStatus) { this.#internals.states.delete("loading"); this.#internals.states.delete("loaded"); this.#internals.states.delete("error"); this.#internals.states.add(newStatus); } /** * Set whether CSS selector `:state(has-animations)` matches this element. */ #setHasAnimations(hasAnimations) { // Check if state actually changed const hadAnimations = this.#internals.states.has("has-animations"); if (hasAnimations === hadAnimations) { return; // No change, skip } // Update internal state if (hasAnimations) { this.#internals.states.add("has-animations"); } else { this.#internals.states.delete("has-animations"); } // Dispatch event so parent OutfitViewer can react this.dispatchEvent( new CustomEvent("hasanimationschange", { detail: { hasAnimations }, bubbles: true, }), ); } #sendMessageToIframe(message) { // If we have no frame, queue play/pause messages for later if (this.iframe == null) { if (message.type === "play" || message.type === "pause") { this.#pendingMessage = message; } return; } if (this.iframe.contentWindow == null) { console.debug( `Queueing message, frame not loaded yet: `, this.iframe, message, ); if (message.type === "play" || message.type === "pause") { this.#pendingMessage = message; } return; } // The frame is sandboxed (origin == null), so send to Any origin. this.iframe.contentWindow.postMessage(message, "*"); } } /** * OutfitViewerPlayPauseToggle is a web component that creates a play/pause * toggle button for an outfit-viewer, referenced by the `for` attribute. * * Usage: * ... * * * Playing * Paused * * * The toggle will: * - Read initial state from the outfit-viewer * - Update the checkbox when the outfit-viewer state changes * - Call play()/pause() on the outfit-viewer when the checkbox changes * - Show/hide based on whether the outfit has animations */ class OutfitViewerPlayPauseToggle extends HTMLElement { #outfitViewer = null; #checkbox = null; connectedCallback() { setTimeout(() => this.#connect(), 0); } #connect() { // Find the outfit-viewer referenced by the `for` attribute const forId = this.getAttribute("for"); if (!forId) { console.warn( " requires a 'for' attribute", ); return; } this.#outfitViewer = document.getElementById(forId); if (!this.#outfitViewer) { console.warn( ` could not find outfit-viewer with id="${forId}"`, ); return; } // Find the checkbox input this.#checkbox = this.querySelector('input[type="checkbox"]'); if (!this.#checkbox) { console.warn( " requires a checkbox input child", ); return; } // Sync initial state from outfit-viewer to checkbox this.#syncFromOutfitViewer(); // Listen for checkbox changes and update outfit-viewer this.#checkbox.addEventListener("change", () => { if (this.#checkbox.checked) { this.#outfitViewer.play(); } else { this.#outfitViewer.pause(); } }); // Listen for outfit-viewer state changes and update checkbox this.#outfitViewer.addEventListener("playingchange", (e) => { this.#checkbox.checked = e.detail.isPlaying; }); // Listen for animation availability changes and update visibility this.#outfitViewer.addEventListener("hasanimationschange", (e) => { this.#updateVisibility(e.detail.hasAnimations); }); // Set initial visibility based on current state this.#updateVisibility( this.#outfitViewer.querySelector( "outfit-layer:state(has-animations)", ) !== null, ); } #syncFromOutfitViewer() { if (this.#outfitViewer && this.#checkbox) { this.#checkbox.checked = this.#outfitViewer.isPlaying; } } #updateVisibility(hasAnimations) { // Show/hide the toggle based on whether there are animations // Use a custom state so CSS can control the display if (hasAnimations) { this.removeAttribute("hidden"); } else { this.setAttribute("hidden", ""); } } } customElements.define("outfit-viewer", OutfitViewer); customElements.define("outfit-layer", OutfitLayer); customElements.define( "outfit-viewer-play-pause-toggle", OutfitViewerPlayPauseToggle, ); // Morph turbo-frames on this page, to reuse asset nodes when we want to—very // important for movies!—but ensure that it *doesn't* do its usual behavior of // aggressively reusing existing nodes for entirely different // assets. (It's a lot clearer for managing the loading state, and not showing // old incorrect layers!) (We also tried using `id` to enforce this… no luck.) function morphWithOutfitLayers(currentElement, newElement) { Idiomorph.morph(currentElement, newElement.innerHTML, { morphStyle: "innerHTML", callbacks: { beforeNodeMorphed: (currentNode, newNode) => { // If Idiomorph wants to transform an to // have a different data-asset-id attribute, we replace // the node ourselves and abort the morph. if ( newNode.tagName === "OUTFIT-LAYER" && newNode.getAttribute("data-asset-id") !== currentNode.getAttribute("data-asset-id") ) { currentNode.replaceWith(newNode); return false; } }, }, }); } function onTurboRender(event) { // Rather than enforce Idiomorph must be loaded, let's just be resilient // and only bother if we have it. (Replacing content is not *that* bad!) if (typeof Idiomorph !== "undefined") { event.detail.render = (a, b) => morphWithOutfitLayers(a, b); } } // On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!) addEventListener("turbo:before-frame-render", onTurboRender); // But on pages that opt into it (namely the wardrobe), we do it for the full page too. if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) { addEventListener("turbo:before-render", onTurboRender); }