Compare commits

..

60 commits

Author SHA1 Message Date
c60dceb0ae Merge branch 'simpler-item-previews' 2024-09-06 11:09:16 -07:00
30eced448d Fix bug precompiling a CSS file that contains a min() expression
When I run `bin/deploy:precompile` on the previous version, I get an
error from libsass that `vw` and `vh` are incompatible units. I don't
get this error in development, only when compiling for production.

My inference is:
1. For the production build, Sass is trying to preprocess even non-SASS
   files, maybe to help minify them?
2. In Sass, their `min()` existed before CSS's `min()`, so it's
   treating it Like That, and returning a reasonable-in-some-cases-but-
   not-here error that `min(100vw, 100vh)` can't be *precomputed*.

Anyway, wrapping it in `calc()` isn't a *problem*, and helps the Sass
compiler not try to precompute it, so. Okay!
https://github.com/sass/node-sass/issues/2815#issuecomment-575926329
2024-09-06 11:00:49 -07:00
6f08abc3aa Add html5 badge to new item previews 2024-09-05 18:48:41 -07:00
edcb21558a Drastically reduce queries for item page preview
Oh right okay, I made a sloppy perf hack long ago, and now let's
actually clean it up!
2024-09-05 17:52:35 -07:00
176ab20fd1 Cache the Item#appearances field
We call it enough times on this page, and it *does* have a SQL query,
that I want to cache it! (Also I want to make it fewer species queries
if I can tbh…)
2024-09-05 17:41:04 -07:00
0305817cec Use fewer SQL queries to get species for species face picker 2024-09-05 17:39:47 -07:00
4d5b583432 Remove some unnecessary console messages for new outfit viewer
For static image layers, this was *always* logging that we failed to
send the frame a "pause" message. Which, like, of course!

It makes sense to log the notable circumstance where we send a message
we *expect* to arrive, but the frame isn't loaded yet. But if there's
just no frame, ignore it and don't bother to say so.
2024-09-05 17:37:16 -07:00
2e48376c5a Auto-submit the species color picker on change, for new item previews 2024-09-05 17:34:54 -07:00
2ea8f16e43 Style the face picker on item page nicely for desktop! 2024-09-05 16:51:06 -07:00
de99e0236b Style the face picker on item page nicely for mobile
The desktop view isn't built yet, but this is nice!
2024-09-05 16:28:17 -07:00
6dd8e585a3 Add responsive layout for item page
We add a new `use_responsive_design` helper, for pages to opt into this
new CSS—mostly just because like… it's *worse* to apply these styles
for pages that don't expect it 😅

And then, I fix up a couple things on the item page (including in the
general items layout) to match!

I'm doing this because the species face picker layout is going to want
some responsive awareness, and I want to be doing that from the start!
2024-09-05 16:18:48 -07:00
77ff55353c Copy selected/focus face picker styles from Impress 2020 2024-09-03 17:54:56 -07:00
a88fc14bd7 Use hi-res pet images in face picker for new item previews 2024-09-03 17:34:31 -07:00
9f44fd47e4 Add "No data yet" to species face tooltip when needed, in item previews 2024-09-03 17:27:43 -07:00
4c44f8d6a4 Fix species face picker going inert again after Turbo frame load
Here, I remember the trick I learned when building the outfit viewer:
web components are great for making sure stuff stays initialized well
in a Turbo environment!

The problem was, after submitting the form and getting a new preview
loaded via Turbo, the part where we remove `inert` would get undone.
Additionally, this script only loads *once* per session, so if you
Turbo-nav to a different item then that part of the page never ran.

Instead, we use web components to remove the attributes on mount, then
again if they're ever reapplied by Idiomorph.
2024-09-03 17:07:53 -07:00
2b2bffd9da Disable pet faces that the item doesn't fit, in new item previews 2024-09-03 16:42:04 -07:00
a184c75575 Handle noscript for the new species face picker
We mark the options as `inert` and `aria-hidden` while the JS is still
loading—and if the `noscript` tag tells us it's never coming, it covers
up the picker with a brief explainer!
2024-09-03 13:46:55 -07:00
c06c297174 Extremely lo-fi new species face picker for simplified item previews
The basics are working great! There's a few known missing things though:
- Add reasonable noscript behavior
- Disable options where there's no valid appearance
- Lay it out actually _good_, instead of just images dumped there
2024-09-03 13:30:12 -07:00
36f8efadbf Add more detailed zone occupy info to simplified item preview page
Adapting what the Impress 2020 UI does, but in Ruby instead!

I feel like this is case is really starting to show the power of doing
this stuff in Rails instead of via an API… we can *really* take
advantage of our models and our handy idioms at all points. This is
just so much less *code* than this feature takes in Node + GraphQL +
React.
2024-09-03 12:55:10 -07:00
e0f9a27adc Merge branch 'main' into simpler-item-previews 2024-08-31 13:43:10 -07:00
2f341cfd39 Merge branch 'main' into simpler-item-previews 2024-08-31 13:31:46 -07:00
8ab5af1aca Remove unused logic for whether a zone is "sometimes" occupied
I'm about to reimplement the more-robust version of what this used to
be: how the item page used to say "sometimes" after certain zones in
the occupied list.

Now, we're going to do parity with 2020, and list the actual species!

I like that this takes away the weird `#sometimes` method on the `Zone`
class, which was always an odd hack for just this small thing.
2024-08-31 13:16:47 -07:00
bd62476722 Add basic zone info to simpler item previews
We're missing the feature where we enumerate the exact species in
ambiguous cases, though!
2024-08-31 13:11:50 -07:00
5cbab5a766 Merge branch 'main' into simpler-item-previews 2024-08-31 12:50:56 -07:00
8ad0025e32 A few more comments and code style refactors for item previews 2024-08-30 17:09:39 -07:00
b2b16a2edc Merge branch 'main' into simpler-item-previews 2024-07-09 14:26:47 -07:00
f0b1d2281e Slight improvement for double-clicking play/pause in new item preview 2024-07-08 17:29:22 -07:00
16bd966a7d Slight improvement for play/pause active style in new item page preview
Oh right, if the *label* is `:active`, that only applies if we're
clicking it with our mouse. But if the *toggle* is actively, that
applies both to mouse events on the label, and keyboard events on the
checkbox.
2024-07-08 17:25:55 -07:00
bf30ca0252 Fix loading cursor bug for new item page preview
Specifically, if a movie layer was the top layer, the `cursor: wait` on
the preview wouldn't show, because the iframe's *contents* would take
priority, and they were using the default cursor.
2024-07-08 17:24:18 -07:00
26954a3bf2 Restyle play/pause button for new item page preview 2024-07-08 17:23:38 -07:00
20202b5cd9 Potentially improve loading state in new item page preview
I thiiiink I've seen the status of a movie `<outfit-layer>` sometimes
be `loading` even when it's clearly already loaded and running. I
haven't been able to track down where and how that happens exactly, so
this is me acting on a hunch: that maybe the
I-would-have-thought-very-unlikely event that the iframe finishes
loading before the `<outfit-layer>` connects with its children maybe
happens more often than one might think!

In this change, we set up the iframe to receive `requestStatus`
messages, which it responds to with the status immediately. And we send
one of these when the `<outfit-layer>` first discovers the iframe.

Fingers crossed!
2024-07-08 16:44:22 -07:00
b03fee8c7e Save playing state to a cookie for the new item page preview 2024-07-08 16:27:38 -07:00
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
7688caebe1 Handle iframe outright failing to load in new item page preview
This is a nice extra error handler to have, but note that it *won't*
catch the case where the iframe successfully loads but the page returns
a bad status code. In this case, we'll just show the loading state
forever.
2024-07-08 10:44:01 -07:00
fcad7e2bc9 Add a clearer error indicator to new item page previews 2024-07-08 10:36:16 -07:00
d18f43d769 Fix a bug transitioning between two loading states in item page preview 2024-07-07 21:52:38 -07:00
059644f847 Improve the loading indicator for the new item page previews
Add some unobtrusive white background for contrast, show it when the
Turbo frame is loading too, add a spinner cursor, and fix a silliness
of how we put the `position: absolute` stuff into the component-y part
of the hanger spinner instead of this specific use case lol oops!
2024-07-07 21:32:41 -07:00
83eec2db60 Merge branch 'main' into simpler-item-previews 2024-07-07 21:11:32 -07:00
6bc0c55000 Use our hanger loading spinner for the new item page previews
It's back, wowie! Also written up in a bit of a component-y way. Cute!
2024-07-07 19:05:48 -07:00
62d9f2d24a Merge branch 'main' into simpler-item-previews 2024-07-07 17:23:08 -07:00
c8c4facb60 Fix styling for embed page for image-only SWF assets
This doesn't actually really matter, because this doesn't actually get
used in the app right now? But I figure, hey it's not hard to maintain,
let's just do it for consistency!
2024-07-06 12:52:00 -07:00
7ec900b6b6 Use {script,style}_src instead of _elem, for better compatibility
Oh, I didn't realize the `_elem` variant of these parts of the
`Content-Security-Policy` is newer, and so doesn't even work on my
current version of Safari on my Mac.

