From c362692c516bec4136b3e0d70e1a1a04f3f6ae70 Mon Sep 17 00:00:00 2001 From: hienp Date: Tue, 31 Mar 2026 14:38:17 +0700 Subject: [PATCH] =?UTF-8?q?Ho=C3=A0n=20th=C3=A0nh=20b=C6=B0=E1=BB=9Bc=20?= =?UTF-8?q?=203.3.=20Interactive=20Workbook=20(The=20Core=20Viewer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 3 +- backend/app/routers/annotations.py | 91 +++ backend/app/schemas.py | 16 + frontend/package.json | 4 +- frontend/src/app/workbook/[id]/page.tsx | 23 + frontend/src/components/WorkbookViewer.tsx | 671 +++++++++++++++++++++ frontend/src/lib/api.ts | 9 + frontend/src/types/index.ts | 9 + 8 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 backend/app/routers/annotations.py create mode 100644 frontend/src/app/workbook/[id]/page.tsx create mode 100644 frontend/src/components/WorkbookViewer.tsx diff --git a/backend/app/main.py b/backend/app/main.py index b69a375..67f9d75 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine -from .routers import auth, pdfs +from .routers import annotations, auth, pdfs @asynccontextmanager @@ -26,3 +26,4 @@ app.add_middleware( app.include_router(auth.router, prefix="/api") app.include_router(pdfs.router, prefix="/api") +app.include_router(annotations.router, prefix="/api") diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py new file mode 100644 index 0000000..fa580e4 --- /dev/null +++ b/backend/app/routers/annotations.py @@ -0,0 +1,91 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..database import get_db +from ..dependencies import get_current_user +from ..models import Annotation, PDF, User +from ..schemas import AnnotationIn, AnnotationOut + +router = APIRouter(prefix="/annotations", tags=["annotations"]) + + +def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF: + pdf = db.get(PDF, pdf_id) + if not pdf or pdf.user_id != user_id: + raise HTTPException(status_code=404, detail="PDF not found.") + return pdf + + +# ── GET /api/annotations/{pdf_id}/{page_number} ─────────────────────────────── + +@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut) +def get_annotation( + pdf_id: int, + page_number: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _verify_pdf_ownership(db, pdf_id, current_user.id) + + ann = ( + db.query(Annotation) + .filter( + Annotation.pdf_id == pdf_id, + Annotation.user_id == current_user.id, + Annotation.page_number == page_number, + ) + .first() + ) + + # Return empty canvas if no annotation exists yet — not an error + if ann is None: + return AnnotationOut( + id=0, + pdf_id=pdf_id, + page_number=page_number, + canvas_data={}, + updated_at=datetime.now(timezone.utc), + ) + + return ann + + +# ── PUT /api/annotations/{pdf_id}/{page_number} ─────────────────────────────── + +@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut) +def upsert_annotation( + pdf_id: int, + page_number: int, + body: AnnotationIn, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _verify_pdf_ownership(db, pdf_id, current_user.id) + + ann = ( + db.query(Annotation) + .filter( + Annotation.pdf_id == pdf_id, + Annotation.user_id == current_user.id, + Annotation.page_number == page_number, + ) + .first() + ) + + if ann is None: + ann = Annotation( + pdf_id=pdf_id, + user_id=current_user.id, + page_number=page_number, + canvas_data=body.canvas_data, + ) + db.add(ann) + else: + ann.canvas_data = body.canvas_data + ann.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(ann) + return ann diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7fa4647..6eea75a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -63,3 +63,19 @@ class PDFUploadResult(BaseModel): success: bool pdf: PDFOut | None = None error: str | None = None + + +# ── Annotations ─────────────────────────────────────────────────────────────── + +class AnnotationIn(BaseModel): + canvas_data: dict + + +class AnnotationOut(BaseModel): + id: int + pdf_id: int + page_number: int + canvas_data: dict + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/frontend/package.json b/frontend/package.json index 32d1746..0f9fca4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "dependencies": { "next": "14.2.18", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "fabric": "^5.3.0", + "pdfjs-dist": "^4.9.124" }, "devDependencies": { "@types/node": "^20", diff --git a/frontend/src/app/workbook/[id]/page.tsx b/frontend/src/app/workbook/[id]/page.tsx new file mode 100644 index 0000000..d0a3f75 --- /dev/null +++ b/frontend/src/app/workbook/[id]/page.tsx @@ -0,0 +1,23 @@ +import dynamic from "next/dynamic"; + +// Disable SSR — WorkbookViewer uses pdfjs-dist and fabric which require browser APIs +const WorkbookViewer = dynamic(() => import("@/components/WorkbookViewer"), { + ssr: false, + loading: () => ( +
+ + + + +
+ ), +}); + +export default function WorkbookPage({ + params, +}: { + params: { id: string }; +}) { + const pdfId = parseInt(params.id, 10); + return ; +} diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx new file mode 100644 index 0000000..7a6cbb1 --- /dev/null +++ b/frontend/src/components/WorkbookViewer.tsx @@ -0,0 +1,671 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/lib/api"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type Tool = "select" | "pen" | "highlighter" | "text"; +type ViewMode = "pan" | "draw"; + +interface Props { + pdfId: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function hexToRgba(hex: string, alpha: number): string { + let h = hex.replace("#", ""); + if (h.length === 3) h = h.split("").map((c) => c + c).join(""); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Toolbar button +// ───────────────────────────────────────────────────────────────────────────── + +function ToolBtn({ + id, + current, + onClick, + title, + children, +}: { + id: Tool; + current: Tool; + onClick: (t: Tool) => void; + title: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main component +// ───────────────────────────────────────────────────────────────────────────── + +export default function WorkbookViewer({ pdfId }: Props) { + const router = useRouter(); + + // DOM refs + const scrollContainerRef = useRef(null); + const pdfCanvasRef = useRef(null); + const fabricElRef = useRef(null); + + // Library instances (dynamic imports to avoid SSR) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fabricRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fabricNSRef = useRef(null); // fabric namespace object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pdfDocRef = useRef(null); + + // Per-page annotation cache (local, not yet flushed to server) + const localAnnotations = useRef>({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const addedObjects = useRef([]); + const skipObjectTracking = useRef(false); + + // Mutable refs kept in sync with state (used inside closures/callbacks) + const currentToolRef = useRef("pen"); + const penColorRef = useRef("#e63946"); + const strokeWidthRef = useRef(3); + + // UI state + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [mode, setViewMode] = useState("pan"); + const [tool, setTool] = useState("pen"); + const [penColor, setPenColor] = useState("#e63946"); + const [strokeWidth, setStrokeWidth] = useState(3); + const [saving, setSaving] = useState(false); + const [saveNotice, setSaveNotice] = useState(false); + const [isReady, setIsReady] = useState(false); + const [pdfTitle, setPdfTitle] = useState("PDF"); + const [loadError, setLoadError] = useState(""); + const [rendering, setRendering] = useState(false); + + // Keep mutable refs in sync + useEffect(() => { currentToolRef.current = tool; }, [tool]); + useEffect(() => { penColorRef.current = penColor; }, [penColor]); + useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]); + + // ───────────────────────────────────────────────────────────────────────── + // renderPageWithAnnotations + // ───────────────────────────────────────────────────────────────────────── + + const renderPageWithAnnotations = useCallback( + async (pageNum: number) => { + const fc = fabricRef.current; + const doc = pdfDocRef.current; + if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; + + setRendering(true); + try { + // ── 1. Render PDF page ───────────────────────────────────────────── + const page = await doc.getPage(pageNum); + const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300); + const viewport1 = page.getViewport({ scale: 1 }); + const scale = containerWidth / viewport1.width; + const viewport = page.getViewport({ scale }); + + const pdfCvs = pdfCanvasRef.current; + pdfCvs.width = Math.floor(viewport.width); + pdfCvs.height = Math.floor(viewport.height); + + await page.render({ + canvasContext: pdfCvs.getContext("2d")!, + viewport, + }).promise; + + // ── 2. Resize Fabric canvas to match ────────────────────────────── + fc.setWidth(pdfCvs.width); + fc.setHeight(pdfCvs.height); + if (fc.wrapperEl) { + fc.wrapperEl.style.width = `${pdfCvs.width}px`; + fc.wrapperEl.style.height = `${pdfCvs.height}px`; + } + + // ── 3. Load annotation data ─────────────────────────────────────── + const cached = localAnnotations.current[pageNum]; + let annotationData: object | null = cached ?? null; + + if (!annotationData) { + try { + const ann = await api.getAnnotation(pdfId, pageNum); + if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { + annotationData = ann.canvas_data; + localAnnotations.current[pageNum] = annotationData; + } + } catch { /* no annotation yet */ } + } + + // Clear and load + skipObjectTracking.current = true; + fc.clear(); + addedObjects.current = []; + + if (annotationData) { + await new Promise((resolve) => { + fc.loadFromJSON(annotationData, () => { + fc.renderAll(); + skipObjectTracking.current = false; + resolve(); + }); + }); + } else { + fc.renderAll(); + skipObjectTracking.current = false; + } + } finally { + setRendering(false); + } + }, + [pdfId] + ); + + // ───────────────────────────────────────────────────────────────────────── + // Initialization (mount) + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + let cancelled = false; + + async function init() { + try { + // Fetch PDF title + try { + const pdfs = await api.listPdfs(); + const found = pdfs.find((p) => p.id === pdfId); + if (found) setPdfTitle(found.title); + } catch { /* ignore */ } + + if (cancelled || !fabricElRef.current) return; + + // ── Init Fabric.js ──────────────────────────────────────────────── + const fabricModule = await import("fabric"); + const fabric = fabricModule.fabric; + fabricNSRef.current = fabric; + + if (cancelled) return; + + const fc = new fabric.Canvas(fabricElRef.current, { + isDrawingMode: false, + selection: false, + enableRetinaScaling: false, + }); + fabricRef.current = fc; + + // Position Fabric wrapper absolutely over PDF canvas + if (fc.wrapperEl) { + fc.wrapperEl.style.position = "absolute"; + fc.wrapperEl.style.top = "0"; + fc.wrapperEl.style.left = "0"; + fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode + } + + // Track added objects for undo (skip objects loaded from JSON) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.on("object:added", (e: any) => { + if (!skipObjectTracking.current) { + addedObjects.current.push(e.target); + } + }); + + // Highlighter: reduce opacity of drawn path after creation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.on("path:created", (options: any) => { + if (currentToolRef.current === "highlighter") { + options.path.set({ opacity: 0.42 }); + fc.renderAll(); + } + }); + + // ── Init PDF.js ─────────────────────────────────────────────────── + const pdfjs = await import("pdfjs-dist"); + pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + + if (cancelled) return; + + const doc = await pdfjs + .getDocument({ url: `/api/pdfs/${pdfId}/file`, withCredentials: true }) + .promise; + + if (cancelled) { doc.destroy(); return; } + + pdfDocRef.current = doc; + setTotalPages(doc.numPages); + setIsReady(true); + + // ── Render first page ───────────────────────────────────────────── + await renderPageWithAnnotations(1); + setCurrentPage(1); + + } catch (err: unknown) { + if (!cancelled) { + setLoadError( + err instanceof Error ? err.message : "Failed to load PDF." + ); + } + } + } + + init(); + + return () => { + cancelled = true; + fabricRef.current?.dispose(); + fabricRef.current = null; + pdfDocRef.current?.destroy(); + pdfDocRef.current = null; + }; + }, [pdfId, renderPageWithAnnotations]); + + // ───────────────────────────────────────────────────────────────────────── + // Apply tool / mode to Fabric canvas + // ───────────────────────────────────────────────────────────────────────── + + useEffect(() => { + const fc = fabricRef.current; + const fabric = fabricNSRef.current; + if (!isReady || !fc || !fabric) return; + + // Clean up previous mouse:down handler (added for text tool) + fc.off("mouse:down"); + + if (mode === "pan") { + fc.isDrawingMode = false; + fc.selection = false; + if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none"; + return; + } + + // Draw mode — enable pointer events + if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "all"; + + switch (tool) { + case "select": + fc.isDrawingMode = false; + fc.selection = true; + break; + + case "pen": { + fc.isDrawingMode = true; + fc.selection = false; + const brush = new fabric.PencilBrush(fc); + brush.color = penColor; + brush.width = strokeWidth; + fc.freeDrawingBrush = brush; + break; + } + + case "highlighter": { + fc.isDrawingMode = true; + fc.selection = false; + const hBrush = new fabric.PencilBrush(fc); + // Full-opacity color during drawing; path:created handler applies opacity:0.42 + hBrush.color = hexToRgba(penColor, 0.99); + hBrush.width = strokeWidth * 7; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (hBrush as any).strokeLineCap = "square"; + fc.freeDrawingBrush = hBrush; + break; + } + + case "text": + fc.isDrawingMode = false; + fc.selection = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fc.on("mouse:down", (options: any) => { + if (options.target) return; // clicked existing object → let Fabric handle it + const pointer = fc.getPointer(options.e); + const textObj = new fabric.IText("Text", { + left: pointer.x, + top: pointer.y, + fontSize: 20, + fill: penColorRef.current, + fontFamily: "Arial, sans-serif", + padding: 4, + }); + fc.add(textObj); + fc.setActiveObject(textObj); + textObj.enterEditing(); + textObj.selectAll(); + fc.renderAll(); + }); + break; + } + }, [isReady, mode, tool, penColor, strokeWidth]); + + // ───────────────────────────────────────────────────────────────────────── + // Page navigation + // ───────────────────────────────────────────────────────────────────────── + + const goToPage = useCallback( + async (newPage: number) => { + const fc = fabricRef.current; + if (!fc || newPage < 1 || newPage > totalPages || rendering) return; + + // Cache current page before leaving + localAnnotations.current[currentPage] = fc.toJSON(); + + await renderPageWithAnnotations(newPage); + setCurrentPage(newPage); + }, + [currentPage, totalPages, rendering, renderPageWithAnnotations] + ); + + // ───────────────────────────────────────────────────────────────────────── + // Save + // ───────────────────────────────────────────────────────────────────────── + + const handleSave = useCallback(async () => { + const fc = fabricRef.current; + if (!fc || !isReady || saving) return; + + setSaving(true); + const canvasData = fc.toJSON(); + + try { + await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); + localAnnotations.current[currentPage] = canvasData; + setSaveNotice(true); + setTimeout(() => setSaveNotice(false), 2500); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Save failed."); + } finally { + setSaving(false); + } + }, [pdfId, currentPage, isReady, saving]); + + // ───────────────────────────────────────────────────────────────────────── + // Undo / Clear + // ───────────────────────────────────────────────────────────────────────── + + const handleUndo = useCallback(() => { + const fc = fabricRef.current; + if (!fc || addedObjects.current.length === 0) return; + const last = addedObjects.current.pop(); + if (last) { fc.remove(last); fc.renderAll(); } + }, []); + + const handleClear = useCallback(() => { + const fc = fabricRef.current; + if (!fc) return; + if (!confirm("Clear all annotations on this page?")) return; + fc.clear(); + addedObjects.current = []; + fc.renderAll(); + }, []); + + // ───────────────────────────────────────────────────────────────────────── + // Render + // ───────────────────────────────────────────────────────────────────────── + + if (loadError) { + return ( +
+

