hoàn thành việc xuất pdf + annotation

This commit is contained in:
2026-04-02 12:01:43 +07:00
parent 51c599e040
commit 4baa94cf38
4 changed files with 372 additions and 18 deletions
+29 -12
View File
@@ -48,22 +48,39 @@ export default function PDFCard({ pdf, currentUserId, onDelete }: Props) {
)}
</div>
{/* Delete button — only for owner, visible on hover */}
{isOwner && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
{/* Action buttons — visible on hover */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
{/* Download original PDF */}
<a
href={`/api/pdfs/${pdf.id}/file`}
download
onClick={(e) => e.stopPropagation()}
title="Tải PDF gốc"
className="bg-white/80 hover:bg-blue-50 text-gray-500 hover:text-blue-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
)}
</a>
{/* Delete — only for owner */}
{isOwner && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(pdf.id);
}}
title="Delete"
className="bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
);
}
+112 -2
View File
@@ -128,6 +128,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [strokeWidth, setStrokeWidth] = useState(3);
const [saving, setSaving] = useState(false);
const [saveNotice, setSaveNotice] = useState(false);
const [exporting, setExporting] = useState(false);
const [isReady, setIsReady] = useState(false);
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
@@ -1088,13 +1089,102 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// Preserve _owner_id so remote-object detection works on cache hits
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username"]);
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username", "width", "height"]);
await renderPageWithAnnotations(newPage);
setCurrentPage(newPage);
},
[currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations]
);
// ─────────────────────────────────────────────────────────────────────────
// Export annotated PDF — renders every page with fabric annotations baked in
// ─────────────────────────────────────────────────────────────────────────
const handleExportPDF = useCallback(async () => {
const doc = pdfDocRef.current;
if (!doc || !isReady || exporting) return;
setExporting(true);
try {
const { jsPDF } = await import("jspdf");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pdf: any = null;
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
const page = await doc.getPage(pageNum);
const scale = 2; // 2× for good print quality
const vp = page.getViewport({ scale });
// ── Render PDF page ──────────────────────────────────────────────
const pdfCvs = document.createElement("canvas");
pdfCvs.width = Math.floor(vp.width);
pdfCvs.height = Math.floor(vp.height);
await page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport: vp }).promise;
// ── Load and merge annotations ───────────────────────────────────
let annotationData: object | null = localAnnotations.current[pageNum] ?? null;
if (!annotationData) {
try {
const ann = await api.getAllAnnotations(pdfId, pageNum);
if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0)
annotationData = ann.canvas_data;
} catch { /* no annotations */ }
}
if (annotationData) {
const fabricModule = await import("fabric");
const fabric = fabricModule.fabric;
// Render annotations at the size they were originally saved at,
// then scale onto the export canvas via drawImage.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ann = annotationData as any;
const savedW = ann.width ?? pdfCvs.width;
const savedH = ann.height ?? pdfCvs.height;
const tmpEl = document.createElement("canvas");
tmpEl.width = savedW;
tmpEl.height = savedH;
// StaticCanvas = single canvas element, no interaction overhead
const tmpFc = new fabric.StaticCanvas(tmpEl, { enableRetinaScaling: false });
tmpFc.setWidth(savedW);
tmpFc.setHeight(savedH);
await new Promise<void>((resolve) => {
tmpFc.loadFromJSON(annotationData, () => { resolve(); });
});
tmpFc.renderAll();
// Composite onto export canvas, scaling from saved size → export size
pdfCvs.getContext("2d")!.drawImage(tmpEl, 0, 0, pdfCvs.width, pdfCvs.height);
tmpFc.dispose();
}
// ── Add page to jsPDF ────────────────────────────────────────────
const imgData = pdfCvs.toDataURL("image/jpeg", 0.92);
const widthMm = (pdfCvs.width / 96) * 25.4;
const heightMm = (pdfCvs.height / 96) * 25.4;
const orient = widthMm > heightMm ? "landscape" : "portrait";
if (pdf === null) {
pdf = new jsPDF({ orientation: orient, unit: "mm", format: [widthMm, heightMm] });
} else {
pdf.addPage([widthMm, heightMm], orient);
}
pdf.addImage(imgData, "JPEG", 0, 0, widthMm, heightMm, undefined, "FAST");
}
if (pdf) {
const safe = pdfTitle.replace(/[^\w\s-]/g, "_");
pdf.save(`${safe}_annotated.pdf`);
}
} catch (err) {
alert(err instanceof Error ? err.message : "Export failed.");
} finally {
setExporting(false);
}
}, [pdfId, pdfTitle, isReady, exporting]);
// ─────────────────────────────────────────────────────────────────────────
// Save
// ─────────────────────────────────────────────────────────────────────────
@@ -1107,7 +1197,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
setSaving(true);
// Include _owner_id and _owner_username in serialization
const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username"]);
const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username", "width", "height"]);
// Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own)
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1519,6 +1609,26 @@ export default function WorkbookViewer({ pdfId }: Props) {
{saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse"> Saved</span>
)}
{/* Export PDF with annotations baked in */}
<button
onClick={handleExportPDF}
disabled={exporting || !isReady}
title="Tải PDF kèm annotation"
className="flex items-center gap-1 text-xs font-semibold px-3 py-1.5 rounded-lg transition bg-gray-100 hover:bg-gray-200 disabled:opacity-60 text-gray-700"
>
{exporting ? (
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
)}
{exporting ? "Đang xuất…" : "PDF"}
</button>
<button
onClick={handleSave}
disabled={saving || !isReady}