Skip to content

feat(canvas): export canvas dashboards as PDF#9593

Open
nishantmonu51 wants to merge 12 commits into
mainfrom
canvas_pdf_export
Open

feat(canvas): export canvas dashboards as PDF#9593
nishantmonu51 wants to merge 12 commits into
mainfrom
canvas_pdf_export

Conversation

@nishantmonu51

Copy link
Copy Markdown
Collaborator

Adds a PDF tab to the share popover for canvas dashboards, allowing users to export the rendered canvas as a downloadable PDF.

  • New web-common/src/features/exports/pdf/ module: captures the canvas, expands tables, settles async rendering, lays out pages, and assembles the PDF via jspdf.
  • ShareDashboardPopover gains a "PDF" tab, shown only for canvas dashboards (when canvasName and instanceId are provided).
  • CanvasDashboardWrapper renders an off-screen CanvasPdfExportHeader used solely as the PDF capture target.
  • Adds jspdf dependency.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

Developed in collaboration with Claude Code

nishantmonu51 and others added 3 commits June 19, 2026 21:33
Add a "PDF" tab to the share popover for canvas dashboards that captures
the rendered canvas and assembles it into a downloadable PDF.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR adds a client-side “Export as PDF” flow for canvas dashboards, integrating a new PDF export module in web-common and exposing it via a new “PDF” tab in the share popover for canvas dashboards.

Changes:

  • Introduces a new web-common/src/features/exports/pdf/ module to settle rendering, capture canvas blocks, paginate, and assemble/download a PDF via jspdf.
  • Updates the share UI to show a “PDF” tab only when canvas identifiers are present, and wires it to the canvas PDF export orchestrator.
  • Adds an off-screen, read-only canvas header render (CanvasPdfExportHeader) as a capture target and adds the jspdf dependency.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web-common/src/features/exports/pdf/types.ts Adds shared PDF export types and capture block schema.
web-common/src/features/exports/pdf/settle.ts Adds “settle before capture” utilities for stores, queries, and fonts.
web-common/src/features/exports/pdf/layout.ts Implements pagination/layout logic for captured blocks across PDF pages.
web-common/src/features/exports/pdf/layout.spec.ts Adds vitest coverage for pagination/orientation behavior.
web-common/src/features/exports/pdf/filename.ts Adds filename slug + timestamp helper for PDF downloads.
web-common/src/features/exports/pdf/ExportPdfOptions.svelte Adds share-popover UI for running the PDF export with options/progress.
web-common/src/features/exports/pdf/export-canvas-pdf.ts Orchestrates canvas-to-PDF export (settle → capture → paginate → assemble).
web-common/src/features/exports/pdf/capture.ts Captures filter header + component cards into raster blocks for PDF assembly.
web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte Off-screen read-only header (time range + filters) for capture.
web-common/src/features/exports/pdf/assemble.ts Renders paginated placements into a jsPDF document and triggers download.
web-common/src/features/exports/pdf/assemble.spec.ts Adds tests for background color parsing used in PDF rendering.
web-common/src/features/canvas/CanvasDashboardWrapper.svelte Mounts the off-screen PDF header capture target for canvases.
web-common/package.json Adds jspdf dependency.
web-admin/src/features/projects/ProjectHeader.svelte Passes canvasName/instanceId to enable the PDF tab for canvas dashboards.
web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte Adds “PDF” tab + hooks it up to exportCanvasPdf.
package-lock.json Updates lockfile for the new dependency and transitive packages.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread web-common/src/features/exports/pdf/settle.ts
Comment thread web-common/src/features/exports/pdf/CanvasPdfExportHeader.svelte
Comment thread web-common/src/features/exports/pdf/layout.spec.ts Outdated
capture.spec.ts uses the DOM (document) but is batched with pure-logic
specs in the same directory. Under vitest's parallel forks pool the
jsdom global was intermittently unavailable, causing 'document is not
defined'. Pin the spec to the jsdom environment to make it deterministic.
The off-screen canvas PDF export header was always mounted, so its
read-only filter chips duplicated the live filter bar's text and the
'Readonly Filter Chips' aria-label. Off-screen positioning still counts
as visible/queryable to Playwright, so this caused strict-mode
violations in unrelated canvas e2e tests (web-local default-filters,
web-admin bookmarks).

Keep it display:none in normal use (excluded from the a11y tree and
text/label locators) and have capture.ts reveal it only while measuring
and rasterizing.
The previous display:none approach did not work: Playwright locators
(getByText/getByLabel) and the accessibility tree match elements
regardless of CSS visibility, so the always-mounted off-screen header
still duplicated the live filter bar's text and 'Readonly Filter Chips'
label, failing web-local default-filters and web-admin bookmarks e2e
tests with strict-mode violations.

Gate the header behind a canvasPdfExportActive store so it only exists
in the DOM during an active export (mirroring the ScreenshotContainer
dialog pattern). prepareCanvasForCapture's tick() flushes it into the
DOM before capture reads it.
P2: prepareCanvasForCapture force-set every component's visible store to
true to bypass lazy-load, but never restored it. Since visible gates
component queries, one export left all below-the-fold queries enabled
for the rest of the session, re-running on every filter/time change. It
now returns a restore closure that exportCanvasPdf calls in finally; the
IntersectionObserver keeps observing those components, so lazy-load
still works on later scroll.

P3: the export button shows a spinner via loading but stays clickable
(Button gates onClick on disabled, not loading), and onExport had no
early return, so a double-click could start overlapping exports sharing
one capture header. Add an early return when already exporting.
P3 (observer race): restoreVisibility set components back to visible=false,
but the IntersectionObserver unobserves a component once it intersects and
visible is otherwise a one-way latch. A component that intersected during
export would end up unobserved AND hidden, so it never lazy-loaded again.
CanvasComponent now re-observes whenever visible transitions back to false:
genuinely on-screen components snap back to true, off-screen ones lazy-load
on later scroll.

P3 (missing title): the exported PDF never rendered the dashboard title;
assemblePdf only set PDF metadata and drew the footer. It now draws the
title as vector text in a band reserved at the top of the first page
(paginate's titleReservePt), matching the design the CanvasPdfExportHeader
comment already described. Long titles are ellipsized to the content width.

Copilot AI 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.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.

Comment on lines +67 to +75
// UTC, e.g. "2026-06-19 02:20 UTC".
const generatedAt = `${new Date().toISOString().replace("T", " ").slice(0, 16)} UTC`;
await assemblePdf(pagination, {
title,
filename: buildPdfFilename(title),
backgroundColor,
generatedAt,
dashboardUrl: window.location.href,
});
Comment on lines +68 to +76
export function isCanvasExportQuery(
queryKey: QueryKey,
instanceId: string,
): boolean {
const [service] = queryKey;
if (service === "metrics_sql") return true;
if (service !== "QueryService") return false;
return queryKey.includes(instanceId);
}
The title reserve started cursorYPt below the margin, but the
page-advance guards used 'cursorYPt > marginPt' as a proxy for 'this
page already has content'. With the reserve that proxy was always true
on page 0, so a tall first component (or any first row that fits a full
page but not the title band) advanced to page 1 before placing anything,
leaving page 0 with only the title and footer.

Track each page's content-top (pageTopPt) and advance only when content
was actually placed (atPageTop). Slicing is now title-band-aware: the
first slice fills page 0 below the title and the remainder flows onto
later full-height pages. Non-title pagination is unchanged (reserve 0).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants