From 3e04357db7bafb93566b6ac7929a37d6704f00ea Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Wed, 17 Jan 2024 12:27:21 +0100 Subject: [PATCH] Add alchemy-picture-thumbnail component --- app/assets/images/alchemy/missing-image.svg | 1 - .../alchemy/alchemy.image_overlay.coffee | 1 - app/assets/stylesheets/alchemy/archive.scss | 12 +- .../stylesheets/alchemy/image_library.scss | 19 +-- .../alchemy/admin/picture_thumbnail.rb | 27 ++++ app/helpers/alchemy/admin/pictures_helper.rb | 14 --- app/javascript/alchemy_admin.js | 3 +- .../components/element_editor.js | 2 - .../components/picture_thumbnail.js | 118 ++++++++++++++++++ app/javascript/alchemy_admin/image_loader.js | 52 -------- app/javascript/alchemy_admin/initializer.js | 3 - .../alchemy_admin/picture_editors.js | 13 +- .../concerns/alchemy/picture_thumbnails.rb | 2 +- app/views/alchemy/admin/crop.html.erb | 1 - .../alchemy/admin/pictures/_picture.html.erb | 14 +-- .../pictures/_picture_to_assign.html.erb | 8 +- .../admin/pictures/archive_overlay.js.erb | 2 - .../alchemy/admin/pictures/index.html.erb | 2 +- .../alchemy/admin/pictures/show.html.erb | 2 +- .../ingredients/_picture_editor.html.erb | 4 +- .../having_picture_thumbnails_examples.rb | 4 +- .../alchemy/admin/picture_thumbnail_spec.rb} | 2 +- .../components/element_editor.spec.js | 8 -- 23 files changed, 177 insertions(+), 137 deletions(-) delete mode 100644 app/assets/images/alchemy/missing-image.svg create mode 100644 app/components/alchemy/admin/picture_thumbnail.rb delete mode 100644 app/helpers/alchemy/admin/pictures_helper.rb create mode 100644 app/javascript/alchemy_admin/components/picture_thumbnail.js delete mode 100644 app/javascript/alchemy_admin/image_loader.js rename spec/{helpers/alchemy/admin/pictures_helper_spec.rb => components/alchemy/admin/picture_thumbnail_spec.rb} (90%) diff --git a/app/assets/images/alchemy/missing-image.svg b/app/assets/images/alchemy/missing-image.svg deleted file mode 100644 index e4a379493f..0000000000 --- a/app/assets/images/alchemy/missing-image.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee b/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee index 8cae82aaa9..8ad8556240 100644 --- a/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee +++ b/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee @@ -5,7 +5,6 @@ class window.Alchemy.ImageOverlay extends Alchemy.Dialog return init: -> - Alchemy.ImageLoader(@dialog_body[0]) $('.zoomed-picture-background').on "click", (e) => e.stopPropagation() return if e.target.nodeName == 'IMG' diff --git a/app/assets/stylesheets/alchemy/archive.scss b/app/assets/stylesheets/alchemy/archive.scss index 03a342b1a5..7f53342b37 100644 --- a/app/assets/stylesheets/alchemy/archive.scss +++ b/app/assets/stylesheets/alchemy/archive.scss @@ -41,7 +41,10 @@ display: flex; align-items: center; justify-content: center; - box-shadow: 0 0 1px 1px $default-border-color; + + &.loaded { + box-shadow: 0 0 1px 1px $default-border-color; + } &:hover { text-decoration: none; @@ -73,12 +76,9 @@ img { max-width: 100%; max-height: 100%; + background: $thumbnail-background; - &:not([src*="alchemy/missing-image"]) { - background: $thumbnail-background; - } - - &[src$=".svg"]:not([src*="alchemy/missing-image"]) { + &[src$=".svg"] { width: var(--picture-width); max-height: var(--picture-height); } diff --git a/app/assets/stylesheets/alchemy/image_library.scss b/app/assets/stylesheets/alchemy/image_library.scss index 0ff397e3a7..ac84dd0eca 100644 --- a/app/assets/stylesheets/alchemy/image_library.scss +++ b/app/assets/stylesheets/alchemy/image_library.scss @@ -135,6 +135,9 @@ $image-overlay-transition-easing: ease-in; } .zoomed-picture-background { + display: flex; + justify-content: center; + align-items: center; width: 100%; height: 100%; padding-top: 2 * $default-padding; @@ -147,23 +150,13 @@ $image-overlay-transition-easing: ease-in; cursor: pointer; transition: padding-right $image-overlay-transition-duration $image-overlay-transition-easing; - - &:before { - content: ""; - vertical-align: middle; - display: inline-block; - height: 100%; - margin-left: -4px; - } + color: $light-gray; img { display: inline-block; height: auto; - max-width: 100%; - max-height: 100%; - box-shadow: 0 0 2 * $default-margin $text-color; - background: $thumbnail-background; - vertical-align: middle; + max-width: 90%; + max-height: 90%; cursor: default; } } diff --git a/app/components/alchemy/admin/picture_thumbnail.rb b/app/components/alchemy/admin/picture_thumbnail.rb new file mode 100644 index 0000000000..e3447c4c5a --- /dev/null +++ b/app/components/alchemy/admin/picture_thumbnail.rb @@ -0,0 +1,27 @@ +module Alchemy + module Admin + class PictureThumbnail < ViewComponent::Base + attr_reader :picture, :url + + def initialize(picture, size: :medium) + @picture = picture + @url = picture.thumbnail_url(size: preview_size(size)) + end + + def call + content_tag("alchemy-picture-thumbnail") do + image_tag(url, alt: picture.name) + end + end + + private + + def preview_size(size) + Alchemy::Picture::THUMBNAIL_SIZES.fetch( + size, + Alchemy::Picture::THUMBNAIL_SIZES[:medium] + ) + end + end + end +end diff --git a/app/helpers/alchemy/admin/pictures_helper.rb b/app/helpers/alchemy/admin/pictures_helper.rb deleted file mode 100644 index d2c5f47cc4..0000000000 --- a/app/helpers/alchemy/admin/pictures_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Alchemy - module Admin - module PicturesHelper - def preview_size(size) - Alchemy::Picture::THUMBNAIL_SIZES.fetch( - size, - Alchemy::Picture::THUMBNAIL_SIZES[:medium] - ) - end - end - end -end diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index 8c2b755834..75d73a224c 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -8,7 +8,6 @@ import GUI from "alchemy_admin/gui" import { translate } from "alchemy_admin/i18n" import Dirty from "alchemy_admin/dirty" import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link" -import ImageLoader from "alchemy_admin/image_loader" import ImageCropper from "alchemy_admin/image_cropper" import Initializer from "alchemy_admin/initializer" import pictureSelector from "alchemy_admin/picture_selector" @@ -31,6 +30,7 @@ import "alchemy_admin/components/node_select" import "alchemy_admin/components/uploader" import "alchemy_admin/components/overlay" import "alchemy_admin/components/page_select" +import "alchemy_admin/components/picture_thumbnail" import "alchemy_admin/components/select" import "alchemy_admin/components/spinner" import "alchemy_admin/components/tinymce" @@ -68,7 +68,6 @@ Object.assign(Alchemy, { ...Dirty, GUI, t: translate, // Global utility method for translating a given string - ImageLoader: ImageLoader.init, ImageCropper, Initializer, IngredientAnchorLink, diff --git a/app/javascript/alchemy_admin/components/element_editor.js b/app/javascript/alchemy_admin/components/element_editor.js index 433b3e08a2..f7de2420db 100644 --- a/app/javascript/alchemy_admin/components/element_editor.js +++ b/app/javascript/alchemy_admin/components/element_editor.js @@ -1,5 +1,4 @@ import TagsAutocomplete from "alchemy_admin/tags_autocomplete" -import ImageLoader from "alchemy_admin/image_loader" import fileEditors from "alchemy_admin/file_editors" import pictureEditors from "alchemy_admin/picture_editors" import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link" @@ -39,7 +38,6 @@ export class ElementEditor extends HTMLElement { } // Init GUI elements - ImageLoader.init(this) fileEditors( `#${this.id} .ingredient-editor.file, #${this.id} .ingredient-editor.audio, #${this.id} .ingredient-editor.video` ) diff --git a/app/javascript/alchemy_admin/components/picture_thumbnail.js b/app/javascript/alchemy_admin/components/picture_thumbnail.js new file mode 100644 index 0000000000..e07826ba9f --- /dev/null +++ b/app/javascript/alchemy_admin/components/picture_thumbnail.js @@ -0,0 +1,118 @@ +import Spinner from "alchemy_admin/spinner" + +const MAX_RETRIES = 10 +const WAIT_TIME = 1000 +const OFFSET_INCREMENT = 100 + +class PictureThumbnail extends HTMLElement { + #retries = MAX_RETRIES + #retryOffset = 0 + #started = false + + constructor() { + super() + this.observer = new MutationObserver(this.handleMutation.bind(this)) + this.classList.add("thumbnail_background") + this.spinner = new Spinner(this.spinnerSize) + } + + connectedCallback() { + if (this.image && !this.image.complete) { + this.start() + } + this.observer.observe(this, { + subtree: true, + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ["src"] + }) + } + + disconnectedCallback() { + this.observer.disconnect() + this.reset() + } + + handleMutation(mutations) { + mutations.forEach((mutation) => { + const hasImage = + mutation.type == "childList" && + Array.from(mutation.addedNodes).some((node) => node.nodeName === "IMG") + const sourceChanged = + mutation.type == "attributes" && + mutation.oldValue !== mutation.target.src + if (hasImage || sourceChanged) { + this.start() + } + }) + } + + handleEvent(event) { + switch (event.type) { + case "load": + this.showImage() + break + case "error": + this.retry() + break + } + } + + start() { + if (this.#started) return + + this.#started = true + this.image.classList.add("hidden") + this.spinner.spin(this) + this.image.addEventListener("load", this) + this.image.addEventListener("error", this) + } + + showImage() { + this.#started = false + this.image.classList.remove("hidden") + this.classList.add("loaded") + this.spinner.stop() + this.reset() + } + + retry() { + if (this.#retries > 0) { + this.#retries-- + setTimeout(() => { + this.image.src = this.image.src + }, WAIT_TIME + this.#retryOffset) + this.#retryOffset += OFFSET_INCREMENT + } else { + this.showError() + } + } + + showError() { + const message = `Could not load "${this.image.src}"` + console.error(message) + this.innerHTML = `` + this.reset() + } + + reset() { + this.#started = false + this.#retries = MAX_RETRIES + + if (this.image) { + this.image.removeEventListener("load", this) + this.image.removeEventListener("error", this) + } + } + + get spinnerSize() { + return this.getAttribute("spinner-size") || "small" + } + + get image() { + return this.querySelector("img") + } +} + +customElements.define("alchemy-picture-thumbnail", PictureThumbnail) diff --git a/app/javascript/alchemy_admin/image_loader.js b/app/javascript/alchemy_admin/image_loader.js deleted file mode 100644 index c18c8dd04b..0000000000 --- a/app/javascript/alchemy_admin/image_loader.js +++ /dev/null @@ -1,52 +0,0 @@ -// Shows spinner while loading images and -// fades the image after its been loaded - -export default class ImageLoader { - static init(scope = document) { - if (typeof scope === "string") { - scope = document.querySelector(scope) - } - scope.querySelectorAll("img").forEach((image) => { - const loader = new ImageLoader(image) - loader.load() - }) - } - - constructor(image) { - this.image = image - this.parent = image.parentNode - this.spinner = new Alchemy.Spinner("small") - this.bind() - } - - bind() { - this.image.addEventListener("load", this.onLoaded.bind(this)) - this.image.addEventListener("error", this.onError.bind(this)) - } - - load(force = false) { - if (!force && this.image.complete) return - - this.image.classList.add("loading") - this.spinner.spin(this.image.parentElement) - } - - onLoaded() { - this.spinner.stop() - this.image.classList.remove("loading") - this.unbind() - } - - onError(evt) { - const message = `Could not load "${this.image.src}"` - this.spinner.stop() - this.parent.innerHTML = `` - console.error(message, evt) - this.unbind() - } - - unbind() { - this.image.removeEventListener("load", this.onLoaded) - this.image.removeEventListener("error", this.onError) - } -} diff --git a/app/javascript/alchemy_admin/initializer.js b/app/javascript/alchemy_admin/initializer.js index 0325829f2d..702a5900f7 100644 --- a/app/javascript/alchemy_admin/initializer.js +++ b/app/javascript/alchemy_admin/initializer.js @@ -49,9 +49,6 @@ function Initialize() { $(this.form).submit() }) - // Attaches the image loader on all images - Alchemy.ImageLoader("#main_content") - // Override the filter of keymaster.js so we can blur the fields on esc key. key.filter = function (event) { let tagName = (event.target || event.srcElement).tagName diff --git a/app/javascript/alchemy_admin/picture_editors.js b/app/javascript/alchemy_admin/picture_editors.js index ae2a3679ca..ac134f62ad 100644 --- a/app/javascript/alchemy_admin/picture_editors.js +++ b/app/javascript/alchemy_admin/picture_editors.js @@ -1,7 +1,6 @@ import debounce from "alchemy_admin/utils/debounce" import max from "alchemy_admin/utils/max" import { get } from "alchemy_admin/utils/ajax" -import ImageLoader from "alchemy_admin/image_loader" const UPDATE_DELAY = 125 const IMAGE_PLACEHOLDER = '' @@ -16,17 +15,13 @@ class PictureEditor { this.targetSizeField = container.querySelector("[data-target-size]") this.imageCropperField = container.querySelector("[data-image-cropper]") this.image = container.querySelector("img") - this.thumbnailBackground = container.querySelector(".thumbnail_background") + this.pictureThumbnail = container.querySelector("alchemy-picture-thumbnail") this.deleteButton = container.querySelector(".picture_tool.delete") this.cropLink = container.querySelector(".crop_link") this.targetSize = this.targetSizeField.dataset.targetSize this.pictureId = this.pictureIdField.value - if (this.image) { - this.imageLoader = new ImageLoader(this.image) - } - // The mutation observer is observing multiple fields that all get updated // simultaneously. We only want to update the image once, so we debounce. this.update = debounce(() => { @@ -62,7 +57,6 @@ class PictureEditor { this.ensureImage() this.image.removeAttribute("alt") this.image.removeAttribute("src") - this.imageLoader.load(true) get(Alchemy.routes.url_admin_picture_path(this.pictureId), { crop: this.imageCropperEnabled, crop_from: this.cropFrom, @@ -85,13 +79,12 @@ class PictureEditor { if (this.image) return const img = new Image() - this.thumbnailBackground.replaceChildren(img) + this.pictureThumbnail.replaceChildren(img) this.image = img - this.imageLoader = new ImageLoader(img) } removeImage() { - this.thumbnailBackground.innerHTML = IMAGE_PLACEHOLDER + this.pictureThumbnail.innerHTML = IMAGE_PLACEHOLDER this.pictureIdField.value = "" this.image = null this.cropLink.classList.add("disabled") diff --git a/app/models/concerns/alchemy/picture_thumbnails.rb b/app/models/concerns/alchemy/picture_thumbnails.rb index 76ace9a392..2f49f192a9 100644 --- a/app/models/concerns/alchemy/picture_thumbnails.rb +++ b/app/models/concerns/alchemy/picture_thumbnails.rb @@ -69,7 +69,7 @@ def picture_url_options def thumbnail_url return if picture.nil? - picture.url(thumbnail_url_options) || "alchemy/missing-image.svg" + picture.url(thumbnail_url_options) end # Thumbnail rendering options diff --git a/app/views/alchemy/admin/crop.html.erb b/app/views/alchemy/admin/crop.html.erb index 781fbb4a7b..651fd92b61 100644 --- a/app/views/alchemy/admin/crop.html.erb +++ b/app/views/alchemy/admin/crop.html.erb @@ -18,7 +18,6 @@ <% end %> <% if @settings %>