Files
Joshen Lim 38fa40e851 Joshenlim/fe 4445 explorer export as pdf option 2 (#50786)
## Context

Another take on [this
PR](https://github.com/supabase/supabase/pull/50733), decided that it's
better UX + DX to use a PDF library (`@react-pdf/renderer`) instead for
handling exporting Notebooks to PDF. The previous method using `print`
involved a lot of sparse patching in various places + had a lot of
limitations, not to mention its awkward UX to "Print".

There's a lot of code changes in this PR but it's because we're
constructing the PDF document based on the notebook's contents using
primitives from `@react-pdf/renderer` which gives us a lot more control
over the PDF's output.

PDF output will differ slightly from the Notebook UI in the browser
- Added a custom header in the report which covers the notebook name +
project name, and when its exported
- Empty markdown cells are skipped
- Query cells will show both SQL content + results (either table or
chart)
- Opting to skip syntax highlighting for SQL content (Can consider
separately)
- Results will only be included in the PDF if the notebook's been run
(Aligns with Copy as Markdown CTA)
- For table results, _all_ rows will be rendered (Aligns with Copy as
Markdown CTA)

Feel free to give it a spin! 🙂 🙏 

<img width="248" height="207" alt="image"
src="https://github.com/user-attachments/assets/e38c14ba-7ee8-40ce-99b1-fa126453bf0f"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added an “Export as PDF” option for notebooks.
- PDFs include notebook details, Markdown content, SQL queries, query
results, tables, and charts.
- Query exports show errors, empty results, row counts, and applicable
row limits.
- Export progress is displayed while the PDF is generated, and failures
are reported via notifications.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-24 13:23:38 +08:00

220 lines
6.0 KiB
TypeScript

import { StyleSheet } from '@react-pdf/renderer'
/**
* Hardcoded light-mode values resolved from the dashboard's theme (packages/ui/build/css/themes/light.css,
* styles/grid.css, MonacoThemeProvider.tsx) — react-pdf can't consume CSS variables, so these are the
* concrete equivalents of `--background`/`--foreground`/`--muted-foreground`/`--border`/brand green.
*/
export const pdfColors = {
background: '#ffffff',
text: '#030303',
textParagraph: '#3a3a3a',
textMuted: '#464646',
textTertiary: '#696969',
border: '#e9e9e9',
brand: '#24b47e',
grayHeader: '#f0f0f0',
graySqlBox: '#f8f8f8',
sqlTextColor: '#444444',
errorText: '#a3402c',
}
export const pdfStyles = StyleSheet.create({
page: {
backgroundColor: pdfColors.background,
color: pdfColors.text,
fontSize: 10,
paddingTop: 36,
paddingBottom: 40,
paddingHorizontal: 40,
},
headerBanner: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
headerLeft: {
flexDirection: 'column',
gap: 4,
},
headerLogoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
headerLogoMark: {
width: 10,
height: 10.4,
},
headerLogoText: {
fontSize: 11,
fontWeight: 700,
color: pdfColors.text,
},
headerLabel: {
fontSize: 8,
letterSpacing: 1,
color: pdfColors.textTertiary,
},
headerRight: {
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
},
headerTitle: {
fontSize: 11,
fontWeight: 700,
color: pdfColors.text,
},
headerMeta: {
fontSize: 8,
color: pdfColors.textMuted,
},
headerRule: {
marginTop: 12,
marginBottom: 20,
borderBottomWidth: 1.5,
borderBottomColor: pdfColors.brand,
},
notebookTitle: {
fontSize: 22,
fontWeight: 700,
color: pdfColors.text,
marginBottom: 6,
},
notebookDescription: {
fontSize: 11,
color: pdfColors.textMuted,
marginBottom: 20,
},
cellSpacing: {
marginBottom: 16,
},
// Markdown
heading1: { fontSize: 16, fontWeight: 700, marginTop: 4, marginBottom: 8 },
heading2: { fontSize: 14, fontWeight: 700, marginTop: 4, marginBottom: 6 },
heading3: { fontSize: 12, fontWeight: 700, marginTop: 4, marginBottom: 6 },
heading4: { fontSize: 11, fontWeight: 700, marginTop: 4, marginBottom: 4 },
heading5: { fontSize: 10, fontWeight: 700, marginTop: 4, marginBottom: 4 },
heading6: { fontSize: 10, fontWeight: 700, marginTop: 4, marginBottom: 4 },
paragraph: { fontSize: 10, lineHeight: 1.5, marginBottom: 8, color: pdfColors.textParagraph },
bold: { fontWeight: 700 },
italic: { fontStyle: 'italic' },
inlineCode: {
fontFamily: 'Courier',
fontSize: 9,
backgroundColor: '#f0f0f0',
color: pdfColors.text,
},
link: { color: pdfColors.brand, textDecoration: 'underline' },
list: { marginBottom: 8 },
listItem: { flexDirection: 'row', marginBottom: 3 },
listItemMarker: { width: 16, fontSize: 10, color: pdfColors.textParagraph },
listItemContent: { flex: 1, fontSize: 10, lineHeight: 1.5, color: pdfColors.textParagraph },
blockquote: {
borderLeftWidth: 2,
borderLeftColor: pdfColors.border,
paddingLeft: 8,
marginBottom: 8,
},
thematicBreak: {
borderBottomWidth: 1,
borderBottomColor: pdfColors.border,
marginVertical: 10,
},
// Query cells
queryCard: {
borderWidth: 1,
borderColor: pdfColors.border,
borderRadius: 4,
marginBottom: 16,
},
queryHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: pdfColors.grayHeader,
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
borderBottomWidth: 1,
borderBottomColor: pdfColors.border,
},
queryTitle: { fontSize: 10, fontWeight: 500, color: pdfColors.text },
sqlBox: {
backgroundColor: pdfColors.graySqlBox,
paddingHorizontal: 10,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: pdfColors.border,
},
sqlText: { fontFamily: 'Courier', fontSize: 8.5, lineHeight: 1.5, color: pdfColors.sqlTextColor },
resultsBox: { paddingHorizontal: 10, paddingVertical: 8 },
resultsPlaceholder: { fontSize: 9, color: pdfColors.textTertiary },
resultsError: { fontSize: 9, color: pdfColors.errorText },
resultsHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: pdfColors.border,
},
resultsHeaderLabel: { fontSize: 8, fontWeight: 700, color: pdfColors.textMuted },
resultsHeaderCount: { fontSize: 8, color: pdfColors.textTertiary },
table: { width: '100%' },
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: pdfColors.border },
// The card's own footer already draws a top border below the table, so the last row
// skips its own bottom border rather than stacking a second line on top of that one.
tableRowLast: { borderBottomWidth: 0 },
tableHeaderRow: {
flexDirection: 'row',
backgroundColor: pdfColors.grayHeader,
borderBottomWidth: 1,
borderBottomColor: pdfColors.border,
},
tableCell: {
flex: 1,
fontSize: 8,
color: pdfColors.text,
paddingVertical: 4,
paddingHorizontal: 4,
borderRightWidth: 1,
borderRightColor: pdfColors.border,
overflow: 'hidden',
},
tableHeaderCell: {
flex: 1,
fontSize: 8,
fontWeight: 700,
color: pdfColors.textMuted,
paddingVertical: 4,
paddingHorizontal: 4,
borderRightWidth: 1,
borderRightColor: pdfColors.border,
},
tableCellNull: {
flex: 1,
fontSize: 8,
color: pdfColors.textTertiary,
fontStyle: 'italic',
paddingVertical: 4,
paddingHorizontal: 4,
borderRightWidth: 1,
borderRightColor: pdfColors.border,
},
footerRow: {
flexDirection: 'row',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: pdfColors.border,
},
footerText: { fontSize: 8, color: pdfColors.textTertiary },
})