diff --git a/next/src/lib/agents/detect.ts b/next/src/lib/agents/detect.ts index c72c8db..8f4520c 100644 --- a/next/src/lib/agents/detect.ts +++ b/next/src/lib/agents/detect.ts @@ -60,6 +60,11 @@ export const AGENTS: AgentDef[] = [ { id: "claude-opus-4-7", label: "claude-opus-4-7" }, { id: "claude-sonnet-4-6", label: "claude-sonnet-4-6" }, { id: "claude-haiku-4-5", label: "claude-haiku-4-5" }, + // DeepSeek models (via `cc switch` or direct config) — kept in the + // Claude Code picker so users who route Claude through a custom model + // endpoint can switch models without leaving html-anything's UI. + { id: "deepseek-v4-pro", label: "deepseek-v4-pro" }, + { id: "deepseek-v4-flash", label: "deepseek-v4-flash" }, ], }, { diff --git a/next/src/lib/export/deck.ts b/next/src/lib/export/deck.ts index 7e2898f..fa3dbe9 100644 --- a/next/src/lib/export/deck.ts +++ b/next/src/lib/export/deck.ts @@ -48,9 +48,31 @@ async function renderSlideToBlob(slide: DeckSlide, scale = 2): Promise { const done = () => res(); if (iframe.contentDocument?.readyState === "complete") return done(); iframe.addEventListener("load", done, { once: true }); - setTimeout(done, 4000); + setTimeout(done, 8000); }); - return await iframeToBlob(iframe, { scale }); + + // Give the browser an extra frame to finish layout — without this, + // Tailwind CDN / fonts / images injected via srcdoc can report a 0px + // scrollHeight on the first paint, causing iframeToBlob to throw + // "preview has no content yet" even though the live preview is fine. + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); + + // Retry once if the first capture returns an empty document. + const maxAttempts = 2; + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await iframeToBlob(iframe, { scale }); + } catch (err) { + lastError = err; + if (attempt < maxAttempts) { + // Wait longer before retry — fonts / Tailwind CDN may still be + // inflight even after the load event fired. + await new Promise((r) => setTimeout(r, 1200)); + } + } + } + throw lastError; } finally { wrap.remove(); } diff --git a/next/src/lib/export/image.ts b/next/src/lib/export/image.ts index 93c1c77..0c9fea5 100644 --- a/next/src/lib/export/image.ts +++ b/next/src/lib/export/image.ts @@ -149,6 +149,10 @@ export async function nodeToBlob(node: HTMLElement, opts: ImageOpts = {}): Promi * the exact viewport the browser used when measuring text. Using * `scrollWidth` here causes a 1–2px drift that wraps Chinese titles to * a new line and shoves them under the body text. + * To keep clientWidth reliable across platforms, overflow is set to + * "hidden" during capture — without this, Windows' always-visible + * scrollbar adds ~15px of chrome that narrows clientWidth and crops + * the left edge of the exported image. * 4. Pass explicit width/height to modern-screenshot so the foreignObject * SVG matches the laid-out size 1:1. */ @@ -172,8 +176,13 @@ export async function iframeToBlob( const fullHeight = fullScrollHeight(doc); if (!fullHeight) throw new Error("preview has no content yet"); iframe.style.height = `${fullHeight}px`; - doc.documentElement.style.overflow = "visible"; - doc.body.style.overflow = "visible"; + // Use overflow:hidden — not visible — to suppress platform scrollbars. + // On Windows where scrollbars are always visible, "visible" adds a ~15px + // vertical scrollbar that reduces clientWidth, causing the exported image + // to be cropped on the left side (the screenshot is taken at the narrowed + // viewport width and the rightmost pixels are lost). + doc.documentElement.style.overflow = "hidden"; + doc.body.style.overflow = "hidden"; // Wait a couple of frames for the browser to re-flow at the new size. await NEXT_FRAME();