Skip to content

Commit b5d01fa

Browse files
authored
fix: thumbnail capture quality — JSX rendering, viewport scaling, Markdown, and save regeneration (#234)
- JSX assets: transpile via sucrase + scaffold with import map (same as JsxRenderer) instead of sending raw source to iframe - HTML dashboards: render iframe at 1280x960 desktop viewport, scale down to 400x300 thumbnail via html2canvas - Markdown: fix blank captures by using visibility:hidden (keeps layout flow) and inline prose styles instead of Tailwind classes - Save regeneration: content saves now mark thumbnail as stale, triggering re-capture with the updated content
1 parent 2b58b6a commit b5d01fa

File tree

3 files changed

+178
-19
lines changed

3 files changed

+178
-19
lines changed

ui/src/components/AssetViewer.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function AssetViewer({
6969
const [editedContent, setEditedContent] = useState<string>("");
7070
const [dirty, setDirty] = useState(false);
7171
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
72+
const [thumbnailStale, setThumbnailStale] = useState(false);
7273

7374
const canEditSource = !!contentUpdateMutation && !!asset && isTextContent(asset.content_type);
7475
const contentStr = typeof content === "string" ? content : "";
@@ -90,7 +91,12 @@ export function AssetViewer({
9091
contentUpdateMutation.mutate(
9192
{ id: asset.id, content: editedContent },
9293
{
93-
onSuccess: () => setSaveStatus("saved"),
94+
onSuccess: () => {
95+
setSaveStatus("saved");
96+
if (isThumbnailSupported(asset.content_type)) {
97+
setThumbnailStale(true);
98+
}
99+
},
94100
onError: () => setSaveStatus("error"),
95101
},
96102
);
@@ -395,8 +401,14 @@ export function AssetViewer({
395401
</div>
396402
)}
397403

398-
{content && typeof content === "string" && !asset.thumbnail_s3_key && isThumbnailSupported(asset.content_type) && (
399-
<ThumbnailGeneratorWithInvalidation assetId={asset.id} content={content} contentType={asset.content_type} />
404+
{content && typeof content === "string" && isThumbnailSupported(asset.content_type) && (!asset.thumbnail_s3_key || thumbnailStale) && (
405+
<ThumbnailGeneratorWithInvalidation
406+
key={thumbnailStale ? "regen" : "initial"}
407+
assetId={asset.id}
408+
content={thumbnailStale ? editedContent : content}
409+
contentType={asset.content_type}
410+
onDone={() => setThumbnailStale(false)}
411+
/>
400412
)}
401413

402414
<ShareDialog assetId={asset.id} open={shareOpen} onOpenChange={setShareOpen} />
@@ -449,19 +461,35 @@ export function AssetViewer({
449461
);
450462
}
451463

452-
function ThumbnailGeneratorWithInvalidation({ assetId, content, contentType }: { assetId: string; content: string; contentType: string }) {
464+
function ThumbnailGeneratorWithInvalidation({
465+
assetId,
466+
content,
467+
contentType,
468+
onDone,
469+
}: {
470+
assetId: string;
471+
content: string;
472+
contentType: string;
473+
onDone?: () => void;
474+
}) {
453475
const qc = useQueryClient();
454476
const handleCaptured = useCallback(() => {
455477
void qc.invalidateQueries({ queryKey: ["asset", assetId] });
456478
void qc.invalidateQueries({ queryKey: ["assets"] });
457-
}, [qc, assetId]);
479+
onDone?.();
480+
}, [qc, assetId, onDone]);
481+
482+
const handleFailed = useCallback(() => {
483+
onDone?.();
484+
}, [onDone]);
458485

459486
return (
460487
<ThumbnailGenerator
461488
assetId={assetId}
462489
content={content}
463490
contentType={contentType}
464491
onCaptured={handleCaptured}
492+
onFailed={handleFailed}
465493
/>
466494
);
467495
}

ui/src/components/ThumbnailGenerator.tsx

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import DOMPurify from "dompurify";
55
import {
66
THUMB_WIDTH,
77
THUMB_HEIGHT,
8+
RENDER_WIDTH,
9+
RENDER_HEIGHT,
810
CAPTURE_TIMEOUT_MS,
911
injectCaptureScript,
12+
buildJsxThumbnailHtml,
1013
captureIframe,
1114
captureElement,
1215
uploadThumbnail,
@@ -38,6 +41,7 @@ export function ThumbnailGenerator({ assetId, content, contentType, onCaptured,
3841
<IframeCapture
3942
assetId={assetId}
4043
content={content}
44+
contentType={contentType}
4145
onCaptured={onCaptured}
4246
onFailed={onFailed}
4347
/>
@@ -67,22 +71,25 @@ export function ThumbnailGenerator({ assetId, content, contentType, onCaptured,
6771
function IframeCapture({
6872
assetId,
6973
content,
74+
contentType,
7075
onCaptured,
7176
onFailed,
7277
}: {
7378
assetId: string;
7479
content: string;
80+
contentType: string;
7581
onCaptured?: () => void;
7682
onFailed?: () => void;
7783
}) {
7884
const capturedRef = useRef(false);
7985
const iframeRef = useRef<HTMLIFrameElement>(null);
86+
const isJsx = contentType.toLowerCase().includes("jsx");
8087

8188
const blobUrl = useMemo(() => {
82-
const injected = injectCaptureScript(content);
83-
const blob = new Blob([injected], { type: "text/html;charset=utf-8" });
89+
const html = isJsx ? buildJsxThumbnailHtml(content) : injectCaptureScript(content);
90+
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
8491
return URL.createObjectURL(blob);
85-
}, [content]);
92+
}, [content, isJsx]);
8693

8794
const doCapture = useCallback(async () => {
8895
if (capturedRef.current || !iframeRef.current) return;
@@ -129,8 +136,8 @@ function IframeCapture({
129136
position: "fixed",
130137
left: -9999,
131138
top: -9999,
132-
width: THUMB_WIDTH,
133-
height: THUMB_HEIGHT,
139+
width: RENDER_WIDTH,
140+
height: RENDER_HEIGHT,
134141
overflow: "hidden",
135142
pointerEvents: "none",
136143
}}
@@ -140,8 +147,8 @@ function IframeCapture({
140147
ref={iframeRef}
141148
sandbox="allow-scripts allow-same-origin"
142149
src={blobUrl}
143-
width={THUMB_WIDTH}
144-
height={THUMB_HEIGHT}
150+
width={RENDER_WIDTH}
151+
height={RENDER_HEIGHT}
145152
style={{ border: "none" }}
146153
title="Thumbnail capture"
147154
/>
@@ -208,24 +215,47 @@ function DomCapture({
208215
ref={containerRef}
209216
style={{
210217
position: "fixed",
211-
left: -9999,
212-
top: -9999,
218+
left: 0,
219+
top: 0,
213220
width: THUMB_WIDTH,
214221
height: THUMB_HEIGHT,
215222
overflow: "hidden",
216223
pointerEvents: "none",
224+
visibility: "hidden",
225+
zIndex: -1,
217226
background: "white",
218227
color: "black",
219228
fontSize: 12,
220229
padding: 16,
230+
lineHeight: 1.6,
231+
fontFamily: "system-ui, -apple-system, sans-serif",
221232
}}
222233
aria-hidden="true"
223234
>
224235
{isSvg ? (
225236
<div dangerouslySetInnerHTML={{ __html: sanitizedSvg }} />
226237
) : (
227-
<div className="prose prose-sm max-w-none">
228-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
238+
<div
239+
style={{
240+
maxWidth: "none",
241+
}}
242+
>
243+
<style>{`
244+
.thumb-prose h1 { font-size: 1.5em; font-weight: 700; margin: 0.5em 0 0.25em; }
245+
.thumb-prose h2 { font-size: 1.25em; font-weight: 600; margin: 0.5em 0 0.25em; }
246+
.thumb-prose h3 { font-size: 1.1em; font-weight: 600; margin: 0.4em 0 0.2em; }
247+
.thumb-prose p { margin: 0.4em 0; }
248+
.thumb-prose ul, .thumb-prose ol { padding-left: 1.5em; margin: 0.4em 0; }
249+
.thumb-prose code { background: #f3f4f6; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.9em; }
250+
.thumb-prose pre { background: #f3f4f6; padding: 0.5em; border-radius: 4px; overflow: auto; margin: 0.4em 0; }
251+
.thumb-prose blockquote { border-left: 3px solid #d1d5db; padding-left: 0.75em; margin: 0.4em 0; color: #6b7280; }
252+
.thumb-prose a { color: #2563eb; text-decoration: underline; }
253+
.thumb-prose table { border-collapse: collapse; margin: 0.4em 0; }
254+
.thumb-prose th, .thumb-prose td { border: 1px solid #d1d5db; padding: 0.25em 0.5em; font-size: 0.9em; }
255+
`}</style>
256+
<div className="thumb-prose">
257+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
258+
</div>
229259
</div>
230260
)}
231261
</div>

ui/src/lib/thumbnail.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { toPng } from "html-to-image";
22
import html2canvas from "html2canvas";
33
import { apiFetchRaw } from "@/api/portal/client";
4+
import { transformJsx, escapeScriptClose, findComponentName } from "@/components/renderers/JsxRenderer";
45

56
export const THUMB_WIDTH = 400;
67
export const THUMB_HEIGHT = 300;
78

9+
/** Desktop viewport dimensions used for rendering before scaling down. */
10+
export const RENDER_WIDTH = 1280;
11+
export const RENDER_HEIGHT = 960;
12+
813
/** Capture timeout in milliseconds. */
914
export const CAPTURE_TIMEOUT_MS = 15_000;
1015

@@ -55,9 +60,11 @@ export async function captureIframe(iframe: HTMLIFrameElement): Promise<Blob> {
5560
if (!doc?.body) throw new Error("Cannot access iframe content");
5661

5762
const canvas = await html2canvas(doc.body, {
58-
width: THUMB_WIDTH,
59-
height: THUMB_HEIGHT,
60-
scale: 1,
63+
width: RENDER_WIDTH,
64+
height: RENDER_HEIGHT,
65+
windowWidth: RENDER_WIDTH,
66+
windowHeight: RENDER_HEIGHT,
67+
scale: THUMB_WIDTH / RENDER_WIDTH,
6168
logging: false,
6269
useCORS: true,
6370
});
@@ -98,6 +105,100 @@ export async function uploadThumbnail(assetId: string, blob: Blob): Promise<void
98105
}
99106
}
100107

108+
/**
109+
* Build a complete HTML document that transpiles and renders JSX content,
110+
* then notifies the parent when ready for capture. Reuses the same pipeline
111+
* as JsxRenderer (sucrase transform, import map, auto-mount) but adds a
112+
* postMessage notifier with a longer delay for async esm.sh loads.
113+
*/
114+
export function buildJsxThumbnailHtml(content: string): string {
115+
const CSP = [
116+
"default-src 'none'",
117+
"script-src 'unsafe-eval' 'unsafe-inline' https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com",
118+
"style-src 'unsafe-inline' https://fonts.googleapis.com",
119+
"img-src data: blob:",
120+
"font-src data: https://fonts.gstatic.com",
121+
"connect-src https://esm.sh https://fonts.googleapis.com https://fonts.gstatic.com",
122+
].join("; ");
123+
124+
const BARE_IMPORT_MAP: Record<string, string> = {
125+
react: "https://esm.sh/react@19",
126+
"react/": "https://esm.sh/react@19/",
127+
"react-dom": "https://esm.sh/react-dom@19",
128+
"react-dom/": "https://esm.sh/react-dom@19/",
129+
"react-dom/client": "https://esm.sh/react-dom@19/client",
130+
recharts: "https://esm.sh/recharts@2?bundle&external=react,react-dom",
131+
"lucide-react": "https://esm.sh/lucide-react@0.469?bundle&external=react",
132+
};
133+
134+
const importMap = JSON.stringify({ imports: BARE_IMPORT_MAP });
135+
136+
let transformed: string;
137+
try {
138+
transformed = escapeScriptClose(transformJsx(content));
139+
} catch {
140+
// If transform fails, return a simple error page that still notifies ready
141+
return `<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>
142+
<pre style="color:#ef4444;padding:16px;font-family:monospace">JSX transform failed</pre>
143+
<script>setTimeout(function(){parent.postMessage({type:'thumbnail-ready'},'*');},500);</script>
144+
</body></html>`;
145+
}
146+
147+
const hasMountCode =
148+
/\bcreateRoot\s*\(/.test(content) ||
149+
/\bReactDOM\s*\.\s*render\s*\(/.test(content);
150+
151+
const componentName = findComponentName(content);
152+
const mountSection = hasMountCode
153+
? transformed
154+
: `import React from 'react';
155+
import { createRoot } from 'react-dom/client';
156+
157+
${transformed}
158+
159+
try {
160+
${componentName ? `createRoot(document.getElementById('root')).render(React.createElement(${componentName}));` : ""}
161+
} catch(e) {
162+
document.getElementById('root').textContent = e.message;
163+
}`;
164+
165+
const notifierScript = `
166+
setTimeout(function() {
167+
parent.postMessage({ type: 'thumbnail-ready' }, '*');
168+
}, 2000);`;
169+
170+
return `<!DOCTYPE html>
171+
<html>
172+
<head>
173+
<meta charset="UTF-8">
174+
<meta http-equiv="Content-Security-Policy" content="${CSP}">
175+
<script type="importmap">${importMap}</script>
176+
<style>
177+
* { margin: 0; padding: 0; box-sizing: border-box; }
178+
body { font-family: system-ui, sans-serif; padding: 16px; }
179+
</style>
180+
</head>
181+
<body>
182+
<div id="root"></div>
183+
<script type="module">
184+
window.onerror = function(msg, src, line, col, err) {
185+
var el = document.createElement('pre');
186+
el.textContent = err && err.stack ? err.stack : msg;
187+
document.getElementById('root').appendChild(el);
188+
};
189+
window.addEventListener('unhandledrejection', function(e) {
190+
var el = document.createElement('pre');
191+
el.textContent = 'Module load error: ' + (e.reason && e.reason.stack ? e.reason.stack : e.reason);
192+
document.getElementById('root').appendChild(el);
193+
});
194+
195+
${mountSection}
196+
</script>
197+
<script>${notifierScript}</script>
198+
</body>
199+
</html>`;
200+
}
201+
101202
/**
102203
* Returns true if the content type supports thumbnail generation.
103204
*/

0 commit comments

Comments
 (0)