1
0
Fork 0
forked from OpenNeo/impress
impress/app/assets/javascripts/outfit-viewer.js
Matchu 1aba4f405e Add hacky play/pause toggle to new item page preview
This doesn't do a good job maintaining state across morphs, but hey
it's Working At All in terms of wiring, and that's good!!

Also need to style up the toggle as a cute button instead of a visible
checkbox and the words "Play/pause"!
2024-07-08 13:43:28 -07:00

173 lines
5 KiB
JavaScript

class OutfitViewer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.#setIsPlaying(false);
}
connectedCallback() {
setTimeout(() => this.#connectToChildren(), 0);
}
#connectToChildren() {
const playPauseToggle = document.querySelector(".play-pause-toggle");
// Read our initial playing state from the toggle, and subscribe to changes.
this.#setIsPlaying(playPauseToggle.checked);
playPauseToggle.addEventListener("change", () => {
this.#setIsPlaying(playPauseToggle.checked);
});
}
#setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new
// nodes arrive, if playing.
if (isPlaying) {
this.#internals.states.add("playing");
for (const child of this.children) {
child.setAttribute("playing", "");
}
} else {
this.#internals.states.delete("playing");
for (const child of this.children) {
child.removeAttribute("playing");
}
}
}
}
class OutfitLayer extends HTMLElement {
static observedAttributes = ["playing"];
#internals;
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() {
window.removeEventListener("message", this.#onMessage);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "playing") {
const isPlaying = newValue != null;
this.#forwardIsPlaying(isPlaying);
}
}
#connectToChildren() {
const image = this.querySelector("img");
const iframe = this.querySelector("iframe");
if (image) {
image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error"));
this.#setStatus(image.complete ? "loaded" : "loading");
} else if (iframe) {
this.iframe = iframe;
window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error"));
this.#setStatus("loading");
} else {
throw new Error(`<outfit-layer> must contain an <img> or <iframe> tag`);
}
}
#onMessage({ source, data }) {
if (source !== this.iframe.contentWindow) {
return;
}
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)}`,
);
}
}
#setStatus(newStatus) {
this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.add(newStatus);
}
#setHasAnimations(hasAnimations) {
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
}
#forwardIsPlaying(isPlaying) {
if (this.iframe == null) {
return;
}
this.iframe.contentWindow.postMessage(
{ type: isPlaying ? "play" : "pause" },
"*", // The frame is sandboxed (origin == null), so send to Any origin.
);
}
}
customElements.define("outfit-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer);
// 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.)
addEventListener("turbo:before-frame-render", (event) => {
if (typeof Idiomorph !== "undefined") {
event.detail.render = (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;
}
},
},
});
};
}
});