Implement lazy image loading (copied from freefall reader)
This commit is contained in:
parent
10ab7fa470
commit
fc8bf49ad9
@ -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
|
||||||
|
32
Services/FileDownloader.cs
Normal file
32
Services/FileDownloader.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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) {
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
66
web/main.ts
66
web/main.ts
@ -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();
|
Loading…
Reference in New Issue
Block a user