Compare commits

..

No commits in common. "f0b1d2281e7d73bc1dca5b4cc0934080948a52b6" and "57c08b5646d8fe160bec132a2ae51200d13924fa" have entirely different histories.

13 changed files with 39 additions and 349 deletions

View file

@ -1,65 +1,9 @@
class OutfitViewer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
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);
this.#setIsPlayingCookie(playPauseToggle.checked);
});
}
#setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new
// nodes arrive, if playing.
const thirtyDays = 60 * 60 * 24 * 30;
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();
}
}
}
#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;
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() {
@ -70,38 +14,22 @@ class OutfitLayer extends HTMLElement {
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) {
// Initialize status based on the image's current `complete` attribute,
// then wait for load/error events to update it further if needed.
this.#setStatus(image.complete ? "loaded" : "loading");
image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error"));
this.#setStatus(image.complete ? "loaded" : "loading");
} 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"));
this.#setStatus("loading");
} else {
throw new Error(`<outfit-layer> must contain an <img> or <iframe> tag`);
throw new Error(
`<outfit-layer> must contain an <img> or <iframe> tag`,
);
}
}
@ -110,17 +38,11 @@ class OutfitLayer extends HTMLElement {
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)}`,
);
}
if (
data.type === "status" &&
["loaded", "error"].includes(data.status)
) {
this.#setStatus(data.status);
} else {
throw new Error(
`<outfit-layer> got unexpected message: ${JSON.stringify(data)}`,
@ -129,31 +51,11 @@ class OutfitLayer extends HTMLElement {
}
#setStatus(newStatus) {
this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.clear();
this.#internals.states.add(newStatus);
}
#setHasAnimations(hasAnimations) {
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
}
#sendMessageToIframe(message) {
if (this.iframe?.contentWindow == null) {
return;
}
// The frame is sandboxed (origin == null), so send to Any origin.
this.iframe.contentWindow.postMessage(message, "*");
}
}
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

View file

@ -237,7 +237,9 @@ function onAnimationFrame() {
if (msSinceLastLog >= 5000) {
const fps = numFramesSinceLastLog / (msSinceLastLog / 1000);
console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`);
console.debug(
`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`,
);
lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0;
}
@ -274,44 +276,6 @@ function getInitialPlayingStatus() {
}
}
/**
* Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas.
*/
function hasAnimations(createjsNode) {
return (
// Some nodes have simple animation frames.
createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations)
);
}
function sendStatus() {
if (loadingStatus === "loading") {
sendMessage({ type: "status", status: "loading" });
} else if (loadingStatus === "loaded") {
sendMessage({
type: "status",
status: "loaded",
hasAnimations: hasAnimations(movieClip),
});
} else if (loadingStatus === "error") {
sendMessage({ type: "status", status: "error" });
} else {
throw new Error(
`unexpected loadingStatus ${JSON.stringify(loadingStatus)}`,
);
}
}
function sendMessage(message) {
parent.postMessage(message, document.location.origin);
}
window.addEventListener("resize", () => {
updateCanvasDimensions();
@ -332,8 +296,6 @@ window.addEventListener("message", ({ data }) => {
play();
} else if (data.type === "pause") {
pause();
} else if (data.type === "requestStatus") {
sendStatus();
} else {
throw new Error(`unexpected message: ${JSON.stringify(data)}`);
}
@ -341,13 +303,19 @@ window.addEventListener("message", ({ data }) => {
startMovie()
.then(() => {
sendStatus();
parent.postMessage(
{ type: "status", status: "loaded" },
document.location.origin,
);
})
.catch((error) => {
console.error(logPrefix, error);
loadingStatus = "error";
sendStatus();
parent.postMessage(
{ type: "status", status: "error" },
document.location.origin,
);
// If loading the movie fails, show the fallback image instead, by moving
// it out of the canvas content and into the body.

View file

@ -1,64 +0,0 @@
.hanger-spinner {
height: 32px;
width: 32px;
@media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite hanger-spinner-swing;
transform-origin: top center;
}
@media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite hanger-spinner-fade-pulse;
}
}
/*
Adapted from animate.css "swing". We spend 75% of the time swinging,
then 25% of the time pausing before the next loop.
We use this animation for folks who are okay with dizzy-ish motion.
For reduced motion, we use a pulse-fade instead.
*/
@keyframes hanger-spinner-swing {
15% {
transform: rotate3d(0, 0, 1, 15deg);
}
30% {
transform: rotate3d(0, 0, 1, -10deg);
}
45% {
transform: rotate3d(0, 0, 1, 5deg);
}
60% {
transform: rotate3d(0, 0, 1, -5deg);
}
75% {
transform: rotate3d(0, 0, 1, 0deg);
}
100% {
transform: rotate3d(0, 0, 1, 0deg);
}
}
/*
A homebrew fade-pulse animation. We use this for folks who don't
like motion. It's an important accessibility thing!
*/
@keyframes hanger-spinner-fade-pulse {
0% {
opacity: 0.2;
}
50% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}

View file

@ -38,118 +38,31 @@ body.items-show
height: 16px
width: 16px
outfit-viewer
display: block
.outfit-viewer
position: relative
display: block
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
overflow: hidden
margin: 0 auto .75em
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
margin: 0 auto .75em
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
&:has(outfit-layer:state(loading))
background: gray
opacity: 0
transition: opacity .5s
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
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
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// 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)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
.error-indicator
font-size: 85%
color: $error-color
margin-top: .25em
margin-bottom: .5em
display: none
// When loading, fade in the loading spinner after a brief delay. (We only
// apply the delay here, because fading *out* on load should be instant.)
// We are loading when the <turbo-frame> is busy, or when at least one layer
// is loading.
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
cursor: wait
.loading-indicator
opacity: 1
transition-delay: 2s
#item-preview:has(outfit-layer:state(error))
outfit-viewer
border: 2px solid red
.error-indicator
display: block
&:has(outfit-layer:state(error))
border-color: $error-border-color
.species-color-picker
.error-icon

View file

@ -1,5 +1,4 @@
#asset-canvas,
#asset-image,
#fallback {
position: absolute;
left: 0;

View file

@ -15,7 +15,7 @@ class SwfAssetsController < ApplicationController
)
}
policy.script_src -> {
policy.script_src_elem -> {
src_list(
helpers.javascript_url("lib/easeljs.min"),
helpers.javascript_url("lib/tweenjs.min"),
@ -24,7 +24,7 @@ class SwfAssetsController < ApplicationController
)
}
policy.style_src -> {
policy.style_src_elem -> {
src_list(
helpers.stylesheet_url("swf_assets/show"),
)

View file

@ -224,10 +224,6 @@ module ItemsHelper
end
end
def outfit_viewer_is_playing
cookies["DTIOutfitViewerIsPlaying"] == "true"
end
private
def build_on_pet_types(species, special_color=nil, &block)

View file

@ -1,6 +0,0 @@
<svg class="hanger-spinner" viewBox="0 0 473 473">
<path
fill="currentColor"
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 979 B

View file

@ -1,16 +1,4 @@
%outfit-viewer
.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-viewer
- outfit.visible_layers.each do |swf_asset|
%outfit-layer{
data: {
@ -19,6 +7,6 @@
},
}
- if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
%iframe{src: swf_asset_path(swf_asset) + "?playing"}
- else
= image_tag swf_asset.image_url, alt: ""

View file

@ -15,8 +15,6 @@
= turbo_frame_tag "item-preview" do
= render partial: "outfit_viewer", locals: {outfit: @preview_outfit}
.error-indicator
💥 We couldn't load all of this outfit. Try again?
= form_for item_path(@item), method: :get, class: "species-color-picker",
data: {"is-valid": @preview_error.nil?} do |f|
@ -41,9 +39,6 @@
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
%footer= t '.contributors.footer'
- content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner"
- content_for :javascripts do
= javascript_include_tag "lib/idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true

View file

@ -13,6 +13,7 @@
%link{href: image_path('favicon.png'), rel: 'icon'}
= yield :stylesheets
= stylesheet_link_tag "application"
= render 'analytics'
= yield :meta
= open_graph_tags
= csrf_meta_tag
@ -23,7 +24,6 @@
= javascript_include_tag 'application', defer: true
= yield :javascripts
= yield :head
= render 'analytics'
%body{:class => body_class}
#container
= yield :before_title

View file

@ -7,13 +7,13 @@
Embed for Asset ##{@swf_asset.id} | #{t "app_name"}
%link{href: image_path("favicon.png"), rel: "icon"}
-# Load the stylesheet first, because displaying things correctly is the
-# actual most essential thing.
= stylesheet_link_tag "swf_assets/show", debug: false
-# NOTE: For all these assets, the Content-Security-Policy doesn't account
-# for asset debug mode, so let's just opt out of it with `debug: false`!
- if @swf_asset.canvas_movie?
-# Load the stylesheet first, because displaying things correctly is the
-# actual most essential thing.
= stylesheet_link_tag "swf_assets/show", debug: false
-# This is optional, but preloading the sprites can help us from having
-# to wait on all the other JS to load and set up before we start!
- @swf_asset.canvas_movie_sprite_urls.each do |sprite_url|
@ -33,4 +33,4 @@
-# the browser won't bother to load it if it's not used.
= image_tag @swf_asset.image_url, id: "fallback", alt: "", loading: "lazy"
- else
= image_tag @swf_asset.image_url, alt: "", id: "asset-image"
= image_tag @swf_asset.image_url, alt: ""

View file

@ -74,7 +74,6 @@ namespace :public_data do
# The connection details for our database!
config = ApplicationRecord.connection_db_config.configuration_hash
args << "--host=#{config[:host]}" if config[:host]
args << "--ssl=false" # SSL is the default for recent MariaDB; override!
args << "--user=#{config[:username]}" if config[:username]
args << "--password=#{config[:password]}" if config[:password]
args << "--database=#{config.fetch(:database)}"