Skip to content

Commit

Permalink
Feat Add DownloadMenu component for exporting markdown files
Browse files Browse the repository at this point in the history
Fix missing file name
  • Loading branch information
minai621 committed Jul 19, 2024
1 parent 57a64b4 commit 508f830
Show file tree
Hide file tree
Showing 9 changed files with 225 additions and 32 deletions.
8 changes: 5 additions & 3 deletions frontend/src/components/cards/DocumentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import moment from "moment";
import { Card, CardActionArea, CardContent, Stack, Typography } from "@mui/material";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import { Document } from "../../hooks/api/types/document.d";
import { Card, CardActionArea, CardContent, Stack, Typography } from "@mui/material";
import moment from "moment";
import { useNavigate, useParams } from "react-router-dom";
import { Document } from "../../hooks/api/types/document.d";
import { documentNameStorage } from "../../utils/localStorage";

interface DocumentCardProps {
document: Document;
Expand All @@ -14,6 +15,7 @@ function DocumentCard(props: DocumentCardProps) {
const params = useParams();

const handleToDocument = () => {
documentNameStorage.setDocumentName(document.title);
navigate(`/${params.workspaceSlug}/${document.id}`);
};

Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/common/DownloadMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { SaveAlt as SaveAltIcon } from "@mui/icons-material";
import { IconButton, Menu, MenuItem, Paper } from "@mui/material";
import { MouseEvent, useCallback, useState } from "react";
import { useFileExport } from "../../hooks/useFileExport";

const DownloadMenu = () => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

const { exportToPDF, exportToTXT, exportToDOCX } = useFileExport();

const handleExportToPDF = useCallback(() => {
exportToPDF();
handleClose();
}, [exportToPDF]);

const handleExportToTXT = useCallback(() => {
exportToTXT();
handleClose();
}, [exportToTXT]);

const handleExportToDOCX = useCallback(() => {
exportToDOCX();
handleClose();
}, [exportToDOCX]);

return (
<Paper>
<IconButton aria-controls="download-menu" aria-haspopup="true" onClick={handleClick}>
<SaveAltIcon />
</IconButton>
<Menu
id="download-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleExportToPDF}>Download as PDF</MenuItem>
<MenuItem onClick={handleExportToTXT}>Download as TXT</MenuItem>
<MenuItem onClick={handleExportToDOCX}>Download as DOCX</MenuItem>
</Menu>
</Paper>
);
};

export default DownloadMenu;
29 changes: 19 additions & 10 deletions frontend/src/components/editor/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import MarkdownPreview from "@uiw/react-markdown-preview";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { useSelector } from "react-redux";
import { selectEditor } from "../../store/editorSlice";
import { CircularProgress, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import "./editor.css";
import { addSoftLineBreak } from "../../utils/document";
import MarkdownPreview from "@uiw/react-markdown-preview";
import katex from "katex";
import "katex/dist/katex.min.css";
import { useContext, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import rehypeExternalLinks from "rehype-external-links";
import rehypeKatex from "rehype-katex";
import { getCodeString } from "rehype-rewrite";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeExternalLinks from "rehype-external-links";
import "katex/dist/katex.min.css";
import { PreviewRefContext } from "../../contexts/PreviewRefContext";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { selectEditor } from "../../store/editorSlice";
import { addSoftLineBreak } from "../../utils/document";
import { documentNameStorage } from "../../utils/localStorage";
import "./editor.css";

function Preview() {
const currentTheme = useCurrentTheme();
const editorStore = useSelector(selectEditor);
const [content, setContent] = useState("");

const { previewRef } = useContext(PreviewRefContext);

useEffect(() => {
if (!editorStore.doc) return;

Expand All @@ -39,6 +43,10 @@ function Preview() {
};
}, [editorStore.doc]);

useEffect(() => {
return () => documentNameStorage.deleteDocumentName();
}, [])

if (!editorStore?.doc)
return (
<Stack direction="row" justifyContent="center">
Expand All @@ -48,6 +56,7 @@ function Preview() {

return (
<MarkdownPreview
ref={previewRef}
source={addSoftLineBreak(content)}
wrapperElement={{
"data-color-mode": currentTheme,
Expand Down
22 changes: 12 additions & 10 deletions frontend/src/components/headers/DocumentHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
AppBar,
Avatar,
Expand All @@ -10,20 +14,17 @@ import {
Toolbar,
Tooltip,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import { useDispatch, useSelector } from "react-redux";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import ThemeButton from "../common/ThemeButton";
import ShareButton from "../common/ShareButton";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useList } from "react-use";
import { ActorID } from "yorkie-js-sdk";
import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import { useNavigate } from "react-router-dom";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import { selectWorkspace } from "../../store/workspaceSlice";
import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync";
import DownloadMenu from "../common/DownloadMenu";
import ShareButton from "../common/ShareButton";
import ThemeButton from "../common/ThemeButton";

function DocumentHeader() {
const dispatch = useDispatch();
Expand Down Expand Up @@ -125,6 +126,7 @@ function DocumentHeader() {
</ToggleButtonGroup>
)}
</Paper>
<DownloadMenu />
</Stack>
<Stack direction="row" alignItems="center" gap={1}>
<AvatarGroup max={4}>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/contexts/PreviewRefContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MarkdownPreviewRef } from "@uiw/react-markdown-preview";
import React from "react";

export interface PreviewRefContextValue {
previewRef: React.RefObject<MarkdownPreviewRef>;
}

export const PreviewRefContext = React.createContext<PreviewRefContextValue>({
previewRef: { current: null },
});
77 changes: 77 additions & 0 deletions frontend/src/hooks/useFileExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Document, Packer, Paragraph, TextRun } from "docx";
import { saveAs } from "file-saver";
import jspdfHtml2canvas from "jspdf-html2canvas";
import { useSnackbar } from "notistack";
import { useCallback, useContext } from "react";
import { useSelector } from "react-redux";
import { PreviewRefContext } from "../contexts/PreviewRefContext";
import { selectEditor } from "../store/editorSlice";
import { documentNameStorage } from "../utils/localStorage";

interface useFileExportReturn {
exportToPDF: () => Promise<void>;
exportToTXT: () => void;
exportToDOCX: () => void;
}

export const useFileExport = (): useFileExportReturn => {
const editorStore = useSelector(selectEditor);
const markdown = editorStore.doc?.getRoot().content?.toString() || "";

const { previewRef } = useContext(PreviewRefContext);

const { enqueueSnackbar } = useSnackbar();

const documentName = documentNameStorage.getDocumentName() || 'codepair_document';

const exportToPDF = useCallback(async () => {
if (previewRef.current?.mdp && previewRef.current.mdp.current instanceof HTMLDivElement) {
try {
await jspdfHtml2canvas(previewRef.current.mdp.current, {
output: `${documentName}`,
jsPDF: {
format: "a4",
orientation: "portrait",
},
html2canvas: {
scale: 3,
},
});
} catch (error) {
if(previewRef.current.mdp.current) {
enqueueSnackbar("Content is empty", { variant: "error" });
} else {
enqueueSnackbar("Failed to export PDF", { variant: "error" });
}
}
} else {
enqueueSnackbar("Please try again", { variant: "error" });
}
}, [previewRef, enqueueSnackbar, documentName]);

const exportToTXT = useCallback(() => {
const blob = new Blob([markdown], { type: "text/plain;charset=utf-8" });
saveAs(blob, documentName);
}, [documentName, markdown]);

const exportToDOCX = useCallback(() => {
const doc = new Document({
sections: [
{
properties: {},
children: [
new Paragraph({
children: [new TextRun(markdown)],
}),
],
},
],
});

Packer.toBlob(doc).then((blob) => {
saveAs(blob, documentName);
});
}, [markdown, documentName]);

return { exportToPDF, exportToTXT, exportToDOCX };
};
12 changes: 12 additions & 0 deletions frontend/src/providers/PreviewRefProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { MarkdownPreviewRef } from "@uiw/react-markdown-preview";
import { PropsWithChildren, useRef } from "react";
import { PreviewRefContext } from "../contexts/PreviewRefContext";

export default function PreviewRefProvider(props: PropsWithChildren) {
const { children } = props;
const previewRef = useRef<MarkdownPreviewRef | null>(null);

return (
<PreviewRefContext.Provider value={{ previewRef }}>{children}</PreviewRefContext.Provider>
);
}
23 changes: 14 additions & 9 deletions frontend/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import DocumentIndex from "./pages/workspace/document/Index";
import MainLayout from "./components/layouts/MainLayout";
import CallbackIndex from "./pages/auth/callback/Index";
import WorkspaceLayout from "./components/layouts/WorkspaceLayout";
import CodePairError from "./components/common/CodePairError";
import GuestRoute from "./components/common/GuestRoute";
import PrivateRoute from "./components/common/PrivateRoute";
import WorkspaceIndex from "./pages/workspace/Index";
import CodePairError from "./components/common/CodePairError";
import JoinIndex from "./pages/workspace/join/Index";
import Index from "./pages/Index";
import DocumentLayout from "./components/layouts/DocumentLayout";
import MainLayout from "./components/layouts/MainLayout";
import WorkspaceLayout from "./components/layouts/WorkspaceLayout";
import Index from "./pages/Index";
import CallbackIndex from "./pages/auth/callback/Index";
import WorkspaceIndex from "./pages/workspace/Index";
import DocumentIndex from "./pages/workspace/document/Index";
import DocumentShareIndex from "./pages/workspace/document/share/Index";
import JoinIndex from "./pages/workspace/join/Index";
import MemberIndex from "./pages/workspace/member/Index";
import PreviewRefProvider from "./providers/PreviewRefProvider";

interface CodePairRoute {
path: string;
Expand Down Expand Up @@ -59,7 +60,11 @@ const codePairRoutes: Array<CodePairRoute> = [
},
{
path: ":workspaceSlug",
element: <DocumentLayout />,
element: (
<PreviewRefProvider>
<DocumentLayout />
</PreviewRefProvider>
),
children: [
{
path: ":documentId",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const CURRENT_DOCUMENT_NAME = "CURRENT_DOCUMENT_NAME";

const getDocumentName = () => {
return localStorage.getItem(CURRENT_DOCUMENT_NAME);
}

const setDocumentName = (name: string) => {
localStorage.setItem(CURRENT_DOCUMENT_NAME, name);
}

const deleteDocumentName = () => {
localStorage.removeItem(CURRENT_DOCUMENT_NAME);
}

const documentNameStorage = {
getDocumentName,
setDocumentName,
deleteDocumentName,
};

export { documentNameStorage };

0 comments on commit 508f830

Please sign in to comment.