+ {loadError} +

+ +
+ ); + } + + return ( +
+ + {/* ── Sticky Toolbar ──────────────────────────────────────────────────── */} +
+
+ + {/* Back button + title */} + + + {pdfTitle} + + +
+ + {/* Page navigation */} +
+ + + {isReady ? `${currentPage} / ${totalPages}` : "…"} + + +
+ +
+ + {/* Pan / Draw mode toggle */} +
+ + +
+ + {/* Drawing tools — only shown in Draw mode */} + {mode === "draw" && ( + <> +
+ + {/* Tool buttons */} +
+ {/* Select */} + + + + + {/* Pen */} + + + + + {/* Highlighter */} + + + + + {/* Text */} + + + +
+ + {/* Color picker */} + setPenColor(e.target.value)} + title="Color" + className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white" + /> + + {/* Stroke width slider */} + setStrokeWidth(Number(e.target.value))} + className="w-16 accent-blue-600" + title={`Stroke width: ${strokeWidth}`} + /> + +
+ + {/* Undo */} + + + {/* Clear page */} + + + )} + + {/* Save button — far right */} +
+ {saveNotice && ( + + ✓ Saved + + )} + +
+
+
+ + {/* ── Viewer area ─────────────────────────────────────────────────────── */} +
+ {/* Loading spinner */} + {!isReady && !loadError && ( +
+ + + + +
+ )} + + {/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */} +
+ {/* PDF.js renders here */} + + + {/* + Fabric.js is initialised on this element. + After init, Fabric wraps it in a div (wrapperEl) that our useEffect + repositions to position:absolute / top:0 / left:0 — sitting above the PDF. + */} + +
+ + {/* Bottom page controls for mobile convenience */} + {isReady && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8a452e3..9af923b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -36,4 +36,13 @@ export const api = { uploadPdfs: (formData: FormData) => request("/api/pdfs/upload", { method: "POST", body: formData }), deletePdf: (id: number) => request(`/api/pdfs/${id}`, { method: "DELETE" }), + + // Annotations + getAnnotation: (pdfId: number, page: number) => + request(`/api/annotations/${pdfId}/${page}`), + upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => + request(`/api/annotations/${pdfId}/${page}`, { + method: "PUT", + body: JSON.stringify(body), + }), }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6babb8f..6ee2241 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -18,3 +18,12 @@ export interface PDFUploadResult { pdf: PDFItem | null; error: string | null; } + +export interface AnnotationData { + id: number; + pdf_id: number; + page_number: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + canvas_data: Record; + updated_at: string; +}