mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 20:33:11 +07:00
các chức năng thumbnail, scroll continuos đã xong
This commit is contained in:
@@ -10,6 +10,8 @@ import { api } from "@/lib/api";
|
||||
|
||||
type Tool = "select" | "pen" | "highlighter" | "text";
|
||||
type ViewMode = "pan" | "draw";
|
||||
type FitMode = "width" | "height";
|
||||
type ScrollMode = "single" | "continuous";
|
||||
|
||||
interface Props {
|
||||
pdfId: number;
|
||||
@@ -106,11 +108,59 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
const [pdfTitle, setPdfTitle] = useState("PDF");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [rendering, setRendering] = useState(false);
|
||||
const [showThumbnails, setShowThumbnails] = useState(true);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [fitMode, setFitMode] = useState<FitMode>("width");
|
||||
const [scrollMode, setScrollMode] = useState<ScrollMode>("single");
|
||||
|
||||
// Ref for auto-scrolling thumbnail panel
|
||||
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const fitModeRef = useRef<FitMode>("width");
|
||||
const scrollModeRef = useRef<ScrollMode>("single");
|
||||
// Per-page canvas refs for continuous mode
|
||||
const pageCanvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
||||
const pageFabricRefs = useRef<any[]>([]);
|
||||
// Active PDF.js render tasks — cancel before re-rendering
|
||||
const renderTasksRef = useRef<any[]>([]);
|
||||
// Generation counter — incremented on each renderAllPages call to abort stale runs
|
||||
const renderAllPagesGenRef = useRef(0);
|
||||
|
||||
// Keep mutable refs in sync
|
||||
useEffect(() => { currentToolRef.current = tool; }, [tool]);
|
||||
useEffect(() => { penColorRef.current = penColor; }, [penColor]);
|
||||
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
|
||||
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
|
||||
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
|
||||
|
||||
// Auto-scroll thumbnail sidebar to keep current page visible
|
||||
useEffect(() => {
|
||||
if (showThumbnails) {
|
||||
thumbRefs.current[currentPage - 1]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [currentPage, showThumbnails]);
|
||||
|
||||
// Re-render current page when fit mode changes — moved below renderAllPages definition
|
||||
|
||||
const generateThumbnails = useCallback(async () => {
|
||||
const doc = pdfDocRef.current;
|
||||
if (!doc) return;
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const vp1 = page.getViewport({ scale: 1 });
|
||||
const scale = 120 / vp1.width;
|
||||
const vp = page.getViewport({ scale });
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = Math.floor(vp.width);
|
||||
cvs.height = Math.floor(vp.height);
|
||||
await page.render({ canvasContext: cvs.getContext("2d")!, viewport: vp }).promise;
|
||||
const dataUrl = cvs.toDataURL("image/jpeg", 0.7);
|
||||
setThumbnails(prev => {
|
||||
const next = [...prev];
|
||||
next[i - 1] = dataUrl;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// renderPageWithAnnotations
|
||||
@@ -124,23 +174,34 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
|
||||
setRendering(true);
|
||||
try {
|
||||
// Cancel any in-progress render on the same canvas
|
||||
renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } });
|
||||
renderTasksRef.current = [];
|
||||
|
||||
// ── 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;
|
||||
let scale: number;
|
||||
if (fitModeRef.current === "height") {
|
||||
const containerHeight = Math.max(scrollContainerRef.current.clientHeight - 48, 400);
|
||||
scale = containerHeight / viewport1.height;
|
||||
} else {
|
||||
const containerWidth = Math.max(scrollContainerRef.current.clientWidth - 32, 300);
|
||||
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({
|
||||
const singleRenderTask = page.render({
|
||||
canvasContext: pdfCvs.getContext("2d")!,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
// ── 2. Resize Fabric canvas to match ──────────────────────────────
|
||||
});
|
||||
renderTasksRef.current.push(singleRenderTask);
|
||||
await singleRenderTask.promise;
|
||||
// ── 2. Resize Fabric canvas to match
|
||||
fc.setWidth(pdfCvs.width);
|
||||
fc.setHeight(pdfCvs.height);
|
||||
if (fc.wrapperEl) {
|
||||
@@ -186,6 +247,105 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
[pdfId]
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// renderAllPages (continuous scroll mode)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const renderAllPages = useCallback(async () => {
|
||||
const gen = ++renderAllPagesGenRef.current;
|
||||
const doc = pdfDocRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!doc || !container) return;
|
||||
|
||||
// Dispose old per-page fabric instances
|
||||
pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } });
|
||||
pageFabricRefs.current = [];
|
||||
|
||||
// Cancel any in-progress render tasks
|
||||
renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } });
|
||||
renderTasksRef.current = [];
|
||||
|
||||
const fabricModule = await import("fabric");
|
||||
if (renderAllPagesGenRef.current !== gen) return;
|
||||
const fabric = fabricModule.fabric;
|
||||
const containerWidth = Math.max(container.clientWidth - 32, 300);
|
||||
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
if (renderAllPagesGenRef.current !== gen) return;
|
||||
const pdfCvs = pageCanvasRefs.current[i - 1];
|
||||
const fabricEl = document.getElementById(`fabric-continuous-${i}`) as HTMLCanvasElement | null;
|
||||
if (!pdfCvs || !fabricEl) continue;
|
||||
|
||||
const page = await doc.getPage(i);
|
||||
const vp1 = page.getViewport({ scale: 1 });
|
||||
let scale: number;
|
||||
if (fitModeRef.current === "height") {
|
||||
const containerHeight = Math.max(container.clientHeight - 48, 400);
|
||||
scale = containerHeight / vp1.height;
|
||||
} else {
|
||||
scale = containerWidth / vp1.width;
|
||||
}
|
||||
const vp = page.getViewport({ scale });
|
||||
pdfCvs.width = Math.floor(vp.width);
|
||||
pdfCvs.height = Math.floor(vp.height);
|
||||
const contRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport: vp });
|
||||
renderTasksRef.current.push(contRenderTask);
|
||||
try {
|
||||
await contRenderTask.promise;
|
||||
} catch (e: any) {
|
||||
if (e?.name === "RenderingCancelledException") return;
|
||||
throw e;
|
||||
}
|
||||
if (renderAllPagesGenRef.current !== gen) return;
|
||||
|
||||
// Create Fabric canvas overlay
|
||||
const fc = new fabric.Canvas(fabricEl, {
|
||||
isDrawingMode: false,
|
||||
selection: false,
|
||||
enableRetinaScaling: false,
|
||||
});
|
||||
fc.setWidth(pdfCvs.width);
|
||||
fc.setHeight(pdfCvs.height);
|
||||
if (fc.wrapperEl) {
|
||||
fc.wrapperEl.style.position = "absolute";
|
||||
fc.wrapperEl.style.top = "0";
|
||||
fc.wrapperEl.style.left = "0";
|
||||
fc.wrapperEl.style.pointerEvents = "none";
|
||||
}
|
||||
pageFabricRefs.current[i - 1] = fc;
|
||||
|
||||
// Load saved annotations
|
||||
const cached = localAnnotations.current[i];
|
||||
let annotationData: object | null = cached ?? null;
|
||||
if (!annotationData) {
|
||||
try {
|
||||
const ann = await api.getAnnotation(pdfId, i);
|
||||
if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) {
|
||||
annotationData = ann.canvas_data;
|
||||
localAnnotations.current[i] = annotationData;
|
||||
}
|
||||
} catch { /* no annotation */ }
|
||||
}
|
||||
if (annotationData) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fc.loadFromJSON(annotationData, () => { fc.renderAll(); resolve(); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}, [pdfId]);
|
||||
|
||||
// Re-render when fit mode changes
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (scrollMode === "continuous") {
|
||||
renderAllPages();
|
||||
} else {
|
||||
renderPageWithAnnotations(currentPage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fitMode]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Initialization (mount)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -263,6 +423,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
await renderPageWithAnnotations(1);
|
||||
setCurrentPage(1);
|
||||
|
||||
// Generate thumbnails in background (non-blocking)
|
||||
setThumbnails([]);
|
||||
generateThumbnails();
|
||||
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setLoadError(
|
||||
@@ -278,10 +442,55 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
cancelled = true;
|
||||
fabricRef.current?.dispose();
|
||||
fabricRef.current = null;
|
||||
pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } });
|
||||
pageFabricRefs.current = [];
|
||||
pdfDocRef.current?.destroy();
|
||||
pdfDocRef.current = null;
|
||||
};
|
||||
}, [pdfId, renderPageWithAnnotations]);
|
||||
}, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]);
|
||||
|
||||
// Re-render when switching scroll modes
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (scrollMode === "continuous") {
|
||||
// small delay so DOM mounts the continuous page elements first
|
||||
setTimeout(() => renderAllPages(), 50);
|
||||
} else {
|
||||
// Dispose continuous fabric instances
|
||||
pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } });
|
||||
pageFabricRefs.current = [];
|
||||
renderPageWithAnnotations(currentPage);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scrollMode]);
|
||||
|
||||
// Scroll-based page tracking for continuous mode
|
||||
useEffect(() => {
|
||||
if (!isReady || scrollMode !== "continuous") return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateCurrentPage = () => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let bestPage = 1;
|
||||
let bestOverlap = -1;
|
||||
pageCanvasRefs.current.forEach((cvs, idx) => {
|
||||
if (!cvs || cvs.height === 0) return;
|
||||
const rect = cvs.getBoundingClientRect();
|
||||
const top = Math.max(rect.top, containerRect.top);
|
||||
const bottom = Math.min(rect.bottom, containerRect.bottom);
|
||||
const overlap = Math.max(0, bottom - top);
|
||||
if (overlap > bestOverlap) {
|
||||
bestOverlap = overlap;
|
||||
bestPage = idx + 1;
|
||||
}
|
||||
});
|
||||
if (bestOverlap > 0) setCurrentPage(bestPage);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", updateCurrentPage, { passive: true });
|
||||
return () => container.removeEventListener("scroll", updateCurrentPage);
|
||||
}, [isReady, scrollMode]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Apply tool / mode to Fabric canvas
|
||||
@@ -359,22 +568,66 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
}
|
||||
}, [isReady, mode, tool, penColor, strokeWidth]);
|
||||
|
||||
// Apply tool/mode to continuous-mode per-page fabric canvases too
|
||||
useEffect(() => {
|
||||
if (!isReady || scrollMode !== "continuous") return;
|
||||
const fabric = fabricNSRef.current;
|
||||
if (!fabric) return;
|
||||
pageFabricRefs.current.forEach((fc) => {
|
||||
if (!fc) return;
|
||||
fc.off("mouse:down");
|
||||
if (mode === "pan") {
|
||||
fc.isDrawingMode = false;
|
||||
fc.selection = false;
|
||||
if (fc.wrapperEl) fc.wrapperEl.style.pointerEvents = "none";
|
||||
return;
|
||||
}
|
||||
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 b = new fabric.PencilBrush(fc); b.color = penColor; b.width = strokeWidth; fc.freeDrawingBrush = b; break;
|
||||
}
|
||||
case "highlighter": {
|
||||
fc.isDrawingMode = true; fc.selection = false;
|
||||
const hb = new fabric.PencilBrush(fc); hb.color = hexToRgba(penColor, 0.99); hb.width = strokeWidth * 7;
|
||||
(hb as any).strokeLineCap = "square"; fc.freeDrawingBrush = hb; break;
|
||||
}
|
||||
case "text":
|
||||
fc.isDrawingMode = false; fc.selection = false;
|
||||
fc.on("mouse:down", (options: any) => {
|
||||
if (options.target) return;
|
||||
const pointer = fc.getPointer(options.e);
|
||||
const t = new fabric.IText("Text", { left: pointer.x, top: pointer.y, fontSize: 20, fill: penColorRef.current, fontFamily: "Arial, sans-serif", padding: 4 });
|
||||
fc.add(t); fc.setActiveObject(t); t.enterEditing(); t.selectAll(); fc.renderAll();
|
||||
}); break;
|
||||
}
|
||||
});
|
||||
}, [isReady, scrollMode, mode, tool, penColor, strokeWidth]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Page navigation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// goToPage — in continuous mode, scroll to the page element
|
||||
|
||||
const goToPage = useCallback(
|
||||
async (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages || rendering) return;
|
||||
|
||||
if (scrollMode === "continuous") {
|
||||
// Scroll the page wrapper into view
|
||||
const el = pageCanvasRefs.current[newPage - 1]?.parentElement;
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setCurrentPage(newPage);
|
||||
return;
|
||||
}
|
||||
|
||||
const fc = fabricRef.current;
|
||||
if (!fc || newPage < 1 || newPage > totalPages || rendering) return;
|
||||
|
||||
// Cache current page before leaving
|
||||
if (!fc) return;
|
||||
localAnnotations.current[currentPage] = fc.toJSON();
|
||||
|
||||
await renderPageWithAnnotations(newPage);
|
||||
setCurrentPage(newPage);
|
||||
},
|
||||
[currentPage, totalPages, rendering, renderPageWithAnnotations]
|
||||
[currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations]
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -382,7 +635,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const fc = fabricRef.current;
|
||||
const fc = scrollMode === "continuous"
|
||||
? pageFabricRefs.current[currentPage - 1]
|
||||
: fabricRef.current;
|
||||
if (!fc || !isReady || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
@@ -441,7 +696,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-100">
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-gray-100">
|
||||
|
||||
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
|
||||
<header className="bg-white border-b shadow-sm sticky top-0 z-20">
|
||||
@@ -463,6 +718,85 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Thumbnail panel toggle */}
|
||||
<button
|
||||
onClick={() => setShowThumbnails(v => !v)}
|
||||
title="Thumbnails"
|
||||
className={`p-1.5 rounded-lg transition flex items-center gap-1 ${
|
||||
showThumbnails
|
||||
? "bg-blue-50 text-blue-600"
|
||||
: "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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium hidden sm:inline">Thumb</span>
|
||||
</button>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Fit mode buttons */}
|
||||
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setFitMode("width")}
|
||||
title="Fit Width"
|
||||
className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
|
||||
fitMode === "width" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h8M8 12h8M4 17h16" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Width</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFitMode("height")}
|
||||
title="Fit Height"
|
||||
className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
|
||||
fitMode === "height" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8V4m0 0H3m4 0l-4 4M17 8V4m0 0h4m-4 0l4 4M7 16v4m0 0H3m4 0l-4-4M17 16v4m0 0h4m-4 0l4-4" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Height</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Scroll mode toggle */}
|
||||
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setScrollMode("single")}
|
||||
title="Single page"
|
||||
className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
|
||||
scrollMode === "single" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6M9 8h6M9 16h6M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Single</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScrollMode("continuous")}
|
||||
title="Continuous scroll"
|
||||
className={`text-xs px-2 py-1 rounded-md font-medium transition flex items-center gap-1 ${
|
||||
scrollMode === "continuous" ? "bg-white shadow text-blue-600" : "text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Continuous</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
||||
|
||||
{/* Page navigation */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
@@ -475,9 +809,25 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
<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>
|
||||
{isReady ? (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-600 tabular-nums">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 1 && v <= totalPages) goToPage(v);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="w-10 text-center text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<span>/ {totalPages}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600 px-1">…</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || rendering || !isReady}
|
||||
@@ -610,6 +960,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Body: thumbnail sidebar + viewer ─────────────────────────────────── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
||||
{/* Thumbnail sidebar */}
|
||||
{showThumbnails && (
|
||||
<aside className="w-36 bg-white border-r border-gray-200 overflow-y-auto flex-shrink-0">
|
||||
<div className="flex flex-col gap-2 py-3 px-2">
|
||||
{Array.from({ length: totalPages }).map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
ref={(el) => { thumbRefs.current[idx] = el; }}
|
||||
onClick={() => goToPage(idx + 1)}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg p-1 w-full transition ${
|
||||
currentPage === idx + 1
|
||||
? "ring-2 ring-blue-500 bg-blue-50"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{thumbnails[idx] ? (
|
||||
<img
|
||||
src={thumbnails[idx]}
|
||||
alt={`Page ${idx + 1}`}
|
||||
className="w-full rounded shadow-sm border border-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-[3/4] bg-gray-100 rounded animate-pulse" />
|
||||
)}
|
||||
<span className="text-[10px] text-gray-500 tabular-nums font-medium">{idx + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── Viewer area ─────────────────────────────────────────────────────── */}
|
||||
<main
|
||||
ref={scrollContainerRef}
|
||||
@@ -630,42 +1014,31 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
{/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{ display: isReady ? "block" : "none", width: "fit-content" }}
|
||||
style={{ display: isReady && scrollMode === "single" ? "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>
|
||||
{/* Continuous scroll: one wrapper per page */}
|
||||
{isReady && scrollMode === "continuous" && (
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{Array.from({ length: totalPages }).map((_, idx) => (
|
||||
<div key={idx} className="relative" style={{ width: "fit-content" }}>
|
||||
<canvas
|
||||
ref={(el) => { pageCanvasRefs.current[idx] = el; }}
|
||||
data-page={idx + 1}
|
||||
className="block shadow-lg rounded"
|
||||
/>
|
||||
<canvas id={`fabric-continuous-${idx + 1}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user