My rationale at the time was: `script_src_elem` is stricter against
things like imports, and I figured, ok let's do the strictest policy
that works. But since it's not fully compatible with browsers even
*I'm* using right now, and I'm not aware of an actual problem it would
prevent, let's back off that a bit! This should have the same effective
security properties for our case.

Note that the effect of this compatibility issue wasn't *weakening* the
policy; it was being *too* strict, by blocking the scripts and the
stylesheets. This is because `script_src_elem` was ignored, and
`script_src` was absent, so it fell back to `default_src none`.
2024-07-06 12:52:00 -07:00
57c08b5646 Use "morphing" for smoother item page preview changes
The most notable thing here is that we keep the movie iframes running!
So if you're trying different pets for an animated item, the animation
keeps going while the new pet layers load alongside it.

This is also nice for like, the species/color picker form, so we're not
taking away input elements from people who depend on e.g. keyboard
focus.
2024-07-03 21:52:43 -07:00
97e6c39402 Load movies in iframe for new item page preview
Hey hey, it's working! Still stuff to add like pause/play, but yeah!
2024-07-03 20:29:04 -07:00
5b2062754d swf_assets/show action to embed a canvas movie in a sandboxed iframe
Not using this on the item page preview yet, but we will!

I like this approach over e.g. a web component specifically for the
sandboxing: while I don't exactly *distrust* JS that we're loading from
Neopets.com, I don't like the idea of *any* part of the site that
executes arbitrary JS unsafely at runtime, even if we theoretically
trust where it theoretically came from. I don't want any failure
upstream to have effects on us!

I copied basically all of the JS from a related project
`impress-media-server` that I had spun up at one point, to investigate
similar embed techniques. Easy peasy drop-in-squeezy!
2024-07-03 19:50:41 -07:00
5ad320fa18 Remove unused swf_assets/links stylesheet
This must be for a page we got rid of! Ok bye!
2024-07-03 19:17:08 -07:00
81a58f8656 Extract outfit-viewer to a separate template
Just cuz this is gonna get more complex down the line!
2024-07-02 22:43:36 -07:00
76d2cc6c21 Add some data attributes to outfit-layer elements
This is mostly for debugging, the zone label especially, to just
eyeball what we're looking at!
2024-07-02 22:38:18 -07:00
e9145251a9 Refactor outfit-layer component to use internal state API, not attrs
Oh right okay, attributes like `status="loading"` are more of an API
for the caller, whereas the internal state API is where you wanna put
things that are meant to be used in CSS selectors and stuff.
2024-07-02 22:34:51 -07:00
3c415e9cd3 Refactor item page outfit-layer to use Web Components
Instead of doing all this listening to Turbo events etc to know when
outfit layers might have changed, making it a custom element and wiring
in the behavior to its actual lifecycle makes it always Just Work!
2024-07-02 22:24:26 -07:00
857812610a Refactor outfit_viewer_layers helper to just be inlined into template
I forget what complexity was in here previously that made this make
sense before, but now it's just a loop, whatever!
2024-07-02 22:03:43 -07:00
0a9193aed7 Add basic loading tracking to new item page preview
The UI for it is just basic for my own testing rn: it sets the preview
background to gray while loading, then back to white when done!

This uses the new CSS `:has()` selector: we have JS manage the loading
state on each layer, then the container just restyles itself based on
whether any currently-loading layers are present.
2024-07-01 17:59:07 -07:00
ac002f3151 Track preferred color/species for new item page previews
Also adapted from the Impress 2020 logic!

Note that I refactored `compatible_pet_type` to a series of scopes on
`PetType`. I think this is a simpler, clearer, and more flexible API!
2024-07-01 17:38:31 -07:00
fe6035d438 Default to compatible pet types in new item page preview
Just adapted from Impress 2020 logic again, easy peasy!
2024-07-01 17:20:38 -07:00
21a8a49f50 Remove redundant SwfAsset relation
Huh, this is just in here twice? Weird. Goodbye!
2024-07-01 17:20:05 -07:00
3f38fbd1b0 Support zone restriction in new item preview
Adapted from wardrobe-2020's `getVisibleLayers`! Thanks past-Matchu for
all the comments lol!
2024-07-01 16:54:39 -07:00
74748acaaf Refactor more of item outfit preview into the Outfit class
This is a cute thing that I think sets us up for other stuff down the
line: move more of the outfit appearance logic into the `Outfit` class!
Now, we set up the item page with a temporary instance of `Outfit`,
then ask for its `visible_layers`.

Still missing restricted-zones logic and such, that's next!
2024-07-01 16:07:25 -07:00
054c809052 Put the new item preview in a Turbo frame
Nice, gotta say, this is a pretty neat way of making things feel more
app-y! There's some missing pieces here about like, loading state etc,
but the vibes are pretty good, and the implementation was dead-easy!
2024-07-01 15:35:58 -07:00
1b000189c4 [WIP] Add species/color picker for simplified item page preview
Still a lot missing here, like choosing the right default for Baby etc
items, and saving the user's preferences. But it's a start!
2024-07-01 15:35:58 -07:00
ea17e76c39 [WIP] Start replacing item page preview with simpler HTML-based version
Just stripping out the big React component, and having Rails output it!

There's a lot of work rn in extracting the Impress 2020 dependency from
the `wardrobe-2020` React app, and I'm just curious to see if we can
simplify it at all by pulling this stuff *way* back to basics, and
deleting the item page part of `wardrobe-2020` altogether.

In this draft, we regress a lot of functionality: it just shows the
item on a Blue Acara, with no ability to change it! I'm gonna play with
putting more of that back in.

I also haven't actually removed any of the item page React code; I just
stopped calling it. That can be a cleanup for another time, once we're
confident in this experiment!
2024-07-01 15:35:58 -07:00
33 changed files with 2456 additions and 108 deletions

View file

