impress/app/assets/javascripts/outfit-viewer.js

457 lines
13 KiB
JavaScript

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 `<outfit-layer>` 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 <outfit-layer> 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 <turbo-frame> 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 `<outfit-layer>` 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(`<outfit-layer> 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(
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
);
}
} else {
throw new Error(
`<outfit-layer> 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:
* <outfit-viewer id="my-viewer">...</outfit-viewer>
* <outfit-viewer-play-pause-toggle for="my-viewer">
* <input type="checkbox" class="toggle-input">
* <span class="playing-label">Playing</span>
* <span class="paused-label">Paused</span>
* </outfit-viewer-play-pause-toggle>
*
* 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(
"<outfit-viewer-play-pause-toggle> requires a 'for' attribute",
);
return;
}
this.#outfitViewer = document.getElementById(forId);
if (!this.#outfitViewer) {
console.warn(
`<outfit-viewer-play-pause-toggle> 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(
"<outfit-viewer-play-pause-toggle> 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 <outfit-layer> 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 <outfit-layer> 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);
}