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,25 +2,40 @@ 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) {
// Old IE don't support this API

View File

@ -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;
}

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}")
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}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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