[WV2] Custom play/pause animations button
This commit is contained in:
parent
cbf69e1189
commit
f545510edc
8 changed files with 389 additions and 40 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
class OutfitViewer extends HTMLElement {
|
class OutfitViewer extends HTMLElement {
|
||||||
#internals;
|
#internals;
|
||||||
|
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -7,26 +8,106 @@ class OutfitViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
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
|
// The `<outfit-layer>` is connected to the DOM right before its
|
||||||
// children are. So, to engage with the children, wait a tick!
|
// children are. So, to engage with the children, wait a tick!
|
||||||
setTimeout(() => this.#connectToChildren(), 0);
|
setTimeout(() => this.#connectToChildren(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#connectToChildren() {
|
#connectToChildren() {
|
||||||
const playPauseToggle = document.querySelector(".play-pause-toggle");
|
// Read initial playing state from cookie and initialize
|
||||||
|
const isPlayingFromCookie = this.#getIsPlayingCookie();
|
||||||
|
|
||||||
// Read our initial playing state from the toggle, and subscribe to changes.
|
// Initialize the boolean before calling #setIsPlaying
|
||||||
this.#setIsPlaying(playPauseToggle.checked);
|
// (We set it to the opposite first so #setIsPlaying detects a change)
|
||||||
playPauseToggle.addEventListener("change", () => {
|
this.#isPlaying = !isPlayingFromCookie;
|
||||||
this.#setIsPlaying(playPauseToggle.checked);
|
this.#setIsPlaying(isPlayingFromCookie);
|
||||||
this.#setIsPlayingCookie(playPauseToggle.checked);
|
|
||||||
});
|
// 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) {
|
#setIsPlaying(isPlaying) {
|
||||||
// TODO: Listen for changes to the child list, and add `playing` when new
|
// Skip if already in this state
|
||||||
// nodes arrive, if playing.
|
if (this.#isPlaying === isPlaying) {
|
||||||
const thirtyDays = 60 * 60 * 24 * 30;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update internal state (both boolean and CustomStateSet for CSS)
|
||||||
|
this.#isPlaying = isPlaying;
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
this.#internals.states.add("playing");
|
this.#internals.states.add("playing");
|
||||||
for (const layer of this.querySelectorAll("outfit-layer")) {
|
for (const layer of this.querySelectorAll("outfit-layer")) {
|
||||||
|
|
@ -38,6 +119,27 @@ class OutfitViewer extends HTMLElement {
|
||||||
layer.pause();
|
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) {
|
#setIsPlayingCookie(isPlaying) {
|
||||||
|
|
@ -49,6 +151,7 @@ class OutfitViewer extends HTMLElement {
|
||||||
|
|
||||||
class OutfitLayer extends HTMLElement {
|
class OutfitLayer extends HTMLElement {
|
||||||
#internals;
|
#internals;
|
||||||
|
#pendingMessage = null; // Queue one message to send when iframe loads
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -105,6 +208,12 @@ class OutfitLayer extends HTMLElement {
|
||||||
this.#sendMessageToIframe({ type: "requestStatus" });
|
this.#sendMessageToIframe({ type: "requestStatus" });
|
||||||
window.addEventListener("message", (m) => this.#onMessage(m));
|
window.addEventListener("message", (m) => this.#onMessage(m));
|
||||||
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
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 {
|
} else {
|
||||||
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
||||||
}
|
}
|
||||||
|
|
@ -151,24 +260,45 @@ class OutfitLayer extends HTMLElement {
|
||||||
* Set whether CSS selector `:state(has-animations)` matches this element.
|
* Set whether CSS selector `:state(has-animations)` matches this element.
|
||||||
*/
|
*/
|
||||||
#setHasAnimations(hasAnimations) {
|
#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) {
|
if (hasAnimations) {
|
||||||
this.#internals.states.add("has-animations");
|
this.#internals.states.add("has-animations");
|
||||||
} else {
|
} else {
|
||||||
this.#internals.states.delete("has-animations");
|
this.#internals.states.delete("has-animations");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event so parent OutfitViewer can react
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("hasanimationschange", {
|
||||||
|
detail: { hasAnimations },
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#sendMessageToIframe(message) {
|
#sendMessageToIframe(message) {
|
||||||
// If we have no frame or it hasn't loaded, ignore this message.
|
// If we have no frame, queue play/pause messages for later
|
||||||
if (this.iframe == null) {
|
if (this.iframe == null) {
|
||||||
|
if (message.type === "play" || message.type === "pause") {
|
||||||
|
this.#pendingMessage = message;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.iframe.contentWindow == null) {
|
if (this.iframe.contentWindow == null) {
|
||||||
console.debug(
|
console.debug(
|
||||||
`Ignoring message, frame not loaded yet: `,
|
`Queueing message, frame not loaded yet: `,
|
||||||
this.iframe,
|
this.iframe,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
|
if (message.type === "play" || message.type === "pause") {
|
||||||
|
this.#pendingMessage = message;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,8 +307,112 @@ class OutfitLayer extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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-viewer", OutfitViewer);
|
||||||
customElements.define("outfit-layer", OutfitLayer);
|
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
|
// 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
|
// important for movies!—but ensure that it *doesn't* do its usual behavior of
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,12 @@ outfit-viewer
|
||||||
&:has(.play-pause-toggle:active)
|
&:has(.play-pause-toggle:active)
|
||||||
transform: translateY(2px)
|
transform: translateY(2px)
|
||||||
|
|
||||||
&:has(outfit-layer:state(has-animations))
|
// Hide the play-pause toggle when there are no animations
|
||||||
|
outfit-viewer-play-pause-toggle[hidden]
|
||||||
|
display: none
|
||||||
|
|
||||||
|
// Show the play-pause button when visible
|
||||||
|
outfit-viewer-play-pause-toggle:not([hidden])
|
||||||
.play-pause-button
|
.play-pause-button
|
||||||
display: flex
|
display: flex
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@
|
||||||
.preview-area
|
.preview-area
|
||||||
margin: 0 auto
|
margin: 0 auto
|
||||||
position: relative
|
position: relative
|
||||||
|
width: 300px
|
||||||
|
height: 300px
|
||||||
|
|
||||||
.customize-more
|
.customize-more
|
||||||
position: absolute
|
position: absolute
|
||||||
|
|
@ -287,6 +289,9 @@ species-face-picker
|
||||||
|
|
||||||
.preview-area
|
.preview-area
|
||||||
grid-area: viewer
|
grid-area: viewer
|
||||||
|
width: 380px
|
||||||
|
height: 380px
|
||||||
|
|
||||||
outfit-viewer
|
outfit-viewer
|
||||||
width: 380px
|
width: 380px
|
||||||
height: 380px
|
height: 380px
|
||||||
|
|
@ -302,6 +307,52 @@ species-face-picker
|
||||||
.item-preview-meta-info
|
.item-preview-meta-info
|
||||||
grid-area: meta
|
grid-area: meta
|
||||||
|
|
||||||
|
.play-pause-button
|
||||||
|
position: absolute
|
||||||
|
z-index: 1001
|
||||||
|
left: 8px
|
||||||
|
bottom: 8px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
color: white
|
||||||
|
background: rgba(0, 0, 0, 0.64)
|
||||||
|
width: 2.5em
|
||||||
|
height: 2.5em
|
||||||
|
border-radius: 100%
|
||||||
|
border: 2px solid transparent
|
||||||
|
transition: all .25s
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
.playing-label, .paused-label
|
||||||
|
display: none
|
||||||
|
width: 1em
|
||||||
|
height: 1em
|
||||||
|
|
||||||
|
input[type=checkbox]
|
||||||
|
// Visually hidden
|
||||||
|
clip: rect(0 0 0 0)
|
||||||
|
clip-path: inset(50%)
|
||||||
|
height: 1px
|
||||||
|
overflow: hidden
|
||||||
|
position: absolute
|
||||||
|
white-space: nowrap
|
||||||
|
width: 1px
|
||||||
|
|
||||||
|
&:checked ~ .playing-label
|
||||||
|
display: block
|
||||||
|
|
||||||
|
&:not(:checked) ~ .paused-label
|
||||||
|
display: block
|
||||||
|
|
||||||
|
&:hover, &:has(.play-pause-toggle:focus)
|
||||||
|
border: 2px solid $module-border-color
|
||||||
|
background: $module-bg-color
|
||||||
|
color: $text-color
|
||||||
|
|
||||||
|
&:has(.play-pause-toggle:active)
|
||||||
|
transform: translateY(2px)
|
||||||
|
|
||||||
@keyframes fade-in
|
@keyframes fade-in
|
||||||
from
|
from
|
||||||
opacity: 0
|
opacity: 0
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
/* Base button defaults - applied to all interactive controls */
|
/* Base button defaults - applied to all interactive controls */
|
||||||
button,
|
button,
|
||||||
input[type="submit"],
|
input[type="submit"],
|
||||||
select {
|
select,
|
||||||
|
.button {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
|
|
@ -29,7 +30,8 @@ select {
|
||||||
.outfit-preview-section {
|
.outfit-preview-section {
|
||||||
button,
|
button,
|
||||||
select,
|
select,
|
||||||
input[type="submit"] {
|
input[type="submit"],
|
||||||
|
.button {
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
@ -217,16 +219,14 @@ body.wardrobe-v2 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preview controls container - groups species/color picker and pose picker */
|
/* Preview controls container - groups all floating controls */
|
||||||
.preview-controls {
|
.preview-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
flex-direction: column;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: space-between;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
|
@ -239,6 +239,59 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Top controls (play/pause) */
|
||||||
|
.preview-controls-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom controls (species/color picker, pose picker) */
|
||||||
|
.preview-controls-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play/Pause control button */
|
||||||
|
.play-pause-control-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/* Hide the checkbox visually */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show/hide labels based on checkbox state */
|
||||||
|
.playing-label,
|
||||||
|
.paused-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked ~ .playing-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:not(:checked) ~ .paused-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the toggle when there are no animations */
|
||||||
|
outfit-viewer-play-pause-toggle[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pose picker button */
|
/* Pose picker button */
|
||||||
.pose-picker-button {
|
.pose-picker-button {
|
||||||
anchor-name: --pose-picker-anchor;
|
anchor-name: --pose-picker-anchor;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
- html_options = {} unless defined? html_options
|
- html_options = {} unless defined? html_options
|
||||||
|
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
|
||||||
= content_tag "outfit-viewer", **html_options do
|
= content_tag "outfit-viewer", **html_options do
|
||||||
.loading-indicator= render partial: "hanger_spinner"
|
.loading-indicator= render partial: "hanger_spinner"
|
||||||
|
|
||||||
%label.play-pause-button{title: "Pause/play animations"}
|
|
||||||
%input.play-pause-toggle{
|
|
||||||
type: "checkbox",
|
|
||||||
checked: outfit_viewer_is_playing,
|
|
||||||
}
|
|
||||||
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
|
|
||||||
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
|
|
||||||
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
|
|
||||||
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
|
|
||||||
|
|
||||||
- outfit.visible_layers.each do |swf_asset|
|
- outfit.visible_layers.each do |swf_asset|
|
||||||
%outfit-layer{
|
%outfit-layer{
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,14 @@
|
||||||
|
|
||||||
= turbo_frame_tag "item-preview" do
|
= turbo_frame_tag "item-preview" do
|
||||||
.preview-area
|
.preview-area
|
||||||
= outfit_viewer @preview_outfit
|
= outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer"
|
||||||
|
%outfit-viewer-play-pause-toggle{for: "item-preview-outfit-viewer"}
|
||||||
|
%label.play-pause-button{title: "Pause/play animations"}
|
||||||
|
%input{type: "checkbox"}
|
||||||
|
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
|
||||||
|
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
|
||||||
|
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
|
||||||
|
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
|
||||||
.error-indicator
|
.error-indicator
|
||||||
💥 We couldn't load all of this outfit. Try again?
|
💥 We couldn't load all of this outfit. Try again?
|
||||||
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
|
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,21 @@
|
||||||
We haven't seen this kind of pet before! Try a different species/color
|
We haven't seen this kind of pet before! Try a different species/color
|
||||||
combination.
|
combination.
|
||||||
- else
|
- else
|
||||||
= outfit_viewer @outfit
|
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
|
||||||
|
|
||||||
.preview-controls
|
.preview-controls
|
||||||
= render "species_color_picker"
|
.preview-controls-top
|
||||||
|
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
||||||
|
%label.play-pause-control-button.button
|
||||||
|
%input{type: "checkbox"}
|
||||||
|
%span.paused-label Paused
|
||||||
|
%span.playing-label Playing
|
||||||
|
|
||||||
- if @pet_type
|
.preview-controls-bottom
|
||||||
= render "pose_picker"
|
= render "species_color_picker"
|
||||||
|
|
||||||
|
- if @pet_type
|
||||||
|
= render "pose_picker"
|
||||||
|
|
||||||
.outfit-controls-section
|
.outfit-controls-section
|
||||||
.item-search-form
|
.item-search-form
|
||||||
|
|
|
||||||
|
|
@ -183,10 +183,10 @@ Below is a comprehensive comparison with the full feature set of Wardrobe 2020 (
|
||||||
|
|
||||||
**Preview Controls**
|
**Preview Controls**
|
||||||
- ✅ Has: Basic outfit viewer rendering
|
- ✅ Has: Basic outfit viewer rendering
|
||||||
- ❌ No overlay controls
|
- ✅ Has: Play/Pause animation button (with cookie persistence)
|
||||||
|
- ❌ No overlay controls beyond play/pause
|
||||||
- ❌ Missing from Wardrobe 2020:
|
- ❌ Missing from Wardrobe 2020:
|
||||||
- Back button (to homepage/Your Outfits)
|
- Back button (to homepage/Your Outfits)
|
||||||
- Play/Pause animation button (with localStorage persistence)
|
|
||||||
- Download outfit as PNG (with pre-generation on hover)
|
- Download outfit as PNG (with pre-generation on hover)
|
||||||
- Copy link to clipboard (with "Copied!" confirmation)
|
- Copy link to clipboard (with "Copied!" confirmation)
|
||||||
- Settings popover:
|
- Settings popover:
|
||||||
|
|
@ -493,7 +493,7 @@ Browser displays (instant if Turbo, full page otherwise)
|
||||||
|
|
||||||
6. **Preview Controls**
|
6. **Preview Controls**
|
||||||
- [ ] Overlay controls (auto-hide on desktop, always visible on touch)
|
- [ ] Overlay controls (auto-hide on desktop, always visible on touch)
|
||||||
- [ ] Play/Pause animation button
|
- [x] Play/Pause animation button
|
||||||
- [ ] Download outfit as PNG
|
- [ ] Download outfit as PNG
|
||||||
- [ ] Copy link to clipboard (with confirmation)
|
- [ ] Copy link to clipboard (with confirmation)
|
||||||
- [ ] Settings dropdown (hi-res mode, use archive)
|
- [ ] Settings dropdown (hi-res mode, use archive)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue