Implement lazy image loading (copied from freefall reader)

This commit is contained in:
Saphire 2022-02-21 16:33:17 +07:00
parent 10ab7fa470
commit fc8bf49ad9
Signed by: Saphire
GPG Key ID: B26EB7A1F07044C4
7 changed files with 123 additions and 5 deletions

View File

@ -22,6 +22,10 @@ public record ThreadPost
public ChapterMetadata? Chapter { get; set; } public ChapterMetadata? Chapter { get; set; }
[JsonIgnore] [JsonIgnore]
public bool AuthorPost { get; set; } = false; public bool AuthorPost { get; set; } = false;
[JsonIgnore]
public int? FileHeight { get; set; }
[JsonIgnore]
public int? FileWidth { get; set; }
} }
public record Metadata public record Metadata

View File

@ -0,0 +1,32 @@
namespace QuestReader.Services;
public class FileDownloader
{
public static async Task DownloadList(string basePath, Uri baseAssetsUrl, IEnumerable<string> files)
{
if (!Directory.Exists(Path.Join(basePath, "assets")))
Directory.CreateDirectory(Path.Join(basePath, "assets"));
var downloadTasks = new List<Task>();
using (var client = new HttpClient()) {
client.BaseAddress = baseAssetsUrl;
Console.Out.WriteLine($"Downloading missing files...");
await Task.WhenAll(
files
.Where(file => !File.Exists(Path.Join(basePath, "assets", file)))
.Select(file => client.GetStreamAsync(file).ContinueWith(async (stream) => {
Console.Out.WriteLine($"Downloading {file}");
using var fileWrite = File.OpenWrite(Path.Join(basePath, "assets", file));
await (await stream).CopyToAsync(fileWrite);
stream.Dispose();
fileWrite.Dispose();
})
)
.ToList()
);
Console.Out.WriteLine($"All files done");
}
}
}

View File

@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using QuestReader.Models; using QuestReader.Models;
using SixLabors.ImageSharp;
public class PostsSource public class PostsSource
{ {
@ -61,6 +62,20 @@ public class PostsSource
Accepted.UnionWith(Posts.Where(p => referenced.Contains(p.Id))); Accepted.UnionWith(Posts.Where(p => referenced.Contains(p.Id)));
Accepted = Accepted.OrderBy(p => p.Id).ToHashSet(); Accepted = Accepted.OrderBy(p => p.Id).ToHashSet();
FileDownloader.DownloadList(BasePath, Metadata.AssetsBaseUrl, Accepted.Where(p => p.File is not null).Select(p => p.File!)).Wait();
foreach (var post in Accepted.Where(f => f.File is not null))
{
using var imageStream = File.OpenRead(Path.Combine(BasePath, "assets", post.File!));
IImageInfo imageInfo = Image.Identify(imageStream);
if (imageInfo is null) {
Console.Out.WriteLine($"Not a valid image: {post.File!}");
continue;
}
post.FileHeight = imageInfo.Height;
post.FileWidth = imageInfo.Width;
}
Console.Out.WriteLine($"Done loading with {Accepted.Count} posts, referencing {Accepted.Where(a => a.File is not null).Count()} files"); Console.Out.WriteLine($"Done loading with {Accepted.Count} posts, referencing {Accepted.Where(a => a.File is not null).Count()} files");
} }
} }

View File

@ -67,7 +67,7 @@
<div class="post-content"> <div class="post-content">
@if (item.File is not null) { @if (item.File is not null) {
<figure class="post-image"> <figure class="post-image">
<img src="@Model.AssetsPath/@item.File" alt="@item.Filename"> <img data-src="@Model.AssetsPath/@item.File" alt="@item.Filename" data-height="@item.FileHeight" data-width="@item.FileWidth">
</figure> </figure>
} }
@if (item.RawHtml.Trim().Length > 0) { @if (item.RawHtml.Trim().Length > 0) {

View File

@ -15,9 +15,10 @@
<PackageReference Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.2" />
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.0.1" /> <PackageReference Include="Microsoft.CodeAnalysis" Version="4.0.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.42" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.42" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.0.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<VersionPrefix>1.0.2</VersionPrefix> <VersionPrefix>1.0.3</VersionPrefix>
<RootNamespace>QuestReader</RootNamespace> <RootNamespace>QuestReader</RootNamespace>
</PropertyGroup> </PropertyGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">

View File

@ -115,17 +115,19 @@ a:visited {
} }
.post-image { .post-image {
margin: 0; margin: 0 0 1rem;
align-self: center; align-self: center;
/* Note: make sure to check this does not break main.ts if changed! */
max-width: 95%; max-width: 95%;
} }
.post-image img { .post-image img {
max-width: 100%; max-width: 100%;
display: block;
} }
.post-text { .post-text {
padding: 16px 40px; padding: 0 40px 16px;
} }

View File

@ -1,5 +1,9 @@
class VisitAnalytics { class VisitAnalytics {
landmarksObserver: IntersectionObserver; landmarksObserver: IntersectionObserver;
lazyloadObserver: IntersectionObserver;
lastWidth: number = 0;
resizeTimeout: number | null = null;
images: HTMLImageElement[] = [];
constructor() { constructor() {
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); }; window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
@ -20,6 +24,14 @@ class VisitAnalytics {
threshold: 1.0 threshold: 1.0
} }
); );
this.lazyloadObserver = new IntersectionObserver(
(entries, observer) => this.handleLazyload(entries, observer),
{
root: null,
rootMargin: "75% 0px 75% 0px",
threshold: 0.1
}
);
{ {
var all = document.querySelectorAll(".chapter-announce"); var all = document.querySelectorAll(".chapter-announce");
@ -27,7 +39,18 @@ class VisitAnalytics {
this.landmarksObserver.observe(document.querySelector("footer")); this.landmarksObserver.observe(document.querySelector("footer"));
} }
console.log("Intersection observer ready"); {
var all = document.querySelectorAll(".image-post");
all.forEach(elem => {
this.lazyloadObserver.observe(elem);
this.images.push(elem.querySelector(".post-image > img"));
});
this.resizeTimeout = setTimeout(() => this.resizeDebounced(), 200);
window.addEventListener("resize", (event) => this.handleResize(event));
}
console.log("Event handlers and observers ready");
} }
handleLandmarks(entries: IntersectionObserverEntry[], observer: IntersectionObserver) { handleLandmarks(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
@ -42,5 +65,46 @@ class VisitAnalytics {
} }
); );
} }
resizeDebounced() {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
const elem = document.querySelector(".post-content");
// Note: depends on main.css
const elemWidth = Math.floor(elem.clientWidth * 0.95);
if (elemWidth != this.lastWidth) {
this.lastWidth = elemWidth;
this.images.forEach(img => {
const naturalHeight = +img.getAttribute("data-height");
const naturalWidth = +img.getAttribute("data-width");
img.height = Math.floor(naturalHeight * Math.min(1, elemWidth / naturalWidth));
})
}
}
handleResize(event) {
if (event && event.type == "resize") {
if (this.resizeTimeout != null)
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => this.resizeDebounced(), 200);
}
}
handleLazyload(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
entries.filter(e => e.isIntersecting).forEach(e => {
const imgElem = e.target.querySelector<HTMLImageElement>(`img`);
imgElem.addEventListener("load", () => {
imgElem.classList.add("loaded");
});
imgElem.src = imgElem.getAttribute("data-src");
observer.unobserve(e.target);
console.log(`Lazyloaded ${ e.target.id ? e.target.id : e.target.tagName }`);
}
);
}
} }
new VisitAnalytics(); new VisitAnalytics();