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} />