Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte";
import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte";
import { featureFlags } from "@rilldata/web-common/features/feature-flags";
import { getCanvasStoreUnguarded } from "@rilldata/web-common/features/canvas/state-managers/state-managers";
import type { LayoutBlock } from "@rilldata/web-common/features/canvas/stores/tab-group";
import ExportDashboardForm from "@rilldata/web-common/features/exports/pdf/ExportDashboardForm.svelte";
import { exportCanvasPdf } from "@rilldata/web-common/features/exports/pdf/export-canvas-pdf";
import type { PdfExportRunOptions } from "@rilldata/web-common/features/exports/pdf/types";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
import { readable } from "svelte/store";

export let createMagicAuthTokens: boolean;
// Provide canvas identifiers to enable the "PDF" tab (canvas dashboards only).
Expand All @@ -42,6 +45,18 @@
exportCanvasPdf({ canvasName: name, instanceId: id, ...o });
}

const emptyLayout = readable<LayoutBlock[]>([]);
// Resolved when the popover opens (the canvas store may not yet be
// initialized when this header button first mounts).
$: layoutStore =
(isOpen &&
canvasName &&
instanceId &&
getCanvasStoreUnguarded(canvasName, instanceId)?.canvasEntity.layout) ||
emptyLayout;
// Gates the all-tabs/active-tab option in the PDF export form.
$: hasTabGroups = $layoutStore.some((block) => block.kind === "tab-group");

