diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 52b8eba2fbd..b31f2a72797 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -122,6 +122,8 @@ interface FileViewerProps {
* never target one editor. See {@link RichMarkdownEditorProps.collaborative}.
*/
collaborative?: boolean
+ /** Reports when a Markdown file's rendered editor is available as a PDF print target. */
+ onMarkdownPrintReadyChange?: (fileId: string, ready: boolean) => void
/**
* Called (debounced) with the markdown document's leading-heading text while the file is still
* untitled, so the caller can name the file after it. Only wired for the editable markdown editor.
@@ -164,6 +166,7 @@ function FileViewerContent({
disableStreamingAutoScroll = false,
previewContextKey,
collaborative,
+ onMarkdownPrintReadyChange,
onDeriveTitleFromHeading,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
@@ -181,7 +184,13 @@ function FileViewerContent({
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
-
+
)
}
return
@@ -211,6 +220,7 @@ function FileViewerContent({
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
collaborative={collaborative}
+ onPrintReadyChange={onMarkdownPrintReadyChange}
onDeriveTitleFromHeading={onDeriveTitleFromHeading}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
index 1af8faa0bd0..828927c80c3 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css
@@ -560,3 +560,199 @@
border-radius: 2px;
pointer-events: none;
}
+
+@media print {
+ html[data-printing-markdown],
+ html[data-printing-markdown] body {
+ width: auto !important;
+ height: auto !important;
+ min-height: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ background: #fff !important;
+ color-scheme: light;
+ }
+
+ html[data-printing-markdown]
+ body
+ *:not(:has([data-markdown-print-active])):not([data-markdown-print-active]):not(
+ [data-markdown-print-active] *
+ ) {
+ display: none !important;
+ }
+
+ html[data-printing-markdown] body :has([data-markdown-print-active]),
+ html[data-printing-markdown] [data-markdown-print-active] {
+ position: static !important;
+ display: block !important;
+ width: auto !important;
+ height: auto !important;
+ min-height: 0 !important;
+ max-height: none !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ border: 0 !important;
+ background: #fff !important;
+ box-shadow: none !important;
+ transform: none !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] {
+ --bg: #fff;
+ --border: #d4d4d8;
+ --border-1: #a1a1aa;
+ --brand-secondary: #2563eb;
+ --code-bg: #f4f4f5;
+ --surface-1: #fff;
+ --surface-2: #fff;
+ --surface-5: #f4f4f5;
+ --text-muted: #71717a;
+ --text-primary: #18181b;
+ --text-secondary: #52525b;
+ --text-tertiary: #71717a;
+ flex: none !important;
+ background: #fff !important;
+ color: #18181b !important;
+ color-scheme: light;
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ > :has(> .rich-markdown-prose):not(.hidden) {
+ display: block !important;
+ width: 100% !important;
+ max-width: none !important;
+ min-height: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose {
+ display: block !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ min-height: 0 !important;
+ overflow: visible !important;
+ color: #18181b !important;
+ caret-color: transparent;
+ font-size: 11pt;
+ line-height: 1.6;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] button,
+ html[data-printing-markdown] [data-markdown-print-active] input:not([type="checkbox"]),
+ html[data-printing-markdown] [data-markdown-print-active] select,
+ html[data-printing-markdown] [data-markdown-print-active] textarea,
+ html[data-printing-markdown] [data-markdown-print-active] [role="listbox"],
+ html[data-printing-markdown] [data-markdown-print-active] [role="menu"],
+ html[data-printing-markdown] [data-markdown-print-active] [role="toolbar"],
+ html[data-printing-markdown] [data-markdown-print-active] [contenteditable="false"]:has(button),
+ html[data-printing-markdown] [data-markdown-print-active] .collaboration-carets__caret,
+ html[data-printing-markdown] [data-markdown-print-active] .collaboration-carets__selection,
+ html[data-printing-markdown] [data-markdown-print-active] .ProseMirror-gapcursor,
+ html[data-printing-markdown] [data-markdown-print-active] .column-resize-handle,
+ html[data-printing-markdown] [data-markdown-print-active] .selectedCell::after {
+ display: none !important;
+ }
+
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .ProseMirror-selectednode,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .ProseMirror-selectednode
+ img,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .rich-leaf-in-selection {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h1,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h2,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h3,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h4,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h5,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose h6 {
+ color: #18181b !important;
+ break-after: avoid-page;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose a {
+ color: #2563eb !important;
+ text-decoration: underline;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose pre,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ pre.code-editor-theme,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ pre.code-editor-theme
+ code,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .raw-markdown-block,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .raw-markdown-inline {
+ max-width: 100% !important;
+ overflow: visible !important;
+ white-space: pre-wrap !important;
+ overflow-wrap: anywhere !important;
+ word-break: break-word !important;
+ background: #f4f4f5 !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose pre,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose blockquote,
+ html[data-printing-markdown]
+ [data-markdown-print-active]
+ .rich-markdown-prose
+ .mermaid-diagram-frame,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose img {
+ break-inside: avoid-page;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose img,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose svg {
+ max-width: 100% !important;
+ height: auto !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose table {
+ width: 100% !important;
+ max-width: 100% !important;
+ table-layout: auto !important;
+ overflow: visible !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose th,
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose td {
+ min-width: 0 !important;
+ overflow-wrap: anywhere !important;
+ color: #18181b !important;
+ border-color: #d4d4d8 !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose th {
+ background: #f4f4f5 !important;
+ }
+
+ html[data-printing-markdown] [data-markdown-print-active] .rich-markdown-prose td {
+ background: #fff !important;
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index f00c89f7a0c..47a2bef770e 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -154,6 +154,8 @@ interface RichMarkdownEditorProps {
* full-document `setContent`, so the stream stays smooth and every peer sees it live.
*/
collaborative?: boolean
+ /** Reports when this file's rendered editor is available as a PDF print target. */
+ onPrintReadyChange?: (fileId: string, ready: boolean) => void
/**
* Called (debounced) with the document's leading-heading text while the file is still untitled, so the
* caller can name the file after it. Omitted on read-only/non-editable surfaces. See
@@ -180,6 +182,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
previewContextKey,
disableTagging,
collaborative = false,
+ onPrintReadyChange,
onDeriveTitleFromHeading,
}: RichMarkdownEditorProps) {
const { data: session, isPending: isSessionPending } = useSession()
@@ -220,6 +223,12 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
canAutosave: collabReady,
})
+ const isPrintReady = !isContentLoading && !isSessionPending && !hasContentError
+ useEffect(() => {
+ onPrintReadyChange?.(file.id, isPrintReady)
+ return () => onPrintReadyChange?.(file.id, false)
+ }, [file.id, isPrintReady, onPrintReadyChange])
+
// Wait for the session too: the child decides collaboration ONCE at mount from
// `userId`, so mounting before the session resolves would latch collaboration off
// for a cold-loaded file (both users would then solo-save, last-write-wins).
@@ -1204,6 +1213,7 @@ export function LoadedRichMarkdownEditor({
return (
{editor && (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index bb716412d30..9139a3f5e6d 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -18,7 +18,7 @@ import {
toast,
Upload,
} from '@sim/emcn'
-import { Download, Send } from '@sim/emcn/icons'
+import { Download, FileText, Send } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
@@ -339,6 +339,7 @@ export function Files() {
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState('idle')
+ const [markdownPrintReadyFileId, setMarkdownPrintReadyFileId] = useState(null)
const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set())
const [activeDropTargetId, setActiveDropTargetId] = useState(null)
const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set())
@@ -389,6 +390,13 @@ export function Files() {
const selectedFileRef = useRef(selectedFile)
selectedFileRef.current = selectedFile
+ const handleMarkdownPrintReadyChange = useCallback((fileId: string, ready: boolean) => {
+ setMarkdownPrintReadyFileId((currentFileId) => {
+ if (ready) return fileId
+ return currentFileId === fileId ? null : currentFileId
+ })
+ }, [])
+
/**
* While a file is still untitled, name it after the leading heading the user types in its editor. The
* editor reports the heading text (debounced); here we re-check the file is still untitled, derive a
@@ -1159,6 +1167,44 @@ export function Files() {
if (file) handleDownload(file)
}, [handleDownload])
+ const handleSaveMarkdownAsPdf = useCallback(() => {
+ const file = selectedFileRef.current
+ const printRoot = Array.from(
+ document.querySelectorAll('[data-markdown-print-root]')
+ ).find((root) => root.dataset.markdownPrintRoot === file?.id)
+
+ if (!file || !isMarkdownFile(file) || !printRoot) {
+ toast.error('Failed to prepare the Markdown file for PDF')
+ return
+ }
+
+ const previousTitle = document.title
+ const documentElement = document.documentElement
+ const suggestedTitle = file.name.replace(/\.(?:md|markdown)$/i, '') || file.name
+ let cleanedUp = false
+
+ const cleanup = () => {
+ if (cleanedUp) return
+ cleanedUp = true
+ printRoot.removeAttribute('data-markdown-print-active')
+ documentElement.removeAttribute('data-printing-markdown')
+ document.title = previousTitle
+ window.removeEventListener('afterprint', cleanup)
+ }
+
+ try {
+ document.title = suggestedTitle
+ printRoot.setAttribute('data-markdown-print-active', '')
+ documentElement.setAttribute('data-printing-markdown', '')
+ window.addEventListener('afterprint', cleanup, { once: true })
+ window.print()
+ } catch (error) {
+ cleanup()
+ logger.error('Failed to prepare Markdown file for PDF:', toError(error))
+ toast.error('Failed to prepare the Markdown file for PDF')
+ }
+ }, [])
+
const handleDeleteSelected = useCallback(() => {
const file = selectedFileRef.current
if (file) {
@@ -1645,6 +1691,16 @@ export function Files() {
icon: Download,
onSelect: handleDownloadSelected,
},
+ ...(isInlineMarkdown
+ ? [
+ {
+ text: 'Save as PDF…',
+ icon: FileText,
+ onSelect: handleSaveMarkdownAsPdf,
+ disabled: markdownPrintReadyFileId !== selectedFile.id,
+ },
+ ]
+ : []),
...(canEdit
? [
{
@@ -1668,6 +1724,8 @@ export function Files() {
handleCyclePreviewMode,
handleTogglePreview,
handleDownloadSelected,
+ handleSaveMarkdownAsPdf,
+ markdownPrintReadyFileId,
handleShareSelected,
handleDeleteSelected,
])
@@ -2069,6 +2127,7 @@ export function Files() {
saveRef={saveRef}
discardRef={discardRef}
collaborative
+ onMarkdownPrintReadyChange={handleMarkdownPrintReadyChange}
onDeriveTitleFromHeading={handleDeriveTitleFromHeading}
/>