Compare commits
No commits in common. "f545510edcf2d5b73832f8b2d89a0aa25ddd2449" and "812e8226bbf19ee851db389605914e7ae825fccb" have entirely different histories.
f545510edc
...
812e8226bb
12 changed files with 113 additions and 465 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
class OutfitViewer extends HTMLElement {
|
class OutfitViewer extends HTMLElement {
|
||||||
#internals;
|
#internals;
|
||||||
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -8,106 +7,26 @@ 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() {
|
||||||
// Read initial playing state from cookie and initialize
|
const playPauseToggle = document.querySelector(".play-pause-toggle");
|
||||||
const isPlayingFromCookie = this.#getIsPlayingCookie();
|
|
||||||
|
|
||||||
// Initialize the boolean before calling #setIsPlaying
|
// Read our initial playing state from the toggle, and subscribe to changes.
|
||||||
// (We set it to the opposite first so #setIsPlaying detects a change)
|
this.#setIsPlaying(playPauseToggle.checked);
|
||||||
this.#isPlaying = !isPlayingFromCookie;
|
playPauseToggle.addEventListener("change", () => {
|
||||||
this.#setIsPlaying(isPlayingFromCookie);
|
this.#setIsPlaying(playPauseToggle.checked);
|
||||||
|
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) {
|
||||||
// Skip if already in this state
|
// TODO: Listen for changes to the child list, and add `playing` when new
|
||||||
if (this.#isPlaying === isPlaying) {
|
// nodes arrive, if playing.
|
||||||
return;
|
const thirtyDays = 60 * 60 * 24 * 30;
|
||||||
}
|
|
||||||
|
|
||||||
// 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")) {
|
||||||
|
|
@ -119,27 +38,6 @@ 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) {
|
||||||
|
|
@ -151,7 +49,6 @@ 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();
|
||||||
|
|
@ -208,12 +105,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -260,45 +151,24 @@ 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, queue play/pause messages for later
|
// If we have no frame or it hasn't loaded, ignore this message.
|
||||||
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(
|
||||||
`Queueing message, frame not loaded yet: `,
|
`Ignoring message, frame not loaded yet: `,
|
||||||
this.iframe,
|
this.iframe,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
if (message.type === "play" || message.type === "pause") {
|
|
||||||
this.#pendingMessage = message;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,112 +177,8 @@ 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,12 +104,7 @@ outfit-viewer
|
||||||
&:has(.play-pause-toggle:active)
|
&:has(.play-pause-toggle:active)
|
||||||
transform: translateY(2px)
|
transform: translateY(2px)
|
||||||
|
|
||||||
// Hide the play-pause toggle when there are no animations
|
&:has(outfit-layer:state(has-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,8 +45,6 @@
|
||||||
.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
|
||||||
|
|
@ -289,9 +287,6 @@ 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
|
||||||
|
|
@ -307,52 +302,6 @@ 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,8 +3,7 @@
|
||||||
/* 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;
|
||||||
|
|
@ -30,8 +29,7 @@ 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;
|
||||||
|
|
@ -219,14 +217,16 @@ body.wardrobe-v2 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preview controls container - groups all floating controls */
|
/* Preview controls container - groups species/color picker and pose picker */
|
||||||
.preview-controls {
|
.preview-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
|
@ -239,67 +239,12 @@ 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;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border-color: transparent;
|
|
||||||
font-size: 85%;
|
|
||||||
|
|
||||||
.pose-emoji {
|
.pose-emoji {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|
@ -463,11 +408,9 @@ body.wardrobe-v2 {
|
||||||
|
|
||||||
/* Species/color picker */
|
/* Species/color picker */
|
||||||
species-color-picker {
|
species-color-picker {
|
||||||
display: contents;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
form {
|
gap: 0.5rem;
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||||
|
|
@ -696,7 +639,7 @@ body.wardrobe-v2 {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover :is(.item-add-button, .item-remove-button) {
|
&:hover .item-add-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
- 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,14 +16,7 @@
|
||||||
|
|
||||||
= turbo_frame_tag "item-preview" do
|
= turbo_frame_tag "item-preview" do
|
||||||
.preview-area
|
.preview-area
|
||||||
= outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer"
|
= outfit_viewer @preview_outfit
|
||||||
%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),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
- is_worn = @outfit.worn_items.include?(item)
|
|
||||||
%li.item-card
|
|
||||||
.item-thumbnail
|
|
||||||
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
|
||||||
.item-info
|
|
||||||
.item-name= item.name
|
|
||||||
.item-badges
|
|
||||||
= render "items/badges/kind", item: item
|
|
||||||
= render "items/badges/first_seen", item: item
|
|
||||||
- if is_worn
|
|
||||||
= button_to wardrobe_v2_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
|
||||||
❌
|
|
||||||
= outfit_state_params @outfit.without_item(item)
|
|
||||||
- else
|
|
||||||
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
|
||||||
➕
|
|
||||||
= outfit_state_params @outfit.with_item(item)
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
- pose_info = pose_emoji_and_label(@selected_pose)
|
|
||||||
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
|
||||||
%span.pose-emoji= pose_info[:emoji]
|
|
||||||
%span.pose-label= pose_info[:label]
|
|
||||||
%span.chevron ▾
|
|
||||||
|
|
||||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
|
||||||
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
|
|
||||||
= outfit_state_params except: [:pose]
|
|
||||||
%table.pose-picker-table
|
|
||||||
%thead
|
|
||||||
%tr
|
|
||||||
%th
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Happy"} 😀
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Sad"} 😢
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Sick"} 🤢
|
|
||||||
%tbody
|
|
||||||
%tr
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Masculine"} 💁♂️
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
|
|
||||||
%tr
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Feminine"} 💁♀️
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
|
|
||||||
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
|
|
||||||
|
|
@ -4,7 +4,17 @@
|
||||||
|
|
||||||
%ul.search-results-list
|
%ul.search-results-list
|
||||||
- @search_results.each do |item|
|
- @search_results.each do |item|
|
||||||
= render "item_card", item: item
|
%li.item-card
|
||||||
|
.item-thumbnail
|
||||||
|
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
||||||
|
.item-info
|
||||||
|
.item-name= item.name
|
||||||
|
.item-badges
|
||||||
|
= render "items/badges/kind", item: item
|
||||||
|
= render "items/badges/first_seen", item: item
|
||||||
|
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
||||||
|
➕
|
||||||
|
= outfit_state_params @outfit.with_item(item)
|
||||||
|
|
||||||
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
%species-color-picker
|
|
||||||
= form_with url: wardrobe_v2_path, method: :get do |f|
|
|
||||||
= outfit_state_params except: [:color, :species]
|
|
||||||
= select_tag :color,
|
|
||||||
options_from_collection_for_select(@colors, "id", "human_name",
|
|
||||||
@selected_color&.id),
|
|
||||||
"aria-label": "Pet color"
|
|
||||||
= select_tag :species,
|
|
||||||
options_from_collection_for_select(@species, "id", "human_name",
|
|
||||||
@selected_species&.id),
|
|
||||||
"aria-label": "Pet species"
|
|
||||||
= submit_tag "Go", name: nil
|
|
||||||
|
|
@ -27,21 +27,62 @@
|
||||||
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, id: "wardrobe-outfit-viewer"
|
= outfit_viewer @outfit
|
||||||
|
|
||||||
.preview-controls
|
.preview-controls
|
||||||
.preview-controls-top
|
%species-color-picker
|
||||||
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
= form_with url: wardrobe_v2_path, method: :get do |f|
|
||||||
%label.play-pause-control-button.button
|
= outfit_state_params except: [:color, :species]
|
||||||
%input{type: "checkbox"}
|
= select_tag :color,
|
||||||
%span.paused-label Paused
|
options_from_collection_for_select(@colors, "id", "human_name",
|
||||||
%span.playing-label Playing
|
@selected_color&.id),
|
||||||
|
"aria-label": "Pet color"
|
||||||
.preview-controls-bottom
|
= select_tag :species,
|
||||||
= render "species_color_picker"
|
options_from_collection_for_select(@species, "id", "human_name",
|
||||||
|
@selected_species&.id),
|
||||||
|
"aria-label": "Pet species"
|
||||||
|
= submit_tag "Go", name: nil
|
||||||
|
|
||||||
- if @pet_type
|
- if @pet_type
|
||||||
= render "pose_picker"
|
- pose_info = pose_emoji_and_label(@selected_pose)
|
||||||
|
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
||||||
|
%span.pose-emoji= pose_info[:emoji]
|
||||||
|
%span.pose-label= pose_info[:label]
|
||||||
|
%span.chevron ▾
|
||||||
|
|
||||||
|
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||||
|
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
|
||||||
|
= outfit_state_params except: [:pose]
|
||||||
|
%table.pose-picker-table
|
||||||
|
%thead
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Happy"} 😀
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Sad"} 😢
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Sick"} 🤢
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Masculine"} 💁♂️
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Feminine"} 💁♀️
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
|
||||||
|
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
|
||||||
|
|
||||||
.outfit-controls-section
|
.outfit-controls-section
|
||||||
.item-search-form
|
.item-search-form
|
||||||
|
|
@ -65,4 +106,14 @@
|
||||||
%h3.zone-label= zone_group[:zone_label]
|
%h3.zone-label= zone_group[:zone_label]
|
||||||
%ul.items-list
|
%ul.items-list
|
||||||
- zone_group[:items].each do |item|
|
- zone_group[:items].each do |item|
|
||||||
= render "item_card", item: item
|
%li.item-card
|
||||||
|
.item-thumbnail
|
||||||
|
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
||||||
|
.item-info
|
||||||
|
.item-name= item.name
|
||||||
|
.item-badges
|
||||||
|
= render "items/badges/kind", item: item
|
||||||
|
= render "items/badges/first_seen", item: item
|
||||||
|
= button_to wardrobe_v2_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||||
|
❌
|
||||||
|
= outfit_state_params @outfit.without_item(item)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
- ✅ Has: Play/Pause animation button (with cookie persistence)
|
- ❌ No overlay controls
|
||||||
- ❌ 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)
|
||||||
- [x] Play/Pause animation button
|
- [ ] 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