Files
LMS/frontend/src/components/WorkbookViewer.tsx
T

1543 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<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);
const [showThumbnails, setShowThumbnails] = useState(true);
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [fitMode, setFitMode] = useState<FitMode>("width");
const [scrollMode, setScrollMode] = useState<ScrollMode>("single");
// Collaboration
const [collabToken, setCollabToken] = useState<string | null>(null);
const currentPageRef = useRef(1); // mutable copy for collab callbacks
const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events
const currentUserIdRef = useRef<number | null>(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<RemoteEvent[]>([]);
// 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<HTMLButtonElement>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
// 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);
// 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<ReturnType<typeof setTimeout> | 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<string, unknown>;
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<void>((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<string, unknown>;
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<void>((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 (
<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="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">
<div className="px-3 py-2 flex items-center gap-0">
{/* Scrollable tools strip */}
<div className="flex items-center gap-2 overflow-x-auto flex-1 min-w-0">
{/* Back button + title */}
<button
onClick={() => router.push("/dashboard")}
title="Back"
className="p-1 text-gray-500 hover:text-blue-600 transition rounded flex-shrink-0"
>
<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-[100px] flex-shrink-0">
{pdfTitle}
</span>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Thumbnail toggle */}
<button
onClick={() => setShowThumbnails(v => !v)}
title="Thumbnails"
className={`p-1.5 rounded-lg transition flex items-center gap-1 flex-shrink-0 ${
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>
</button>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Fit mode */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5 flex-shrink-0">
<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 lg: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 lg:inline">Height</span>
</button>
</div>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Scroll mode */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5 flex-shrink-0">
<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 lg: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 lg:inline">Continuous</span>
</button>
</div>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Page navigation */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<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>
{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}
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 flex-shrink-0" />
{/* Pan / Draw mode toggle */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5 flex-shrink-0">
<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 — inline, visible only in draw mode */}
{mode === "draw" && (
<>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
<div className="flex items-center gap-0.5 flex-shrink-0">
<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>
<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>
<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>
<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>
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-1.5 1.5M4.5 19.5l9-9m0 0L9 6l-4.5 4.5 4.5 4.5m4.5-4.5L18 6" />
</ToolBtn>
</div>
{/* Color picker button */}
<button
ref={colorBtnRef}
onClick={() => {
if (colorPickerOpen) {
setColorPickerOpen(false);
setColorPickerPos(null);
} else {
const rect = colorBtnRef.current!.getBoundingClientRect();
setColorPickerPos({ top: rect.bottom + 6, left: rect.left });
setColorPickerOpen(true);
}
}}
title="Pen color"
className="w-7 h-7 rounded-full border-2 border-white shadow flex-shrink-0 hover:scale-110 transition-transform"
style={{ background: penColor, outline: `2px solid ${penColor}`, outlineOffset: 2 }}
/>
<input
type="range"
min={1}
max={12}
value={strokeWidth}
onChange={(e) => setStrokeWidth(Number(e.target.value))}
className="w-20 accent-blue-600 flex-shrink-0"
title={`Stroke width: ${strokeWidth}`}
/>
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
<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 flex-shrink-0"
>
<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>
<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 flex-shrink-0"
>
<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>
</>
)}
</div>
{/* Save — fixed right, never scrolls away */}
<div className="flex items-center gap-2 pl-3 flex-shrink-0">
{/* Collaboration presence */}
<div className="flex items-center gap-1 flex-shrink-0" title={collabConnected ? `${collabUsers.length} user(s) connected` : "Connecting…"}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${collabConnected ? "bg-green-500" : "bg-gray-300"}`} />
{collabUsers.length > 0 && (
<div className="flex -space-x-1">
{collabUsers.slice(0, 5).map(u => {
const isSelf = u.user_id === currentUserIdRef.current;
const canClear = !isSelf && (userRole === "admin" || userRole === "teacher");
return (
<div key={u.user_id} className="relative group">
<span
title={u.username}
style={{ background: u.color }}
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase select-none"
>
{u.username[0]}
</span>
{canClear && (
<button
onClick={async () => {
if (!confirm(`Xoá annotation của "${u.username}" trên trang ${currentPageRef.current}?`)) return;
try {
await api.clearUserAnnotation(pdfId, currentPageRef.current, u.user_id);
// Remove their objects from canvas
const removeFromCanvas = (fc: any) => {
const toRemove = fc.getObjects().filter((o: any) => o._owner_id === u.user_id);
toRemove.forEach((o: any) => fc.remove(o));
if (toRemove.length) fc.renderAll();
};
if (scrollModeRef.current === "continuous") {
pageFabricRefs.current.forEach(fc => { if (fc) removeFromCanvas(fc); });
} else if (fabricRef.current) {
removeFromCanvas(fabricRef.current);
}
localAnnotations.current = {};
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Xoá thất bại.");
}
}}
title={`Xoá annotation của ${u.username} trang này`}
className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-red-500 text-white hidden group-hover:flex items-center justify-center shadow z-10"
style={{ fontSize: 8 }}
>
</button>
)}
</div>
);
})}
{collabUsers.length > 5 && (
<span className="w-5 h-5 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] text-white font-bold">
+{collabUsers.length - 5}
</span>
)}
</div>
)}
</div>
{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>
{/* ── 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}
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 && scrollMode === "single" ? "block" : "none", width: "fit-content" }}
>
{/* PDF.js renders here */}
<canvas ref={pdfCanvasRef} className="block shadow-lg rounded" />
<canvas ref={fabricElRef} />
</div>
{/* 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>
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */}
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div
ref={colorPickerRef}
style={{ position: "fixed", top: colorPickerPos.top, left: colorPickerPos.left, zIndex: 9999 }}
className="bg-white rounded-xl shadow-2xl border border-gray-200 p-3"
>
<p className="text-[10px] text-gray-400 mb-2 font-medium uppercase tracking-wide">Chọn màu bút</p>
<div className="grid grid-cols-6 gap-1.5">
{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 (
<button
key={c}
disabled={takenByPeer}
onClick={() => { setPenColor(c); setColorPickerOpen(false); setColorPickerPos(null); }}
style={{ background: c }}
className={[
"w-7 h-7 rounded-full border-2 transition-transform",
isSelected ? "border-white shadow-lg scale-110 ring-2 ring-offset-1 ring-gray-400" : "border-transparent hover:scale-110",
takenByPeer ? "opacity-25 cursor-not-allowed" : "cursor-pointer",
].join(" ")}
title={takenByPeer ? "Màu này đã được người dùng khác chọn" : c}
/>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}