docuflow / src /hooks /useDocumentOperations.ts
Joedroid's picture
fix: replace window.print() with html2pdf.js for direct PDF export
27e7d1a
Raw
History Blame Contribute Delete
12.1 kB
import { useCallback } from "react";
import { Document } from "../types";
import { findConsecutiveDuplicates } from "../utils/grammarUtils";
import { syncDocument as apiSyncDocument, uploadAndConvert, getConvertStatus, deleteDocument as apiDeleteDocument } from "../lib/apiClient";
export function useDocumentOperations(
documents: Document[],
setDocuments: React.Dispatch<React.SetStateAction<Document[]>>,
activeDocId: string,
setActiveDocId: (id: string) => void,
editorText: string,
setEditorText: (val: string) => void,
isOnline: boolean,
syncStatus: string,
setSyncStatus: (status: "synced" | "syncing" | "offline" | "error") => void,
addLog: (text: string, type: "success" | "info" | "sync" | "error") => void,
setToast: (toast: { show: boolean; message: string; timestamp: string }) => void,
saveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>,
autoSaveEnabled: boolean
) {
const activeDoc = documents.find((d) => d.id === activeDocId) || documents[0];
const createHistoryCheckpoint = useCallback(
(id: string, customLabel?: string) => {
setDocuments((prevDocs: Document[]) =>
prevDocs.map((doc) => {
if (doc.id === id) {
const currentHistory = doc.history || [];
if (currentHistory.length > 0 && currentHistory[0].content === doc.content) {
if (customLabel && currentHistory[0].label !== customLabel) {
const updated = [...currentHistory];
updated[0] = { ...updated[0], label: customLabel };
return { ...doc, history: updated };
}
return doc;
}
const newCheckpoint = {
id: `snap-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
timestamp: new Date().toISOString(),
content: doc.content,
version: doc.version,
label: customLabel || `Auto Backup (v${doc.version})`,
};
return {
...doc,
history: [newCheckpoint, ...currentHistory].slice(0, 15),
};
}
return doc;
})
);
},
[setDocuments]
);
const syncDocument = async (id: string, text: string, triggerTeammateContribution = false) => {
const currentDoc = documents.find((d) => d.id === id);
if (!currentDoc) return;
if (!isOnline) {
setSyncStatus("offline");
return;
}
setSyncStatus("syncing");
try {
const data = await apiSyncDocument(id, text, currentDoc.version, triggerTeammateContribution, currentDoc.title);
setDocuments((prevDocs: Document[]) =>
prevDocs.map((doc) => {
if (doc.id === id) {
return { ...doc, content: data.content, version: data.newVersion, lastSaved: data.timestamp, isSynced: true };
}
return doc;
})
);
if (triggerTeammateContribution) {
if (id === activeDocId && data.content !== text) {
setEditorText(data.content);
}
if (data.logs && data.logs.length > 0) {
data.logs.forEach((logStr: string) => addLog(logStr, "sync"));
}
} else {
addLog("Document saved to cloud database successfully.", "success");
createHistoryCheckpoint(id, "Auto Saved Checkpoint");
}
setSyncStatus("synced");
} catch (err: any) {
console.error("Sync error:", err);
setSyncStatus("error");
const isFailedToFetch = err.message && err.message.includes("Failed to fetch");
if (isFailedToFetch) {
addLog("Sync server is temporarily unreachable. Edits are safely buffered in local cache.", "info");
} else {
addLog(`Sync failed: ${err.message || "Connection issue"}`, "error");
}
}
};
const handleEditorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setEditorText(val);
setDocuments((prevDocs: Document[]) =>
prevDocs.map((doc) => {
if (doc.id === activeDocId) {
return { ...doc, content: val, isSynced: false, lastSaved: new Date().toISOString() };
}
return doc;
})
);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
syncDocument(activeDocId, val, false);
}, 2000);
};
const handleCreateDocument = (
e: React.FormEvent,
newDocTitle: string,
setShowDocModal: (v: boolean) => void,
setNewDocTitle: (v: string) => void
) => {
e.preventDefault();
if (!newDocTitle.trim()) return;
const newId = `doc-${Date.now()}`;
const newDoc: Document = {
id: newId,
title: newDocTitle.endsWith(".md") ? newDocTitle : `${newDocTitle}.md`,
content: `# ${newDocTitle}\n\nStart typing or drop raw markdown text right here.`,
version: 1,
lastSaved: new Date().toISOString(),
isSynced: true,
};
setDocuments((prev: Document[]) => [...prev, newDoc]);
setActiveDocId(newId);
setNewDocTitle("");
setShowDocModal(false);
addLog(`Created new empty workspace segment "${newDoc.title}".`, "success");
};
const handleUploadAndConvert = async (
e: React.FormEvent,
uploadFile: File | null,
uploadLoading: boolean,
setUploadLoading: (v: boolean) => void,
setUploadError: (v: string | null) => void,
setUploadFile: (v: File | null) => void,
setNewDocTitle: (v: string) => void,
setShowDocModal: (v: boolean) => void,
newDocTitle: string,
handleCreateDocumentFn: (e: React.FormEvent) => void
) => {
e.preventDefault();
if (uploadLoading) return;
if (!uploadFile) {
if (!newDocTitle.trim()) return;
handleCreateDocumentFn(e);
return;
}
setUploadLoading(true);
setUploadError(null);
addLog(`Uploading "${uploadFile.name}" for conversion...`, "sync");
try {
const data = await uploadAndConvert(uploadFile);
const jobId = data.job_id;
let attempts = 0;
const maxAttempts = 60;
const poll = async () => {
try {
const jobData = await getConvertStatus(jobId);
if (jobData.status === "completed") {
const newId = `doc-${Date.now()}`;
const newDoc: Document = {
id: newId,
title: uploadFile.name,
content: jobData.markdown || "",
version: 1,
lastSaved: new Date().toISOString(),
isSynced: true,
};
setDocuments((prev: Document[]) => [...prev, newDoc]);
setActiveDocId(newId);
setUploadFile(null);
setNewDocTitle("");
setShowDocModal(false);
setUploadLoading(false);
addLog(`Successfully converted "${uploadFile.name}" to markdown!`, "success");
} else if (jobData.status === "failed") {
throw new Error(jobData.error || "Conversion failed");
} else {
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 1000);
} else {
throw new Error("Conversion timed out after 60 seconds");
}
}
} catch (pollErr: any) {
setUploadError(pollErr.message || "Error polling conversion job");
setUploadLoading(false);
addLog(`Conversion failed: ${pollErr.message}`, "error");
}
};
setTimeout(poll, 1000);
} catch (err: any) {
setUploadError(err.message || "File upload error");
setUploadLoading(false);
addLog(`File upload failed: ${err.message}`, "error");
}
};
const handleDeleteDocument = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (documents.length <= 1) {
addLog("Cannot delete the last remaining document in workspace.", "error");
return;
}
const docToDelete = documents.find((d) => d.id === id);
const updated = documents.filter((d) => d.id !== id);
setDocuments(updated);
if (activeDocId === id) {
setActiveDocId(updated[0].id);
}
// Delete from server
apiDeleteDocument(id).catch(() => {});
addLog(`Deleted document "${docToDelete?.title || id}".`, "info");
};
const handleExportMarkdownFile = () => {
const blob = new Blob([editorText], { type: "text/markdown;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", activeDoc.title);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
addLog(`Exported "${activeDoc.title}" as local markdown segment successfully.`, "success");
};
const handleCopyHtmlValue = (setCopiedDoc: (v: boolean) => void) => {
const mockHtml = editorText
.replace(/^#\s+(.*$)/gim, "<h1>$1</h1>")
.replace(/^##\s+(.*$)/gim, "<h2>$1</h2>")
.replace(/^###\s+(.*$)/gim, "<h3>$1</h3>")
.replace(/^\>\s+(.*$)/gim, "<blockquote>$1</blockquote>")
.replace(/\*\*(.*)\*\*/gim, "<strong>$1</strong>")
.replace(/\*(.*)\*/gim, "<em>$1</em>");
navigator.clipboard.writeText(mockHtml);
setCopiedDoc(true);
setTimeout(() => setCopiedDoc(false), 2000);
addLog("Raw processed HTML segment copied to clipboard bounds.", "success");
};
const handlePrintPdf = () => {
// Generate a real PDF from the preview content
const element = document.getElementById("pdf-export-content");
if (!element) {
addLog("PDF export failed: no content to render.", "error");
return;
}
import("html2pdf.js").then((html2pdfModule) => {
const html2pdf = html2pdfModule.default;
const filename = activeDoc.title.replace(/\.[^/.]+$/, "") + ".pdf";
html2pdf()
.set({
margin: [10, 10, 10, 10],
filename,
image: { type: "jpeg", quality: 0.95 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
})
.from(element)
.save()
.then(() => {
addLog(`PDF exported: "${filename}"`, "success");
});
});
};
const handleFixConsecutiveDuplicate = (duplicateWord: string) => {
const escaped = duplicateWord.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
const regex = new RegExp(`\\b(${escaped})\\s+\\1\\b`, "gi");
const updated = editorText.replace(regex, "$1");
setEditorText(updated);
setDocuments((prevDocs: Document[]) =>
prevDocs.map((doc) => {
if (doc.id === activeDocId) {
return { ...doc, content: updated, isSynced: false, lastSaved: new Date().toISOString() };
}
return doc;
})
);
syncDocument(activeDocId, updated, false);
addLog(`Consecutive duplicate phrase references resolved for word "${duplicateWord}".`, "success");
};
const handleFixAllConsecutiveDuplicates = () => {
const duplicates = findConsecutiveDuplicates(editorText);
if (duplicates.length === 0) return;
let text = editorText;
duplicates.forEach((dup) => {
const escaped = dup.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
const regex = new RegExp(`\\b(${escaped})\\s+\\1\\b`, "gi");
text = text.replace(regex, "$1");
});
setEditorText(text);
setDocuments((prevDocs: Document[]) =>
prevDocs.map((doc) => {
if (doc.id === activeDocId) {
return { ...doc, content: text, isSynced: false, lastSaved: new Date().toISOString() };
}
return doc;
})
);
syncDocument(activeDocId, text, false);
addLog(`Batch cleaned all ${duplicates.length} duplicate word sequences.`, "success");
};
return {
activeDoc,
createHistoryCheckpoint,
syncDocument,
handleEditorChange,
handleCreateDocument,
handleUploadAndConvert,
handleDeleteDocument,
handleExportMarkdownFile,
handleCopyHtmlValue,
handlePrintPdf,
handleFixConsecutiveDuplicate,
handleFixAllConsecutiveDuplicates,
};
}