Skip to content
Closed
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 @@ -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.
Expand Down Expand Up @@ -164,6 +166,7 @@ function FileViewerContent({
disableStreamingAutoScroll = false,
previewContextKey,
collaborative,
onMarkdownPrintReadyChange,
onDeriveTitleFromHeading,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
Expand All @@ -181,7 +184,13 @@ function FileViewerContent({
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={false}
onPrintReadyChange={onMarkdownPrintReadyChange}
/>
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
Expand Down Expand Up @@ -211,6 +220,7 @@ function FileViewerContent({
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
collaborative={collaborative}
onPrintReadyChange={onMarkdownPrintReadyChange}
onDeriveTitleFromHeading={onDeriveTitleFromHeading}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -180,6 +182,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
previewContextKey,
disableTagging,
collaborative = false,
onPrintReadyChange,
onDeriveTitleFromHeading,
}: RichMarkdownEditorProps) {
const { data: session, isPending: isSessionPending } = useSession()
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -1204,6 +1213,7 @@ export function LoadedRichMarkdownEditor({
return (
<div
ref={containerRef}
data-markdown-print-root={file.id}
className={cn('relative flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && (
Expand Down
61 changes: 60 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -339,6 +339,7 @@ export function Files() {
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [markdownPrintReadyFileId, setMarkdownPrintReadyFileId] = useState<string | null>(null)
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(() => new Set())
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(null)
const [draggedRowIds, setDraggedRowIds] = useState<Set<string>>(() => new Set())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement>('[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
}
Comment thread
cursor[bot] marked this conversation as resolved.

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) {
Expand Down Expand Up @@ -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
? [
{
Expand All @@ -1668,6 +1724,8 @@ export function Files() {
handleCyclePreviewMode,
handleTogglePreview,
handleDownloadSelected,
handleSaveMarkdownAsPdf,
markdownPrintReadyFileId,
handleShareSelected,
handleDeleteSelected,
])
Expand Down Expand Up @@ -2069,6 +2127,7 @@ export function Files() {
saveRef={saveRef}
discardRef={discardRef}
collaborative
onMarkdownPrintReadyChange={handleMarkdownPrintReadyChange}
onDeriveTitleFromHeading={handleDeriveTitleFromHeading}
/>

Expand Down
Loading