feat(core): add "Export as image PPTX" download option#188
Conversation
Render each slide to an image via html-to-image and stitch them into a one-page-per-slide PPTX. Adds a coming-soon "Export as PPTX" entry whose tooltip points users at the image export until the editable format lands. https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThis PR adds an "Export as image PPTX" feature that renders each slide to a PNG and packages them into a one-image-per-slide PPTX, including progress UI, client-side PPTX assembly/zip, and localized strings across four locales. ChangesImage-based PPTX Export Feature
Sequence DiagramsequenceDiagram
participant SlideUI as Slide Route
participant ExportFn as exportSlideAsImagePptx
participant Render as React Offscreen Render
participant HtmlToImage as html-to-image
participant ZipBuild as PPTX Zip Builder
participant Download as Browser Download
SlideUI->>ExportFn: call with slide, slideId, onProgress
ExportFn->>Render: mount offscreen React roots per page
Render->>Render: settle animations & fonts
ExportFn->>HtmlToImage: capture each page as PNG blob
ExportFn->>ExportFn: onProgress(processing → generating)
ExportFn->>ZipBuild: build XML parts & include image*.png
ExportFn->>Download: create object URL, click anchor
ExportFn->>ExportFn: onProgress(done) and cleanup
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.changeset/export-image-pptx.md:
- Line 5: The changeset entry is too long and reads like release notes; replace
the current multi-clause sentence in .changeset/export-image-pptx.md with a
single short present-tense user-facing sentence that describes the change (e.g.,
"Add Export as image PPTX download option") and remove implementation details
and any "coming soon" rationale so it conforms to the one-line changeset rule.
In `@packages/core/src/app/lib/export-pptx.ts`:
- Around line 50-71: The current loop (creating hosts, pushing to frames and
reactRoots and rendering all pages) keeps the whole deck mounted and accumulates
PNGs, causing high peak memory; change export logic to process each page
sequentially: for each Page from pages createHost (host div), render with
createRoot and SlidePageProvider, wait for the page to finish
rendering/animations and capture the PNG, immediately unmount the root
(root.unmount()) and remove the host from DOM, then store only the captured
image buffer; do not keep frames or reactRoots arrays populated for every
page—reuse local variables for host/root per iteration so only one page is live
at a time (apply same change to the similar block referenced around lines
87-107).
In `@packages/core/src/app/routes/slide.tsx`:
- Around line 550-569: The tooltip trigger is an unfocusable div (inside
TooltipTrigger) so keyboard users can’t access the tooltip; replace that div
with a focusable, semantic element—preferably a disabled button—so it can
receive focus and expose the tooltip: change the child of TooltipTrigger to a
<button disabled aria-disabled="true" className="..." ...> (keeping the same
inner content: Presentation, {t.slide.exportAsPptx} and the coming-soon badge)
or alternatively add tabIndex={0} role="button" aria-disabled="true" to the
element if you must keep it non-button; ensure the TooltipTrigger,
TooltipContent and localized strings (t.slide.pptxComingSoonTooltip /
t.slide.comingSoon) remain unchanged.
- Around line 529-544: The export failure toast is immediately removed because
toast.error(..., { id: toastId }) is followed by toast.dismiss(toastId) in the
finally block; update the export flow in the exportSlideAsImagePptx call
handling so the shared toastId is only dismissed on success (move
toast.dismiss(toastId) into the success path after the export completes) or use
a separate id for the error toast so the failure message remains visible; keep
setExporting(false) in finally. Also make the TooltipTrigger focusable: the
current TooltipTrigger asChild wraps a non-focusable <div>, so change the
trigger element used by TooltipTrigger (or add tabIndex={0}) so keyboard users
can open the tooltip (refer to TooltipTrigger, asChild, and the element used as
the trigger).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 89f55784-5422-4343-bb69-5b9dcd428d85
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (10)
.changeset/export-image-pptx.mdpackages/core/package.jsonpackages/core/src/app/components/pptx-progress-toast.tsxpackages/core/src/app/lib/export-pptx.tspackages/core/src/app/routes/slide.tsxpackages/core/src/locale/en.tspackages/core/src/locale/ja.tspackages/core/src/locale/types.tspackages/core/src/locale/zh-cn.tspackages/core/src/locale/zh-tw.ts
| "@open-slide/core": minor | ||
| --- | ||
|
|
||
| Add an "Export as image PPTX" download option that renders each slide to an image via html-to-image and stitches them into a one-page-per-slide PPTX, plus a coming-soon "Export as PPTX" entry for the editable format. |
There was a problem hiding this comment.
Shorten the changeset entry.
This reads like release notes. Changesets here should stay as one short user-facing sentence without implementation detail or “coming soon” rationale.
As per coding guidelines, ".changeset/*.md: Changeset descriptions must be short and direct: one line, present-tense, describing what changed from a user's perspective. No paragraphs, no rationale, no 'this PR…'."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/export-image-pptx.md at line 5, The changeset entry is too long
and reads like release notes; replace the current multi-clause sentence in
.changeset/export-image-pptx.md with a single short present-tense user-facing
sentence that describes the change (e.g., "Add Export as image PPTX download
option") and remove implementation details and any "coming soon" rationale so it
conforms to the one-line changeset rule.
| const reactRoots: Root[] = []; | ||
| const frames: HTMLElement[] = []; | ||
| for (let i = 0; i < pages.length; i++) { | ||
| const Page = pages[i]; | ||
| if (!Page) continue; | ||
| const host = document.createElement('div'); | ||
| host.setAttribute('data-osd-canvas', ''); | ||
| host.style.width = `${SLIDE_W}px`; | ||
| host.style.height = `${SLIDE_H}px`; | ||
| host.style.overflow = 'hidden'; | ||
| host.style.background = '#fff'; | ||
| if (designVars) { | ||
| for (const [k, v] of Object.entries(designVars)) host.style.setProperty(k, v); | ||
| } | ||
| container.appendChild(host); | ||
| frames.push(host); | ||
| const r = createRoot(host); | ||
| r.render( | ||
| createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)), | ||
| ); | ||
| reactRoots.push(r); | ||
| } |
There was a problem hiding this comment.
Process slides incrementally to cap peak memory.
This keeps the full deck mounted while also accumulating every PNG in memory before zipping. On larger decks, the combination of live React trees, running animations, and image buffers is likely to freeze or OOM the browser. Render/capture/unmount one page at a time instead of holding the whole deck live.
Also applies to: 87-107
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/app/lib/export-pptx.ts` around lines 50 - 71, The current
loop (creating hosts, pushing to frames and reactRoots and rendering all pages)
keeps the whole deck mounted and accumulates PNGs, causing high peak memory;
change export logic to process each page sequentially: for each Page from pages
createHost (host div), render with createRoot and SlidePageProvider, wait for
the page to finish rendering/animations and capture the PNG, immediately unmount
the root (root.unmount()) and remove the host from DOM, then store only the
captured image buffer; do not keep frames or reactRoots arrays populated for
every page—reuse local variables for host/root per iteration so only one page is
live at a time (apply same change to the similar block referenced around lines
87-107).
| await exportSlideAsImagePptx(slide, slideId, (p) => { | ||
| toast.custom(() => <PptxProgressToast progress={p} />, { | ||
| id: toastId, | ||
| duration: Infinity, | ||
| }); | ||
| }); | ||
| } catch (err) { | ||
| console.error('[open-slide] image pptx export failed', err); | ||
| toast.error(t.slide.imagePptxExportFailed, { | ||
| id: toastId, | ||
| duration: 4000, | ||
| }); | ||
| } finally { | ||
| setExporting(false); | ||
| toast.dismiss(toastId); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Sonner v2.x, if toast.error(..., { id })updates an existing toast andtoast.dismiss(id)is called immediately afterward infinally, does that remove the updated error toast right away?
💡 Result:
Yes. If you call toast.error(..., { id }) which updates an existing toast, and then you immediately call toast.dismiss(id) in a finally block, that same toast (including the updated error content) will be removed immediately from the active toast list; it won’t remain visible. In Sonner’s implementation, an existing toast update is handled by merging the new toast data into the existing toast with the same id [1]. Separately, dismissToast marks the toast as “deleting” and schedules its DOM removal on a short exit animation delay (default exitTime 400ms in the shown Toast.ts implementation), and it also deletes it from the internal toast map right away [2]. So even though the element may linger briefly for the dismiss animation, the updated toast is considered dismissed immediately after toast.dismiss(id) is called. Therefore, in the scenario you described (“update then immediately dismiss in finally”), the updated error toast will be dismissed right away (disappear from the toaster state immediately, with a short exit animation before it’s physically removed).
Citations:
- 1: https://github.com/emilkowalski/sonner/blob/main/src/index.tsx
- 2: https://cdn.jsdelivr.net/npm/@numer/sonner@1.2.3/src/toast.ts
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/routes/slide.tsx"
# show the snippet around the PPTX export error/finally block
nl -ba "$FILE" | sed -n '500,580p'Repository: 1weiho/open-slide
Length of output: 104
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/routes/slide.tsx"
# show the snippet around the PPTX export error/finally block and nearby tooltip row
cat -n "$FILE" | sed -n '500,590p'Repository: 1weiho/open-slide
Length of output: 5142
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/routes/slide.tsx"
# show top imports and where TooltipTrigger is imported from
rg -n "TooltipTrigger|`@radix-ui/react-tooltip`|TooltipProvider|TooltipContent" "$FILE" -n
# show a bit of the import section for context
sed -n '1,120p' "$FILE'Repository: 1weiho/open-slide
Length of output: 869
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the tooltip component file that is aliased as "`@/components/ui/tooltip`"
# Use fd to locate likely matches in src/components.
fd -a -t f "tooltip.*\.(ts|tsx)" packages/core/src/components || true
fd -a -t f "tooltip.*\.(ts|tsx)" packages/core/src || true
# Also directly list common location if it exists
if [ -f "packages/core/src/components/ui/tooltip.tsx" ]; then
echo "FOUND: packages/core/src/components/ui/tooltip.tsx"
sed -n '1,220p' packages/core/src/components/ui/tooltip.tsx
fi
if [ -f "packages/core/src/components/ui/tooltip.ts" ]; then
echo "FOUND: packages/core/src/components/ui/tooltip.ts"
sed -n '1,220p' packages/core/src/components/ui/tooltip.ts
fiRepository: 1weiho/open-slide
Length of output: 262
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/components/ui/tooltip.tsx"
wc -l "$FILE"
sed -n '1,220p' "$FILE"
sed -n '220,420p' "$FILE"Repository: 1weiho/open-slide
Length of output: 2049
Fix PPTX export error toast dismissal + improve tooltip keyboard accessibility
- In
packages/core/src/app/routes/slide.tsx, the error toast is updated withtoast.error(..., { id: toastId })but then immediately removed infinallybytoast.dismiss(toastId), so the failure message won’t stay visible—only dismiss on success (or use a different id for the error).
Suggested fix
+ let failed = false;
try {
await exportSlideAsImagePptx(slide, slideId, (p) => {
toast.custom(() => <PptxProgressToast progress={p} />, {
id: toastId,
duration: Infinity,
});
});
} catch (err) {
+ failed = true;
console.error('[open-slide] image pptx export failed', err);
toast.error(t.slide.imagePptxExportFailed, {
id: toastId,
duration: 4000,
});
} finally {
setExporting(false);
- toast.dismiss(toastId);
+ if (!failed) toast.dismiss(toastId);
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/app/routes/slide.tsx` around lines 529 - 544, The export
failure toast is immediately removed because toast.error(..., { id: toastId })
is followed by toast.dismiss(toastId) in the finally block; update the export
flow in the exportSlideAsImagePptx call handling so the shared toastId is only
dismissed on success (move toast.dismiss(toastId) into the success path after
the export completes) or use a separate id for the error toast so the failure
message remains visible; keep setExporting(false) in finally. Also make the
TooltipTrigger focusable: the current TooltipTrigger asChild wraps a
non-focusable <div>, so change the trigger element used by TooltipTrigger (or
add tabIndex={0}) so keyboard users can open the tooltip (refer to
TooltipTrigger, asChild, and the element used as the trigger).
| <TooltipProvider delayDuration={200}> | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <div | ||
| aria-disabled | ||
| className="relative flex cursor-help items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] opacity-45 select-none [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:opacity-80" | ||
| > | ||
| <span className="flex items-center gap-2"> | ||
| <Presentation /> | ||
| {t.slide.exportAsPptx} | ||
| </span> | ||
| <span className="rounded-[3px] bg-muted px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.04em] text-muted-foreground"> | ||
| {t.slide.comingSoon} | ||
| </span> | ||
| </div> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="left" className="max-w-[240px] leading-relaxed"> | ||
| {t.slide.pptxComingSoonTooltip} | ||
| </TooltipContent> | ||
| </Tooltip> |
There was a problem hiding this comment.
Make the “coming soon” row keyboard-reachable.
The tooltip trigger is a plain div, so keyboard users cannot focus it and never get the explanatory tooltip. Use a disabled button/menu item with aria-disabled, or make the trigger focusable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/app/routes/slide.tsx` around lines 550 - 569, The tooltip
trigger is an unfocusable div (inside TooltipTrigger) so keyboard users can’t
access the tooltip; replace that div with a focusable, semantic
element—preferably a disabled button—so it can receive focus and expose the
tooltip: change the child of TooltipTrigger to a <button disabled
aria-disabled="true" className="..." ...> (keeping the same inner content:
Presentation, {t.slide.exportAsPptx} and the coming-soon badge) or alternatively
add tabIndex={0} role="button" aria-disabled="true" to the element if you must
keep it non-button; ensure the TooltipTrigger, TooltipContent and localized
strings (t.slide.pptxComingSoonTooltip / t.slide.comingSoon) remain unchanged.
html-to-image clones each frame and replays its intro keyframes from the hidden 0% frame, dropping animated content from the snapshot. Fast-forward animations to their end frame in the live DOM and pin the settled opacity/transform/filter/clip-path inline (with animation disabled) so the clone rasterises the final, visible state. Pseudo-elements are handled by a scoped capture stylesheet. https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq
Adds a new export format that renders each slide to a PNG image via
html-to-imageand packages them into a PPTX file with one image per slide. This provides a quick way to share presentations in PowerPoint format while editable PPTX export is in development.Changes:
export-pptx.ts): Renders all slides to images at 2x pixel ratio, waits for animations and fonts to settle, then assembles them into a valid PPTX structure usingfflatefor compression. Includes progress tracking through three phases (processing, generating, done).pptx-progress-toast.tsx): Displays current page being rendered and overall progress bar, matching the existing PDF export UX.html-to-imagefor client-side rendering to PNG.The implementation reuses existing infrastructure (
SlidePageProvider,isFrameAnimationSettled,waitForFonts,waitForDataWaitfor) to ensure slides are fully rendered before capture, and generates minimal but valid PPTX XML with embedded images.https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq
Summary by CodeRabbit