fix(poster): bound preview loading and allow failed thumbnails to retry

This commit is contained in:
tianma-if committed 2026-10-19 17:50:01 +08:00
1 parent 617311cf3a
commit d525d60fc0
5 files changed
+222 -59

No files matched your search

@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState } from "react";
import { Search } from "lucide-react";
import { RefreshCw, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import {
@@ -29,7 +29,9 @@ const Preview = memo(function Preview({
}) {
const host = useRef<HTMLButtonElement>(null);
const [visible, setVisible] = useState(false),
[url, setUrl] = useState<string>();
[url, setUrl] = useState<string>(),
[status, setStatus] = useState<"loading" | "ready" | "error">("loading"),
[attempt, setAttempt] = useState(0);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
@@ -45,59 +47,92 @@ const Preview = memo(function Preview({
}, []);
useEffect(() => {
if (!visible) return;
let cancelled = false,
preview: string | undefined;
void buildPosterDesign({
id: template.id,
title: zh ? "把想法\n变成作品" : "MAKE IDEAS\nWORTH SHARING",
subtitle: zh ? "一个好想法,值得被看见。" : "Ideas deserve to be seen.",
badge: zh ? "创作笔记" : "CREATIVE NOTES",
width,
height,
zh,
})
.then(renderPosterThumbnail)
.then((next) => {
preview = next;
if (cancelled) URL.revokeObjectURL(next);
else setUrl(next);
})
.catch(() => {
if (!cancelled) setUrl(undefined);
});
let cancelled = false, expired = false, preview: string | undefined;
setUrl(undefined);
setStatus("loading");
const timer = setTimeout(() => {
expired = true;
setStatus("error");
console.warn("Poster template preview timed out", template.id);
}, 15_000);
void (async () => {
try {
const document = await buildPosterDesign({
id: template.id,
title: zh ? "把想法\n变成作品" : "MAKE IDEAS\nWORTH SHARING",
subtitle: zh ? "一个好想法,值得被看见。" : "Ideas deserve to be seen.",
badge: zh ? "创作笔记" : "CREATIVE NOTES",
width,
height,
zh,
});
if (cancelled || expired) return;
const next = await renderPosterThumbnail(document);
if (cancelled || expired) URL.revokeObjectURL(next);
else {
preview = next;
setUrl(next);
setStatus("ready");
}
} catch (error) {
if (!cancelled && !expired) {
setStatus("error");
console.warn("Poster template preview failed", template.id, error);
}
} finally {
clearTimeout(timer);
}
})();
return () => {
cancelled = true;
clearTimeout(timer);
if (preview) URL.revokeObjectURL(preview);
};
}, [visible, width, height, zh, template.id]);
}, [visible, width, height, zh, template.id, attempt]);
return (
<button
ref={host}
disabled={disabled}
data-poster-template={template.id}
aria-label={zh ? template.zh : template.en}
aria-pressed={selected}
onClick={onSelect}
className={`group overflow-hidden rounded-lg border p-1.5 text-left transition disabled:opacity-50 ${selected ? "border-emerald-500 bg-emerald-50/50 ring-1 ring-emerald-500" : "border-slate-200/80 bg-card hover:border-emerald-400"}`}
>
<div className="relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded bg-slate-100">
{url ? (
<img
src={url}
alt={zh ? template.zh : template.en}
loading="lazy"
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-slate-400">
{zh ? "载入版式…" : "Loading layout…"}
</span>
)}
</div>
<div className="mt-2 truncate px-1 text-[11px] font-medium">
{zh ? template.zh : template.en}
</div>
</button>
<div className="relative">
<button
ref={host}
disabled={disabled}
data-poster-template={template.id}
aria-label={zh ? template.zh : template.en}
aria-busy={status === "loading"}
aria-pressed={selected}
onClick={onSelect}
className={`group w-full overflow-hidden rounded-lg border p-1.5 text-left transition disabled:opacity-50 ${selected ? "border-emerald-500 bg-emerald-50/50 ring-1 ring-emerald-500" : "border-slate-200/80 bg-card hover:border-emerald-400"}`}
>
<div className="relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded bg-slate-100">
{url ? (
<img
src={url}
alt={zh ? template.zh : template.en}
loading="lazy"
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-slate-400">
{status === "error"
? (zh ? "预览未能加载" : "Preview unavailable")
: (zh ? "载入版式…" : "Loading layout…")}
</span>
)}
</div>
<div className="mt-2 truncate px-1 text-[11px] font-medium">
{zh ? template.zh : template.en}
</div>
</button>
{status === "error" && (
<button
type="button"
disabled={disabled}
aria-label={zh ? "重试预览" : "Retry preview"}
onClick={() => setAttempt(value => value + 1)}
className="absolute right-3 top-3 rounded border bg-card p-1.5 text-muted-foreground hover:text-foreground focus-visible:outline focus-visible:outline-2"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
);
});
export function PosterTemplatePanel({
+13 -9
View File
@@ -4,15 +4,19 @@ import { cache } from "fabric";
import { POSTER_FONTS } from "@edgeever/shared";
export { POSTER_FONTS };
export async function loadPosterFont(family: string, text = "", { deadline = true }: { deadline?: boolean } = {}) {
await loadOfflineFonts(text || "EdgeEver 中文");
const registrationFamily = family.split(",")[0].trim().replace(/^["']|["']$/g, "");
const id = findCatalogFont(registrationFamily);
if (!id) return;
const pending = loadCatalogFont(id, text || `${FONT_CATALOG[id].zh} ${FONT_CATALOG[id].en}`, { family: registrationFamily });
await (deadline ? withFontDeadline(pending) : pending);
cache.clearFontCache(family);
// Fabric keys glyph metrics by the complete runtime fallback stack.
cache.clearFontCache(`${registrationFamily}, "EdgeEver Offline Sans", sans-serif`);
// The deadline must include bundled fallback preparation: a damaged local
// asset can fall through to slow remote mirrors before catalog loading starts.
const prepare = async () => {
await loadOfflineFonts(text || "EdgeEver 中文");
const registrationFamily = family.split(",")[0].trim().replace(/^["']|["']$/g, "");
const id = findCatalogFont(registrationFamily);
if (!id) return;
await loadCatalogFont(id, text || `${FONT_CATALOG[id].zh} ${FONT_CATALOG[id].en}`, { family: registrationFamily });
cache.clearFontCache(family);
// Fabric keys glyph metrics by the complete runtime fallback stack.
cache.clearFontCache(`${registrationFamily}, "EdgeEver Offline Sans", sans-serif`);
};
await (deadline ? withFontDeadline(prepare()) : prepare());
}
export const loadDesignFonts = (families: string[], text: string) =>
Promise.all([...new Set(families)].map(family => loadPosterFont(family, text).catch(() => { /* Templates still render with the offline default. */ })));
+79
View File
@@ -0,0 +1,79 @@
// Run against `bun run dev`. Uses installed Google Chrome and isolated fixture
// pages, without opening a user profile or touching workspace notes.
import assert from "node:assert/strict";
import { chromium } from "@playwright/test";
const origin = process.env.EDGE_EVER_BROWSER_TEST_URL ?? "http://127.0.0.1:5173";
const fixture = `${origin}/@fs${process.cwd()}/tests/browser/poster-previews.html`;
const browser = await chromium.launch({ channel: "chrome", headless: true });
const open = async (setup) => {
const page = await browser.newPage();
if (setup) await page.addInitScript(setup);
await page.goto(fixture);
await page.waitForFunction(() => window.posterPreviewTest);
return page;
};
const retryFirst = page => page.locator("[data-poster-template]").first().locator("..").getByRole("button", { name: "重试预览", exact: true });
const firstReady = page => page.waitForFunction(() => {
const cards = [...document.querySelectorAll("[data-poster-template]")].slice(0, 6);
return cards.length === 6 && cards.every(card => card.querySelector("img")?.naturalWidth > 0);
}, null, { timeout: 14_000 });
try {
const page = await open();
const results = await page.evaluate(() => Promise.all(window.posterPreviewTest.templateIds.map(window.posterPreviewTest.checkPoster)));
assert.deepEqual(results.filter(result => !result.ok), [], "All template thumbnails must render");
await firstReady(page);
assert.equal(await page.locator('[role="tooltip"]').count(), 0);
console.log(`PASS: all ${results.length} templates render; visible cards show previews`);
await page.screenshot({ path: "/tmp/edgeever-poster-previews-fixed.png" });
await page.close();
// Use fresh pages so a previous render cannot satisfy an error assertion.
const failed = await open(() => {
window.originalToBlob = HTMLCanvasElement.prototype.toBlob;
HTMLCanvasElement.prototype.toBlob = function(callback) { callback(null); };
});
await retryFirst(failed).waitFor({ timeout: 14_000 });
assert.equal(await failed.locator('[data-poster-template]').first().getAttribute("aria-busy"), "false");
await failed.evaluate(() => { HTMLCanvasElement.prototype.toBlob = window.originalToBlob; });
await retryFirst(failed).focus();
await failed.keyboard.press("Enter");
await failed.locator('[data-poster-template]').first().locator('img').waitFor();
console.log("PASS: canvas export failure ends loading; retry recovers");
await failed.close();
// A canvas callback that never arrives must also leave loading within 15s.
const stalled = await open(() => {
window.originalToBlob = HTMLCanvasElement.prototype.toBlob;
window.lateExports = [];
HTMLCanvasElement.prototype.toBlob = function(callback) { window.lateExports.push([this, callback]); };
});
await retryFirst(stalled).waitFor({ timeout: 18_000 });
await stalled.evaluate(() => { HTMLCanvasElement.prototype.toBlob = window.originalToBlob; });
await retryFirst(stalled).click();
await stalled.locator('[data-poster-template]').first().locator('img').waitFor();
// Deliver the previous attempt's result late: it must be released, not replace
// the successful retry or switch the card back to a failed/loading state.
await stalled.evaluate(async () => {
const before = document.querySelector('[data-poster-template] img')?.getAttribute('src');
await Promise.all(window.lateExports.map(([canvas, callback]) => new Promise(resolve => {
window.originalToBlob.call(canvas, blob => { callback(blob); resolve(); });
})));
await new Promise(resolve => setTimeout(resolve, 100));
if (document.querySelector('[data-poster-template] img')?.getAttribute('src') !== before) throw new Error('Late preview replaced retry');
});
console.log("PASS: stalled export times out; retry succeeds; late results are ignored");
await stalled.close();
const slow = await browser.newPage();
// Leave both local font assets and remote font requests unanswered. This
// reproduced blank previews before the whole font preparation was bounded.
await slow.route("**/*", route => ["xhr", "font"].includes(route.request().resourceType()) ? undefined : route.continue());
await slow.goto(fixture);
await slow.waitForFunction(() => window.posterPreviewTest);
await firstReady(slow);
console.log("PASS: stalled font requests still produce fallback thumbnails");
await slow.close();
} finally {
await browser.close();
}
+36
View File
@@ -0,0 +1,36 @@
// Local Chrome fixture: real template builders, fonts, Fabric canvas and React
// panel; never reads or changes notes. Driven by verify-poster-previews.mjs.
import React from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import i18next from "i18next";
import { enUS } from "../../packages/shared/src/i18n/en-US";
import { PosterTemplatePanel } from "../../apps/web/src/components/poster/PosterTemplatePanel";
import { buildPosterDesign, DESIGN_TEMPLATES } from "../../apps/web/src/lib/poster-design/templates";
import { renderPosterThumbnail } from "../../apps/web/src/lib/poster-canvas";
import { installEditorBodyFontFaces } from "../../apps/web/src/lib/editor-body-font-faces";
import "../../apps/web/src/styles/globals.css";
installEditorBodyFontFaces();
const i18n = i18next.createInstance();
await i18n.init({ lng: "en", resources: { en: { translation: enUS } }, initAsync: false });
const root = createRoot(document.getElementById("fixture")!);
const mount = (width = 1080, zh = true) => root.render(
<I18nextProvider i18n={i18n}>
<div style={{ width: 300, height: 800, overflow: "auto" }}>
<PosterTemplatePanel width={width} height={1440} zh={zh} disabled={false} onApply={() => {}} />
</div>
</I18nextProvider>,
);
const checkPoster = async (id: string) => {
try {
const doc = await buildPosterDesign({ id, title: "把想法\n变成作品", subtitle: "一个好想法,值得被看见。", badge: "创作笔记", width: 1080, height: 1440, zh: true });
const url = await renderPosterThumbnail(doc);
URL.revokeObjectURL(url);
return { id, ok: true };
} catch (error) {
return { id, ok: false, error: String(error) };
}
};
Object.assign(window, { posterPreviewTest: { mount, checkPoster, templateIds: DESIGN_TEMPLATES.map(template => template.id) } });
mount();
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><title>Poster preview regression checks</title></head>
<body><div id="fixture"></div><script type="module">
import RefreshRuntime from "/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script><script type="module" src="./poster-previews.browser.tsx"></script></body></html>