Merge branch 'simpler-item-previews'

This commit is contained in:
Emi Matchu 2024-09-06 11:09:16 -07:00
commit c60dceb0ae
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 text-align: center
input[type=text] .item-search-form
font-size: 125% display: flex
width: 15em gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1 h1
margin-bottom: 1em 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 partials/clean/mixins
@import layout @import layout
@import responsive
@import partials/jquery.jgrowl @import partials/jquery.jgrowl
@ -20,5 +21,4 @@
@import outfits/index @import outfits/index
@import outfits/new @import outfits/new
@import pets/bulk @import pets/bulk
@import swf_assets/links
@import users/top_contributors @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 .nc-icon
height: 16px height: 16px
width: 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 text-align: left
display: flex display: flex
align-items: center align-items: center
flex-wrap: wrap
gap: 1em gap: 1em
abbr abbr
@ -127,6 +128,7 @@
.item-subpages-nav .item-subpages-nav
display: flex display: flex
align-items: flex-end align-items: flex-end
gap: 1em
.preview-link .preview-link
margin-right: auto margin-right: auto
@ -167,4 +169,4 @@
background: $background-color background: $background-color
padding-bottom: calc(.5em + 1px) padding-bottom: calc(.5em + 1px)
font-weight: bold font-weight: bold
margin-bottom: -1px margin-bottom: -1px

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 group_by_owned
@current_user_quantities = current_user.item_quantities_for(@item) @current_user_quantities = current_user.item_quantities_for(@item)
end 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 end
format.gif do format.gif do
@ -180,7 +195,7 @@ class ItemsController < ApplicationController
appearance_params[:color_id], appearance_params[:species_id]) appearance_params[:color_id], appearance_params[:species_id])
end end
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]). target.appearances_for(@items, swf_asset_includes: [:zone]).
tap do |appearances| tap do |appearances|
# Preload the manifests for these SWF assets concurrently, rather than # Preload the manifests for these SWF assets concurrently, rather than
# loading them in sequence when we generate the JSON. # loading them in sequence when we generate the JSON.
@ -188,6 +203,43 @@ class ItemsController < ApplicationController
SwfAsset.preload_manifests(swf_assets) SwfAsset.preload_manifests(swf_assets)
end end
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) def search_error(e)
@items = [] @items = []

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

