Reproduce: 1. Add an item with animations, and play them. 2. Remove the item. 3. Add it back. 4. Observe the button shows up in "Paused" state, even though it's playing. This is because the server-side template wasn't doing anything to try to keep the play/pause button it renders in sync with the current saved state in the cookies, so it was always causing a morph to the pause state. Now we listen to the cookie instead! I also updated the JS behavior to be a bit more consistent: treat the behavior as defaulting to true, unless it's explicitly set to the string "false".
465 lines
14 KiB
JavaScript
465 lines
14 KiB
JavaScript
class OutfitViewer extends HTMLElement {
|
|
#internals;
|
|
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
|
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
|
|
|
|
constructor() {
|
|
super();
|
|
this.#internals = this.attachInternals(); // for CSS `:state()`
|
|
}
|
|
|
|
connectedCallback() {
|
|
const observer = new MutationObserver((mutations) => {
|
|
// When a layer is added, update its playing state to match ours.
|
|
const addedLayers = mutations
|
|
.flatMap(m => [...m.addedNodes])
|
|
.filter(n => n.tagName === "OUTFIT-LAYER");
|
|
for (const layer of addedLayers) {
|
|
if (this.#internals.states.has("playing")) {
|
|
layer.play();
|
|
} else {
|
|
layer.pause();
|
|
}
|
|
}
|
|
|
|
const removedLayers = mutations
|
|
.flatMap(m => [...m.removedNodes])
|
|
.filter(n => n.tagName === "OUTFIT-LAYER");
|
|
|
|
// If any layers were added or removed, updated our hasAnimations state.
|
|
if (addedLayers.length > 0 || removedLayers.length > 0) {
|
|
this.#updateHasAnimations();
|
|
}
|
|
});
|
|
observer.observe(this, { childList: true });
|
|
|
|
// When a new layer finishes loading and determines it has animations, update.
|
|
this.addEventListener("hasanimationschange", (e) => {
|
|
// Only handle events from outfit-layer children, not from ourselves
|
|
if (e.target === this) return;
|
|
|
|
this.#updateHasAnimations();
|
|
});
|
|
|
|
// 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
|
|
if (hasAnimations === this.#hasAnimations) {
|
|
return; // No change, skip
|
|
}
|
|
|
|
// Update internal state
|
|
this.#hasAnimations = hasAnimations;
|
|
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] !== "false";
|
|
}
|
|
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);
|
|
}
|