|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|