mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
fix(web): render thumbnails at displayed pixel density (#3454)
* fix(web): render thumbnails at displayed pixel density * fix: cancel obsolete thumbnail raster work
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
|
||||
import type { ResumeThumbnailSize } from "./resume-thumbnail.shared";
|
||||
import { getResumeThumbnailRenderSize } from "./resume-thumbnail.shared";
|
||||
|
||||
const canvasToBlob = (canvas: HTMLCanvasElement) =>
|
||||
new Promise<Blob>((resolve, reject) => {
|
||||
@@ -13,26 +14,35 @@ const canvasToBlob = (canvas: HTMLCanvasElement) =>
|
||||
}, "image/png");
|
||||
});
|
||||
|
||||
export const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
export const createPdfFirstPageImageUrl = async (file: Blob, targetSize: ResumeThumbnailSize, signal?: AbortSignal) => {
|
||||
signal?.throwIfAborted();
|
||||
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
signal?.throwIfAborted();
|
||||
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
|
||||
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
|
||||
let renderTask: { cancel: () => void } | undefined;
|
||||
let destruction: Promise<void> | undefined;
|
||||
const destroy = () => (destruction ??= loadingTask.destroy());
|
||||
const abort = () => {
|
||||
renderTask?.cancel();
|
||||
void destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
pdfDocument = await loadingTask.promise;
|
||||
signal?.throwIfAborted();
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width };
|
||||
const renderSize = getResumeThumbnailRenderSize(
|
||||
pageSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const renderSize = getResumeThumbnailRenderSize(pageSize, targetSize);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvasContext = canvas.getContext("2d");
|
||||
@@ -43,22 +53,29 @@ export const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
canvas.width = renderSize.width;
|
||||
|
||||
const viewport = page.getViewport({ scale: renderSize.scale });
|
||||
const renderTask = page.render({
|
||||
const task = page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
annotationMode: AnnotationMode.DISABLE,
|
||||
background: "white",
|
||||
});
|
||||
renderTask = task;
|
||||
|
||||
await renderTask.promise;
|
||||
await task.promise;
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const image = await canvasToBlob(canvas);
|
||||
signal?.throwIfAborted();
|
||||
return URL.createObjectURL(image);
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
|
||||
throw error;
|
||||
} finally {
|
||||
void loadingTask.destroy();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
void destroy();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const pdfjsMock = vi.hoisted(() => {
|
||||
const page = {
|
||||
cleanup: vi.fn(),
|
||||
getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve() })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() })),
|
||||
};
|
||||
|
||||
const pdfDocument = {
|
||||
@@ -15,7 +15,7 @@ const pdfjsMock = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
const loadingTask = {
|
||||
destroy: vi.fn(),
|
||||
destroy: vi.fn(async () => {}),
|
||||
promise: Promise.resolve(pdfDocument),
|
||||
};
|
||||
|
||||
@@ -85,9 +85,9 @@ describe("PDF.js browser entrypoints", () => {
|
||||
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
|
||||
await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe(
|
||||
"blob:thumbnail",
|
||||
);
|
||||
await expect(
|
||||
createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }), { width: 600, height: 900 }),
|
||||
).resolves.toBe("blob:thumbnail");
|
||||
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
@@ -100,4 +100,39 @@ describe("PDF.js browser entrypoints", () => {
|
||||
);
|
||||
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels obsolete thumbnail rasterization and releases the PDF loading task", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL);
|
||||
let rejectRender!: (error: Error) => void;
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
rejectRender = reject;
|
||||
});
|
||||
const cancel = vi.fn(() => rejectRender(new pdfjsMock.legacyModule.RenderingCancelledException("cancelled")));
|
||||
pdfjsMock.page.render.mockReturnValueOnce({ promise, cancel });
|
||||
const controller = new AbortController();
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
const pending = createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await vi.waitFor(() => expect(pdfjsMock.page.render).toHaveBeenCalledTimes(1));
|
||||
try {
|
||||
controller.abort();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rejectRender(new DOMException("cancelled", "AbortError"));
|
||||
await rejected;
|
||||
}
|
||||
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(pdfjsMock.createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not load a PDF for an already aborted thumbnail request", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
await expect(
|
||||
createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(pdfjsMock.legacyModule.getDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getResumeThumbnailCacheKey,
|
||||
getResumeThumbnailRenderSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
getResumeThumbnailSize,
|
||||
} from "./resume-thumbnail.shared";
|
||||
|
||||
describe("getResumeThumbnailCacheKey", () => {
|
||||
@@ -20,45 +20,40 @@ describe("getResumeThumbnailCacheKey", () => {
|
||||
});
|
||||
|
||||
describe("getResumeThumbnailRenderSize", () => {
|
||||
it("uses the default target width when not provided", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 });
|
||||
|
||||
expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH);
|
||||
// Scale = 420/800 = 0.525 → height rounds to 630
|
||||
expect(size.height).toBe(630);
|
||||
expect(size.scale).toBeCloseTo(0.525, 5);
|
||||
it("covers the measured 607 CSS pixel card at DPR 3", () => {
|
||||
const target = getResumeThumbnailSize({ width: 607, height: 858.47 }, 3);
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
|
||||
expect(size.width).toBeGreaterThanOrEqual(1821);
|
||||
expect(size.height).toBeGreaterThanOrEqual(2575);
|
||||
});
|
||||
|
||||
it("scales relative to the provided target width", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400);
|
||||
expect(size.width).toBe(400);
|
||||
expect(size.scale).toBeCloseTo(0.5, 5);
|
||||
expect(size.height).toBe(600);
|
||||
it("fits landscape pages within the measured container", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 1200, height: 800 }, { width: 600, height: 900 });
|
||||
expect(size).toEqual({ width: 600, height: 400, scale: 0.5 });
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a minimum of 1", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1);
|
||||
it("fits tall unpaginated pages by height rather than allocating their full width", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 600, height: 12000 }, { width: 600, height: 900 });
|
||||
expect(size).toEqual({ width: 45, height: 900, scale: 0.075 });
|
||||
});
|
||||
|
||||
it("bounds extreme zoom and viewport sizes without stretching the page", () => {
|
||||
const target = getResumeThumbnailSize({ width: 5000, height: 7071 }, 8);
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
|
||||
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
|
||||
expect(Math.max(size.width, size.height)).toBeLessThanOrEqual(3072);
|
||||
expect(size.width / size.height).toBeCloseTo(595.28 / 841.89, 2);
|
||||
});
|
||||
|
||||
it("enforces the canvas budget when retained width and height came from different size buckets", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, { width: 2112, height: 2985 });
|
||||
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
|
||||
});
|
||||
|
||||
it("groups small fractional layout changes into the same physical size", () => {
|
||||
const a = getResumeThumbnailSize({ width: 295.5, height: 417.93 }, 2);
|
||||
const b = getResumeThumbnailSize({ width: 296, height: 418.64 }, 2);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a maximum of 2", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("multiplies width/height by pixelRatio when within bounds", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
// pageScale = 0.5, outputScale = 2 → width = 800, height = 1200
|
||||
expect(size.width).toBe(800);
|
||||
expect(size.height).toBe(1200);
|
||||
expect(size.scale).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("rounds the output dimensions to integers", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5);
|
||||
expect(Number.isInteger(size.width)).toBe(true);
|
||||
expect(Number.isInteger(size.height)).toBe(true);
|
||||
expect(a.width).toBeGreaterThanOrEqual(592);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
type PageSize = {
|
||||
export type ResumeThumbnailSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const RESUME_THUMBNAIL_TARGET_WIDTH = 420;
|
||||
const MAX_THUMBNAIL_PIXEL_RATIO = 2;
|
||||
// A single-column Grid card is at most 608 CSS px before the sm breakpoint.
|
||||
// Its A4 container at DPR 3 needs 1824 x 2580 px. This budget accommodates it
|
||||
// with room for size buckets, while limiting extreme zoom/ultrawide canvases
|
||||
// to 24 MiB of RGBA pixels and 3072 px on either side.
|
||||
const MAX_THUMBNAIL_PIXELS = 2048 * 3072;
|
||||
const MAX_THUMBNAIL_DIMENSION = 3072;
|
||||
const THUMBNAIL_SIZE_STEP = 64;
|
||||
|
||||
export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => {
|
||||
return `${resumeId}:${updatedAt.getTime()}`;
|
||||
};
|
||||
|
||||
export const getResumeThumbnailRenderSize = (
|
||||
pageSize: PageSize,
|
||||
targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
pixelRatio = 1,
|
||||
) => {
|
||||
const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO);
|
||||
const pageScale = targetWidth / pageSize.width;
|
||||
const limitThumbnailSize = ({ width, height }: ResumeThumbnailSize): ResumeThumbnailSize => {
|
||||
const scale = Math.min(
|
||||
1,
|
||||
MAX_THUMBNAIL_DIMENSION / Math.max(width, height),
|
||||
Math.sqrt(MAX_THUMBNAIL_PIXELS / (width * height)),
|
||||
);
|
||||
return { width: Math.floor(width * scale), height: Math.floor(height * scale) };
|
||||
};
|
||||
|
||||
export const getResumeThumbnailSize = (container: ResumeThumbnailSize, pixelRatio: number): ResumeThumbnailSize => {
|
||||
const ratio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
|
||||
return limitThumbnailSize({
|
||||
width: Math.ceil((container.width * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
|
||||
height: Math.ceil((container.height * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
|
||||
});
|
||||
};
|
||||
|
||||
export const getResumeThumbnailRenderSize = (pageSize: ResumeThumbnailSize, target: ResumeThumbnailSize) => {
|
||||
const bounds = limitThumbnailSize(target);
|
||||
// Match background-size: contain, including landscape and unpaginated PDFs.
|
||||
const scale = Math.min(bounds.width / pageSize.width, bounds.height / pageSize.height);
|
||||
|
||||
return {
|
||||
height: Math.round(pageSize.height * pageScale * outputScale),
|
||||
scale: pageScale * outputScale,
|
||||
width: Math.round(targetWidth * outputScale),
|
||||
height: Math.min(bounds.height, Math.ceil(pageSize.height * scale)),
|
||||
scale,
|
||||
width: Math.min(bounds.width, Math.ceil(pageSize.width * scale)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { ResumeThumbnail } from "./resume-thumbnail";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inView: true,
|
||||
toPdf: vi.fn(async () => new Blob(["pdf"])),
|
||||
toImage: vi.fn(async (_pdf: Blob, size?: { width: number; height: number }) => `blob:${size?.width}x${size?.height}`),
|
||||
}));
|
||||
|
||||
vi.mock("motion/react", () => ({ useInView: () => mocks.inView }));
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob: mocks.toPdf }));
|
||||
vi.mock("@/features/resume/preview/pdf-thumbnail", () => ({ createPdfFirstPageImageUrl: mocks.toImage }));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
getById: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ["resume-data"],
|
||||
queryFn: async () => ({ data: sampleResumeData }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const resume: ComponentProps<typeof ResumeThumbnail>["resume"] = {
|
||||
id: "thumbnail-test",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
isLocked: false,
|
||||
isPublic: false,
|
||||
showDownloadButtons: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
let resize: () => void;
|
||||
let media: EventTarget;
|
||||
let width = 270;
|
||||
let height = 382;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.inView = true;
|
||||
mocks.toPdf.mockClear();
|
||||
mocks.toImage.mockClear();
|
||||
width = 270;
|
||||
height = 382;
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width);
|
||||
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => height);
|
||||
vi.stubGlobal("devicePixelRatio", 2);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
resize = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(() => {
|
||||
media = new EventTarget();
|
||||
return media as MediaQueryList;
|
||||
});
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
const ui = () => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ResumeThumbnail resume={resume} isLocked={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = render(ui());
|
||||
const image = () => result.container.querySelector<HTMLElement>("[style*='background-image']")?.style.backgroundImage;
|
||||
return { ...result, image, refresh: () => result.rerender(ui()) };
|
||||
}
|
||||
|
||||
it("upgrades a cached image after growth and keeps it visible until replacement is ready", async () => {
|
||||
const view = setup();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
let finish!: (url: string) => void;
|
||||
mocks.toImage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
width = 607;
|
||||
height = 859;
|
||||
resize();
|
||||
});
|
||||
await waitFor(() => expect(mocks.toImage).toHaveBeenCalledTimes(2));
|
||||
expect(view.image()).toContain("blob:576x768");
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
expect(mocks.toImage.mock.calls[1]?.[1]).toEqual({ width: 1216, height: 1728 });
|
||||
await act(async () => finish("blob:1216x1728"));
|
||||
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:576x768");
|
||||
act(() => {
|
||||
width = 295;
|
||||
height = 418;
|
||||
resize();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(view.image()).toContain("blob:1216x1728");
|
||||
expect(mocks.toImage).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:1216x1728");
|
||||
});
|
||||
|
||||
it("upgrades after a DPR change even when CSS dimensions do not change", async () => {
|
||||
const view = setup();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
act(() => {
|
||||
vi.stubGlobal("devicePixelRatio", 3);
|
||||
media.dispatchEvent(new Event("change"));
|
||||
});
|
||||
await waitFor(() => expect(view.image()).toContain("blob:832x1152"));
|
||||
});
|
||||
|
||||
it("does not generate thumbnails before an offscreen card enters view", async () => {
|
||||
mocks.inView = false;
|
||||
const view = setup();
|
||||
await act(async () => {});
|
||||
expect(view.image()).toBeUndefined();
|
||||
expect(mocks.toPdf).not.toHaveBeenCalled();
|
||||
mocks.inView = true;
|
||||
view.refresh();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
mocks.inView = false;
|
||||
view.refresh();
|
||||
act(() => {
|
||||
width = 607;
|
||||
height = 859;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(mocks.toImage).toHaveBeenCalledTimes(1);
|
||||
mocks.inView = true;
|
||||
view.refresh();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { RefObject } from "react";
|
||||
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { FileTextIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { createPdfFirstPageImageUrl } from "@/features/resume/preview/pdf-thumbnail";
|
||||
import { getResumeThumbnailCacheKey } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type ResumeListItem = RouterOutput["resume"]["list"][number];
|
||||
@@ -24,11 +26,11 @@ const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
|
||||
};
|
||||
|
||||
const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) => {
|
||||
const createResumeThumbnailUrl = async (data: ResumeData, size: ResumeThumbnailSize, signal: AbortSignal) => {
|
||||
const pdf = await createResumePdfBlob(data);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const url = await createPdfFirstPageImageUrl(pdf);
|
||||
const url = await createPdfFirstPageImageUrl(pdf, size, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -38,18 +40,69 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
|
||||
return url;
|
||||
};
|
||||
|
||||
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
|
||||
function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enabled: boolean) {
|
||||
const [size, setSize] = useState<ResumeThumbnailSize>({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !container) return;
|
||||
let measured = { width: container.clientWidth, height: container.clientHeight };
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let media: MediaQueryList;
|
||||
const syncSize = () => {
|
||||
clearTimeout(timeout);
|
||||
// Coalesce sidebar animations and continuous viewport resizing.
|
||||
timeout = setTimeout(() => {
|
||||
if (measured.width <= 0 || measured.height <= 0) return;
|
||||
const next = getResumeThumbnailSize(measured, window.devicePixelRatio);
|
||||
setSize((current) => {
|
||||
if (current.width >= next.width && current.height >= next.height) return current;
|
||||
return { width: Math.max(current.width, next.width), height: Math.max(current.height, next.height) };
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
const watchPixelRatio = () => {
|
||||
media?.removeEventListener("change", watchPixelRatio);
|
||||
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
media.addEventListener("change", watchPixelRatio);
|
||||
syncSize();
|
||||
};
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
measured = entries?.[0]?.contentRect ?? { width: container.clientWidth, height: container.clientHeight };
|
||||
syncSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
watchPixelRatio();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
media.removeEventListener("change", watchPixelRatio);
|
||||
};
|
||||
}, [containerRef, enabled]);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function useResumeThumbnail(
|
||||
data: ResumeData | undefined,
|
||||
cacheKey: string,
|
||||
size: ResumeThumbnailSize,
|
||||
enabled: boolean,
|
||||
): ThumbnailState {
|
||||
const {
|
||||
data: thumbnailData,
|
||||
error: thumbnailError,
|
||||
isError: thumbnailIsError,
|
||||
} = useQuery({
|
||||
queryKey: ["resume-thumbnail", cacheKey],
|
||||
queryKey: ["resume-thumbnail", cacheKey, size.width, size.height],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
|
||||
return createResumeThumbnailUrl(data, signal);
|
||||
return createResumeThumbnailUrl(data, size, signal);
|
||||
},
|
||||
enabled: Boolean(data && cacheKey),
|
||||
enabled: Boolean(enabled && data && size.width && size.height),
|
||||
placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
@@ -74,14 +127,17 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
|
||||
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px" });
|
||||
const size = useThumbnailSize(containerRef, isInView);
|
||||
const { data: resumeData, isError: resumeIsError } = useQuery({
|
||||
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
|
||||
enabled: isInView,
|
||||
});
|
||||
const thumbnail = useResumeThumbnail(
|
||||
resumeData?.data,
|
||||
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
|
||||
getResumeThumbnailCacheKey(resume.id, resume.updatedAt),
|
||||
size,
|
||||
isInView,
|
||||
);
|
||||
const hasFailed = resumeIsError || thumbnail.status === "error";
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createSampleResumeFromDashboard } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
async function measureThumbnail(page: Page) {
|
||||
const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first();
|
||||
await expect(image).toBeVisible({ timeout: 60_000 });
|
||||
return image.evaluate(async (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const url = style.backgroundImage.slice(5, -2);
|
||||
const blob = await (await fetch(url)).blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height);
|
||||
const result = {
|
||||
url,
|
||||
viewport: innerWidth,
|
||||
dpr: devicePixelRatio,
|
||||
cssWidth: rect.width,
|
||||
cssHeight: rect.height,
|
||||
pngWidth: bitmap.width,
|
||||
pngHeight: bitmap.height,
|
||||
coverage: 1 / (scale * devicePixelRatio),
|
||||
};
|
||||
bitmap.close();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSharpThumbnail(page: Page) {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect
|
||||
.poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 })
|
||||
.toBeGreaterThanOrEqual(0.99);
|
||||
return measureThumbnail(page);
|
||||
}
|
||||
|
||||
test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({
|
||||
authPage,
|
||||
browser,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await createSampleResumeFromDashboard(authPage, info);
|
||||
const storageState = await authPage.context().storageState();
|
||||
const measurements: unknown[] = [];
|
||||
for (const deviceScaleFactor of [1, 2, 3]) {
|
||||
const context = await browser.newContext({
|
||||
baseURL: String(info.project.use.baseURL),
|
||||
storageState,
|
||||
deviceScaleFactor,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto("/dashboard/resumes?view=grid");
|
||||
const desktop = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "fresh-desktop-grid", ...desktop });
|
||||
// Immediately below sm, one Grid column is 607 CSS px wide.
|
||||
await page.setViewportSize({ width: 639, height: 1000 });
|
||||
const grown = await expectSharpThumbnail(page);
|
||||
expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth);
|
||||
measurements.push({ scenario: "grown-cached-grid", ...grown });
|
||||
await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) });
|
||||
await page.getByRole("tab", { name: "Compact", exact: true }).click();
|
||||
const compact = await expectSharpThumbnail(page);
|
||||
expect(compact.url).toBe(grown.url);
|
||||
measurements.push({ scenario: "cached-compact", ...compact });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) });
|
||||
await page.getByRole("tab", { name: "Grid", exact: true }).click();
|
||||
measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
const phone = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "phone-grid", ...phone });
|
||||
if (deviceScaleFactor === 1) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 1000,
|
||||
deviceScaleFactor: 3,
|
||||
mobile: false,
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3);
|
||||
const higherDpr = await expectSharpThumbnail(page);
|
||||
expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth);
|
||||
measurements.push({ scenario: "changed-dpr-grid", ...higherDpr });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2));
|
||||
});
|
||||
Reference in New Issue
Block a user