2024-07-08 13:43:28 -07:00
|
|
|
class OutfitViewer extends HTMLElement {
|
|
|
|
|
#internals;
|
2026-01-03 10:44:10 -08:00
|
|
|
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
2024-07-08 13:43:28 -07:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
2024-08-30 17:09:39 -07:00
|
|
|
this.#internals = this.attachInternals(); // for CSS `:state()`
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connectedCallback() {
|
2026-01-03 10:44:10 -08:00
|
|
|
// 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 });
|
|
|
|
|
|
2024-08-30 17:09:39 -07:00
|
|
|
// The `<outfit-layer>` is connected to the DOM right before its
|
|
|
|
|
// children are. So, to engage with the children, wait a tick!
|
2024-07-08 13:43:28 -07:00
|
|
|
setTimeout(() => this.#connectToChildren(), 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#connectToChildren() {
|
2026-01-03 10:44:10 -08:00
|
|
|
// Read initial playing state from cookie and initialize
|
|
|
|
|
const isPlayingFromCookie = this.#getIsPlayingCookie();
|
2024-07-08 13:43:28 -07:00
|
|
|
|
2026-01-03 10:44:10 -08:00
|
|
|
// 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;
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#setIsPlaying(isPlaying) {
|
2026-01-03 10:44:10 -08:00
|
|
|
// Skip if already in this state
|
|
|
|
|
if (this.#isPlaying === isPlaying) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update internal state (both boolean and CustomStateSet for CSS)
|
|
|
|
|
this.#isPlaying = isPlaying;
|
|
|
|
|
|
2024-07-08 13:43:28 -07:00
|
|
|
if (isPlaying) {
|
|
|
|
|
this.#internals.states.add("playing");
|
2024-07-08 16:27:38 -07:00
|
|
|
for (const layer of this.querySelectorAll("outfit-layer")) {
|
|
|
|
|
layer.play();
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.#internals.states.delete("playing");
|
2024-07-08 16:27:38 -07:00
|
|
|
for (const layer of this.querySelectorAll("outfit-layer")) {
|
|
|
|
|
layer.pause();
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-03 10:44:10 -08:00
|
|
|
|
|
|
|
|
// 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
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
2024-07-08 16:27:38 -07:00
|
|
|
|
|
|
|
|
#setIsPlayingCookie(isPlaying) {
|
|
|
|
|
const thirtyDays = 60 * 60 * 24 * 30;
|
|
|
|
|
const value = isPlaying ? "true" : "false";
|
|
|
|
|
document.cookie = `DTIOutfitViewerIsPlaying=${value};max-age=${thirtyDays}`;
|
|
|
|
|
}
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
|
2024-07-02 22:16:37 -07:00
|
|
|
class OutfitLayer extends HTMLElement {
|
2024-07-02 22:34:51 -07:00
|
|
|
#internals;
|
2026-01-03 10:44:10 -08:00
|
|
|
#pendingMessage = null; // Queue one message to send when iframe loads
|
2024-07-02 22:34:51 -07:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
|
|
|
|
this.#internals = this.attachInternals();
|
2024-07-07 21:52:38 -07:00
|
|
|
|
|
|
|
|
// 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");
|
2024-07-02 22:34:51 -07:00
|
|
|
}
|
|
|
|
|
|
2024-07-02 22:16:37 -07:00
|
|
|
connectedCallback() {
|
2024-07-03 20:15:35 -07:00
|
|
|
setTimeout(() => this.#connectToChildren(), 0);
|
2024-07-02 22:16:37 -07:00
|
|
|
}
|
|
|
|
|
|
2024-07-03 20:15:35 -07:00
|
|
|
disconnectedCallback() {
|
2024-08-30 17:09:39 -07:00
|
|
|
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
|
|
|
|
|
// messages, if we were.
|
2024-07-03 20:15:35 -07:00
|
|
|
window.removeEventListener("message", this.#onMessage);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-08 16:27:38 -07:00
|
|
|
play() {
|
2024-07-08 16:44:22 -07:00
|
|
|
this.#sendMessageToIframe({ type: "play" });
|
2024-07-08 16:27:38 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pause() {
|
2024-07-08 16:44:22 -07:00
|
|
|
this.#sendMessageToIframe({ type: "pause" });
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
|
2024-07-03 20:15:35 -07:00
|
|
|
#connectToChildren() {
|
|
|
|
|
const image = this.querySelector("img");
|
|
|
|
|
const iframe = this.querySelector("iframe");
|
|
|
|
|
|
|
|
|
|
if (image) {
|
2024-08-30 17:09:39 -07:00
|
|
|
// 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).
|
2024-07-08 16:44:22 -07:00
|
|
|
this.#setStatus(image.complete ? "loaded" : "loading");
|
2024-07-03 20:15:35 -07:00
|
|
|
image.addEventListener("load", () => this.#setStatus("loaded"));
|
|
|
|
|
image.addEventListener("error", () => this.#setStatus("error"));
|
|
|
|
|
} else if (iframe) {
|
|
|
|
|
this.iframe = iframe;
|
2024-07-08 16:44:22 -07:00
|
|
|
|
2024-08-30 17:09:39 -07:00
|
|
|
// 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.
|
2024-07-08 16:44:22 -07:00
|
|
|
this.#setStatus("loading");
|
|
|
|
|
this.#sendMessageToIframe({ type: "requestStatus" });
|
2024-07-03 20:15:35 -07:00
|
|
|
window.addEventListener("message", (m) => this.#onMessage(m));
|
2024-09-09 16:10:45 -07:00
|
|
|
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
2026-01-03 10:44:10 -08:00
|
|
|
|
|
|
|
|
// If there was a pending play/pause message, send it now
|
|
|
|
|
if (this.#pendingMessage) {
|
|
|
|
|
this.#sendMessageToIframe(this.#pendingMessage);
|
|
|
|
|
this.#pendingMessage = null;
|
|
|
|
|
}
|
2024-07-03 20:15:35 -07:00
|
|
|
} else {
|
2024-09-06 17:57:18 -07:00
|
|
|
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
2024-07-02 22:16:37 -07:00
|
|
|
}
|
2024-07-03 20:15:35 -07:00
|
|
|
}
|
2024-07-02 22:16:37 -07:00
|
|
|
|
2024-07-03 20:15:35 -07:00
|
|
|
#onMessage({ source, data }) {
|
2024-08-30 17:09:39 -07:00
|
|
|
// Ignore messages that aren't from *our* frame.
|
2024-07-03 20:15:35 -07:00
|
|
|
if (source !== this.iframe.contentWindow) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-07-02 22:16:37 -07:00
|
|
|
|
2024-08-30 17:09:39 -07:00
|
|
|
// Validate the incoming status message, then set our status to match.
|
2024-07-08 13:43:28 -07:00
|
|
|
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(
|
2024-08-30 17:09:39 -07:00
|
|
|
`<outfit-layer> got unexpected status: ` +
|
2025-11-02 01:55:17 -07:00
|
|
|
JSON.stringify(data.status),
|
2024-07-08 13:43:28 -07:00
|
|
|
);
|
|
|
|
|
}
|
2024-07-03 20:15:35 -07:00
|
|
|
} else {
|
|
|
|
|
throw new Error(
|
2024-09-09 16:10:45 -07:00
|
|
|
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
|
2024-07-03 20:15:35 -07:00
|
|
|
);
|
|
|
|
|
}
|
2024-07-02 22:16:37 -07:00
|
|
|
}
|
|
|
|
|
|
2024-08-30 17:09:39 -07:00
|
|
|
/**
|
|
|
|
|
* Set the status value that the CSS `:state()` selector will match.
|
|
|
|
|
* For example, when loading, `:state(loading)` matches this element.
|
|
|
|
|
*/
|
2024-07-02 22:16:37 -07:00
|
|
|
#setStatus(newStatus) {
|
2024-07-08 13:43:28 -07:00
|
|
|
this.#internals.states.delete("loading");
|
|
|
|
|
this.#internals.states.delete("loaded");
|
|
|
|
|
this.#internals.states.delete("error");
|
2024-07-02 22:34:51 -07:00
|
|
|
this.#internals.states.add(newStatus);
|
2024-07-02 22:16:37 -07:00
|
|
|
}
|
2024-07-08 13:43:28 -07:00
|
|
|
|
2024-08-30 17:09:39 -07:00
|
|
|
/**
|
|
|
|
|
* Set whether CSS selector `:state(has-animations)` matches this element.
|
|
|
|
|
*/
|
2024-07-08 13:43:28 -07:00
|
|
|
#setHasAnimations(hasAnimations) {
|
2026-01-03 10:44:10 -08:00
|
|
|
// Check if state actually changed
|
|
|
|
|
const hadAnimations = this.#internals.states.has("has-animations");
|
|
|
|
|
if (hasAnimations === hadAnimations) {
|
|
|
|
|
return; // No change, skip
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update internal state
|
2024-07-08 13:43:28 -07:00
|
|
|
if (hasAnimations) {
|
|
|
|
|
this.#internals.states.add("has-animations");
|
|
|
|
|
} else {
|
|
|
|
|
this.#internals.states.delete("has-animations");
|
|
|
|
|
}
|
2026-01-03 10:44:10 -08:00
|
|
|
|
|
|
|
|
// Dispatch event so parent OutfitViewer can react
|
|
|
|
|
this.dispatchEvent(
|
|
|
|
|
new CustomEvent("hasanimationschange", {
|
|
|
|
|
detail: { hasAnimations },
|
|
|
|
|
bubbles: true,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
|
|
|
|
|
2024-07-08 16:44:22 -07:00
|
|
|
#sendMessageToIframe(message) {
|
2026-01-03 10:44:10 -08:00
|
|
|
// If we have no frame, queue play/pause messages for later
|
2024-09-05 17:37:16 -07:00
|
|
|
if (this.iframe == null) {
|
2026-01-03 10:44:10 -08:00
|
|
|
if (message.type === "play" || message.type === "pause") {
|
|
|
|
|
this.#pendingMessage = message;
|
|
|
|
|
}
|
2024-09-05 17:37:16 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.iframe.contentWindow == null) {
|
2024-08-30 17:09:39 -07:00
|
|
|
console.debug(
|
2026-01-03 10:44:10 -08:00
|
|
|
`Queueing message, frame not loaded yet: `,
|
2024-08-30 17:09:39 -07:00
|
|
|
this.iframe,
|
|
|
|
|
message,
|
|
|
|
|
);
|
2026-01-03 10:44:10 -08:00
|
|
|
if (message.type === "play" || message.type === "pause") {
|
|
|
|
|
this.#pendingMessage = message;
|
|
|
|
|
}
|
2024-07-08 13:43:28 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-08 16:44:22 -07:00
|
|
|
// The frame is sandboxed (origin == null), so send to Any origin.
|
|
|
|
|
this.iframe.contentWindow.postMessage(message, "*");
|
2024-07-08 13:43:28 -07:00
|
|
|
}
|
2024-07-02 22:16:37 -07:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 10:44:10 -08:00
|
|
|
/**
|
|
|
|
|
* 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", "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-08 13:43:28 -07:00
|
|
|
customElements.define("outfit-viewer", OutfitViewer);
|
2024-07-02 22:16:37 -07:00
|
|
|
customElements.define("outfit-layer", OutfitLayer);
|
2026-01-03 10:44:10 -08:00
|
|
|
customElements.define(
|
|
|
|
|
"outfit-viewer-play-pause-toggle",
|
|
|
|
|
OutfitViewerPlayPauseToggle,
|
|
|
|
|
);
|
2024-07-03 21:52:43 -07:00
|
|
|
|
|
|
|
|
// 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.)
|
2024-08-30 17:09:39 -07:00
|
|
|
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") !==
|
2025-11-02 01:55:17 -07:00
|
|
|
currentNode.getAttribute("data-asset-id")
|
2024-08-30 17:09:39 -07:00
|
|
|
) {
|
|
|
|
|
currentNode.replaceWith(newNode);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-11-02 01:55:17 -07:00
|
|
|
|
|
|
|
|
function onTurboRender(event) {
|
2024-08-30 17:09:39 -07:00
|
|
|
// 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!)
|
2024-07-03 21:52:43 -07:00
|
|
|
if (typeof Idiomorph !== "undefined") {
|
2024-08-30 17:09:39 -07:00
|
|
|
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
|
2024-07-03 21:52:43 -07:00
|
|
|
}
|
2025-11-02 01:55:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|