Skip to content

fix(export): prevent left-side crop of exported images on Windows#73

Open
111wukong wants to merge 3 commits into
nexu-io:mainfrom
111wukong:fix/image-export-left-crop
Open

fix(export): prevent left-side crop of exported images on Windows#73
111wukong wants to merge 3 commits into
nexu-io:mainfrom
111wukong:fix/image-export-left-crop

Conversation

@111wukong

Copy link
Copy Markdown

Fixes #70 (image export left-side crop)

On platforms with always-visible scrollbars (Windows), overflow: visible during iframe screenshot capture added a ~15px scrollbar that reduced documentElement.clientWidth, causing the exported image to be cropped on the left edge.

Switching to overflow: hidden suppresses the scrollbar while preserving the full layout — the iframe height is already pinned to scrollHeight, so no content is hidden.

The Gemini CLI deprecation part of #70 is not addressed here — that requires a separate adapter for the Antigravity CLI.

111wukong added 3 commits May 21, 2026 02:26
Users who route Claude Code through a custom model endpoint (e.g. via
`cc switch`) need to select `deepseek-v4-pro` or `deepseek-v4-flash`
from the model picker. Without these entries, the only way to switch
models is to leave html-anything, change the CLI config, and reload.

Closes nexu-io#65
The off-screen iframe used to capture each slide for PPTX / PNG-ZIP export
could report a 0px scrollHeight when fonts or Tailwind CDN styles had not
yet been applied, even after the `load` event fired. This caused
`iframeToBlob` to throw "preview has no content yet" for decks that
rendered correctly in the live preview.

Changes:
- Increased the srcdoc load timeout from 4 s → 8 s (Tailwind CDN can be slow)
- Added a double-rAF flush before the first capture to let the browser
  finish layout after the load event
- Added a single retry with 1.2 s backoff when the first capture fails

Fixes nexu-io#62
On platforms with always-visible scrollbars (Windows), setting overflow to
"visible" during iframe screenshot capture added a vertical scrollbar that
reduced documentElement.clientWidth by ~15px. The screenshot was taken at
this narrower width, cropping the left edge of the exported image.

Switching overflow from "visible" to "hidden" during capture suppresses
the scrollbar while preserving the full content layout — the iframe height
is already pinned to scrollHeight on the previous line, so no content is
hidden.

Fixes nexu-io#70

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Thanks @111wukong — three small, well-reasoned bugfixes, each with a clear root-cause comment. The overflow: visibleoverflow: hidden switch in iframeToBlob (next/src/lib/export/image.ts) is a sound, standard way to keep documentElement.clientWidth independent of platform scrollbars; since iframe.style.height is already pinned to fullScrollHeight and captureHeight is re-measured after the overflow change, nothing is clipped vertically. The PPTX path in next/src/lib/export/deck.ts (double-rAF flush + single 1.2 s-backoff retry + 4 s→8 s load timeout) is a reasonable defense against the 0 px-scrollHeight race.

No blocking issues. One non-blocking process note here, plus one nit inline.