function onCopy() {
navigator.clipboard.writeText(window.location.href).catch(console.error);
copied = true;
Expand Down Expand Up @@ -106,6 +121,7 @@
<TabsContent value="pdf" class="mt-0 p-4">
<ExportDashboardForm
runExport={runPdfExport}
showTabOptions={hasTabGroups}
onComplete={() => (isOpen = false)}
/>
</TabsContent>
Expand Down
4 changes: 4 additions & 0 deletions web-common/src/features/canvas/stores/canvas-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export class CanvasEntity {
// data query (see BaseCanvasComponent.dataEnabled) so all components fetch and
// render regardless of the lazy-load latch, without mutating that latch.
exportMode = writable<boolean>(false);
// Whether the PDF export renders every tab of each tab group (stacked in strip
// order) or only each group's active tab. Set by exportCanvasPdf alongside
// exportMode; read by CanvasPdfExportView.
exportAllTabs = writable<boolean>(true);

// This is to skip processing the spec the first time the store updates with a value
// We've already called it as part of the constructor
Expand Down
56 changes: 56 additions & 0 deletions web-common/src/features/exports/pdf/CanvasPdfExportTab.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script lang="ts">
import type { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent";
import StaticCanvasRow from "@rilldata/web-common/features/canvas/StaticCanvasRow.svelte";
import type { Tab } from "@rilldata/web-common/features/canvas/stores/tab-group";

// One exported tab: a label band naming the tab (captured as its own block,
// see captureTargetsIn) followed by the tab's rows. The idPrefix namespaces
// the row ids so the same rowIndex in different tabs stays unique.
let {
groupName,
tab,
components,
maxWidth,
}: {
groupName: string;
tab: Tab;
components: Map<string, BaseCanvasComponent>;
maxWidth: number;
} = $props();

const rows = $derived(tab.grid);
</script>

<section
id={`pdf-tab-label-${groupName}-${tab.name}`}
class="pdf-tab-label-row w-full h-fit relative"
style:max-width="{maxWidth}px"
>
<h2>{tab.displayName}</h2>
</section>

{#each $rows as row, rowIndex (rowIndex)}
<StaticCanvasRow
{row}
{rowIndex}
{components}
{maxWidth}
navigationEnabled={false}
lazy={false}
idPrefix={`${groupName}-${tab.name}-`}
/>
{/each}

<style lang="postcss">
/* Mimics the live tab strip: the tab's name underlined in the accent color on
a hairline spanning the row, so the PDF shows which tab the rows below
belong to. */
.pdf-tab-label-row {
@apply flex items-end border-b border-gray-200 px-2 pt-3;
}

h2 {
@apply -mb-px w-fit border-b-2 border-primary-500 px-1 pb-1;
@apply text-sm font-medium text-fg-primary;
}
</style>
32 changes: 32 additions & 0 deletions web-common/src/features/exports/pdf/CanvasPdfExportTabGroup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import type { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent";
import type { TabGroup } from "@rilldata/web-common/features/canvas/stores/tab-group";
import CanvasPdfExportTab from "./CanvasPdfExportTab.svelte";

// PDF-capture render of a tab group. Unlike the live CanvasTabGroupView
// (interactive strip, only the active tab mounted), the exported tabs are
// stacked: each tab contributes a label band naming it followed by its rows,
// one tab below the previous in strip order. When allTabs is false only the
// group's active tab is rendered, so the PDF matches what the user sees.
let {
group,
components,
maxWidth,
allTabs,
}: {
group: TabGroup;
components: Map<string, BaseCanvasComponent>;
maxWidth: number;
allTabs: boolean;
} = $props();

const tabs = $derived(group.tabs);
const activeTabIndex = $derived(group.activeTabIndex);
const exportTabs = $derived(
allTabs ? $tabs : $tabs[$activeTabIndex] ? [$tabs[$activeTabIndex]] : [],
);
</script>

{#each exportTabs as tab (tab.name)}
<CanvasPdfExportTab groupName={group.name} {tab} {components} {maxWidth} />
{/each}
46 changes: 34 additions & 12 deletions web-common/src/features/exports/pdf/CanvasPdfExportView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,28 @@
import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers";
import StaticCanvasRow from "@rilldata/web-common/features/canvas/StaticCanvasRow.svelte";
import CanvasPdfExportHeader from "./CanvasPdfExportHeader.svelte";
import CanvasPdfExportTabGroup from "./CanvasPdfExportTabGroup.svelte";

// A dedicated, read-only render of the whole canvas used solely as the PDF
// capture target. It reuses the live dashboard's row/component rendering off
// capture target. It reuses the live dashboard's block/component rendering off
// the same canvas store, but renders every component eagerly (lazy={false}) so
// the capture is complete without touching the live dashboard's lazy-load
// state. See captureCanvasBlocks, which reads from #canvas-pdf-export-view.
// state. Tab groups are flattened for print: every exported tab (all tabs, or
// only each group's active one — see exportAllTabs) renders as a label band
// followed by its rows, stacked in strip order.
// See captureCanvasBlocks, which reads from #canvas-pdf-export-view.
export let canvasName: string;
export let instanceId: string;
// Render width in px; matches the on-screen content width for fidelity.
export let width: number;

$: ({
canvasEntity: { componentsStore, _rows },
canvasEntity: { componentsStore, _rows, layout, exportAllTabs },
} = getCanvasStore(canvasName, instanceId));

$: components = $componentsStore;
$: rows = $_rows;
$: blocks = $layout;
</script>

<div
Expand All @@ -34,15 +39,24 @@
class="row-container w-full h-fit flex flex-col items-center relative"
style:width="{width}px"
>
{#each rows as row, rowIndex (rowIndex)}
<StaticCanvasRow
{row}
{rowIndex}
{components}
maxWidth={width}
navigationEnabled={false}
lazy={false}
/>
{#each blocks as block (block.kind === "tab-group" ? `g-${block.group.name}` : `r-${block.rowIndex}`)}
{#if block.kind === "tab-group"}
<CanvasPdfExportTabGroup
group={block.group}
{components}
maxWidth={width}
allTabs={$exportAllTabs}
/>
{:else if rows[block.freeRowIndex]}
<StaticCanvasRow
row={rows[block.freeRowIndex]}
rowIndex={block.rowIndex}
{components}
maxWidth={width}
navigationEnabled={false}
lazy={false}
/>
{/if}
{/each}
</div>
</div>
Expand All @@ -52,4 +66,12 @@
container-type: inline-size;
container-name: canvas-container;
}

/* Scrollbars on overflowing components (tables, legends) would be rasterized
into the capture. Applied per element via `*` because html-to-image inlines
each element's computed styles into its clone: computed scrollbar-width
survives the cloning, whereas ::-webkit-scrollbar stylesheet rules do not. */
#canvas-pdf-export-view :global(*) {
scrollbar-width: none;
}
</style>
13 changes: 13 additions & 0 deletions web-common/src/features/exports/pdf/ExportDashboardForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
// in the future.
export let runExport: (opts: PdfExportRunOptions) => Promise<void>;
export let onComplete: () => void = () => {};
// Shown only when the dashboard has tab groups: whether to export every tab
// or only each group's active tab.
export let showTabOptions = false;

let includeFilters = true;
let allTabs = true;

let exporting = false;
let progressLabel = m.export_pdf_button();
Expand All @@ -34,6 +38,7 @@
try {
await runExport({
includeFilters,
allTabs,
onProgress: ({ phase }) => {
progressLabel = PROGRESS_COPY[phase];
},
Expand Down Expand Up @@ -66,6 +71,14 @@
label={m.export_pdf_include_filters()}
/>

{#if showTabOptions}
<Checkbox
id="pdf-all-tabs"
bind:checked={allTabs}
label={m.export_pdf_tabs_all()}
/>
{/if}

<Button
type="primary"
loading={exporting}
Expand Down
83 changes: 82 additions & 1 deletion web-common/src/features/exports/pdf/capture.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { inlineSvgStyles } from "./capture";
import { captureTargetsIn, inlineSvgStyles, rowIndexFor } from "./capture";

describe("inlineSvgStyles", () => {
it("restores original SVG style attributes", () => {
Expand All @@ -27,3 +27,84 @@ describe("inlineSvgStyles", () => {
expect(circle.hasAttribute("style")).toBe(false);
});
});

describe("captureTargetsIn", () => {
// Mirrors the export render: free rows and tab rows are top-level <section>s
// holding component cards, and each exported tab is preceded by its label
// band (a section.pdf-tab-label-row; see CanvasPdfExportTab).
function renderExportDom(): HTMLElement {
const container = document.createElement("div");
container.innerHTML = `
<section id="canvas-row-0">
<article id="free-a" class="component-card"></article>
<article id="free-b" class="component-card"></article>
</section>
<section id="pdf-tab-label-overview-first" class="pdf-tab-label-row"></section>
<section id="canvas-row-overview-first-0">
<article id="tab-a" class="component-card"></article>
<article id="tab-b" class="component-card"></article>
</section>
<section id="pdf-tab-label-overview-second" class="pdf-tab-label-row"></section>
<section id="canvas-row-overview-second-0">
<article id="tab-c" class="component-card"></article>
</section>
<section id="canvas-row-2">
<article id="free-c" class="component-card"></article>
</section>
`;
return container;
}

it("captures every card plus each tab's label band, in document order", () => {
const container = renderExportDom();

const targets = captureTargetsIn(container);

expect(targets.map((el) => el.id)).toStrictEqual([
"free-a",
"free-b",
"pdf-tab-label-overview-first",
"tab-a",
"tab-b",
"pdf-tab-label-overview-second",
"tab-c",
"free-c",
]);
});

it("groups each tab's label with the tab's first row, between the free rows", () => {
const container = renderExportDom();
const indexOf = (selector: string) =>
rowIndexFor(container.querySelector<HTMLElement>(selector)!, container);

expect(indexOf("#free-a")).toBe(0);
expect(indexOf("#free-b")).toBe(0);
// A label shares its rowIndex with the row that follows it, so the two
// paginate as one unit and the label can't be orphaned by a page break.
expect(indexOf("#pdf-tab-label-overview-first")).toBe(2);
expect(indexOf("#tab-a")).toBe(2);
expect(indexOf("#tab-b")).toBe(2);
expect(indexOf("#pdf-tab-label-overview-second")).toBe(4);
expect(indexOf("#tab-c")).toBe(4);
expect(indexOf("#free-c")).toBe(5);
});

it("keeps an empty tab's label on its own row", () => {
const container = document.createElement("div");
container.innerHTML = `
<section id="pdf-tab-label-overview-empty" class="pdf-tab-label-row"></section>
<section id="pdf-tab-label-overview-second" class="pdf-tab-label-row"></section>
<section id="canvas-row-overview-second-0">
<article id="tab-a" class="component-card"></article>
</section>
`;
const indexOf = (selector: string) =>
rowIndexFor(container.querySelector<HTMLElement>(selector)!, container);

// No row follows the empty tab's label (the next section is another label),
// so it keeps its own index instead of merging into an unrelated row.
expect(indexOf("#pdf-tab-label-overview-empty")).toBe(0);
expect(indexOf("#pdf-tab-label-overview-second")).toBe(2);
expect(indexOf("#tab-a")).toBe(2);
});
});
Loading
Loading