@ -1,6 +1,6 @@
module ApplicationHelper module ApplicationHelper
include FragmentLocalization include FragmentLocalization
def absolute_url(path_or_url) def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL if path_or_url.include?('://') # already an absolute URL
path_or_url path_or_url
@ -231,6 +231,15 @@ module ApplicationHelper
@hide_title_header = true @hide_title_header = true
end 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 def signed_in_meta_tag
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe %(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
end end

View file

@ -33,7 +33,9 @@ module ItemsHelper
def standard_species_search_links def standard_species_search_links
all_species = Species.alphabetical.map(&:id) all_species = Species.alphabetical.map(&:id)
PetType.random_basic_per_species(all_species).map do |pet_type| 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}" query = "species:#{pet_type.species.name}"
link_to(image, items_path(:q => query)) link_to(image, items_path(:q => query))
end.join.html_safe end.join.html_safe
@ -218,12 +220,37 @@ module ItemsHelper
end end
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:) src = pet_type_image_url(pet_type, emotion:, size:)
human_name = pet_type.species.name.humanize srcset = if size == :face
image_tag(src, :alt => human_name, :title => human_name) [[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
end
image_tag(src, srcset:, **options)
end end
def item_header_user_lists_form_state 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 swf_asset.image_url
end end
# Given a list of item IDs, return how they look on this alt style. # Given a list of items, return how they look on this alt style.
def appearances_for(item_ids, ...) def appearances_for(items, ...)
Item.appearances_for(item_ids, self, ...) Item.appearances_for(items, self, ...)
end end
def biology=(biology) def biology=(biology)

View file

@ -276,28 +276,14 @@ class Item < ApplicationRecord
end end
@restricted_zone_ids @restricted_zone_ids
end end
def occupied_zone_ids def occupied_zone_ids
occupied_zones.map(&:id) occupied_zones.map(&:id)
end end
def occupied_zones(options={}) def occupied_zones
options[:scope] ||= Zone.all zone_ids = swf_assets.map(&:zone_id).uniq
all_body_ids = [] Zone.find(zone_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
end end
def affected_zones def affected_zones
@ -438,6 +424,15 @@ class Item < ApplicationRecord
}.merge(options)) }.merge(options))
end 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! def handle_assets!
if @parent_swf_asset_relationships_to_update && @current_body_id if @parent_swf_asset_relationships_to_update && @current_body_id
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_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 # 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 # the `include` option. This feels clunky though, I wish I had something a
# bit more suited to it! # bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do Appearance = Struct.new(:item, :body, :swf_assets) do
include ActiveModel::Serializers::JSON include ActiveModel::Serializers::JSON
delegate :present?, :empty?, to: :swf_assets
delegate :species, :fits?, :fits_all?, to: :body
def attributes 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
end end
Appearance::Body = Struct.new(:id, :species) do Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON include ActiveModel::Serializers::JSON
def attributes 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
end end
def appearances def appearances
@appearances ||= build_appearances
end
def build_appearances
all_swf_assets = swf_assets.to_a all_swf_assets = swf_assets.to_a
# If there are no assets yet, there are no appearances. # 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 there are no body-specific assets, return one appearance for them all.
if swf_assets_by_body_id.empty? if swf_assets_by_body_id.empty?
body = Appearance::Body.new(0, nil) 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 end
# Otherwise, create an appearance for each real (nonzero) body ID. We don't # 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, # 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? # 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_by_body_id.map do |body_id, body_specific_assets|
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies 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_by_body_id[body_id])
body = Appearance::Body.new(body_id, species) Appearance.new(self, body, swf_assets_for_body)
Appearance.new(body, swf_assets_for_body)
end end
end end
# Given a list of item IDs, return how they look on the given target (either def appearance_for(target, ...)
# a pet type or an alt style). Item.appearances_for([self], target, ...)[id]
def self.appearances_for(item_ids, target, swf_asset_includes: []) 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 # First, load all the relationships for these items that also fit this
# body. # body.
relationships = ParentSwfAssetRelationship. relationships = ParentSwfAssetRelationship.
includes(swf_asset: swf_asset_includes). 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]}) where(swf_asset: {body_id: [target.body_id, 0]})
pet_type_body = Appearance::Body.new(target.body_id, target.species) 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) } transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty! # Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id| items.to_h do |item|
assets = assets_by_item_id.fetch(item_id, []) assets = assets_by_item_id.fetch(item.id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 } fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
body = fits_all_pets ? all_pets_body : pet_type_body 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
end end

View file

@ -25,7 +25,12 @@ class Outfit < ApplicationRecord
before_validation :ensure_unique_name, if: :user_id? before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology 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) } scope :wardrobe_order, -> { order('starred DESC', :name) }
@ -107,18 +112,6 @@ class Outfit < ApplicationRecord
) )
end 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) def biology=(biology)
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id) @biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
@ -166,6 +159,78 @@ class Outfit < ApplicationRecord
self.item_outfit_relationships = new_relationships self.item_outfit_relationships = new_relationships
end 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 def ensure_unique_name
# If no name was provided, start with "Untitled outfit". # If no name was provided, start with "Untitled outfit".
self.name = "Untitled outfit" if name.blank? self.name = "Untitled outfit" if name.blank?

View file

@ -17,6 +17,17 @@ class PetType < ApplicationRecord
species = Species.find_by_name!(species_name) species = Species.find_by_name!(species_name)
where(color_id: color.id, species_id: species.id) 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) def self.random_basic_per_species(species_ids)
random_pet_types = [] random_pet_types = []
@ -119,9 +130,9 @@ class PetType < ApplicationRecord
}.first }.first
end end
# Given a list of item IDs, return how they look on this pet type. # Given a list of items, return how they look on this pet type.
def appearances_for(item_ids, ...) def appearances_for(item, ...)
Item.appearances_for(item_ids, self, ...) Item.appearances_for(item, self, ...)
end end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)

View file

@ -3,11 +3,6 @@ class Species < ApplicationRecord
has_many :alt_styles has_many :alt_styles
scope :alphabetical, -> { order(:name) } 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={}) def as_json(options={})
super({only: [:id, :name], methods: [:human_name]}.merge(options)) super({only: [:id, :name], methods: [:human_name]}.merge(options))
@ -20,4 +15,15 @@ class Species < ApplicationRecord
I18n.translate('species.default_human_name') I18n.translate('species.default_human_name')
end end
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 end