PR scope vs. description — This branch (fix/image-export-left-crop) actually contains three independent commits, not one: 227baf7 adds DeepSeek models to the Claude Code picker (Closes #65, touches next/src/lib/agents/detect.ts), f194370 fixes the PPTX export race (Fixes #62, touches next/src/lib/export/deck.ts), and b8d9f17 is the left-crop fix (Fixes #70, touches next/src/lib/export/image.ts). The PR title and body describe only #70 — they don't mention the DeepSeek picker change or the PPTX retry logic at all. Why it matters: a maintainer reviewing or release-noting this PR from its title/body would not realize that merging it also lands a model-picker change and new export-retry behavior, and the Closes #65 / Fixes #62 lines in the commit messages will auto-close those issues on merge to the default branch. Suggested fix: either update the PR body to enumerate all three fixes (and their issues), or split the unrelated #65 detect.ts change into its own PR — #62 and #70 are at least both export fixes, but #65 is unrelated to image export.

🔁 Powered by Looper · runner=reviewer · agent=claude-code · An autonomous AI dev team for your GitHub repos.

Comment on lines +66 to +67
{ id: "deepseek-v4-pro", label: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", label: "deepseek-v4-flash" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two DeepSeek entries are added to the Claude Code adapter's fallbackModels, but unlike the Claude/alias models above them they only resolve when the user has routed Claude Code through a custom model endpoint (e.g. via cc switch). For a user who has not done that, picking deepseek-v4-pro here makes invokeAgent run claude --model deepseek-v4-pro, which the stock Claude CLI/API rejects with an opaque "unknown model" failure surfaced as a failed run.

Why it matters: the new code comment documents this precondition for developers, but the user-facing label ("deepseek-v4-pro") gives the end user no hint that these require a routed setup. The picker therefore advertises two models that fail by default, which works against the "Curated, evidence-based model list" intent documented on AgentDef.fallbackModels (lines 37-40) and on DEFAULT_MODEL.

Suggested change: surface the precondition in the user-visible label, e.g. { id: "deepseek-v4-pro", label: "deepseek-v4-pro (routed endpoint)" } and likewise for deepseek-v4-flash; or gate these two entries behind a detected routed config so they only appear when usable. This is a nit, not merge-blocking.

🔁 Powered by Looper · runner=reviewer · agent=claude-code · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown

Hey @111wukong — carrying over fresh verification from #70 so it is visible on the PR too.

@LuckyFishGeek tested this branch on Windows 10 and the left-edge crop still reproduces. The new evidence points beyond the scrollbar case: when a centered template card is wider than the preview iframe, part of the layout can sit at negative X coordinates, and modern-screenshot starts capturing from x = 0, so those left-side pixels are already outside the capture viewport.

Could you refine iframeToBlob so the iframe/capture viewport is widened enough for the full laid-out content before domToBlob, then restore the original iframe width afterward? Prefer deriving that width from the document/card dimensions rather than hard-coding 1080 where possible. Once that centered-overflow case is covered, we can ask @LuckyFishGeek to verify again.

@lefarcen

Copy link
Copy Markdown

Hey @111wukong — just a gentle nudge on this one. @LuckyFishGeek mapped out the remaining fix in #70: temporarily widen the iframe to the full scroll-width before calling domToBlob, then restore afterward so centered layouts don't overflow into negative X coordinates. Once you push that revision, we can ask them to re-verify. Everything else in the PR looks solid per @PerishCode's review. 🙌

@lefarcen

Copy link
Copy Markdown

Hey @111wukong, the PR has been quiet for a couple of days since @LuckyFishGeek's centered-layout feedback — still on this one? Any comment, commit, or progress note keeps the claim active; happy to help if you get stuck on the width-expansion approach.

@LuckyFishGeek

Copy link
Copy Markdown

@lefarcen The image cropping issue can be closed now. Thanks.

@lefarcen

Copy link
Copy Markdown

Hey @LuckyFishGeek — thanks for the update! Just to confirm: are you saying the left-crop issue is resolved for you now (e.g., you found a workaround or it no longer affects your workflow), or are you saying the PR as-is (with the overflow: hidden fix alone) now works on your Windows setup?

I want to make sure we're on the same page before closing #70 — your earlier detailed feedback on 2026-05-21 showed the overflow: hidden alone didn't fix the centered-layout case, so I'm curious what changed. 🙏

@LuckyFishGeek

LuckyFishGeek commented May 25, 2026

Copy link
Copy Markdown

Hi there, @lefarcen

Sorry for the confusion! To clarify: the PR as-is (overflow: hidden alone) DOES NOT fix the issue. The left-side crop still happens on my Windows setup if we only hide the scrollbar.

When I said it was resolved, I meant that it is resolved locally for me ONLY because I manually applied the "Ultimate Fix" (forcing the iframe width) that I posted in my previous detailed feedback.

As a quick recap of why overflow: hidden alone isn't enough:
The Xiaohongshu template card is 1080px wide. If the user's preview iframe is narrower than 1080px (due to screen size or UI scaling), the centered layout (margin: auto) causes the card to overflow to the left (into negative X coordinates). Since modern-screenshot captures starting from X: 0, the left side gets permanently cropped. Hiding the scrollbar only reclaims ~15px, but it doesn't fix the negative X offset.

To officially fix #70, the export logic in image.ts MUST temporarily expand the iframe's width to match the true scrollWidth (e.g., >= 1080px) right before capturing, and then restore it afterward.

So please do not close #70 until the width-forcing logic (from my previous code snippet) is merged. Let me know if you need me to re-paste that code snippet!

Here is the code adjustment that completely solved the issue for me in next/src/lib/export/image.ts (iframeToBlob function):

export async function iframeToBlob(
iframe: HTMLIFrameElement,
opts: ImageOpts = {},
): Promise {
// ...
const prevIframeHeight = iframe.style.height;
const prevIframeWidth = iframe.style.width; // <--- 1. Record original width
const prevDocOverflow = doc.documentElement.style.overflow;
const prevBodyOverflow = doc.body.style.overflow;

const fullHeight = fullScrollHeight(doc);
if (!fullHeight) throw new Error("preview has no content yet");

// <--- 2. Calculate true width (ensure it fits the 1080px card)
const fullWidth = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth, 1080);

iframe.style.height = ${fullHeight}px;
iframe.style.width = ${fullWidth}px; // <--- 3. Force iframe width to prevent center-overflow

doc.documentElement.style.overflow = "hidden";
doc.body.style.overflow = "hidden";

await NEXT_FRAME();
await sleep(100); // Give browser a moment to reflow the new width
await NEXT_FRAME();

try {
// <--- 4. Use the forced width
const layoutWidth = doc.documentElement.scrollWidth || fullWidth;
const layoutHeight = fullScrollHeight(doc);

// ... domToBlob execution ...

} finally {
iframe.style.height = prevIframeHeight;
iframe.style.width = prevIframeWidth; // <--- 5. Restore original width
doc.documentElement.style.overflow = prevDocOverflow;
doc.body.style.overflow = prevBodyOverflow;
}
}

@lefarcen

Copy link
Copy Markdown

Thanks for clarifying, @LuckyFishGeek — that resolves the ambiguity. We will not close #70 based on the current overflow: hidden-only change.

@111wukong, this confirms the current head still needs the width-expansion step before it can be considered a fix for #70: temporarily widen the iframe/capture viewport to the full content width before domToBlob, then restore it afterward. The snippet above is enough context; no need for @LuckyFishGeek to re-paste anything unless you need more Windows-side verification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk change size/S Small change: 20-99 changed lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

There is an issue with image export. Gemini CLI will be discontinued soon, please switch to Antigravity CLI.

4 participants