mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
hoàn thành việc xuất pdf + annotation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user