View file

@ -15,7 +15,6 @@ class SwfAsset < ApplicationRecord
belongs_to :zone belongs_to :zone
has_many :parent_swf_asset_relationships has_many :parent_swf_asset_relationships
has_one :contribution, :as => :contributed, :inverse_of => :contributed has_one :contribution, :as => :contributed, :inverse_of => :contributed
has_many :parent_swf_asset_relationships
before_validation :normalize_manifest_url, if: :manifest_url? 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 # 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 # ones are fixed. I don't know the logic exactly, but that's what we've
# seen! # seen!
{ js: assets_by_ext[:js].last } {
js: assets_by_ext[:js].last,
sprites: assets_by_ext.fetch(:png, []),
}
else else
# Otherwise, return the first PNG and the first SVG. (Unlike the JS # Otherwise, return the first PNG and the first SVG. (Unlike the JS
# case, it's important to choose the *first* PNG, because sometimes # case, it's important to choose the *first* PNG, because sometimes
@ -186,8 +188,21 @@ class SwfAsset < ApplicationRecord
nil nil
end 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 def canvas_movie_image_url
return nil unless manifest_asset_urls[:js] return nil unless canvas_movie?
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand( CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
libraryUrl: manifest_asset_urls[:js], libraryUrl: manifest_asset_urls[:js],
@ -221,6 +236,17 @@ class SwfAsset < ApplicationRecord
self[:known_glitches] = new_known_glitches self[:known_glitches] = new_known_glitches
end 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 def restricted_zone_ids
[].tap do |ids| [].tap do |ids|
zones_restrict.chars.each_with_index do |bit, index| zones_restrict.chars.each_with_index do |bit, index|

View file

@ -1,8 +1,4 @@
class Zone < ActiveRecord::Base 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 :alphabetical, -> { order(:label) }
scope :matching_label, ->(label) { scope :matching_label, ->(label) {
where(plain_label: Zone.plainify_label(label)) where(plain_label: Zone.plainify_label(label))
@ -13,10 +9,6 @@ class Zone < ActiveRecord::Base
super({only: [:id, :depth, :label]}.merge(options)) super({only: [:id, :depth, :label]}.merge(options))
end end
def uncertain_label
@sometimes ? "#{label} sometimes" : label
end
def is_commonly_used_by_items def is_commonly_used_by_items
# Zone metadata marks item zones with types 2, 3, and 4. But also, in # 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 # 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 - title @item.name
- canonical_path @item - canonical_path @item
- use_responsive_design
= render partial: "item_header", = render partial: "item_header",
locals: {item: @item, trades: @trades, current_subpage: "preview", 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, how we handle zones. Until then, these items will be <em>very</em> buggy,
sorry! 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? - unless @contributors_with_counts.empty?
#item-contributors #item-contributors
@ -23,6 +113,10 @@
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count) %li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
%footer= t '.contributors.footer' %footer= t '.contributors.footer'
- content_for :javascripts_body do - content_for :stylesheets do
= javascript_include_tag 'item-page', defer: true = 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'} %link{href: image_path('favicon.png'), rel: 'icon'}
= yield :stylesheets = yield :stylesheets
= stylesheet_link_tag "application" = stylesheet_link_tag "application"
- if use_responsive_design?
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
= yield :meta = yield :meta
= open_graph_tags = open_graph_tags
= csrf_meta_tag = csrf_meta_tag

View file

@ -7,7 +7,7 @@
= image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif' = image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif'
%span= t 'infinite_closet' %span= t 'infinite_closet'
- content_for :content do - 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 = text_field_tag :q, @query.to_s
= submit_tag t('.search'), :name => nil = submit_tag t('.search'), :name => nil
= yield = 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] resources :alt_styles, path: 'alt-styles', only: [:index]
end end
resources :alt_styles, path: 'alt-styles', only: [:index] resources :alt_styles, path: 'alt-styles', only: [:index]
resources :swf_assets, path: 'swf-assets', only: [:show]
# Loading and modeling pets! # Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet post '/pets/load' => 'pets#load', :as => :load_pet