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:
parent
e593d68beb
commit
662cfc416b
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue