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.
This commit is contained in:
Robin Ward 2018-12-14 17:44:38 -05:00
parent e593d68beb
commit 662cfc416b
7 changed files with 93 additions and 25 deletions

View File

@ -2,24 +2,39 @@ const OBSERVER_OPTIONS = {
rootMargin: "50%" // load images slightly before they're visible 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 // We hide an image by replacing it with a transparent gif
function hide(image) { function hide(image) {
image.classList.add("d-lazyload"); image.classList.add("d-lazyload");
image.classList.add("d-lazyload-hidden"); 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( image.setAttribute(
"src", "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) { function show(image) {
let dataSrc = image.getAttribute("data-src"); let sources = imageSources.get(image);
if (dataSrc) { if (sources) {
image.setAttribute("src", dataSrc); image.setAttribute("src", sources.src);
image.classList.remove("d-lazyload-hidden"); if (sources.srcSet) {
image.setAttribute("srcset", sources.srcSet);
}
} }
image.classList.remove("d-lazyload-hidden");
} }
export function setupLazyLoading(api) { export function setupLazyLoading(api) {

View File

@ -1,6 +1,7 @@
.lightbox-wrapper .lightbox { .lightbox-wrapper .lightbox {
position: relative; position: relative;
display: inline-block; display: inline-block;
overflow: hidden;
background: $primary-low; background: $primary-low;
&:hover .meta { &:hover .meta {
opacity: 0.9; opacity: 0.9;
@ -9,7 +10,6 @@
} }
.d-lazyload-hidden { .d-lazyload-hidden {
opacity: 0;
box-sizing: border-box; box-sizing: border-box;
} }

View File

@ -68,7 +68,7 @@ class OptimizedImage < ActiveRecord::Base
Rails.logger.error("Could not find file in the store located at url: #{upload.url}") Rails.logger.error("Could not find file in the store located at url: #{upload.url}")
else else
# create a temp file with the same extension as the original # create a temp file with the same extension as the original
extension = ".#{upload.extension}" extension = ".#{opts[:format] || upload.extension}"
if extension.length == 1 if extension.length == 1
return nil return nil
@ -96,6 +96,7 @@ class OptimizedImage < ActiveRecord::Base
url: "", url: "",
filesize: File.size(temp_path) filesize: File.size(temp_path)
) )
# store the optimized image and update its url # store the optimized image and update its url
File.open(temp_path) do |file| File.open(temp_path) do |file|
url = Discourse.store.store_optimized_image(file, thumbnail) 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 IM_DECODERS ||= /\A(jpe?g|png|tiff?|bmp|ico|gif)\z/i
def self.prepend_decoder!(path, ext_path = nil, opts = nil) 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) raise Discourse::InvalidAccess if !extension || !extension.match?(IM_DECODERS)
"#{extension}:#{path}" "#{extension}:#{path}"
end end
@ -189,10 +190,14 @@ class OptimizedImage < ActiveRecord::Base
from = prepend_decoder!(from, to, opts) from = prepend_decoder!(from, to, opts)
to = prepend_decoder!(to, 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! # NOTE: ORDER is important!
%W{ instructions.concat(%W{
convert
#{from}[0]
-auto-orient -auto-orient
-gravity center -gravity center
-background transparent -background transparent
@ -204,7 +209,7 @@ class OptimizedImage < ActiveRecord::Base
-quality 98 -quality 98
-profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')} -profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')}
#{to} #{to}
} })
end end
def self.resize_instructions_animated(from, to, dimensions, opts = {}) def self.resize_instructions_animated(from, to, dimensions, opts = {})
@ -212,7 +217,7 @@ class OptimizedImage < ActiveRecord::Base
%W{ %W{
gifsicle gifsicle
--colors=256 --colors=#{opts[:colors] || 256}
--resize-fit #{dimensions} --resize-fit #{dimensions}
--optimize=3 --optimize=3
--output #{to} --output #{to}
@ -248,7 +253,7 @@ class OptimizedImage < ActiveRecord::Base
%W{ %W{
gifsicle gifsicle
--crop 0,0+#{dimensions} --crop 0,0+#{dimensions}
--colors=256 --colors=#{opts[:colors] || 256}
--optimize=3 --optimize=3
--output #{to} --output #{to}
#{from} #{from}

View File

@ -65,7 +65,8 @@ class Upload < ActiveRecord::Base
opts = opts.merge(raise_on_error: true) opts = opts.merge(raise_on_error: true)
begin begin
OptimizedImage.create_for(self, width, height, opts) 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) opts = opts.merge(raise_on_error: false)
if fix_image_extension if fix_image_extension
OptimizedImage.create_for(self, width, height, opts) OptimizedImage.create_for(self, width, height, opts)

View File

@ -10,6 +10,8 @@ class CookedPostProcessor
INLINE_ONEBOX_LOADING_CSS_CLASS = "inline-onebox-loading" INLINE_ONEBOX_LOADING_CSS_CLASS = "inline-onebox-loading"
INLINE_ONEBOX_CSS_CLASS = "inline-onebox" INLINE_ONEBOX_CSS_CLASS = "inline-onebox"
LOADING_SIZE = 10
LOADING_COLORS = 32
attr_reader :cooking_options, :doc attr_reader :cooking_options, :doc
@ -27,6 +29,8 @@ class CookedPostProcessor
@doc = Nokogiri::HTML::fragment(post.cook(post.raw, @cooking_options)) @doc = Nokogiri::HTML::fragment(post.cook(post.raw, @cooking_options))
@has_oneboxes = post.post_analyzer.found_oneboxes? @has_oneboxes = post.post_analyzer.found_oneboxes?
@size_cache = {} @size_cache = {}
@disable_loading_image = !!opts[:disable_loading_image]
end end
def post_process(bypass_bump = false) def post_process(bypass_bump = false)
@ -332,11 +336,19 @@ class CookedPostProcessor
upload.create_thumbnail!(resized_w, resized_h, crop: crop) upload.create_thumbnail!(resized_w, resized_h, crop: crop)
end end
end end
unless @disable_loading_image
upload.create_thumbnail!(LOADING_SIZE, LOADING_SIZE, format: 'png', colors: LOADING_COLORS)
end
end end
add_lightbox!(img, original_width, original_height, upload, cropped: crop) add_lightbox!(img, original_width, original_height, upload, cropped: crop)
end end
def loading_image(upload)
upload.thumbnail(LOADING_SIZE, LOADING_SIZE)
end
def is_a_hyperlink?(img) def is_a_hyperlink?(img)
parent = img.parent parent = img.parent
while parent while parent
@ -398,6 +410,10 @@ class CookedPostProcessor
else else
img["src"] = upload.url img["src"] = upload.url
end end
if small_upload = loading_image(upload)
img["data-small-upload"] = small_upload.url
end
end end
# then, some overlay informations # then, some overlay informations

View File

@ -15,7 +15,7 @@ describe CookedPostProcessor do
RAW RAW
end end
let(:cpp) { CookedPostProcessor.new(post) } let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) }
let(:post_process) { sequence("post_process") } let(:post_process) { sequence("post_process") }
it "post process in sequence" do it "post process in sequence" do
@ -288,10 +288,22 @@ describe CookedPostProcessor do
filesize: 800 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 = CookedPostProcessor.new(post)
cpp.add_to_size_cache(upload.url, 2000, 1500) cpp.add_to_size_cache(upload.url, 2000, 1500)
cpp.post_process_images cpp.post_process_images
expect(cpp.loading_image(upload)).to be_present
# 1.5x is skipped cause we have a missing thumb # 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"') 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(:upload) { Fabricate(:upload) }
let(:post) { Fabricate(:post_with_large_image) } let(:post) { Fabricate(:post_with_large_image) }
let(:cpp) { CookedPostProcessor.new(post) } let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) }
before do before do
SiteSetting.max_image_height = 2000 SiteSetting.max_image_height = 2000
@ -447,7 +459,7 @@ describe CookedPostProcessor do
let(:upload) { Fabricate(:upload) } let(:upload) { Fabricate(:upload) }
let(:post) { Fabricate(:post_with_large_image) } let(:post) { Fabricate(:post_with_large_image) }
let(:cpp) { CookedPostProcessor.new(post) } let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) }
before do before do
SiteSetting.create_thumbnails = true SiteSetting.create_thumbnails = true
@ -462,7 +474,7 @@ describe CookedPostProcessor do
it "crops the image" do it "crops the image" do
cpp.post_process_images 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 expect(cpp).to be_dirty
end end
@ -472,7 +484,7 @@ describe CookedPostProcessor do
let(:upload) { Fabricate(:upload) } let(:upload) { Fabricate(:upload) }
let(:post) { Fabricate(:post_with_large_image) } let(:post) { Fabricate(:post_with_large_image) }
let(:cpp) { CookedPostProcessor.new(post) } let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) }
before do before do
SiteSetting.create_thumbnails = true SiteSetting.create_thumbnails = true
@ -499,7 +511,7 @@ describe CookedPostProcessor do
let(:upload) { Fabricate(:upload) } let(:upload) { Fabricate(:upload) }
let(:post) { Fabricate(:post_with_large_image_on_subfolder) } 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_url) { "http://test.localhost/subfolder" }
let(:base_uri) { "/subfolder" } let(:base_uri) { "/subfolder" }
@ -538,7 +550,7 @@ describe CookedPostProcessor do
let(:upload) { Fabricate(:upload) } let(:upload) { Fabricate(:upload) }
let(:post) { Fabricate(:post_with_large_image_and_title) } 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 before do
SiteSetting.max_image_height = 2000 SiteSetting.max_image_height = 2000

View File

@ -29,6 +29,21 @@ describe OptimizedImage do
end end
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 describe '.resize' do
it 'should work correctly when extension is bad' do it 'should work correctly when extension is bad' do
@ -186,7 +201,6 @@ describe OptimizedImage do
describe ".create_for" do describe ".create_for" do
it "is able to 'optimize' an svg" do it "is able to 'optimize' an svg" do
# we don't really optimize anything, we simply copy # we don't really optimize anything, we simply copy
# but at least this confirms this actually works # 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") expect(oi.url).to eq("/internally/stored/optimized/image.png")
end 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
end end