"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration"; // ───────────────────────────────────────────────────────────────────────────── // Preset color palette (30 hues, 3 brightness levels × 10 hue families) // Each color is visually distinct to avoid confusion between collaborators. // ───────────────────────────────────────────────────────────────────────────── const PRESET_COLORS = [ "#e63946", "#9d0208", "#ff6b6b", // Red "#f4572a", "#a23b17", "#ff9472", // Orange-red "#f7b731", "#a07800", "#ffe066", // Yellow "#80b918", "#4a6c0a", "#b5e853", // Yellow-green "#2dc653", "#0a7a2e", "#6ee08a", // Green "#00b4d8", "#005f73", "#48cae4", // Cyan "#4361ee", "#1a237e", "#7b9cff", // Blue "#7209b7", "#3a0068", "#b56aff", // Purple "#e040fb", "#880e62", "#f48fff", // Magenta "#ff6392", "#a3003f", "#ffadc5", // Rose ] as const; function userIdToPresetColor(id: number): string { return PRESET_COLORS[id % PRESET_COLORS.length]; } // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── type Tool = "select" | "pen" | "highlighter" | "text" | "eraser"; type ViewMode = "pan" | "draw"; type FitMode = "width" | "height"; type ScrollMode = "single" | "continuous"; interface Props { pdfId: number; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function hexToRgba(hex: string, alpha: number): string { let h = hex.replace("#", ""); if (h.length === 3) h = h.split("").map((c) => c + c).join(""); const r = parseInt(h.substring(0, 2), 16); const g = parseInt(h.substring(2, 4), 16); const b = parseInt(h.substring(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } // ───────────────────────────────────────────────────────────────────────────── // Toolbar button // ───────────────────────────────────────────────────────────────────────────── function ToolBtn({ id, current, onClick, title, children, }: { id: Tool; current: Tool; onClick: (t: Tool) => void; title: string; children: React.ReactNode; }) { return ( ); } // ───────────────────────────────────────────────────────────────────────────── // Main component // ───────────────────────────────────────────────────────────────────────────── export default function WorkbookViewer({ pdfId }: Props) { const router = useRouter(); // DOM refs const scrollContainerRef = useRef(null); const pdfCanvasRef = useRef(null); const fabricElRef = useRef(null); // Library instances (dynamic imports to avoid SSR) // eslint-disable-next-line @typescript-eslint/no-explicit-any const fabricRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const fabricNSRef = useRef(null); // fabric namespace object // eslint-disable-next-line @typescript-eslint/no-explicit-any const pdfDocRef = useRef(null); // Per-page annotation cache (local, not yet flushed to server) const localAnnotations = useRef>({}); // eslint-disable-next-line @typescript-eslint/no-explicit-any const addedObjects = useRef([]); const skipObjectTracking = useRef(false); // Mutable refs kept in sync with state (used inside closures/callbacks) const currentToolRef = useRef("pen"); const penColorRef = useRef("#e63946"); const strokeWidthRef = useRef(3); // UI state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [mode, setViewMode] = useState("pan"); const [tool, setTool] = useState("pen"); const [penColor, setPenColor] = useState("#e63946"); const [strokeWidth, setStrokeWidth] = useState(3); const [saving, setSaving] = useState(false); const [saveNotice, setSaveNotice] = useState(false); const [isReady, setIsReady] = useState(false); const [pdfTitle, setPdfTitle] = useState("PDF"); const [loadError, setLoadError] = useState(""); const [rendering, setRendering] = useState(false); const [showThumbnails, setShowThumbnails] = useState(true); const [thumbnails, setThumbnails] = useState([]); const [fitMode, setFitMode] = useState("width"); const [scrollMode, setScrollMode] = useState("single"); // Collaboration const [collabToken, setCollabToken] = useState(null); const currentPageRef = useRef(1); // mutable copy for collab callbacks const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events const currentUserIdRef = useRef(null); // populated from api.me(); used to identify own vs remote objects const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student"); const userRoleRef = useRef<"admin" | "teacher" | "student">("student"); // Buffer for remote events that arrive while renderPageWithAnnotations is in progress const renderingRef = useRef(false); const pendingRemoteEvents = useRef([]); // True once api.me() has resolved and pen color has been set from user id const [userColorReady, setUserColorReady] = useState(false); // Color picker const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerPos, setColorPickerPos] = useState<{ top: number; left: number } | null>(null); const colorBtnRef = useRef(null); const colorPickerRef = useRef(null); // Ref for auto-scrolling thumbnail panel const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); const fitModeRef = useRef("width"); const scrollModeRef = useRef("single"); // Per-page canvas refs for continuous mode const pageCanvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); const pageFabricRefs = useRef([]); // Active PDF.js render tasks — cancel before re-rendering const renderTasksRef = useRef([]); // Generation counter — incremented on each renderAllPages call to abort stale runs const renderAllPagesGenRef = useRef(0); // Collab: stable ref to send functions (set after hook initializes) // eslint-disable-next-line @typescript-eslint/no-explicit-any const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any; colorSync: (c: string) => void } | null>(null); // Temp-cache sync: debounce unsaved canvas state to Redis (1.5 s after last stroke) const tempSyncTimerRef = useRef | null>(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const syncTempCanvasRef = useRef<(page: number) => void>(() => {}); // Keep currentPageRef in sync useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); // 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]); // Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId useEffect(() => { syncTempCanvasRef.current = (page: number) => { const fc = fabricRef.current; if (!fc) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const fullData: any = fc.toJSON(["_owner_id"]); const uid = currentUserIdRef.current; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ownObjects = ((fullData.objects as any[]) ?? []).filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any (o: any) => o._owner_id == null || o._owner_id === uid ); const canvasData = { ...fullData, objects: ownObjects }; if (tempSyncTimerRef.current) clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = setTimeout(() => { api.upsertTempAnnotation(pdfId, page, { canvas_data: canvasData }).catch(() => {}); }, 1500); }; }, [pdfId]); // Auto-scroll thumbnail sidebar to keep current page visible useEffect(() => { if (showThumbnails) { thumbRefs.current[currentPage - 1]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [currentPage, showThumbnails]); // Fetch JWT token for WebSocket auth (cookie not sent on WS upgrade) useEffect(() => { fetch("/api/auth/token", { credentials: "include" }) .then(r => r.ok ? r.json() : null) .then((d: { access_token?: string } | null) => { if (d?.access_token) setCollabToken(d.access_token); }) .catch(() => {}); }, []); // Fetch current user id — used to distinguish own from remote objects when merging /all useEffect(() => { api.me().then((u) => { currentUserIdRef.current = u.id; setUserRole(u.role); userRoleRef.current = u.role; // Assign deterministic color from preset palette based on user id const assigned = userIdToPresetColor(u.id); setPenColor(assigned); penColorRef.current = assigned; setUserColorReady(true); // unblocks the color-sync broadcast }).catch(() => {}); }, []); // Close color picker when clicking outside useEffect(() => { if (!colorPickerOpen) return; const handler = (e: MouseEvent) => { if ( colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node) && colorBtnRef.current && !colorBtnRef.current.contains(e.target as Node) ) { setColorPickerOpen(false); setColorPickerPos(null); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [colorPickerOpen]); // Handle incoming remote annotation events const onRemoteEvent = useCallback((event: RemoteEvent) => { const fabric = fabricNSRef.current; if (!fabric) return; // Resolve the right fabric canvas for the event's page const getCanvas = (page: number) => { if (scrollModeRef.current === "continuous") { return pageFabricRefs.current[page - 1] ?? null; } return currentPageRef.current === page ? fabricRef.current : null; }; if (event.type === "clear") { const fc = getCanvas(event.page ?? currentPageRef.current); if (!fc) return; skipRemoteRef.current = true; skipObjectTracking.current = true; fc.clear(); fc.renderAll(); skipRemoteRef.current = false; skipObjectTracking.current = false; return; } if (event.type === "object_remove") { const fc = getCanvas(event.page ?? currentPageRef.current); if (!fc) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const collab_id = (event.payload as any)?.obj_id; if (!collab_id) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const target = fc.getObjects().find((o: any) => o.collab_id === collab_id); if (target) { skipRemoteRef.current = true; fc.remove(target); fc.renderAll(); skipRemoteRef.current = false; } return; } if (event.type === "object_add") { // If a page render is in progress, the canvas is about to be cleared + reloaded. // Buffer this event and replay it after renderPageWithAnnotations finishes. if (renderingRef.current && scrollModeRef.current === "single") { pendingRemoteEvents.current.push(event); return; } const page = event.page ?? currentPageRef.current; const fc = getCanvas(page); if (!fc) return; const objJson = event.payload as Record; skipRemoteRef.current = true; skipObjectTracking.current = true; fabric.util.enlivenObjects([objJson], (objects: any[]) => { const canInteract = userRoleRef.current !== "student"; objects.forEach((obj: any) => { obj.collab_id = objJson.collab_id; obj._isRemote = true; // don't save other users' live strokes under our account obj.selectable = canInteract; obj.evented = canInteract; fc.add(obj); }); fc.renderAll(); skipRemoteRef.current = false; skipObjectTracking.current = false; }); return; } }, []); // When a new peer joins the room, broadcast all our own (non-remote) canvas // objects so they receive our pre-existing annotations immediately. const handlePeerJoined = useCallback(() => { const sendAdd = collabSendRef.current?.objectAdd; if (!sendAdd) return; const broadcastCanvas = (fc: any, page: number) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.getObjects().forEach((obj: any) => { // Skip objects from other users — they already have their own if (obj._isRemote) return; if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const json = (obj as any).toJSON(["collab_id"]); sendAdd(json, page); }); }; if (scrollModeRef.current === "continuous") { pageFabricRefs.current.forEach((fc, idx) => { if (fc) broadcastCanvas(fc, idx + 1); }); } else { const fc = fabricRef.current; if (fc) broadcastCanvas(fc, currentPageRef.current); } // Also broadcast our current pen color so the new peer can disable our swatch collabSendRef.current?.colorSync(penColorRef.current); }, []); // Collaboration hook const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } = useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined }); // Keep send functions accessible in stable refs (used in Fabric event handlers) useEffect(() => { collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync }; }, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]); // Broadcast pen color only when BOTH WS is connected AND user color has been fetched. // This prevents both users broadcasting the same default "#e63946" before api.me() resolves. // Also re-broadcasts whenever the user manually picks a new color. useEffect(() => { if (collabConnected && userColorReady) { sendColorSync(penColor); } }, [collabConnected, userColorReady, penColor, sendColorSync]); // If our color collides with a peer's color, pick the first free preset color and rebroadcast. useEffect(() => { if (!collabConnected || !userColorReady) return; const uid = currentUserIdRef.current; if (uid == null) return; const taken = new Set( Object.entries(peerColors) .filter(([id]) => Number(id) !== uid) .map(([, c]) => c.toLowerCase()) ); const current = penColor.toLowerCase(); if (!taken.has(current)) return; const replacement = PRESET_COLORS.find(c => !taken.has(c.toLowerCase())) ?? penColor; if (replacement.toLowerCase() === current) return; setPenColor(replacement); penColorRef.current = replacement; sendColorSync(replacement); }, [collabConnected, userColorReady, peerColors, penColor, sendColorSync]); // 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 // ───────────────────────────────────────────────────────────────────────── const renderPageWithAnnotations = useCallback( async (pageNum: number) => { const fc = fabricRef.current; const doc = pdfDocRef.current; if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; setRendering(true); renderingRef.current = true; pendingRemoteEvents.current = []; 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 viewport1 = page.getViewport({ scale: 1 }); 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); const singleRenderTask = page.render({ canvasContext: pdfCvs.getContext("2d")!, viewport, }); renderTasksRef.current.push(singleRenderTask); await singleRenderTask.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.getAllAnnotations(pdfId, pageNum); if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) { annotationData = ann.canvas_data; localAnnotations.current[pageNum] = annotationData; } } catch { /* no annotation yet */ } } // Clear and load skipObjectTracking.current = true; fc.clear(); addedObjects.current = []; if (annotationData) { await new Promise((resolve) => { fc.loadFromJSON(annotationData, () => { // Mark objects from other users as remote — prevents saving them under current user const uid = currentUserIdRef.current; if (uid !== null) { const canInteract = userRoleRef.current !== "student"; // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.getObjects().forEach((obj: any) => { if (obj._owner_id != null && obj._owner_id !== uid) { obj._isRemote = true; obj.selectable = canInteract; obj.evented = canInteract; } }); } fc.renderAll(); skipObjectTracking.current = false; resolve(); }); }); } else { fc.renderAll(); skipObjectTracking.current = false; } // Replay any remote events that arrived while the canvas was being loaded renderingRef.current = false; const queued = pendingRemoteEvents.current.splice(0); const fabric = fabricNSRef.current; if (fabric && queued.length > 0) { for (const evt of queued) { if (evt.type !== "object_add") continue; if ((evt.page ?? currentPageRef.current) !== pageNum) continue; const objJson = evt.payload as Record; skipRemoteRef.current = true; skipObjectTracking.current = true; fabric.util.enlivenObjects([objJson], (objects: any[]) => { const canInteract = userRoleRef.current !== "student"; objects.forEach((obj: any) => { obj.collab_id = objJson.collab_id; obj._isRemote = true; obj.selectable = canInteract; obj.evented = canInteract; fc.add(obj); }); fc.renderAll(); skipRemoteRef.current = false; skipObjectTracking.current = false; }); } } } finally { renderingRef.current = false; setRendering(false); } }, [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.getAllAnnotations(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((resolve) => { fc.loadFromJSON(annotationData, () => { // Mark objects from other users as remote const uid = currentUserIdRef.current; if (uid !== null) { const canInteract = userRoleRef.current !== "student"; // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.getObjects().forEach((obj: any) => { if (obj._owner_id != null && obj._owner_id !== uid) { obj._isRemote = true; obj.selectable = canInteract; obj.evented = canInteract; } }); } 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) // ───────────────────────────────────────────────────────────────────────── 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 } // Single object:added handler: undo tracking + collab broadcast for text // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.on("object:added", (e: any) => { if (skipObjectTracking.current) return; addedObjects.current.push(e.target); if (skipRemoteRef.current) return; const obj = e.target; // Text objects: broadcast final content when editing ends, not the placeholder if (obj.type === "i-text" || obj.type === "text") { obj.once("editing:exited", () => { if (skipRemoteRef.current) return; if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const json = (obj as any).toJSON(["collab_id"]); collabSendRef.current?.objectAdd(json, currentPageRef.current); }); } }); // 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(); } // Broadcast stroke to collaborators if (!skipRemoteRef.current) { options.path.collab_id = crypto.randomUUID(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj = (options.path as any).toJSON(["collab_id"]); collabSendRef.current?.objectAdd(obj, currentPageRef.current); } // Sync unsaved canvas to Redis so late-joining users see this stroke syncTempCanvasRef.current(currentPageRef.current); }); // Object removed (eraser / undo) — sync temp cache fc.on("object:removed", () => { if (skipObjectTracking.current || skipRemoteRef.current) return; syncTempCanvasRef.current(currentPageRef.current); }); // ── 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); // Generate thumbnails in background (non-blocking) setThumbnails([]); generateThumbnails(); } 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; pageFabricRefs.current.forEach(fc => { try { fc?.dispose(); } catch { /* ignore */ } }); pageFabricRefs.current = []; pdfDocRef.current?.destroy(); pdfDocRef.current = null; }; }, [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 // ───────────────────────────────────────────────────────────────────────── 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; case "eraser": 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; const obj = options.target as any; const ownerId: number | undefined = obj._owner_id; const uid = currentUserIdRef.current; const role = userRoleRef.current; // Students can only erase their own annotations if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return; const collab_id = obj.collab_id as string | undefined; fc.remove(obj); addedObjects.current = addedObjects.current.filter(o => o !== obj); fc.renderAll(); if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } }, [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; case "eraser": fc.isDrawingMode = false; fc.selection = false; fc.on("mouse:down", (options: any) => { if (!options.target) return; const obj = options.target as any; const ownerId: number | undefined = obj._owner_id; const uid = currentUserIdRef.current; const role = userRoleRef.current; // Students can only erase their own annotations if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return; const collab_id = obj.collab_id as string | undefined; fc.remove(obj); fc.renderAll(); if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } }); }, [isReady, scrollMode, mode, tool, penColor, strokeWidth]); // ───────────────────────────────────────────────────────────────────────── // 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) return; // Preserve _owner_id so remote-object detection works on cache hits localAnnotations.current[currentPage] = fc.toJSON(["_owner_id"]); await renderPageWithAnnotations(newPage); setCurrentPage(newPage); }, [currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations] ); // ───────────────────────────────────────────────────────────────────────── // Save // ───────────────────────────────────────────────────────────────────────── const handleSave = useCallback(async () => { const fc = scrollMode === "continuous" ? pageFabricRefs.current[currentPage - 1] : fabricRef.current; if (!fc || !isReady || saving) return; setSaving(true); // Include _owner_id in serialization so we can filter remote objects const fullCanvasData = fc.toJSON(["_owner_id"]); // Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own) const uid = currentUserIdRef.current; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ownObjects = (fullCanvasData.objects as any[] ?? []).filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any (o: any) => o._owner_id == null || o._owner_id === uid ); const canvasData = { ...fullCanvasData, objects: ownObjects }; try { await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); // Data is now in DB — cancel any pending debounce (backend deletes Redis key too) if (tempSyncTimerRef.current) { clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = null; } // Cache the full display state (including remote objects) so the page looks correct on revisit localAnnotations.current[currentPage] = fullCanvasData; 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; const role = userRoleRef.current; const uid = currentUserIdRef.current; if (role === "student") { // Students: only remove their own objects, leave others untouched if (!confirm("Xoá annotation của bạn trên trang này?")) return; const toRemove = fc.getObjects().filter((o: any) => !o._isRemote && (o._owner_id == null || o._owner_id === uid)); toRemove.forEach((o: any) => { fc.remove(o); // Broadcast removal so teacher canvas updates in real time const collab_id = o.collab_id as string | undefined; if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); addedObjects.current = addedObjects.current.filter(o => toRemove.indexOf(o) === -1); fc.renderAll(); // Sync own cleared state to Redis syncTempCanvasRef.current(currentPageRef.current); } else { // Teacher / Admin: clear everything if (!confirm("Xoá tất cả annotation trên trang này?")) return; fc.clear(); addedObjects.current = []; fc.renderAll(); collabSendRef.current?.clear(currentPageRef.current); syncTempCanvasRef.current(currentPageRef.current); } }, []); // ───────────────────────────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────────────────────────── if (loadError) { return (

