fix(poster): bound preview loading and allow failed thumbnails to retry
This commit is contained in:
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({
|
||||
|
||||
@@ -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. */ })));
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user