mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add multi-format document export functionality to editor routes and UI components
- Implemented a new export endpoint in the backend to support exporting documents in various formats (PDF, DOCX, HTML, LaTeX, EPUB, ODT, plain text). - Enhanced DocumentNode and FolderTreeView components to include export options in context and dropdown menus. - Created shared ExportMenuItems component for consistent export options across the application. - Integrated loading indicators for export actions to improve user experience.
This commit is contained in:
parent
0aa9cd6dfc
commit
b5ef7afb1c
6 changed files with 411 additions and 81 deletions
|
|
@ -1,19 +1,43 @@
|
|||
"""
|
||||
Editor routes for document editing with markdown (Plate.js frontend).
|
||||
Includes multi-format export (PDF, DOCX, HTML, LaTeX, EPUB, ODT, plain text).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import pypandoc
|
||||
import typst
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import Document, DocumentType, Permission, User, get_async_session
|
||||
from app.routes.reports_routes import (
|
||||
ExportFormat,
|
||||
_FILE_EXTENSIONS,
|
||||
_MEDIA_TYPES,
|
||||
_normalize_latex_delimiters,
|
||||
_strip_wrapping_code_fences,
|
||||
)
|
||||
from app.templates.export_helpers import (
|
||||
get_html_css_path,
|
||||
get_reference_docx_path,
|
||||
get_typst_template_path,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
|
@ -212,3 +236,153 @@ async def save_document(
|
|||
"message": "Document saved and will be reindexed in the background",
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search-spaces/{search_space_id}/documents/{document_id}/export"
|
||||
)
|
||||
async def export_document(
|
||||
search_space_id: int,
|
||||
document_id: int,
|
||||
format: ExportFormat = Query(
|
||||
ExportFormat.PDF,
|
||||
description="Export format: pdf, docx, html, latex, epub, odt, or plain",
|
||||
),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Export a document in the requested format (reuses the report export pipeline)."""
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.DOCUMENTS_READ.value,
|
||||
"You don't have permission to read documents in this search space",
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.filter(
|
||||
Document.id == document_id,
|
||||
Document.search_space_id == search_space_id,
|
||||
)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Resolve markdown content (same priority as editor-content endpoint)
|
||||
markdown_content: str | None = document.source_markdown
|
||||
if markdown_content is None and document.blocknote_document:
|
||||
from app.utils.blocknote_to_markdown import blocknote_to_markdown
|
||||
|
||||
markdown_content = blocknote_to_markdown(document.blocknote_document)
|
||||
if markdown_content is None:
|
||||
chunks = sorted(document.chunks, key=lambda c: c.id)
|
||||
if chunks:
|
||||
markdown_content = "\n\n".join(chunk.content for chunk in chunks)
|
||||
|
||||
if not markdown_content or not markdown_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Document has no content to export"
|
||||
)
|
||||
|
||||
markdown_content = _strip_wrapping_code_fences(markdown_content)
|
||||
markdown_content = _normalize_latex_delimiters(markdown_content)
|
||||
|
||||
doc_title = document.title or "Document"
|
||||
formatted_date = (
|
||||
document.created_at.strftime("%B %d, %Y") if document.created_at else ""
|
||||
)
|
||||
input_fmt = "gfm+tex_math_dollars"
|
||||
meta_args = ["-M", f"title:{doc_title}", "-M", f"date:{formatted_date}"]
|
||||
|
||||
def _convert_and_read() -> bytes:
|
||||
if format == ExportFormat.PDF:
|
||||
typst_template = str(get_typst_template_path())
|
||||
typst_markup: str = pypandoc.convert_text(
|
||||
markdown_content,
|
||||
"typst",
|
||||
format=input_fmt,
|
||||
extra_args=[
|
||||
"--standalone",
|
||||
f"--template={typst_template}",
|
||||
"-V", "mainfont:Libertinus Serif",
|
||||
"-V", "codefont:DejaVu Sans Mono",
|
||||
*meta_args,
|
||||
],
|
||||
)
|
||||
return typst.compile(typst_markup.encode("utf-8"))
|
||||
|
||||
if format == ExportFormat.DOCX:
|
||||
return _pandoc_to_tempfile(
|
||||
format.value,
|
||||
["--standalone", f"--reference-doc={get_reference_docx_path()}", *meta_args],
|
||||
)
|
||||
|
||||
if format == ExportFormat.HTML:
|
||||
html_str: str = pypandoc.convert_text(
|
||||
markdown_content,
|
||||
"html5",
|
||||
format=input_fmt,
|
||||
extra_args=[
|
||||
"--standalone", "--embed-resources",
|
||||
f"--css={get_html_css_path()}",
|
||||
"--syntax-highlighting=pygments",
|
||||
*meta_args,
|
||||
],
|
||||
)
|
||||
return html_str.encode("utf-8")
|
||||
|
||||
if format == ExportFormat.EPUB:
|
||||
return _pandoc_to_tempfile("epub3", ["--standalone", *meta_args])
|
||||
|
||||
if format == ExportFormat.ODT:
|
||||
return _pandoc_to_tempfile("odt", ["--standalone", *meta_args])
|
||||
|
||||
if format == ExportFormat.LATEX:
|
||||
tex_str: str = pypandoc.convert_text(
|
||||
markdown_content, "latex", format=input_fmt,
|
||||
extra_args=["--standalone", *meta_args],
|
||||
)
|
||||
return tex_str.encode("utf-8")
|
||||
|
||||
plain_str: str = pypandoc.convert_text(
|
||||
markdown_content, "plain", format=input_fmt,
|
||||
extra_args=["--wrap=auto", "--columns=80"],
|
||||
)
|
||||
return plain_str.encode("utf-8")
|
||||
|
||||
def _pandoc_to_tempfile(output_format: str, extra_args: list[str]) -> bytes:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=f".{output_format}")
|
||||
os.close(fd)
|
||||
try:
|
||||
pypandoc.convert_text(
|
||||
markdown_content, output_format, format=input_fmt,
|
||||
extra_args=extra_args, outputfile=tmp_path,
|
||||
)
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
output = await loop.run_in_executor(None, _convert_and_read)
|
||||
except Exception as e:
|
||||
logger.exception("Document export failed")
|
||||
raise HTTPException(status_code=500, detail=f"Export failed: {e!s}") from e
|
||||
|
||||
safe_title = (
|
||||
"".join(c if c.isalnum() or c in " -_" else "_" for c in doc_title)
|
||||
.strip()[:80]
|
||||
or "document"
|
||||
)
|
||||
ext = _FILE_EXTENSIONS[format]
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output),
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_title}.{ext}"'},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Clock, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { ExportContextItems, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -41,6 +48,7 @@ interface DocumentNodeProps {
|
|||
onEdit: (doc: DocumentNodeDoc) => void;
|
||||
onDelete: (doc: DocumentNodeDoc) => void;
|
||||
onMove: (doc: DocumentNodeDoc) => void;
|
||||
onExport?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -54,6 +62,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
onExport,
|
||||
contextMenuOpen,
|
||||
onContextMenuOpenChange,
|
||||
}: DocumentNodeProps) {
|
||||
|
|
@ -79,8 +88,19 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
|
||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState<string | null>(null);
|
||||
const rowRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: string) => {
|
||||
if (!onExport) return;
|
||||
setExporting(format);
|
||||
onExport(doc, format);
|
||||
setTimeout(() => setExporting(null), 2000);
|
||||
},
|
||||
[doc, onExport]
|
||||
);
|
||||
|
||||
const attachRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
(rowRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
|
||||
|
|
@ -167,7 +187,7 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 transition-opacity hover:bg-transparent",
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen ? "opacity-100 bg-accent hover:bg-accent" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -190,6 +210,17 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
|
|
@ -219,6 +250,17 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface FolderTreeViewProps {
|
|||
onEditDocument: (doc: DocumentNodeDoc) => void;
|
||||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
onDropIntoFolder?: (
|
||||
itemType: "folder" | "document",
|
||||
|
|
@ -62,6 +63,7 @@ export function FolderTreeView({
|
|||
onEditDocument,
|
||||
onDeleteDocument,
|
||||
onMoveDocument,
|
||||
onExportDocument,
|
||||
activeTypes,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
|
|
@ -181,6 +183,7 @@ export function FolderTreeView({
|
|||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
onExport={onExportDocument}
|
||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||
import {
|
||||
DocumentsTableShell,
|
||||
|
|
@ -33,6 +34,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
|
|||
import { useDocuments } from "@/hooks/use-documents";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
|
|
@ -234,6 +236,43 @@ export function DocumentsSidebar({
|
|||
setFolderPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleExportDocument = useCallback(
|
||||
async (doc: DocumentNodeDoc, format: string) => {
|
||||
const safeTitle =
|
||||
doc.title
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 80) || "document";
|
||||
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
||||
throw new Error(errorData.detail || "Export failed");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error(`Export ${format} failed:`, err);
|
||||
toast.error(err instanceof Error ? err.message : `Export failed`);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const handleFolderPickerSelect = useCallback(
|
||||
async (targetFolderId: number | null) => {
|
||||
if (!folderPickerTarget) return;
|
||||
|
|
@ -606,6 +645,7 @@ export function DocumentsSidebar({
|
|||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
activeTypes={activeTypes}
|
||||
onDropIntoFolder={handleDropIntoFolder}
|
||||
onReorderFolder={handleReorderFolder}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ExportDropdownItems, EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
@ -198,19 +197,6 @@ export function ReportPanelContent({
|
|||
}
|
||||
}, [currentMarkdown]);
|
||||
|
||||
// Maps backend format values to download file extensions
|
||||
const FILE_EXTENSIONS: Record<string, string> = {
|
||||
pdf: "pdf",
|
||||
docx: "docx",
|
||||
html: "html",
|
||||
latex: "tex",
|
||||
epub: "epub",
|
||||
odt: "odt",
|
||||
plain: "txt",
|
||||
md: "md",
|
||||
};
|
||||
|
||||
// Export report
|
||||
const handleExport = useCallback(
|
||||
async (format: string) => {
|
||||
setExporting(format);
|
||||
|
|
@ -219,7 +205,7 @@ export function ReportPanelContent({
|
|||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 80) || "report";
|
||||
const ext = FILE_EXTENSIONS[format] ?? format;
|
||||
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
|
||||
try {
|
||||
if (format === "md") {
|
||||
if (!currentMarkdown) return;
|
||||
|
|
@ -329,68 +315,11 @@ export function ReportPanelContent({
|
|||
align="start"
|
||||
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
{!shareToken && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Documents
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("docx")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
Word (.docx)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("odt")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
OpenDocument (.odt)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Web & E-Book
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("html")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
HTML (.html)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("epub")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
EPUB (.epub)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Source & Plain
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("latex")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
LaTeX (.tex)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => handleExport("md")} disabled={exporting !== null}>
|
||||
Markdown (.md)
|
||||
</DropdownMenuItem>
|
||||
{!shareToken && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("plain")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
Plain Text (.txt)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ExportDropdownItems
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
showAllFormats={!shareToken}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
|
|
|||
142
surfsense_web/components/shared/ExportMenuItems.tsx
Normal file
142
surfsense_web/components/shared/ExportMenuItems.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||
|
||||
export const EXPORT_FILE_EXTENSIONS: Record<string, string> = {
|
||||
pdf: "pdf",
|
||||
docx: "docx",
|
||||
html: "html",
|
||||
latex: "tex",
|
||||
epub: "epub",
|
||||
odt: "odt",
|
||||
plain: "txt",
|
||||
md: "md",
|
||||
};
|
||||
|
||||
interface ExportMenuItemsProps {
|
||||
onExport: (format: string) => void;
|
||||
exporting: string | null;
|
||||
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
|
||||
showAllFormats?: boolean;
|
||||
}
|
||||
|
||||
export function ExportDropdownItems({
|
||||
onExport,
|
||||
exporting,
|
||||
showAllFormats = true,
|
||||
}: ExportMenuItemsProps) {
|
||||
const handle = (format: string) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onExport(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAllFormats && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Documents
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
|
||||
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
PDF (.pdf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handle("docx")} disabled={exporting !== null}>
|
||||
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Word (.docx)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handle("odt")} disabled={exporting !== null}>
|
||||
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
OpenDocument (.odt)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Web & E-Book
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={handle("html")} disabled={exporting !== null}>
|
||||
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
HTML (.html)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handle("epub")} disabled={exporting !== null}>
|
||||
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
EPUB (.epub)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Source & Plain
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={handle("latex")} disabled={exporting !== null}>
|
||||
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
LaTeX (.tex)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handle("md")} disabled={exporting !== null}>
|
||||
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Markdown (.md)
|
||||
</DropdownMenuItem>
|
||||
{showAllFormats && (
|
||||
<DropdownMenuItem onClick={handle("plain")} disabled={exporting !== null}>
|
||||
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Plain Text (.txt)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportContextItems({
|
||||
onExport,
|
||||
exporting,
|
||||
showAllFormats = true,
|
||||
}: ExportMenuItemsProps) {
|
||||
const handle = (format: string) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onExport(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAllFormats && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
|
||||
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
PDF (.pdf)
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handle("docx")} disabled={exporting !== null}>
|
||||
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Word (.docx)
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handle("odt")} disabled={exporting !== null}>
|
||||
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
OpenDocument (.odt)
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handle("html")} disabled={exporting !== null}>
|
||||
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
HTML (.html)
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handle("epub")} disabled={exporting !== null}>
|
||||
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
EPUB (.epub)
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handle("latex")} disabled={exporting !== null}>
|
||||
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
LaTeX (.tex)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={handle("md")} disabled={exporting !== null}>
|
||||
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Markdown (.md)
|
||||
</ContextMenuItem>
|
||||
{showAllFormats && (
|
||||
<ContextMenuItem onClick={handle("plain")} disabled={exporting !== null}>
|
||||
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
||||
Plain Text (.txt)
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue