diff --git a/client/features/upload-form/components/UploadView.tsx b/client/features/upload-form/components/UploadView.tsx index d0913df..208d899 100644 --- a/client/features/upload-form/components/UploadView.tsx +++ b/client/features/upload-form/components/UploadView.tsx @@ -31,12 +31,16 @@ interface UploadViewProps { fileInputRef: RefObject; isPreviewOpen: boolean; isPreviewLoading: boolean; + isParsingMarkdown: boolean; + isGeneratingPdf: boolean; + canGeneratePdf: boolean; turnstileToken: string | null; onFileSelect: (file: File) => void; onExpirationChange: (days: number) => void; onThemeChange: (theme: ThemeId) => void; onPreview: () => void; onUpload: () => void; + onGeneratePdf: () => void; } export function UploadView({ @@ -50,12 +54,16 @@ export function UploadView({ fileInputRef, isPreviewOpen, isPreviewLoading, + isParsingMarkdown, + isGeneratingPdf, + canGeneratePdf, turnstileToken, onFileSelect, onExpirationChange, onThemeChange, onPreview, onUpload, + onGeneratePdf, }: UploadViewProps) { const { t, i18n } = useTranslation(); const [isDragover, setIsDragover] = useState(false); @@ -441,6 +449,22 @@ export function UploadView({ {t("upload.createPage")} )} + + ); diff --git a/client/lib/i18nResources.ts b/client/lib/i18nResources.ts index 3c26c84..6c2301f 100644 --- a/client/lib/i18nResources.ts +++ b/client/lib/i18nResources.ts @@ -72,6 +72,8 @@ export const resources = { closePreview: "Close Preview", tryLater: "Try later", createPage: "Create Page", + createPdf: "Create PDF", + generatingPdf: "Preparing PDF...", wikiLinkWarningTitle: "Wiki links detected", wikiLinkWarningBody: "External URLs inside wiki links won't be turned into pages here.", @@ -282,6 +284,8 @@ export const resources = { closePreview: "미리보기 닫기", tryLater: "나중에 다시 시도", createPage: "페이지 만들기", + createPdf: "PDF 생성", + generatingPdf: "PDF 준비 중...", wikiLinkWarningTitle: "위키 링크가 감지되었어요", wikiLinkWarningBody: "위키 링크 안의 외부 URL은 여기서 페이지로 만들어지지 않아요.", @@ -491,6 +495,8 @@ export const resources = { closePreview: "关闭预览", tryLater: "稍后再试", createPage: "创建页面", + createPdf: "生成 PDF", + generatingPdf: "正在准备 PDF...", wikiLinkWarningTitle: "检测到 Wiki 链接", wikiLinkWarningBody: "Wiki 链接中的外部 URL 不会在这里生成页面。", errors: { @@ -693,6 +699,8 @@ export const resources = { closePreview: "プレビューを閉じる", tryLater: "後でもう一度試す", createPage: "ページを作成", + createPdf: "PDF を生成", + generatingPdf: "PDF を準備中...", wikiLinkWarningTitle: "Wiki リンクを検出しました", wikiLinkWarningBody: "Wiki リンク内の外部 URL はここではページ化されません。", diff --git a/client/routes/index.tsx b/client/routes/index.tsx index 26c4534..6b42ddc 100644 --- a/client/routes/index.tsx +++ b/client/routes/index.tsx @@ -1,6 +1,7 @@ import { Github } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { getApiDocsHref } from "@shared/docs/api-docs"; +import { ViewTemplate } from "@shared/templates/view.template"; import type { ThemeId } from "@shared/themes/theme-registry"; import { createFileRoute, Link } from "@tanstack/react-router"; import { lazy, Suspense, useEffect, useState } from "react"; @@ -34,6 +35,56 @@ const MarkdownParser = lazy(() => ); const SITE_ORIGIN = "https://mdto.page"; +const PDF_IFRAME_CLEANUP_TIMEOUT_MS = 5000; +function printHtmlDocument(html: string, onPrintInitiated?: () => void): void { + const iframe = document.createElement("iframe"); + iframe.setAttribute("aria-hidden", "true"); + iframe.style.position = "fixed"; + iframe.style.right = "0"; + iframe.style.bottom = "0"; + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.border = "0"; + iframe.style.opacity = "0"; + iframe.style.pointerEvents = "none"; + let hasNotifiedPrintInitiated = false; + const notifyPrintInitiated = () => { + if (hasNotifiedPrintInitiated) { + return; + } + hasNotifiedPrintInitiated = true; + onPrintInitiated?.(); + }; + const cleanupIframe = () => { + iframe.remove(); + }; + + iframe.onerror = () => { + notifyPrintInitiated(); + cleanupIframe(); + }; + + iframe.onload = () => { + const frameWindow = iframe.contentWindow; + if (!frameWindow) { + notifyPrintInitiated(); + cleanupIframe(); + return; + } + const cleanupAfterPrint = () => { + frameWindow.removeEventListener("afterprint", cleanupAfterPrint); + cleanupIframe(); + }; + frameWindow.addEventListener("afterprint", cleanupAfterPrint); + notifyPrintInitiated(); + frameWindow.focus(); + frameWindow.print(); + window.setTimeout(cleanupAfterPrint, PDF_IFRAME_CLEANUP_TIMEOUT_MS); + }; + + iframe.srcdoc = html; + document.body.appendChild(iframe); +} export const Route = createFileRoute("/")({ head: () => ({ @@ -50,6 +101,7 @@ function Home() { const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isWarningDialogOpen, setIsWarningDialogOpen] = useState(false); const [isUploadLimitDialogOpen, setIsUploadLimitDialogOpen] = useState(false); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [parsedMarkdown, setParsedMarkdown] = useState( null, ); @@ -139,6 +191,8 @@ function Home() { const hasWikiLink = !isParsingMarkdown && parsedMarkdown?.metadata.hasWikiLink === true; + const canGeneratePdf = + !!selectedFile && !!parsedMarkdown && !isParsingMarkdown; const handleUploadClick = () => { if (!session?.user) { @@ -148,6 +202,39 @@ function Home() { handleUpload(); }; + const handleGeneratePdf = () => { + if ( + !canGeneratePdf || + !parsedMarkdown || + !selectedFile || + isGeneratingPdf + ) { + return; + } + + setIsGeneratingPdf(true); + const expirationTime = + expirationDays === -1 + ? undefined + : (Date.now() + expirationDays * 24 * 60 * 60 * 1000).toString(); + + const printHtml = ViewTemplate({ + lang: parsedMarkdown.metadata.lang, + title: selectedFile.name, + description: parsedMarkdown.metadata.description, + expiresAt: expirationTime, + html: parsedMarkdown.html, + theme: selectedTheme, + markdown: parsedMarkdown.markdown, + hasKatex: parsedMarkdown.metadata.hasKatex, + hasMermaid: parsedMarkdown.metadata.hasMermaid, + }); + + printHtmlDocument(printHtml.toString(), () => { + setIsGeneratingPdf(false); + }); + }; + return ( <> {/* Main Container - Conditional Styles for Split View */} @@ -262,8 +349,14 @@ function Home() { onPreview={togglePreview} isPreviewOpen={showPreview} isPreviewLoading={isPreviewLoading} + isParsingMarkdown={isParsingMarkdown} + isGeneratingPdf={isGeneratingPdf} + canGeneratePdf={canGeneratePdf} turnstileToken={turnstileToken} onUpload={handleUploadClick} + onGeneratePdf={() => { + handleGeneratePdf(); + }} /> )}