{loadError}

); } return (
{/* ── Sticky Toolbar ──────────────────────────────────────────────────── */}
{/* Scrollable tools strip */}
{/* Back button + title */} {pdfTitle}
{/* Thumbnail toggle */}
{/* Fit mode */}
{/* Scroll mode */}
{/* Page navigation */}
{isReady ? ( { 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" /> / {totalPages} ) : ( )}
{/* Pan / Draw mode toggle */}
{/* Drawing tools — inline, visible only in draw mode */} {mode === "draw" && ( <>
{/* Color picker button */} )}
{/* Save — fixed right, never scrolls away */}
{/* Collaboration presence */}
{collabUsers.length > 0 && (
{collabUsers.slice(0, 5).map(u => { const isSelf = u.user_id === currentUserIdRef.current; const canClear = !isSelf && (userRole === "admin" || userRole === "teacher"); return (
{u.username[0]} {canClear && ( )}
); })} {collabUsers.length > 5 && ( +{collabUsers.length - 5} )}
)}
{saveNotice && ( ✓ Saved )}
{/* ── Body: thumbnail sidebar + viewer ─────────────────────────────────── */}
{/* Thumbnail sidebar */} {showThumbnails && ( )} {/* ── Viewer area ─────────────────────────────────────────────────────── */}
{/* Loading spinner */} {!isReady && !loadError && (
)} {/* Canvas stack — PDF layer below, Fabric wrapper above (absolutely) */}
{/* PDF.js renders here */}
{/* Continuous scroll: one wrapper per page */} {isReady && scrollMode === "continuous" && (
{Array.from({ length: totalPages }).map((_, idx) => (
{ pageCanvasRefs.current[idx] = el; }} data-page={idx + 1} className="block shadow-lg rounded" />
))}
)}
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */} {colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(

Chọn màu bút

{PRESET_COLORS.map((c) => { const takenByPeer = Object.entries(peerColors) .filter(([uid]) => Number(uid) !== currentUserIdRef.current) .some(([, pc]) => pc.toLowerCase() === c.toLowerCase()); const isSelected = penColor.toLowerCase() === c.toLowerCase(); return (
, document.body )}
); }