@ -0,0 +1,76 @@
// When the species face picker changes, update and submit the main picker form.
document.addEventListener("change", (e) => {
if (!e.target.matches("species-face-picker")) return;
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form");
const mainSpeciesField =
mainPickerForm.querySelector("[name='preview[species_id]']");
mainSpeciesField.value = e.target.value;
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
} catch (error) {
console.error("Couldn't update species picker: ", error);
}
});
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
get value() {
return this.querySelector("input[type=radio]:checked")?.value;
}
#handleClick(e) {
if (e.target.matches("input[type=radio]")) {
this.dispatchEvent(new Event("change", {bubbles: true}));
}
}
}
class SpeciesFacePickerOptions extends HTMLElement {
static observedAttributes = ["inert", "aria-hidden"];
connectedCallback() {
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
this.#activate();
}
attributeChangedCallback() {
// If a Turbo Frame tries to morph us into being inert again, activate again!
// (It's important that the server's HTML always return `inert`, for progressive
// enhancement; and it's important to morph this element, so radio focus state
// is preserved. To thread that needle, we have to monitor and remove!)
this.#activate();
}
#activate() {
this.removeAttribute("inert");
this.removeAttribute("aria-hidden");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,850 @@
// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/dist/idiomorph.js
// base IIFE to define idiomorph
var Idiomorph = (function () {
'use strict';
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
let EMPTY_SET = new Set();
// default configuration values, updatable by users now
let defaults = {
morphStyle: "outerHTML",
callbacks : {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
},
head: {
style: 'merge',
shouldPreserve: function (elt) {
return elt.getAttribute("im-preserve") === "true";
},
shouldReAppend: function (elt) {
return elt.getAttribute("im-re-append") === "true";
},
shouldRemove: noOp,
afterHeadMorphed: noOp,
}
};
//=============================================================================
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
//=============================================================================
function morph(oldNode, newContent, config = {}) {
if (oldNode instanceof Document) {
oldNode = oldNode.documentElement;
}
if (typeof newContent === 'string') {
newContent = parseContent(newContent);
}
let normalizedContent = normalizeContent(newContent);
let ctx = createMorphContext(oldNode, normalizedContent, config);
return morphNormalizedContent(oldNode, normalizedContent, ctx);
}
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
if (ctx.head.block) {
let oldHead = oldNode.querySelector('head');
let newHead = normalizedNewContent.querySelector('head');
if (oldHead && newHead) {
let promises = handleHeadElement(newHead, oldHead, ctx);
// when head promises resolve, call morph again, ignoring the head tag
Promise.all(promises).then(function () {
morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
head: {
block: false,
ignore: true
}
}));
});
return;
}
}
if (ctx.morphStyle === "innerHTML") {
// innerHTML, so we are only updating the children
morphChildren(normalizedNewContent, oldNode, ctx);
return oldNode.children;
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
// otherwise find the best element match in the new content, morph that, and merge its siblings
// into either side of the best match
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
// stash the siblings that will need to be inserted on either side of the best match
let previousSibling = bestMatch?.previousSibling;
let nextSibling = bestMatch?.nextSibling;
// morph it
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
if (bestMatch) {
// if there was a best match, merge the siblings in too and return the
// whole bunch
return insertSiblings(previousSibling, morphedNode, nextSibling);
} else {
// otherwise nothing was added to the DOM
return []
}
} else {
throw "Do not understand how to morph style " + ctx.morphStyle;
}
}
/**
* @param possibleActiveElement
* @param ctx
* @returns {boolean}
*/
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
}
/**
* @param oldNode root node to merge content into
* @param newContent new content to merge
* @param ctx the merge context
* @returns {Element} the element that ended up in the DOM
*/
function morphOldNodeTo(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
} else if (newContent == null) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
oldNode.remove();
ctx.callbacks.afterNodeRemoved(oldNode);
return null;
} else if (!isSoftMatch(oldNode, newContent)) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
oldNode.parentElement.replaceChild(newContent, oldNode);
ctx.callbacks.afterNodeAdded(newContent);
ctx.callbacks.afterNodeRemoved(oldNode);
return newContent;
} else {
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
// ignore the head element
} else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
handleHeadElement(newContent, oldNode, ctx);
} else {
syncNodeFrom(newContent, oldNode, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
morphChildren(newContent, oldNode, ctx);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
}
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm is, for each node in the new content:
*
* - if we have reached the end of the old parent, append the new content
* - if the new content has an id set match with the current insertion point, morph
* - search for an id set match
* - if id set match found, morph
* - otherwise search for a "soft" match
* - if a soft match is found, morph
* - otherwise, prepend the new node before the current insertion point
*
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
*
* @param {Element} newParent the parent element of the new content
* @param {Element } oldParent the old content that we are merging the new content into
* @param ctx the merge context
*/
function morphChildren(newParent, oldParent, ctx) {
let nextNewChild = newParent.firstChild;
let insertionPoint = oldParent.firstChild;
let newChild;
// run through all the new content
while (nextNewChild) {
newChild = nextNewChild;
nextNewChild = newChild.nextSibling;
// if we are at the end of the exiting parent's children, just append
if (insertionPoint == null) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.appendChild(newChild);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// if the current node has an id set match then morph
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
morphOldNodeTo(insertionPoint, newChild, ctx);
insertionPoint = insertionPoint.nextSibling;
removeIdsFromConsideration(ctx, newChild);
continue;
}
// otherwise search forward in the existing old children for an id set match
let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
// if we found a potential match, remove the nodes until that point and morph
if (idSetMatch) {
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
morphOldNodeTo(idSetMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// no id set match found, so scan forward for a soft match for the current node
let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
// if we found a soft match for the current node, morph
if (softMatch) {
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
morphOldNodeTo(softMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// abandon all hope of morphing, just insert the new child before the insertion point
// and move on
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.insertBefore(newChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint !== null) {
let tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(tempNode, ctx);
}
}
//=============================================================================
// Attribute Syncing Code
//=============================================================================
/**
* @param attr {String} the attribute to be mutated
* @param to {Element} the element that is going to be updated
* @param updateType {("update"|"remove")}
* @param ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, to, updateType, ctx) {
if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
return true;
}
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
}
/**
* syncs a given node with another node, copying over all attributes and
* inner element state from the 'from' node to the 'to' node
*
* @param {Element} from the element to copy attributes & state from
* @param {Element} to the element to copy attributes & state to
* @param ctx the merge context
*/
function syncNodeFrom(from, to, ctx) {
let type = from.nodeType
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const fromAttributes = from.attributes;
const toAttributes = to.attributes;
for (const fromAttribute of fromAttributes) {
if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
continue;
}
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
to.setAttribute(fromAttribute.name, fromAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = toAttributes.length - 1; 0 <= i; i--) {
const toAttribute = toAttributes[i];
if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
continue;
}
if (!from.hasAttribute(toAttribute.name)) {
to.removeAttribute(toAttribute.name);
}
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (to.nodeValue !== from.nodeValue) {
to.nodeValue = from.nodeValue;
}
}
if (!ignoreValueOfActiveElement(to, ctx)) {
// sync input values
syncInputValue(from, to, ctx);
}
}
/**
* @param from {Element} element to sync the value from
* @param to {Element} element to sync the value to
* @param attributeName {String} the attribute name
* @param ctx the merge context
*/
function syncBooleanAttribute(from, to, attributeName, ctx) {
if (from[attributeName] !== to[attributeName]) {
let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
if (!ignoreUpdate) {
to[attributeName] = from[attributeName];
}
if (from[attributeName]) {
if (!ignoreUpdate) {
to.setAttribute(attributeName, from[attributeName]);
}
} else {
if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
to.removeAttribute(attributeName);
}
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param from {Element} the element to sync the input value from
* @param to {Element} the element to sync the input value to
* @param ctx the merge context
*/
function syncInputValue(from, to, ctx) {
if (from instanceof HTMLInputElement &&
to instanceof HTMLInputElement &&
from.type !== 'file') {
let fromValue = from.value;
let toValue = to.value;
// sync boolean attributes
syncBooleanAttribute(from, to, 'checked', ctx);
syncBooleanAttribute(from, to, 'disabled', ctx);
if (!from.hasAttribute('value')) {
if (!ignoreAttribute('value', to, 'remove', ctx)) {
to.value = '';
to.removeAttribute('value');
}
} else if (fromValue !== toValue) {
if (!ignoreAttribute('value', to, 'update', ctx)) {
to.setAttribute('value', fromValue);
to.value = fromValue;
}
}
} else if (from instanceof HTMLOptionElement) {
syncBooleanAttribute(from, to, 'selected', ctx)
} else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
let fromValue = from.value;
let toValue = to.value;
if (ignoreAttribute('value', to, 'update', ctx)) {
return;
}
if (fromValue !== toValue) {
to.value = fromValue;
}
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
to.firstChild.nodeValue = fromValue
}
}
}
//=============================================================================
// the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
//=============================================================================
function handleHeadElement(newHeadTag, currentHead, ctx) {
let added = []
let removed = []
let preserved = []
let nodesToAppend = []
let headMergeStyle = ctx.head.style;
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (headMergeStyle === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
let promises = [];
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
log(newElt);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (newElt.href || newElt.src) {
let resolve = null;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener('load', function () {
resolve();
});
promises.push(promise);
}
currentHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
currentHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
return promises;
}
//=============================================================================
// Misc
//=============================================================================
function log() {
//console.log(arguments);
}
function noOp() {
}
/*
Deep merges the config object and the Idiomoroph.defaults object to
produce a final configuration object
*/
function mergeDefaults(config) {
let finalConfig = {};
// copy top level stuff into final config
Object.assign(finalConfig, defaults);
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = {};
Object.assign(finalConfig.callbacks, defaults.callbacks);
Object.assign(finalConfig.callbacks, config.callbacks);
// copy head config into final config (do this to deep merge the head)
finalConfig.head = {};
Object.assign(finalConfig.head, defaults.head);
Object.assign(finalConfig.head, config.head);
return finalConfig;
}
function createMorphContext(oldNode, newContent, config) {
config = mergeDefaults(config);
return {
target: oldNode,
newContent: newContent,
config: config,
morphStyle: config.morphStyle,
ignoreActive: config.ignoreActive,
ignoreActiveValue: config.ignoreActiveValue,
idMap: createIdMap(oldNode, newContent),
deadIds: new Set(),
callbacks: config.callbacks,
head: config.head
}
}
function isIdSetMatch(node1, node2, ctx) {
if (node1 == null || node2 == null) {
return false;
}
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
if (node1.id !== "" && node1.id === node2.id) {
return true;
} else {
return getIdIntersectionCount(ctx, node1, node2) > 0;
}
}
return false;
}
function isSoftMatch(node1, node2) {
if (node1 == null || node2 == null) {
return false;
}
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
}
function removeNodesBetween(startInclusive, endExclusive, ctx) {
while (startInclusive !== endExclusive) {
let tempNode = startInclusive;
startInclusive = startInclusive.nextSibling;
removeNode(tempNode, ctx);
}
removeIdsFromConsideration(ctx, endExclusive);
return endExclusive.nextSibling;
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential id match
// for the newChild. We stop if we find a potential id match for the new child OR
// if the number of potential id matches we are discarding is greater than the
// potential id matches for the new child
//=============================================================================
function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
// max id matches we are willing to discard in our search
let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
let potentialMatch = null;
// only search forward if there is a possibility of an id match
if (newChildPotentialIdCount > 0) {
let potentialMatch = insertionPoint;
// if there is a possibility of an id match, scan forward
// keep track of the potential id match count we are discarding (the
// newChildPotentialIdCount must be greater than this to make it likely
// worth it)
let otherMatchCount = 0;
while (potentialMatch != null) {
// If we have an id match, return the current potential match
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
return potentialMatch;
}
// computer the other potential matches of this new content
otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
if (otherMatchCount > newChildPotentialIdCount) {
// if we have more potential id matches in _other_ content, we
// do not have a good candidate for an id match, so return null
return null;
}
// advanced to the next old content child
potentialMatch = potentialMatch.nextSibling;
}
}
return potentialMatch;
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential soft match
// for the newChild. We stop if we find a potential soft match for the new child OR
// if we find a potential id match in the old parents children OR if we find two
// potential soft matches for the next two pieces of new content
//=============================================================================
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
let potentialSoftMatch = insertionPoint;
let nextSibling = newChild.nextSibling;
let siblingSoftMatchCount = 0;
while (potentialSoftMatch != null) {
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
// the current potential soft match has a potential id set match with the remaining new
// content so bail out of looking
return null;
}
// if we have a soft match with the current node, return it
if (isSoftMatch(newChild, potentialSoftMatch)) {
return potentialSoftMatch;
}
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
// the next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
nextSibling = nextSibling.nextSibling;
// If there are two future soft matches, bail to allow the siblings to soft match
// so that we don't consume future soft matches for the sake of the current node
if (siblingSoftMatchCount >= 2) {
return null;
}
}
// advanced to the next old content child
potentialSoftMatch = potentialSoftMatch.nextSibling;
}
return potentialSoftMatch;
}
function parseContent(newContent) {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
let content = parser.parseFromString(newContent, "text/html");
// if it is a full HTML document, return the document itself as the parent container
if (contentWithSvgsRemoved.match(/<\/html>/)) {
content.generatedByIdiomorph = true;
return content;
} else {
// otherwise return the html element as the parent container
let htmlElement = content.firstChild;
if (htmlElement) {
htmlElement.generatedByIdiomorph = true;
return htmlElement;
} else {
return null;
}
}
} else {
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
// deal with touchy tags like tr, tbody, etc.
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
let content = responseDoc.body.querySelector('template').content;
content.generatedByIdiomorph = true;
return content
}
}
function normalizeContent(newContent) {
if (newContent == null) {
// noinspection UnnecessaryLocalVariableJS
const dummyParent = document.createElement('div');
return dummyParent;
} else if (newContent.generatedByIdiomorph) {
// the template tag created by idiomorph parsing can serve as a dummy parent
return newContent;
} else if (newContent instanceof Node) {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement('div');
dummyParent.append(newContent);
return dummyParent;
} else {
// all nodes in the array or HTMLElement collection are consolidated under
// a single dummy parent element
const dummyParent = document.createElement('div');
for (const elt of [...newContent]) {
dummyParent.append(elt);
}
return dummyParent;
}
}
function insertSiblings(previousSibling, morphedNode, nextSibling) {
let stack = []
let added = []
while (previousSibling != null) {
stack.push(previousSibling);
previousSibling = previousSibling.previousSibling;
}
while (stack.length > 0) {
let node = stack.pop();
added.push(node); // push added preceding siblings on in order and insert
morphedNode.parentElement.insertBefore(node, morphedNode);
}
added.push(morphedNode);
while (nextSibling != null) {
stack.push(nextSibling);
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
nextSibling = nextSibling.nextSibling;
}
while (stack.length > 0) {
morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
}
return added;
}
function findBestNodeMatch(newContent, oldNode, ctx) {
let currentElement;
currentElement = newContent.firstChild;
let bestElement = currentElement;
let score = 0;
while (currentElement) {
let newScore = scoreElement(currentElement, oldNode, ctx);
if (newScore > score) {
bestElement = currentElement;
score = newScore;
}
currentElement = currentElement.nextSibling;
}
return bestElement;
}
function scoreElement(node1, node2, ctx) {
if (isSoftMatch(node1, node2)) {
return .5 + getIdIntersectionCount(ctx, node1, node2);
}
return 0;
}
function removeNode(tempNode, ctx) {
removeIdsFromConsideration(ctx, tempNode)
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
tempNode.remove();
ctx.callbacks.afterNodeRemoved(tempNode);
}
//=============================================================================
// ID Set Functions
//=============================================================================
function isIdInConsideration(ctx, id) {
return !ctx.deadIds.has(id);
}
function idIsWithinNode(ctx, id, targetNode) {
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
return idSet.has(id);
}
function removeIdsFromConsideration(ctx, node) {
let idSet = ctx.idMap.get(node) || EMPTY_SET;
for (const id of idSet) {
ctx.deadIds.add(id);
}
}
function getIdIntersectionCount(ctx, node1, node2) {
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
let matchCount = 0;
for (const id of sourceSet) {
// a potential match is an id in the source and potentialIdsSet, but
// that has not already been merged into the DOM
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
++matchCount;
}
}
return matchCount;
}
/**
* A bottom up algorithm that finds all elements with ids inside of the node
* argument and populates id sets for those nodes and all their parents, generating
* a set of ids contained within all nodes for the entire hierarchy in the DOM
*
* @param node {Element}
* @param {Map<Node, Set<String>>} idMap
*/
function populateIdMapForNode(node, idMap) {
let nodeParent = node.parentElement;
// find all elements with an id property
let idElements = node.querySelectorAll('[id]');
for (const elt of idElements) {
let current = elt;
// walk up the parent hierarchy of that element, adding the id
// of element to the parent's id set
while (current !== nodeParent && current != null) {
let idSet = idMap.get(current);
// if the id set doesn't exist, create it and insert it in the map
if (idSet == null) {
idSet = new Set();
idMap.set(current, idSet);
}
idSet.add(elt.id);
current = current.parentElement;
}
}
}
/**
* This function computes a map of nodes to all ids contained within that node (inclusive of the
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
* for a looser definition of "matching" than tradition id matching, and allows child nodes
* to contribute to a parent nodes matching.
*
* @param {Element} oldContent the old content that will be morphed
* @param {Element} newContent the new content to morph to
* @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
*/
function createIdMap(oldContent, newContent) {
let idMap = new Map();
populateIdMapForNode(oldContent, idMap);
populateIdMapForNode(newContent, idMap);
return idMap;
}
//=============================================================================
// This is what ends up becoming the Idiomorph global object
//=============================================================================
return {
morph,
defaults
}
})();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,219 @@
class OutfitViewer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals(); // for CSS `:state()`
}
connectedCallback() {
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
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() {
setTimeout(() => this.#connectToChildren(), 0);
}
disconnectedCallback() {
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
// messages, if we were.
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) {
// 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).
this.#setStatus(image.complete ? "loaded" : "loading");
image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error"));
} 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"),
);
} else {
throw new Error(
`<outfit-layer> must contain an <img> or <iframe> tag`,
);
}
}
#onMessage({ source, data }) {
// Ignore messages that aren't from *our* frame.
if (source !== this.iframe.contentWindow) {
return;
}
// Validate the incoming status message, then set our status to match.
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),
);
}
}
/**
* Set the status value that the CSS `:state()` selector will match.
* For example, when loading, `:state(loading)` matches this element.
*/
#setStatus(newStatus) {
this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.add(newStatus);
}
/**
* Set whether CSS selector `:state(has-animations)` matches this element.
*/
#setHasAnimations(hasAnimations) {
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
}
#sendMessageToIframe(message) {
// If we have no frame or it hasn't loaded, ignore this message.
if (this.iframe == null) {
return;
}
if (this.iframe.contentWindow == null) {
console.debug(
`Ignoring message, frame not loaded yet: `,
this.iframe,
message,
);
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
// 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.)
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") !==
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
}
},
},
});
}
addEventListener("turbo:before-frame-render", (event) => {
// 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!)
if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
}
});

