Hoàn thành bước 3.3. Interactive Workbook (The Core Viewer)

This commit is contained in:
2026-03-31 14:38:17 +07:00
parent 9450cd3ed4
commit c362692c51
8 changed files with 824 additions and 2 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine from .database import Base, engine
from .routers import auth, pdfs from .routers import annotations, auth, pdfs
@asynccontextmanager @asynccontextmanager
@@ -26,3 +26,4 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api") app.include_router(pdfs.router, prefix="/api")
app.include_router(annotations.router, prefix="/api")
+91
View File
@@ -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
+16
View File
@@ -63,3 +63,19 @@ class PDFUploadResult(BaseModel):
success: bool success: bool
pdf: PDFOut | None = None pdf: PDFOut | None = None
error: str | 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}
+3 -1
View File
@@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"next": "14.2.18", "next": "14.2.18",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"fabric": "^5.3.0",
"pdfjs-dist": "^4.9.124"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
+23
View File
@@ -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: () => (
<div className="min-h-screen flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500" 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>
</div>
),
});
export default function WorkbookPage({
params,
}: {
params: { id: string };
}) {
const pdfId = parseInt(params.id, 10);
return <WorkbookViewer pdfId={pdfId} />;
}
+671
View File
@@ -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 (
<button
onClick={() => onClick(id)}
title={title}
className={`p-1.5 rounded-lg transition ${
current === id
? "bg-blue-600 text-white shadow"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{children}
</svg>
</button>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main component
// ─────────────────────────────────────────────────────────────────────────────
export default function WorkbookViewer({ pdfId }: Props) {
const router = useRouter();
// DOM refs
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
const fabricElRef = useRef<HTMLCanvasElement>(null);
// Library instances (dynamic imports to avoid SSR)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fabricRef = useRef<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fabricNSRef = useRef<any>(null); // fabric namespace object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdfDocRef = useRef<any>(null);
// Per-page annotation cache (local, not yet flushed to server)
const localAnnotations = useRef<Record<number, object>>({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addedObjects = useRef<any[]>([]);
const skipObjectTracking = useRef(false);
// Mutable refs kept in sync with state (used inside closures/callbacks)
const currentToolRef = useRef<Tool>("pen");
const penColorRef = useRef<string>("#e63946");
const strokeWidthRef = useRef<number>(3);
// UI state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [mode, setViewMode] = useState<ViewMode>("pan");
const [tool, setTool] = useState<Tool>("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<void>((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 (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
<p className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-4 py-3">
{loadError}
</p>
<button
onClick={() => router.push("/dashboard")}
className="text-blue-600 hover:underline text-sm"
>
Back to Dashboard
</button>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-100">
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
<div className="max-w-5xl mx-auto px-3 py-2 flex flex-wrap items-center gap-2">
{/* Back button + title */}
<button
onClick={() => router.push("/dashboard")}
title="Back"
className="p-1 text-gray-500 hover:text-blue-600 transition rounded"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-semibold text-gray-800 truncate max-w-[130px] sm:max-w-xs">
{pdfTitle}
</span>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Page navigation */}
<div className="flex items-center gap-0.5">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1 || rendering || !isReady}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
title="Previous page"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-xs text-gray-600 px-1 whitespace-nowrap tabular-nums">
{isReady ? `${currentPage} / ${totalPages}` : "…"}
</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering || !isReady}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 transition"
title="Next page"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
{/* Pan / Draw mode toggle */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => setViewMode("pan")}
className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
mode === "pan" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
}`}
>
Pan
</button>
<button
onClick={() => setViewMode("draw")}
className={`text-xs px-2.5 py-1 rounded-md font-medium transition ${
mode === "draw" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
}`}
>
Draw
</button>
</div>
{/* Drawing tools — only shown in Draw mode */}
{mode === "draw" && (
<>
<div className="h-5 w-px bg-gray-200" />
{/* Tool buttons */}
<div className="flex items-center gap-0.5">
{/* Select */}
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</ToolBtn>
{/* Pen */}
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</ToolBtn>
{/* Highlighter */}
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</ToolBtn>
{/* Text */}
<ToolBtn id="text" current={tool} onClick={setTool} title="Text">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-3-3v6M7 20h10a2 2 0 002-2V6a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</ToolBtn>
</div>
{/* Color picker */}
<input
type="color"
value={penColor}
onChange={(e) => 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 */}
<input
type="range"
min={1}
max={12}
value={strokeWidth}
onChange={(e) => setStrokeWidth(Number(e.target.value))}
className="w-16 accent-blue-600"
title={`Stroke width: ${strokeWidth}`}
/>
<div className="h-5 w-px bg-gray-200" />
{/* Undo */}
<button
onClick={handleUndo}
title="Undo last stroke"
className="p-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
{/* Clear page */}
<button
onClick={handleClear}
title="Clear page annotations"
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
>
<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-6v6M4 7h16M10 3h4a1 1 0 011 1v1H9V4a1 1 0 011-1z" />
</svg>
</button>
</>
)}
{/* Save button — far right */}
<div className="ml-auto flex items-center gap-2">
{saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse">
Saved
</span>
)}
<button
onClick={handleSave}
disabled={saving || !isReady}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-xs font-semibold px-3 py-1.5 rounded-lg transition"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</header>
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
<main
ref={scrollContainerRef}
className="flex-1 overflow-auto py-6 px-4"
// In Draw mode: lock touch-action so all touch events go to Fabric
style={{ touchAction: mode === "draw" ? "none" : "auto" }}
>
{/* Loading spinner */}
{!isReady && !loadError && (
<div className="flex items-center justify-center h-64">
<svg className="animate-spin h-8 w-8 text-blue-500" 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>
</div>
)}
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
<div
className="relative mx-auto"
style={{ display: isReady ? "block" : "none", width: "fit-content" }}
>
{/* PDF.js renders here */}
<canvas ref={pdfCanvasRef} className="block shadow-lg rounded" />
{/*
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.
*/}
<canvas ref={fabricElRef} />
</div>
{/* Bottom page controls for mobile convenience */}
{isReady && (
<div className="flex justify-center mt-6 gap-4">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1 || rendering}
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
>
Prev
</button>
<span className="text-sm text-gray-500 tabular-nums">
{currentPage} / {totalPages}
</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages || rendering}
className="text-sm text-blue-600 hover:underline disabled:opacity-40 disabled:no-underline"
>
Next
</button>
</div>
)}
</main>
</div>
);
}
+9
View File
@@ -36,4 +36,13 @@ export const api = {
uploadPdfs: (formData: FormData) => uploadPdfs: (formData: FormData) =>
request<import("@/types").PDFUploadResult[]>("/api/pdfs/upload", { method: "POST", body: formData }), request<import("@/types").PDFUploadResult[]>("/api/pdfs/upload", { method: "POST", body: formData }),
deletePdf: (id: number) => request<void>(`/api/pdfs/${id}`, { method: "DELETE" }), deletePdf: (id: number) => request<void>(`/api/pdfs/${id}`, { method: "DELETE" }),
// Annotations
getAnnotation: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
method: "PUT",
body: JSON.stringify(body),
}),
}; };
+9
View File
@@ -18,3 +18,12 @@ export interface PDFUploadResult {
pdf: PDFItem | null; pdf: PDFItem | null;
error: string | 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<string, any>;
updated_at: string;
}