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
+3 -1
View File
@@ -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",
+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) =>
request<import("@/types").PDFUploadResult[]>("/api/pdfs/upload", { method: "POST", body: formData }),
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;
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;
}