View file

@ -0,0 +1,356 @@
const canvas = document.getElementById("asset-canvas");
const libraryScript = document.getElementById("canvas-movie-library");
const libraryUrl = libraryScript.getAttribute("src");
// Read the asset ID from the URL, as an extra hint of what asset we're
// logging for. (This is helpful when there's a lot of assets animating!)
const assetId = document.location.pathname.split("/").at(-1);
const logPrefix = `[${assetId}] `.padEnd(9);
// State for controlling the movie.
let loadingStatus = "loading";
let playingStatus = getInitialPlayingStatus();
// State for loading the movie.
let library = null;
let movieClip = null;
let stage = null;
// State for animating the movie.
let frameRequestId = null;
let lastFrameTime = null;
let lastLogTime = null;
let numFramesSinceLastLog = 0;
// State for error reporting.
let hasLoggedRenderError = false;
function loadImage(src) {
const image = new Image();
image.crossOrigin = "anonymous";
const promise = new Promise((resolve, reject) => {
image.onload = () => {
resolve(image);
};
image.onerror = () => {
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
};
image.src = src;
});
return promise;
}
async function getLibrary() {
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
throw new Error(
`Movie library ${libraryUrl} did not add a composition to window.AdobeAn.compositions.`,
);
}
const [compositionId, composition] = Object.entries(
window.AdobeAn.compositions,
)[0];
if (Object.keys(window.AdobeAn.compositions).length > 1) {
console.warn(
`Grabbing composition ${compositionId}, but there are >1 here: `,
Object.keys(window.AdobeAn.compositions).length,
);
}
delete window.AdobeAn.compositions[compositionId];
const library = composition.getLibrary();
// One more loading step as part of loading this library is loading the
// images it uses for sprites.
//
// TODO: I guess the manifest has these too, so we could put them in preload
// meta tags to get them here faster?
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [
id,
loadImage(librarySrcDir + "/" + src),
]),
);
await Promise.all(manifestImages.values());
// Finally, once we have the images loaded, the library object expects us to
// mutate it (!) to give it the actual image and sprite sheet objects from
// the loaded images. That's how the MovieClip's internal JS objects will
// access the loaded data!
const images = composition.getImages();
for (const [id, image] of manifestImages.entries()) {
images[id] = await image;
}
const spriteSheets = composition.getSpriteSheet();
for (const { name, frames } of library.ssMetadata) {
const image = await manifestImages.get(name);
spriteSheets[name] = new window.createjs.SpriteSheet({
images: [image],
frames,
});
}
return library;
}
function buildMovieClip(library) {
let constructorName;
try {
const fileName = decodeURI(libraryUrl).split("/").pop();
const fileNameWithoutExtension = fileName.split(".")[0];
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
if (constructorName.match(/^[0-9]/)) {
constructorName = "_" + constructorName;
}
} catch (e) {
throw new Error(
`Movie libraryUrl ${JSON.stringify(libraryUrl)} did not match expected ` +
`format: ${e.message}`,
);
}
const LibraryMovieClipConstructor = library[constructorName];
if (!LibraryMovieClipConstructor) {
throw new Error(
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
`named ${constructorName}, but it did not: ${Object.keys(library)}`,
);
}
const movieClip = new LibraryMovieClipConstructor();
return movieClip;
}
function updateStage() {
try {
stage.update();
} catch (e) {
// If rendering the frame fails, log it and proceed. If it's an
// animation, then maybe the next frame will work? Also alert the user,
// just as an FYI. (This is pretty uncommon, so I'm not worried about
// being noisy!)
if (!hasLoggedRenderError) {
console.error(`Error rendering movie clip ${libraryUrl}`, e);
// TODO: Inform user about the failure
hasLoggedRenderError = true;
}
}
}
function updateCanvasDimensions() {
// Set the canvas's internal dimensions to be higher, if the device has high
// DPI. Scale the movie clip to match, too.
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
canvas.width = internalWidth;
canvas.height = internalHeight;
movieClip.scaleX = internalWidth / library.properties.width;
movieClip.scaleY = internalHeight / library.properties.height;
}
async function startMovie() {
// Load the movie's library (from the JS file already run), and use it to
// build a movie clip.
library = await getLibrary();
movieClip = buildMovieClip(library);
updateCanvasDimensions();
if (canvas.getContext("2d") == null) {
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
// TODO: "Too many animations!"
return;
}
stage = new window.createjs.Stage(canvas);
stage.addChild(movieClip);
updateStage();
loadingStatus = "loaded";
canvas.setAttribute("data-status", "loaded");
updateAnimationState();
}
function updateAnimationState() {
const shouldRunAnimations =
loadingStatus === "loaded" && playingStatus === "playing";
if (shouldRunAnimations && frameRequestId == null) {
lastFrameTime = document.timeline.currentTime;
lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0;
documentHiddenSinceLastFrame = document.hidden;
frameRequestId = requestAnimationFrame(onAnimationFrame);
} else if (!shouldRunAnimations && frameRequestId != null) {
cancelAnimationFrame(frameRequestId);
lastFrameTime = null;
lastLogTime = null;
numFramesSinceLastLog = 0;
documentHiddenSinceLastFrame = false;
frameRequestId = null;
}
}
function onAnimationFrame() {
const targetFps = library.properties.fps;
const msPerFrame = 1000 / targetFps;
const msSinceLastFrame = document.timeline.currentTime - lastFrameTime;
const msSinceLastLog = document.timeline.currentTime - lastLogTime;
// If it takes too long to render a frame, cancel the movie, on the
// assumption that we're riding the CPU too hard. (Some movies do this!)
//
// But note that, if the page is hidden (e.g. the window is not visible),
// it's normal for the browser to pause animations. So, if we detected that
// the document became hidden between this frame and the last, no
// intervention is necesary.
if (msSinceLastFrame >= 2000 && !documentHiddenSinceLastFrame) {
pause();
console.warn(`Paused movie for taking too long: ${msSinceLastFrame}ms`);
// TODO: Display message about low FPS, and sync up to the parent.
return;
}
if (msSinceLastFrame >= msPerFrame) {
updateStage();
lastFrameTime = document.timeline.currentTime;
// If we're a little bit late to this frame, probably because the frame
// rate isn't an even divisor of 60 FPS, backdate it to what the ideal time
// for this frame *would* have been. (For example, without this tweak, a
// 24 FPS animation like the Floating Negg Faerie actually runs at 20 FPS,
// because it wants to run every 41.66ms, but a 60 FPS browser checks in
// every 16.66ms, so the best it can do is 50ms. With this tweak, we can
// *pretend* we ran at 41.66ms, so that the next frame timing correctly
// takes the extra 9.33ms into account.)
const msFrameDelay = msSinceLastFrame - msPerFrame;
if (msFrameDelay < msPerFrame) {
lastFrameTime -= msFrameDelay;
}
numFramesSinceLastLog++;
}
if (msSinceLastLog >= 5000) {
const fps = numFramesSinceLastLog / (msSinceLastLog / 1000);
console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`);
lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0;
}
frameRequestId = requestAnimationFrame(onAnimationFrame);
documentHiddenSinceLastFrame = document.hidden;
}
// If `document.hidden` becomes true at any point, log it for the next
// animation frame. (The next frame will reset the state, as will starting or
// stopping the animation.)
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
documentHiddenSinceLastFrame = true;
}
});
function play() {
playingStatus = "playing";
updateAnimationState();
}
function pause() {
playingStatus = "paused";
updateAnimationState();
}
function getInitialPlayingStatus() {
const params = new URLSearchParams(document.location.search);
if (params.has("playing")) {
return "playing";
} else {
return "paused";
}
}
/**
* 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();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
window.addEventListener("message", ({ data }) => {
// NOTE: For more sensitive messages, it's important for security to also
// check the `origin` property of the incoming event. But in this case, I'm
// okay with whatever site is embedding us being able to send play/pause!
if (data.type === "play") {
play();
} else if (data.type === "pause") {
pause();
} else if (data.type === "requestStatus") {
sendStatus();
} else {
throw new Error(`unexpected message: ${JSON.stringify(data)}`);
}
});
startMovie()
.then(() => {
sendStatus();
})
.catch((error) => {
console.error(logPrefix, error);
loadingStatus = "error";
sendStatus();
// If loading the movie fails, show the fallback image instead, by moving
// it out of the canvas content and into the body.
document.body.appendChild(document.getElementById("fallback"));
console.warn("Showing fallback image instead.");
});

View file

@ -5,9 +5,15 @@ body.items-index, body.items-show, body.items-needed, body.item_trades
text-align: center
input[type=text]
font-size: 125%
width: 15em
.item-search-form
display: flex
gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1
margin-bottom: 1em

View file

@ -0,0 +1,12 @@
body.use-responsive-design
#container
max-width: 100%
padding-inline: 1rem
box-sizing: border-box
#home-link
margin-left: 1rem
padding-inline: 0
#userbar
margin-right: 1rem

View file

@ -4,6 +4,7 @@
@import partials/clean/mixins
@import layout
@import responsive
@import partials/jquery.jgrowl
@ -20,5 +21,4 @@
@import outfits/index
@import outfits/new
@import pets/bulk
@import swf_assets/links
@import users/top_contributors

View file

@ -0,0 +1,64 @@
.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

@ -37,3 +37,314 @@ body.items-show
.nc-icon
height: 16px
width: 16px
outfit-viewer
display: block
position: relative
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
overflow: hidden
margin: 0 auto
// 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
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)
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
species-color-picker
.error-icon
cursor: help
margin-right: .25em
form[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color
// If JS is enabled, but auto-loading isn't ready yet (script loading or
// failed?), hide the submit button for .75sec, to give it time to load.
@media (scripting: enabled)
input[type=submit]
position: absolute
margin-left: .5em
opacity: 0
animation: fade-in .25s forwards
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
input[type=submit]
display: none
species-face-picker
display: block
position: relative
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
padding: 10px // leave enough room for the zoomed-in selected face
margin-top: -10px
overflow: auto
species-face-picker-options
display: flex
justify-content: center
flex-wrap: wrap
img
width: 50px
height: 50px
transition: all 0.2s
// Calm down the default color, just a smidge! There's a lot of color
// on this page already, y'know?
opacity: .9
filter: saturate(90%)
label
display: flex
overflow: hidden
transition: all 0.2s
position: relative
line-height: 1
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
// Chakra UI's styling system to generate them! (The colors are from their
// color palette, too.)
&:has(input:checked)
border-radius: 6px
z-index: 1
background: #9AE6B4
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
transform: scale(1.1)
&:has(input:focus)
background: #BEE3F8
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
transform: scale(1.2)
input[type=radio]
position: absolute
left: -10000px
top: auto
width: 1px
height: 1px
overflow: hidden
&:checked + img
opacity: 1
filter: saturate(110%)
&:disabled + img
opacity: .6
filter: saturate(0%)
label:has(input[type=radio]:disabled)
cursor: not-allowed
noscript
position: absolute
inset: 0
background: rgba(white, .75)
z-index: 1
cursor: auto
display: flex
align-items: center
justify-content: center
text-align: center
&:has(species-face-picker-options[inert])
cursor: wait
.item-preview-meta-info
display: grid
grid-template-columns: 1fr auto
gap: .5em
align-items: center
.item-zones-info
h3
display: inline
font: inherit
font-weight: bold
&:after
content: ": "
ul
list-style-type: none
display: inline
li
display: inline
&:not(:last-of-type):after
content: ", "
.no-zones
font-style: italic
opacity: .85
.zone-species-info
font-style: italic
text-decoration: underline dotted
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
.item-html5-info
display: flex
align-items: center
border: 1px solid
border-radius: .375em
padding: 4px 8px
min-height: 30px
box-sizing: border-box
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
&[data-status=converted]
background: $module-bg-color
color: $text-color
svg:nth-of-type(2)
margin-right: -4px // spacing hacks!
&[data-status=unconverted]
background: $warning-bg-color
color: #975A16
gap: .25em // spacing hacks!
svg:first-of-type
width: 12px
height: 12px
svg:nth-of-type(2)
width: 20px
height: 20px
#item-preview
display: flex
flex-direction: column
gap: .75em
@media (min-width: 600px)
display: grid
grid-template-areas: "viewer faces" "picker meta"
gap: .5em
outfit-viewer
grid-area: viewer
width: 350px
height: 350px
species-color-picker
grid-area: picker
species-face-picker
grid-area: faces
max-height: 350px
margin: -10px
.item-preview-meta-info
grid-area: meta
@keyframes fade-in
from
opacity: 0
to
opacity: 1

View file

@ -35,6 +35,7 @@
text-align: left
display: flex
align-items: center
flex-wrap: wrap
gap: 1em
abbr
@ -127,6 +128,7 @@
.item-subpages-nav
display: flex
align-items: flex-end
gap: 1em
.preview-link
margin-right: auto

View file

@ -1,10 +0,0 @@
@import "../partials/assets-list"
body.swf_assets-links
#swf-assets
+assets-list
li
span
font-size: 75%
word-wrap: break-word

View file

@ -0,0 +1,12 @@
#asset-canvas,
#asset-image,
#fallback {
position: absolute;
left: 0;
top: 0;
/* HACK: `calc` isn't needed, but works around a bug in our asset pipeline,
* where libsass is trying to preprocess it. (We're not SASS tho?) */
width: calc(min(100vw, 100vh));
height: calc(min(100vw, 100vh));
}

View file

@ -82,6 +82,21 @@ class ItemsController < ApplicationController
group_by_owned
@current_user_quantities = current_user.item_quantities_for(@item)
end
@selected_preview_pet_type = load_selected_preview_pet_type
@preview_outfit = Outfit.new(
pet_state: load_preview_pet_type.canonical_pet_state,
worn_items: [@item],
)
@preview_error = validate_preview
@all_appearances = @item.appearances
@appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
sort_by { |z, a| z.label }
@selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
includes(:species).merge(Species.alphabetical)
end
format.gif do
@ -180,7 +195,7 @@ class ItemsController < ApplicationController
appearance_params[:color_id], appearance_params[:species_id])
end
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]).
target.appearances_for(@items, swf_asset_includes: [:zone]).
tap do |appearances|
# Preload the manifests for these SWF assets concurrently, rather than
# loading them in sequence when we generate the JSON.
@ -189,6 +204,43 @@ class ItemsController < ApplicationController
end
end
def load_selected_preview_pet_type
color_id = params.dig(:preview, :color_id)
species_id = params.dig(:preview, :species_id)
return load_default_preview_pet_type if color_id.nil? || species_id.nil?
PetType.find_or_initialize_by(color_id:, species_id:).tap do |pet_type|
if pet_type.persisted?
cookies["preferred-preview-color-id"] = color_id
cookies["preferred-preview-species-id"] = species_id
end
end
end
def load_preview_pet_type
if @selected_preview_pet_type.persisted?
@selected_preview_pet_type
else
load_default_preview_pet_type
end
end
def load_default_preview_pet_type
@item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first
end
def validate_preview
if @selected_preview_pet_type.new_record?
:pet_type_does_not_exist
elsif @preview_outfit.item_appearances.any?(&:empty?)
:no_item_data
end
end
def search_error(e)
@items = []
@query = params[:q]

View file

@ -0,0 +1,44 @@
class SwfAssetsController < ApplicationController
# We're very careful with what content is allowed to load. This is because
# asset movies run arbitrary JS, and, while we generally trust content from
# Neopets.com, let's not be *allowing* movie JS to do whatever it wants! This
# is a good default security stance, even if we don't foresee an attack.
content_security_policy do |policy|
policy.sandbox "allow-scripts"
policy.default_src "none"
policy.img_src -> {
src_list(
helpers.image_url("favicon.png"),
@swf_asset.image_url,
*@swf_asset.canvas_movie_sprite_urls,
)
}
policy.script_src -> {
src_list(
helpers.javascript_url("lib/easeljs.min"),
helpers.javascript_url("lib/tweenjs.min"),
helpers.javascript_url("swf_assets/show"),
@swf_asset.canvas_movie_library_url,
)
}
policy.style_src -> {
src_list(
helpers.stylesheet_url("swf_assets/show"),
)
}
end
def show
@swf_asset = SwfAsset.find params[:id]
render layout: nil
end
private
def src_list(*urls)
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
end
end

View file

@ -231,6 +231,15 @@ module ApplicationHelper
@hide_title_header = true
end
def use_responsive_design
@use_responsive_design = true
add_body_class "use-responsive-design"
end
def use_responsive_design?
@use_responsive_design || false
end
def signed_in_meta_tag
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
end

View file

@ -33,7 +33,9 @@ module ItemsHelper
def standard_species_search_links
all_species = Species.alphabetical.map(&:id)
PetType.random_basic_per_species(all_species).map do |pet_type|
image = pet_type_image(pet_type, :happy, :zoom)
human_name = pet_type.species.human_name
image = pet_type_image pet_type, :happy, :zoom,
alt: human_name, title: human_name
query = "species:#{pet_type.species.name}"
link_to(image, items_path(:q => query))
end.join.html_safe
@ -218,12 +220,37 @@ module ItemsHelper
end
end
private
def outfit_viewer_is_playing
cookies["DTIOutfitViewerIsPlaying"] == "true"
end
def pet_type_image(pet_type, emotion, size)
def item_fits?(item, pet_type)
item.appearances.any? { |a| a.fits? pet_type }
end
def species_face_tooltip(pet_type, item)
if item_fits?(item, pet_type)
"#{pet_type.species.human_name}"
else
"#{pet_type.species.human_name}: No data yet"
end
end
def item_zone_partial_fit?(appearances_in_zone, all_appearances)
appearances_in_zone.size < all_appearances.size
end
def item_zone_species_list(appearances_in_zone)
appearances_in_zone.map(&:species).uniq.map(&:human_name).sort.join(", ")
end
def pet_type_image(pet_type, emotion, size, **options)
src = pet_type_image_url(pet_type, emotion:, size:)
human_name = pet_type.species.name.humanize
image_tag(src, :alt => human_name, :title => human_name)
srcset = if size == :face
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
end
image_tag(src, srcset:, **options)
end
def item_header_user_lists_form_state

View file

@ -1,15 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
import { AppProvider, ItemPageOutfitPreview } from "./wardrobe-2020";
const rootNode = document.querySelector("#outfit-preview-root");
const itemId = rootNode.getAttribute("data-item-id");
// TODO: Use the new React 18 APIs instead!
// eslint-disable-next-line react/no-deprecated
ReactDOM.render(
<AppProvider>
<ItemPageOutfitPreview itemId={itemId} />
</AppProvider>,
rootNode,
);

View file

@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord
swf_asset.image_url
end
# Given a list of item IDs, return how they look on this alt style.
def appearances_for(item_ids, ...)
Item.appearances_for(item_ids, self, ...)
# Given a list of items, return how they look on this alt style.
def appearances_for(items, ...)
Item.appearances_for(items, self, ...)
end
def biology=(biology)

View file

@ -281,23 +281,9 @@ class Item < ApplicationRecord
occupied_zones.map(&:id)
end
def occupied_zones(options={})
options[:scope] ||= Zone.all
all_body_ids = []
zone_body_ids = {}
selected_assets = swf_assets.select('body_id, zone_id').each do |swf_asset|
zone_body_ids[swf_asset.zone_id] ||= []
body_ids = zone_body_ids[swf_asset.zone_id]
body_ids << swf_asset.body_id unless body_ids.include?(swf_asset.body_id)
all_body_ids << swf_asset.body_id unless all_body_ids.include?(swf_asset.body_id)
end
zones = options[:scope].find(zone_body_ids.keys)
zones_by_id = zones.inject({}) { |h, z| h[z.id] = z; h }
total_body_ids = all_body_ids.size
zone_body_ids.each do |zone_id, body_ids|
zones_by_id[zone_id].sometimes = true if body_ids.size < total_body_ids
end
zones
def occupied_zones
zone_ids = swf_assets.map(&:zone_id).uniq
Zone.find(zone_ids)
end
def affected_zones
@ -438,6 +424,15 @@ class Item < ApplicationRecord
}.merge(options))
end
def compatible_body_ids
swf_assets.map(&:body_id).uniq
end
def compatible_pet_types
return PetType.all if compatible_body_ids.include?(0)
PetType.where(body_id: compatible_body_ids)
end
def handle_assets!
if @parent_swf_asset_relationships_to_update && @current_body_id
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
@ -504,20 +499,48 @@ class Item < ApplicationRecord
# instead of like a hash, so you can target its children with things like
# the `include` option. This feels clunky though, I wish I had something a
# bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do
Appearance = Struct.new(:item, :body, :swf_assets) do
include ActiveModel::Serializers::JSON
delegate :present?, :empty?, to: :swf_assets
delegate :species, :fits?, :fits_all?, to: :body
def attributes
{body: body, swf_assets: swf_assets}
{item:, body:, swf_assets:}
end
def html5?
swf_assets.all?(&:html5?)
end
def occupied_zone_ids
swf_assets.map(&:zone_id).uniq.sort
end
def restricted_zone_ids
return [] if empty?
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
end
end
Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON
def attributes
{id: id, species: species}
{id:, species:}
end
def fits_all?
id == 0
end
def fits?(target)
fits_all? || target.body_id == id
end
end
def appearances
@appearances ||= build_appearances
end
def build_appearances
all_swf_assets = swf_assets.to_a
# If there are no assets yet, there are no appearances.
@ -530,28 +553,48 @@ class Item < ApplicationRecord
# If there are no body-specific assets, return one appearance for them all.
if swf_assets_by_body_id.empty?
body = Appearance::Body.new(0, nil)
return [Appearance.new(body, swf_assets_for_all_bodies)]
return [Appearance.new(self, body, swf_assets_for_all_bodies)]
end
# Otherwise, create an appearance for each real (nonzero) body ID. We don't
# generally expect body_id = 0 and body_id != 0 to mix, but if they do,
# uhh, let's merge the body_id = 0 ones in?
species_by_body_id = Species.with_body_ids(swf_assets_by_body_id.keys)
swf_assets_by_body_id.map do |body_id, body_specific_assets|
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
species = Species.with_body_id(body_id).first!
body = Appearance::Body.new(body_id, species)
Appearance.new(body, swf_assets_for_body)
body = Appearance::Body.new(body_id, species_by_body_id[body_id])
Appearance.new(self, body, swf_assets_for_body)
end
end
# Given a list of item IDs, return how they look on the given target (either
# a pet type or an alt style).
def self.appearances_for(item_ids, target, swf_asset_includes: [])
def appearance_for(target, ...)
Item.appearances_for([self], target, ...)[id]
end
def appearances_by_occupied_zone_id
{}.tap do |h|
appearances.each do |appearance|
appearance.occupied_zone_ids.each do |zone_id|
h[zone_id] ||= []
h[zone_id] << appearance
end
end
end
end
def appearances_by_occupied_zone
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
appearances_by_occupied_zone_id.transform_keys { |zid| zones_by_id[zid] }
end
# Given a list of items, return how they look on the given target (either a
# pet type or an alt style).
def self.appearances_for(items, target, swf_asset_includes: [])
# First, load all the relationships for these items that also fit this
# body.
relationships = ParentSwfAssetRelationship.
includes(swf_asset: swf_asset_includes).
where(parent_type: "Item", parent_id: item_ids).
where(parent_type: "Item", parent_id: items.map(&:id)).
where(swf_asset: {body_id: [target.body_id, 0]})
pet_type_body = Appearance::Body.new(target.body_id, target.species)
@ -562,13 +605,13 @@ class Item < ApplicationRecord
transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id|
assets = assets_by_item_id.fetch(item_id, [])
items.to_h do |item|
assets = assets_by_item_id.fetch(item.id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
body = fits_all_pets ? all_pets_body : pet_type_body
[item_id, Appearance.new(body, assets)]
[item.id, Appearance.new(item, body, assets)]
end
end

View file

@ -25,7 +25,12 @@ class Outfit < ApplicationRecord
before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology
delegate :color, to: :pet_state
delegate :pose, to: :pet_state
delegate :pet_type, to: :pet_state
delegate :color, to: :pet_type
delegate :color_id, to: :pet_type
delegate :species, to: :pet_type
delegate :species_id, to: :pet_type
scope :wardrobe_order, -> { order('starred DESC', :name) }
@ -107,18 +112,6 @@ class Outfit < ApplicationRecord
)
end
def color_id
pet_state.pet_type.color_id
end
def species_id
pet_state.pet_type.species_id
end
def pose
pet_state.pose
end
def biology=(biology)
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
@ -166,6 +159,78 @@ class Outfit < ApplicationRecord
self.item_outfit_relationships = new_relationships
end
def item_appearances(...)
Item.appearances_for(worn_items, pet_type, ...).values
end
def visible_layers
item_appearances = item_appearances(swf_asset_includes: [:zone])
pet_layers = pet_state.swf_assets.includes(:zone).to_a
item_layers = item_appearances.map(&:swf_assets).flatten
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
flatten.to_set
item_restricted_zone_ids = item_appearances.
map(&:restricted_zone_ids).flatten.to_set
# When an item restricts a zone, it hides pet layers of the same zone.
# We use this to e.g. make a hat hide a hair ruff.
#
# NOTE: Items' restricted layers also affect what items you can wear at
# the same time. We don't enforce anything about that here, and
# instead assume that the input by this point is valid!
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
# When a pet appearance restricts a zone, or when the pet is Unconverted,
# it makes body-specific items incompatible. We use this to disallow UCs
# from wearing certain body-specific Biology Effects, Statics, etc, while
# still allowing non-body-specific items in those zones! (I think this
# happens for some Invisible pet stuff, too?)
#
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
# should be doing this way earlier, to prevent the item from even
# showing up even in search results!
#
# NOTE: This can result in both pet layers and items occupying the same
# zone, like Static, so long as the item isn't body-specific! That's
# correct, and the item layer should be on top! (Here, we implement
# it by placing item layers second in the list, and rely on JS sort
# stability, and *then* rely on the UI to respect that ordering when
# rendering them by depth. Not great! 😅)
#
# NOTE: We used to also include the pet appearance's *occupied* zones in
# this condition, not just the restricted zones, as a sensible
# defensive default, even though we weren't aware of any relevant
# items. But now we know that actually the "Bruce Brucey B Mouth"
# occupies the real Mouth zone, and still should be visible and
# above pet layers! So, we now only check *restricted* zones.
#
# NOTE: UCs used to implement their restrictions by listing specific
# zones, but it seems that the logic has changed to just be about
# UC-ness and body-specific-ness, and not necessarily involve the
# set of restricted zones at all. (This matters because e.g. UCs
# shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
# don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
# zone restriction case running too, because I don't think it
# _hurts_ anything, and I'm not confident enough in this conclusion.
#
# TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
# use zone restrictions?
if pet_state.pose === "UNCONVERTED"
item_layers.reject! { |sa| sa.body_specific? }
else
item_layers.reject! { |sa| sa.body_specific? &&
pet_restricted_zone_ids.include?(sa.zone_id) }
end
# A pet appearance can also restrict its own zones. The Wraith Uni is an
# interesting example: it has a horn, but its zone restrictions hide it!
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
(pet_layers + item_layers).sort_by(&:depth)
end
def ensure_unique_name
# If no name was provided, start with "Untitled outfit".
self.name = "Untitled outfit" if name.blank?

View file

@ -17,6 +17,17 @@ class PetType < ApplicationRecord
species = Species.find_by_name!(species_name)
where(color_id: color.id, species_id: species.id)
}
scope :preferring_species, ->(species_id) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
}
scope :preferring_color, ->(color_id) {
joins(:color).order([Arel.sql("color_id = ? DESC"), color_id])
}
scope :preferring_simple, -> {
joins(:species, :color).
merge(Species.order(name: :asc)).
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
}
def self.random_basic_per_species(species_ids)
random_pet_types = []
@ -119,9 +130,9 @@ class PetType < ApplicationRecord
}.first
end
# Given a list of item IDs, return how they look on this pet type.
def appearances_for(item_ids, ...)
Item.appearances_for(item_ids, self, ...)
# Given a list of items, return how they look on this pet type.
def appearances_for(item, ...)
Item.appearances_for(item, self, ...)
end
def self.all_by_ids_or_children(ids, pet_states)

View file

@ -4,11 +4,6 @@ class Species < ApplicationRecord
scope :alphabetical, -> { order(:name) }
scope :with_body_id, -> body_id {
pt = PetType.arel_table
joins(:pet_types).where(pt[:body_id].eq(body_id)).limit(1)
}
def as_json(options={})
super({only: [:id, :name], methods: [:human_name]}.merge(options))
end
@ -20,4 +15,15 @@ class Species < ApplicationRecord
I18n.translate('species.default_human_name')
end
end
# Given a list of body IDs, return a hash from body ID to Species.
# (We assume that each body ID belongs to just one species; if not, which
# species we return for that body ID is undefined.)
def self.with_body_ids(body_ids)
species_ids_by_body_id = PetType.where(body_id: body_ids).distinct.
pluck(:body_id, :species_id).to_h
species_by_id = Species.where(id: species_ids_by_body_id.values).
to_h { |s| [s.id, s] }
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
end
end

View file

@ -15,7 +15,6 @@ class SwfAsset < ApplicationRecord
belongs_to :zone
has_many :parent_swf_asset_relationships
has_one :contribution, :as => :contributed, :inverse_of => :contributed
has_many :parent_swf_asset_relationships
before_validation :normalize_manifest_url, if: :manifest_url?
@ -141,7 +140,10 @@ class SwfAsset < ApplicationRecord
# assets in the same manifest, and earlier ones are broken and later
# ones are fixed. I don't know the logic exactly, but that's what we've
# seen!
{ js: assets_by_ext[:js].last }
{
js: assets_by_ext[:js].last,
sprites: assets_by_ext.fetch(:png, []),
}
else
# Otherwise, return the first PNG and the first SVG. (Unlike the JS
# case, it's important to choose the *first* PNG, because sometimes
@ -186,8 +188,21 @@ class SwfAsset < ApplicationRecord
nil
end
def canvas_movie?
canvas_movie_library_url.present?
end
def canvas_movie_library_url
manifest_asset_urls[:js]
end
def canvas_movie_sprite_urls
return [] unless canvas_movie?
manifest_asset_urls[:sprites]
end
def canvas_movie_image_url
return nil unless manifest_asset_urls[:js]
return nil unless canvas_movie?
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
libraryUrl: manifest_asset_urls[:js],
@ -221,6 +236,17 @@ class SwfAsset < ApplicationRecord
self[:known_glitches] = new_known_glitches
end
def html5?
# NOTE: This is slightly different than how Impress 2020 reasons about
# this; it checks for an SVG or canvas movie. I *think* we did
# this just to keep the API simpler, and this check is more
# correct? But I do wonder if any assets have a manifest but are
# arguably "not converted" because the manifest is just so bad.
# NOTE: Just checking `manifest_url` isn't enough, because there *are*
# assets with a `manifest_url` saved but it 404s.
manifest.present?
end
def restricted_zone_ids
[].tap do |ids|
zones_restrict.chars.each_with_index do |bit, index|

View file

@ -1,8 +1,4 @@
class Zone < ActiveRecord::Base
# When selecting zones that an asset occupies, we allow the zone to set
# whether or not the zone is "sometimes" occupied. This is false by default.
attr_writer :sometimes
scope :alphabetical, -> { order(:label) }
scope :matching_label, ->(label) {
where(plain_label: Zone.plainify_label(label))
@ -13,10 +9,6 @@ class Zone < ActiveRecord::Base
super({only: [:id, :depth, :label]}.merge(options))
end
def uncertain_label
@sometimes ? "#{label} sometimes" : label
end
def is_commonly_used_by_items
# Zone metadata marks item zones with types 2, 3, and 4. But also, in
# practice, the Biology Effects zone (type 1, ID 4) has been used for a few

View file

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 979 B

View file

@ -0,0 +1,24 @@
%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.visible_layers.each do |swf_asset|
%outfit-layer{
data: {
"asset-id": swf_asset.id,
"zone": swf_asset.zone.label,
},
}
- if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
- else
= image_tag swf_asset.image_url, alt: ""

View file

@ -1,5 +1,6 @@
- title @item.name
- canonical_path @item
- use_responsive_design
= render partial: "item_header",
locals: {item: @item, trades: @trades, current_subpage: "preview",
@ -13,7 +14,96 @@
how we handle zones. Until then, these items will be <em>very</em> buggy,
sorry!
#outfit-preview-root{'data-item-id': @item.id}
= 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?
%species-color-picker
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
= select_tag "preview[color_id]",
options_from_collection_for_select(Color.funny.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil
%species-face-picker
%noscript
This fancy species picker requires Javascript, but you can still use the
dropdowns instead!
%species-face-picker-options{
inert: true, # waits for JS to remove
"aria-hidden": true, # waits for JS to remove
}
- @preview_pet_type_options.each do |pet_type|
%label{
title: species_face_tooltip(pet_type, @item),
}
= radio_button_tag "species_face_id", pet_type.species_id,
checked: pet_type == @preview_outfit.pet_type,
disabled: !item_fits?(@item, pet_type)
= pet_type_image pet_type,
item_fits?(@item, pet_type) ? :happy : :sad,
:face
.item-preview-meta-info
.item-zones-info
%section
%h3 Occupies
- if @appearances_by_occupied_zone.present?
%ul
- @appearances_by_occupied_zone.each do |zone, appearances_in_zone|
%li
= zone.label
- if item_zone_partial_fit? appearances_in_zone, @all_appearances
%span.zone-species-info{
title: item_zone_species_list(appearances_in_zone)
}
(#{appearances_in_zone.size} species)
- else
%span.no-zones (None)
%section
%h3 Restricts
- if @item.restricted_zones.present?
%ul
- @item.restricted_zones.sort_by(&:label).each do |zone|
%li= zone.label
- else
%span.no-zones (None)
%div
- if @selected_item_appearance.html5?
.item-html5-info{
"data-status": "converted",
"aria-label": "HTML5 supported!",
title: "This item is converted to HTML5, and ready to use on Neopets.com!",
}
%svg{viewBox: "0 0 24 24"}
%path{fill: "currentColor", d: "M12,0A12,12,0,1,0,24,12,12.014,12.014,0,0,0,12,0Zm6.927,8.2-6.845,9.289a1.011,1.011,0,0,1-1.43.188L5.764,13.769a1,1,0,1,1,1.25-1.562l4.076,3.261,6.227-8.451A1,1,0,1,1,18.927,8.2Z"}
%svg{viewBox: "0 0 36 36"}
%path{fill: "currentColor", d: "M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"}
- else
.item-html5-info{
"data-status": "unconverted",
"aria-label": "HTML5 not supported",
title: "This item isn't converted to HTML5 yet, so it might not appear in " +
"Neopets.com customization yet. Once it's ready, it could look a " +
"bit different than our temporary preview here. It might even be " +
"animated!"
}
%svg{viewBox: "0 0 24 24"}
%path{fill: "currentColor", d: "M23.119,20,13.772,2.15h0a2,2,0,0,0-3.543,0L.881,20a2,2,0,0,0,1.772,2.928H21.347A2,2,0,0,0,23.119,20ZM11,8.423a1,1,0,0,1,2,0v6a1,1,0,1,1-2,0Zm1.05,11.51h-.028a1.528,1.528,0,0,1-1.522-1.47,1.476,1.476,0,0,1,1.448-1.53h.028A1.527,1.527,0,0,1,13.5,18.4,1.475,1.475,0,0,1,12.05,19.933Z"}
%svg{viewBox: "0 0 36 36"}
%path{fill: "currentColor", d: "M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"}
%path{fill: "#DD2E44", opacity: "0.75", d: "M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"}
- unless @contributors_with_counts.empty?
#item-contributors
@ -23,6 +113,10 @@
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
%footer= t '.contributors.footer'
- content_for :javascripts_body do
= javascript_include_tag 'item-page', defer: true
- 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
= javascript_include_tag "items/show", async: true

View file

@ -13,6 +13,8 @@
%link{href: image_path('favicon.png'), rel: 'icon'}
= yield :stylesheets
= stylesheet_link_tag "application"
- if use_responsive_design?
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
= yield :meta
= open_graph_tags
= csrf_meta_tag

View file

@ -7,7 +7,7 @@
= image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif'
%span= t 'infinite_closet'
- content_for :content do
= form_tag items_path, :method => :get do
= form_tag items_path, method: :get, class: "item-search-form" do
= text_field_tag :q, @query.to_s
= submit_tag t('.search'), :name => nil
= yield

View file

@ -0,0 +1,36 @@
!!! 5
%html
%head
%meta{charset: "utf-8"}
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
%title
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?
-# 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|
%link{rel: "preload", href: sprite_url, as: "image", crossorigin: "anonymous"}
-# Load the scripts: EaselJS libs first, then the asset's "library" file,
-# then our page script that starts the movie.
= javascript_include_tag "lib/easeljs.min", defer: true, debug: false
= javascript_include_tag "lib/tweenjs.min", defer: true, debug: false
= javascript_include_tag @swf_asset.canvas_movie_library_url, defer: true,
id: "canvas-movie-library"
= javascript_include_tag "swf_assets/show", defer: true, debug: false
%body
- if @swf_asset.canvas_movie?
%canvas#asset-canvas
-# Show a fallback image, for users with JS disabled. Lazy-load it, so
-# 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"

View file

@ -37,6 +37,7 @@ OpenneoImpressItems::Application.routes.draw do
resources :alt_styles, path: 'alt-styles', only: [:index]
end
resources :alt_styles, path: 'alt-styles', only: [:index]
resources :swf_assets, path: 'swf-assets', only: [:show]
# Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet