diff --git a/next.config.mjs b/next.config.mjs index b4acabe..6e8602e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,5 +17,22 @@ export default withPWAConfig({ hostname: '*', } ] - } -}); \ No newline at end of file + }, + webpack: (config, { isServer }) => { + // If it's a server build, exclude the `.node` file from bundling + if (isServer) { + config.externals = config.externals || []; + config.externals.push({ + '@resvg/resvg-js-darwin-arm64/resvgjs.darwin-arm64.node': 'commonjs2 @resvg/resvg-js-darwin-arm64/resvgjs.darwin-arm64.node', + }); + } + + // Use node-loader for .node files + config.module.rules.push({ + test: /\.node$/, + use: 'node-loader', + }); + + return config; + }, +}); diff --git a/package.json b/package.json index 67321fc..6270eef 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "embla-carousel-react": "^8.0.4", "express": "^4.19.2", "framer-motion": "^11.1.9", + "html2canvas": "^1.4.1", + "jspdf": "^2.5.1", "langchain": "^0.2.4", "lucide-react": "^0.378.0", "moment": "^2.30.1", @@ -70,6 +72,7 @@ "resend": "^3.3.0", "sharp": "^0.33.3", "sonner": "^1.5.0", + "svg2img": "^1.0.0-beta.2", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "three": "^0.164.1", @@ -84,6 +87,7 @@ "editorjs-inline-image": "^2.1.1", "eslint": "^8.57.0", "eslint-config-next": "^14.2.3", + "node-loader": "^2.0.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", "typescript": "^5.4.5" diff --git a/src/app/workspace/[fileId]/page.tsx b/src/app/workspace/[fileId]/page.tsx index 2654666..c2725ee 100644 --- a/src/app/workspace/[fileId]/page.tsx +++ b/src/app/workspace/[fileId]/page.tsx @@ -1,21 +1,31 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef, MutableRefObject } from "react"; import WorkspaceHeader from "../_components/WorkspaceHeader"; import Editor from "../_components/Editor"; import { useConvex } from "convex/react"; import { api } from "../../../../convex/_generated/api"; import { FILE } from "../../dashboard/_components/FileList"; import Canvas from "../_components/Canvas"; +import dynamic from 'next/dynamic'; +import EditorJS, { OutputData } from "@editorjs/editorjs"; + +// Dynamic imports for server-side libraries +const jsPDFPromise = import('jspdf'); +const excalidrawPromise = import('@excalidraw/excalidraw'); function Workspace({ params }: any) { const [triggerSave, setTriggerSave] = useState(false); const convex = useConvex(); const [fileData, setFileData] = useState(); const [fullScreen, setFullScreen] = useState(false); + const editorRef = useRef(null); + const canvasRef = useRef(null); useEffect(() => { - params.fileId && getFileData(); - }, []); + if (params.fileId) { + getFileData(); + } + }, [params.fileId]); const getFileData = async () => { const result = await convex.query(api.files.getFileById, { @@ -23,7 +33,160 @@ function Workspace({ params }: any) { }); setFileData(result); }; - + + const saveAsPdf = async () => { + const { default: jsPDF } = await jsPDFPromise; + const { exportToSvg } = await excalidrawPromise; + + const editorInstance = editorRef.current; + const canvasInstance = canvasRef.current; + + if (editorInstance && canvasInstance) { + const pdf = new jsPDF("p", "mm", "a4"); + + // Extract text content from the editor + editorInstance.save().then((editorContent: OutputData) => { + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const textWidth = pageWidth - margin * 2; + const textHeight = pageHeight - margin * 2; + let y = margin; + + editorContent.blocks.forEach((block: any) => { + let lines: any[] = []; + + switch (block.type) { + case "paragraph": + lines = parseText(block.data.text); + break; + case "header": + pdf.setFontSize(16); // Set font size for header + lines = [{ text: block.data.text, style: 'header' }]; + pdf.setFontSize(12); // Reset font size + break; + case "list": + lines = block.data.items.map((item: string) => ({ text: `• ${item}`, style: 'normal' })); + break; + // Add more cases if needed for different block types + default: + lines = [{ text: block.data.text, style: 'normal' }]; + } + + lines.forEach((line: any) => { + if (y + 10 > textHeight) { + pdf.addPage(); + y = margin; + } + + switch (line.style) { + case 'bold': + pdf.setFont("helvetica", "bold"); + break; + case 'italic': + pdf.setFont("helvetica", "italic"); + break; + case 'header': + pdf.setFont("helvetica", "bold"); + const headerWidth = pdf.getStringUnitWidth(line.text) * 16 / pdf.internal.scaleFactor; + pdf.text(line.text, (pageWidth - headerWidth) / 2, y); + y += 10; + break; + default: + pdf.setFont("helvetica", "normal"); + } + + if (line.style !== 'header') { + // Split text if it's too wide and handle separately + const wrappedLines = pdf.splitTextToSize(line.text, textWidth); + wrappedLines.forEach((wrappedLine: string) => { + if (y + 10 > textHeight) { + pdf.addPage(); + y = margin; + } + pdf.text(wrappedLine, margin, y); + y += 10; + }); + } + }); + + // Reset font style and size after each block + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(12); + }); + + // Export flowchart as SVG from Excalidraw + const elements = canvasInstance.getSceneElements(); + const appState = canvasInstance.getAppState(); + const files = canvasInstance.getFiles(); + + exportToSvg({ + elements: elements, + appState: { ...appState, exportBackground: false }, // No background + files: files, + }).then((svg: SVGSVGElement) => { + // Add heading for the flowchart + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(16); // Set font size for the heading + const headingText = "Flowchart"; + const headingWidth = pdf.getStringUnitWidth(headingText) * pdf.internal.scaleFactor; + const headingX = (pageWidth - headingWidth) / 2; + pdf.text(headingText, headingX, y + 10); + pdf.setFontSize(12); // Reset font size + pdf.setFont("helvetica", "normal"); + y += 20; // Adjust y position to avoid overlap with the heading + + // Convert SVG to PNG using the Canvas API + const svgData = new XMLSerializer().serializeToString(svg); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + const img = new Image(); + + img.onload = () => { + if (context) { + canvas.width = img.width; + canvas.height = img.height; + context.drawImage(img, 0, 0); + + const imgData = canvas.toDataURL("image/png"); + const imgProps = pdf.getImageProperties(imgData); + const imgHeight = (imgProps.height * pageWidth) / imgProps.width; + + // Add canvas image just below the heading + pdf.addImage(imgData, "PNG", margin, y, pageWidth - margin * 2, imgHeight); + y += imgHeight; + + // Save the PDF + pdf.save("document.pdf"); + } else { + console.error("Failed to get canvas context"); + } + }; + + img.src = `data:image/svg+xml;base64,${btoa(svgData)}`; + }); + }); + } else { + console.error("Unable to find the content to save as PDF"); + } + }; + + const parseText = (text: string) => { + const lines: any[] = []; + const parser = new DOMParser(); + const parsedHtml = parser.parseFromString(text, 'text/html'); + parsedHtml.body.childNodes.forEach((node: ChildNode) => { + if (node.nodeType === Node.TEXT_NODE) { + lines.push({ text: node.textContent, style: 'normal' }); + } else if (node.nodeName === 'B') { + lines.push({ text: node.textContent, style: 'bold' }); + } else if (node.nodeName === 'I') { + lines.push({ text: node.textContent, style: 'italic' }); + } + }); + return lines; + }; + return (
@@ -32,24 +195,21 @@ function Workspace({ params }: any) { name={fileData?.fileName || "New Document"} setFullScreen={setFullScreen} setFileData={setFileData} + onSaveAsPdf={saveAsPdf} /> -
-
- -
-
- {/*Render the - Canvas component here. - */} +
+
+ } + onSaveTrigger={triggerSave} + fileId={params.fileId} + fileData={fileData} + /> +
+
} onSaveTrigger={triggerSave} fileId={params.fileId} fileData={fileData} diff --git a/src/app/workspace/_components/Canvas.tsx b/src/app/workspace/_components/Canvas.tsx index 6d4c8d1..1f05fa6 100644 --- a/src/app/workspace/_components/Canvas.tsx +++ b/src/app/workspace/_components/Canvas.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, forwardRef, useImperativeHandle } from "react"; import { Excalidraw, MainMenu, WelcomeScreen } from "@excalidraw/excalidraw"; import { FILE } from "../../dashboard/_components/FileList"; import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; @@ -7,7 +7,7 @@ import { useTheme } from "next-themes"; import { api } from "../../../../convex/_generated/api"; import { toast } from "sonner"; -function Canvas({ +const Canvas = forwardRef(({ onSaveTrigger, fileId, fileData, @@ -15,11 +15,12 @@ function Canvas({ onSaveTrigger: any; fileId: any; fileData: FILE; -}) { +}, ref) => { const [whiteBoardData, setWhiteBoardData] = useState(); const { theme } = useTheme(); const updateWhiteboard = useMutation(api.files.updateWhiteboard); + useEffect(() => { onSaveTrigger && saveWhiteboard(); }, [onSaveTrigger]); @@ -33,7 +34,11 @@ function Canvas({ }); }; - + useImperativeHandle(ref, () => ({ + getSceneElements: () => whiteBoardData, + getAppState: () => ({ viewBackgroundColor: "#e6e6e6" }), + getFiles: () => [], // Implement getFiles based on your app's requirements + })); const handleMermaidToExcalidraw = async () => { const mermaidCode = `flowchart TD @@ -107,6 +112,8 @@ function Canvas({
); -} +}); + +Canvas.displayName = "Canvas"; export default Canvas; diff --git a/src/app/workspace/_components/Editor.tsx b/src/app/workspace/_components/Editor.tsx index 3002459..57f0a62 100644 --- a/src/app/workspace/_components/Editor.tsx +++ b/src/app/workspace/_components/Editor.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; -import EditorJS from "@editorjs/editorjs"; +import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react"; +import EditorJS, { OutputData } from "@editorjs/editorjs"; // @ts-ignore import Header from "@editorjs/header"; // @ts-ignore @@ -14,13 +14,12 @@ import Warning from "@editorjs/warning"; // @ts-ignore import InlineImage from 'editorjs-inline-image'; // @ts-ignore -import Table from '@editorjs/table' +import Table from '@editorjs/table'; import { useMutation } from "convex/react"; import { api } from "../../../../convex/_generated/api"; import { toast } from "sonner"; import { FILE } from "../../dashboard/_components/FileList"; import { useTheme } from "next-themes"; -import { table } from "console"; const rawDocument = { time: 1550476186479, @@ -44,43 +43,52 @@ const rawDocument = { version: "2.8.1", }; -function Editor({ - onSaveTrigger, - fileId, - fileData, -}: { - onSaveTrigger: any; +type EditorProps = { + onSaveTrigger: boolean; fileId: any; fileData: FILE; -}) { - const ref = useRef(null); +}; + +const Editor = forwardRef((props: EditorProps, ref) => { + const editorInstanceRef = useRef(null); const updateDocument = useMutation(api.files.updateDocument); const [document, setDocument] = useState(rawDocument); const { theme } = useTheme(); + useImperativeHandle(ref, () => ({ + get instance() { + return editorInstanceRef.current; + }, + save: () => { + return editorInstanceRef.current + ? editorInstanceRef.current.save() + : Promise.resolve({ blocks: [] } as OutputData); + }, + }), [editorInstanceRef]); + useEffect(() => { - if (fileData) { + if (props.fileData) { initEditor(); } return () => { - if (ref.current) { - ref.current.destroy(); - ref.current = null; + if (editorInstanceRef.current) { + editorInstanceRef.current.destroy(); + editorInstanceRef.current = null; } }; - }, [fileData]); + }, [props.fileData]); useEffect(() => { - onSaveTrigger && onSaveDocument(); - }, [onSaveTrigger]); + props.onSaveTrigger && onSaveDocument(); + }, [props.onSaveTrigger]); const initEditor = () => { - if (ref.current) { - ref.current.destroy(); + if (editorInstanceRef.current) { + editorInstanceRef.current.destroy(); } - ref.current = new EditorJS({ + editorInstanceRef.current = new EditorJS({ tools: { header: { class: Header, @@ -102,9 +110,9 @@ function Editor({ class: Checklist, inlineToolbar: true, }, - paragraph:{ - class:Paragraph, - inlineToolbar:true + paragraph: { + class: Paragraph, + inlineToolbar: true, }, warning: Warning, image: { @@ -118,8 +126,8 @@ function Editor({ appName: 'india', apiUrl: 'https://unsplash.com/s/photos/', maxResults: 30, - } - } + }, + }, }, table: { class: Table, @@ -127,23 +135,23 @@ function Editor({ config: { rows: 2, cols: 3, - withHeadings:true + withHeadings: true, }, }, }, holder: "editorjs", - data: fileData?.document ? JSON.parse(fileData.document) : rawDocument, + data: props.fileData?.document ? JSON.parse(props.fileData.document) : rawDocument, }); }; const onSaveDocument = () => { - if (ref.current) { - ref.current + if (editorInstanceRef.current) { + editorInstanceRef.current .save() .then((outputData) => { updateDocument({ - _id: fileId, + _id: props.fileId, document: JSON.stringify(outputData), }).then( (resp) => { @@ -160,17 +168,17 @@ function Editor({ } }; - - return (
); -} +}); + +Editor.displayName = 'Editor'; export default Editor; diff --git a/src/app/workspace/_components/WorkspaceHeader.tsx b/src/app/workspace/_components/WorkspaceHeader.tsx index f0c8a8a..dc58352 100644 --- a/src/app/workspace/_components/WorkspaceHeader.tsx +++ b/src/app/workspace/_components/WorkspaceHeader.tsx @@ -7,8 +7,7 @@ import { toast } from "sonner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { GenAIModal } from "@/components/shared/AiModal"; - -function WorkspaceHeader({ onSave, name, setFullScreen, setFileData }: any) { +function WorkspaceHeader({ onSave, onSaveAsPdf, name, setFullScreen, setFileData }: any) { return ( <>
@@ -27,7 +26,10 @@ function WorkspaceHeader({ onSave, name, setFullScreen, setFileData }: any) {
+
@@ -52,9 +54,9 @@ function WorkspaceHeader({ onSave, name, setFullScreen, setFileData }: any) { }} >

- Share + Share

- {" "} +