From 662cfc416b138b74bfd7707de8940248e4812d7b Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 14 Dec 2018 17:44:38 -0500 Subject: [PATCH] FEATURE: Show a blurry preview when lazy loading images This generates a 10x10 PNG thumbnail for each lightboxed image. If Image Lazy Loading is enabled (IntersectionObserver API) then we'll load the low res version when offscreen. As the image scrolls in we'll swap it for the high res version. We use a WeakMap to track the old image attributes. It's much less memory than storing them as `data-*` attributes and swapping them back and forth all the time. --- .../discourse/lib/lazy-load-images.js.es6 | 29 ++++++++++++++----- .../stylesheets/common/base/lightbox.scss | 2 +- app/models/optimized_image.rb | 21 +++++++++----- app/models/upload.rb | 3 +- lib/cooked_post_processor.rb | 16 ++++++++++ spec/components/cooked_post_processor_spec.rb | 26 ++++++++++++----- spec/models/optimized_image_spec.rb | 21 +++++++++++++- 7 files changed, 93 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 index 4f2b7729b96..8835b0cacce 100644 --- a/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 +++ b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 @@ -2,24 +2,39 @@ const OBSERVER_OPTIONS = { rootMargin: "50%" // load images slightly before they're visible }; +const imageSources = new WeakMap(); + +const LOADING_DATA = + "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + // We hide an image by replacing it with a transparent gif function hide(image) { image.classList.add("d-lazyload"); image.classList.add("d-lazyload-hidden"); - image.setAttribute("data-src", image.getAttribute("src")); + + imageSources.set(image, { + src: image.getAttribute("src"), + srcSet: image.getAttribute("srcset") + }); + image.removeAttribute("srcset"); + image.setAttribute( "src", - "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" + image.getAttribute("data-small-upload") || LOADING_DATA ); + image.removeAttribute("data-small-upload"); } -// Restore an image from the `data-src` attribute +// Restore an image when onscreen function show(image) { - let dataSrc = image.getAttribute("data-src"); - if (dataSrc) { - image.setAttribute("src", dataSrc); - image.classList.remove("d-lazyload-hidden"); + let sources = imageSources.get(image); + if (sources) { + image.setAttribute("src", sources.src); + if (sources.srcSet) { + image.setAttribute("srcset", sources.srcSet); + } } + image.classList.remove("d-lazyload-hidden"); } export function setupLazyLoading(api) { diff --git a/app/assets/stylesheets/common/base/lightbox.scss b/app/assets/stylesheets/common/base/lightbox.scss index 7fc53af5192..9ddc6e919e7 100644 --- a/app/assets/stylesheets/common/base/lightbox.scss +++ b/app/assets/stylesheets/common/base/lightbox.scss @@ -1,6 +1,7 @@ .lightbox-wrapper .lightbox { position: relative; display: inline-block; + overflow: hidden; background: $primary-low; &:hover .meta { opacity: 0.9; @@ -9,7 +10,6 @@ } .d-lazyload-hidden { - opacity: 0; box-sizing: border-box; } diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index c9cdbd94ef5..a80103c7b44 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -68,7 +68,7 @@ class OptimizedImage < ActiveRecord::Base Rails.logger.error("Could not find file in the store located at url: #{upload.url}") else # create a temp file with the same extension as the original - extension = ".#{upload.extension}" + extension = ".#{opts[:format] || upload.extension}" if extension.length == 1 return nil @@ -96,6 +96,7 @@ class OptimizedImage < ActiveRecord::Base url: "", filesize: File.size(temp_path) ) + # store the optimized image and update its url File.open(temp_path) do |file| url = Discourse.store.store_optimized_image(file, thumbnail) @@ -173,7 +174,7 @@ class OptimizedImage < ActiveRecord::Base IM_DECODERS ||= /\A(jpe?g|png|tiff?|bmp|ico|gif)\z/i def self.prepend_decoder!(path, ext_path = nil, opts = nil) - extension = File.extname((opts && opts[:filename]) || ext_path || path)[1..-1] + extension = File.extname((opts && opts[:filename]) || path || ext_path)[1..-1] raise Discourse::InvalidAccess if !extension || !extension.match?(IM_DECODERS) "#{extension}:#{path}" end @@ -189,10 +190,14 @@ class OptimizedImage < ActiveRecord::Base from = prepend_decoder!(from, to, opts) to = prepend_decoder!(to, to, opts) + instructions = ['convert', "#{from}[0]"] + + if opts[:colors] + instructions << "-colors" << opts[:colors].to_s + end + # NOTE: ORDER is important! - %W{ - convert - #{from}[0] + instructions.concat(%W{ -auto-orient -gravity center -background transparent @@ -204,7 +209,7 @@ class OptimizedImage < ActiveRecord::Base -quality 98 -profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')} #{to} - } + }) end def self.resize_instructions_animated(from, to, dimensions, opts = {}) @@ -212,7 +217,7 @@ class OptimizedImage < ActiveRecord::Base %W{ gifsicle - --colors=256 + --colors=#{opts[:colors] || 256} --resize-fit #{dimensions} --optimize=3 --output #{to} @@ -248,7 +253,7 @@ class OptimizedImage < ActiveRecord::Base %W{ gifsicle --crop 0,0+#{dimensions} - --colors=256 + --colors=#{opts[:colors] || 256} --optimize=3 --output #{to} #{from} diff --git a/app/models/upload.rb b/app/models/upload.rb index 3f1f27960c4..a14b71b6fea 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -65,7 +65,8 @@ class Upload < ActiveRecord::Base opts = opts.merge(raise_on_error: true) begin OptimizedImage.create_for(self, width, height, opts) - rescue + rescue => ex + Rails.logger.info ex if Rails.env.development? opts = opts.merge(raise_on_error: false) if fix_image_extension OptimizedImage.create_for(self, width, height, opts) diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb index 7458622c34c..eefac2fdaa2 100644 --- a/lib/cooked_post_processor.rb +++ b/lib/cooked_post_processor.rb @@ -10,6 +10,8 @@ class CookedPostProcessor INLINE_ONEBOX_LOADING_CSS_CLASS = "inline-onebox-loading" INLINE_ONEBOX_CSS_CLASS = "inline-onebox" + LOADING_SIZE = 10 + LOADING_COLORS = 32 attr_reader :cooking_options, :doc @@ -27,6 +29,8 @@ class CookedPostProcessor @doc = Nokogiri::HTML::fragment(post.cook(post.raw, @cooking_options)) @has_oneboxes = post.post_analyzer.found_oneboxes? @size_cache = {} + + @disable_loading_image = !!opts[:disable_loading_image] end def post_process(bypass_bump = false) @@ -332,11 +336,19 @@ class CookedPostProcessor upload.create_thumbnail!(resized_w, resized_h, crop: crop) end end + + unless @disable_loading_image + upload.create_thumbnail!(LOADING_SIZE, LOADING_SIZE, format: 'png', colors: LOADING_COLORS) + end end add_lightbox!(img, original_width, original_height, upload, cropped: crop) end + def loading_image(upload) + upload.thumbnail(LOADING_SIZE, LOADING_SIZE) + end + def is_a_hyperlink?(img) parent = img.parent while parent @@ -398,6 +410,10 @@ class CookedPostProcessor else img["src"] = upload.url end + + if small_upload = loading_image(upload) + img["data-small-upload"] = small_upload.url + end end # then, some overlay informations diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index 0c5d05c20a3..21d360d0b60 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -15,7 +15,7 @@ describe CookedPostProcessor do RAW end - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } let(:post_process) { sequence("post_process") } it "post process in sequence" do @@ -288,10 +288,22 @@ describe CookedPostProcessor do filesize: 800 ) + # Fake a loading image + OptimizedImage.create!( + url: 'http://a.b.c/10x10.png', + width: CookedPostProcessor::LOADING_SIZE, + height: CookedPostProcessor::LOADING_SIZE, + upload_id: upload.id, + sha1: SecureRandom.hex, + extension: '.png', + filesize: 123 + ) + cpp = CookedPostProcessor.new(post) cpp.add_to_size_cache(upload.url, 2000, 1500) cpp.post_process_images + expect(cpp.loading_image(upload)).to be_present # 1.5x is skipped cause we have a missing thumb expect(cpp.html).to include('srcset="http://a.b.c/666x500.jpg, http://a.b.c/1998x1500.jpg 3x"') @@ -379,7 +391,7 @@ describe CookedPostProcessor do let(:upload) { Fabricate(:upload) } let(:post) { Fabricate(:post_with_large_image) } - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } before do SiteSetting.max_image_height = 2000 @@ -447,7 +459,7 @@ describe CookedPostProcessor do let(:upload) { Fabricate(:upload) } let(:post) { Fabricate(:post_with_large_image) } - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } before do SiteSetting.create_thumbnails = true @@ -462,7 +474,7 @@ describe CookedPostProcessor do it "crops the image" do cpp.post_process_images - expect(cpp.html).to match /width="690" height="500">/ + expect(cpp.html).to match(/width="690" height="500">/) expect(cpp).to be_dirty end @@ -472,7 +484,7 @@ describe CookedPostProcessor do let(:upload) { Fabricate(:upload) } let(:post) { Fabricate(:post_with_large_image) } - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } before do SiteSetting.create_thumbnails = true @@ -499,7 +511,7 @@ describe CookedPostProcessor do let(:upload) { Fabricate(:upload) } let(:post) { Fabricate(:post_with_large_image_on_subfolder) } - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } let(:base_url) { "http://test.localhost/subfolder" } let(:base_uri) { "/subfolder" } @@ -538,7 +550,7 @@ describe CookedPostProcessor do let(:upload) { Fabricate(:upload) } let(:post) { Fabricate(:post_with_large_image_and_title) } - let(:cpp) { CookedPostProcessor.new(post) } + let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } before do SiteSetting.max_image_height = 2000 diff --git a/spec/models/optimized_image_spec.rb b/spec/models/optimized_image_spec.rb index 1eefbb01c35..5483a433e1a 100644 --- a/spec/models/optimized_image_spec.rb +++ b/spec/models/optimized_image_spec.rb @@ -29,6 +29,21 @@ describe OptimizedImage do end end + describe ".resize_instructions" do + let(:image) { "#{Rails.root}/spec/fixtures/images/logo.png" } + + it "doesn't return any color options by default" do + instructions = described_class.resize_instructions(image, image, "50x50") + expect(instructions).to_not include('-colors') + end + + it "supports an optional color option" do + instructions = described_class.resize_instructions(image, image, "50x50", colors: 12) + expect(instructions).to include('-colors') + end + + end + describe '.resize' do it 'should work correctly when extension is bad' do @@ -186,7 +201,6 @@ describe OptimizedImage do describe ".create_for" do it "is able to 'optimize' an svg" do - # we don't really optimize anything, we simply copy # but at least this confirms this actually works @@ -239,6 +253,11 @@ describe OptimizedImage do expect(oi.url).to eq("/internally/stored/optimized/image.png") end + it "is able to change the format" do + oi = OptimizedImage.create_for(upload, 100, 200, format: 'gif') + expect(oi.url).to eq("/internally/stored/optimized/image.gif") + end + end end