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

1543 lines
69 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
2026-04-01 14:58:17 +07:00
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
// ─────────────────────────────────────────────────────────────────────────────
2026-04-01 09:25:31 +07:00
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");
2026-04-01 14:58:17 +07:00
// 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");
2026-04-01 14:58:17 +07:00
// 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);
2026-04-01 14:58:17 +07:00
// 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);
2026-04-01 14:58:17 +07:00
// 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>(() => {});
2026-04-01 14:58:17 +07:00
// 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]);
2026-04-01 14:58:17 +07:00
// 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(() => {});
2026-04-01 14:58:17 +07:00
}, []);
// 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]);
2026-04-01 14:58:17 +07:00
// 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";
2026-04-01 14:58:17 +07:00
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;
2026-04-01 14:58:17 +07:00
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);
2026-04-01 14:58:17 +07:00
}, []);
// Collaboration hook
const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } =
2026-04-01 14:58:17 +07:00
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]);
2026-04-01 14:58:17 +07:00
// 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);
2026-04-01 14:58:17 +07:00
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 {
2026-04-01 14:58:17 +07:00
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, () => {
2026-04-01 14:58:17 +07:00
// 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";
2026-04-01 14:58:17 +07:00
// 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;
2026-04-01 14:58:17 +07:00
}
});
}
fc.renderAll();
skipObjectTracking.current = false;
resolve();
});
});
} else {
fc.renderAll();
skipObjectTracking.current = false;
}
2026-04-01 14:58:17 +07:00
// 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";
2026-04-01 14:58:17 +07:00
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
2026-04-01 14:58:17 +07:00
fc.add(obj);
});
fc.renderAll();
skipRemoteRef.current = false;
skipObjectTracking.current = false;
});
}
}
} finally {
2026-04-01 14:58:17 +07:00
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 {
2026-04-01 14:58:17 +07:00
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) => {
2026-04-01 14:58:17 +07:00
fc.loadFromJSON(annotationData, () => {
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
const canInteract = userRoleRef.current !== "student";
2026-04-01 14:58:17 +07:00
// 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;
2026-04-01 14:58:17 +07:00
}
});
}
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
}
2026-04-01 14:58:17 +07:00
// 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) => {
2026-04-01 14:58:17 +07:00
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();
}
2026-04-01 14:58:17 +07:00
// 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;
2026-04-01 09:25:31 +07:00
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);
2026-04-01 09:25:31 +07:00
fc.renderAll();
2026-04-01 14:58:17 +07:00
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
2026-04-01 09:25:31 +07:00
});
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;
2026-04-01 09:25:31 +07:00
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();
2026-04-01 14:58:17 +07:00
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
2026-04-01 09:25:31 +07:00
}); 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;
2026-04-01 14:58:17 +07:00
// 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);
2026-04-01 14:58:17 +07:00
// 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; }
2026-04-01 14:58:17 +07:00
// 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">
2026-04-01 09:25:31 +07:00
<div className="px-3 py-2 flex items-center gap-0">
2026-04-01 09:25:31 +07:00
{/* Scrollable tools strip */}
<div className="flex items-center gap-2 overflow-x-auto flex-1 min-w-0">
2026-04-01 09:25:31 +07:00
{/* Back button + title */}
<button
2026-04-01 09:25:31 +07:00
onClick={() => router.push("/dashboard")}
title="Back"
className="p-1 text-gray-500 hover:text-blue-600 transition rounded flex-shrink-0"
>
2026-04-01 09:25:31 +07:00
<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>
2026-04-01 09:25:31 +07:00
<span className="text-sm font-semibold text-gray-800 truncate max-w-[100px] flex-shrink-0">
{pdfTitle}
</span>
2026-04-01 09:25:31 +07:00
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
2026-04-01 09:25:31 +07:00
{/* Thumbnail toggle */}
<button
2026-04-01 09:25:31 +07:00
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">
2026-04-01 09:25:31 +07:00
<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>
2026-04-01 09:25:31 +07:00
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
2026-04-01 09:25:31 +07:00
{/* 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>
2026-04-01 09:25:31 +07:00
<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
2026-04-01 09:25:31 +07:00
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"
}`}
>
2026-04-01 09:25:31 +07:00
<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>
2026-04-01 09:25:31 +07:00
<span className="hidden lg:inline">Single</span>
</button>
2026-04-01 09:25:31 +07:00
<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>
2026-04-01 09:25:31 +07:00
<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
2026-04-01 09:25:31 +07:00
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">
2026-04-01 09:25:31 +07:00
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
2026-04-01 09:25:31 +07:00
</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 }}
2026-04-01 09:25:31 +07:00
/>
<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>
2026-04-01 09:25:31 +07:00
{/* Save — fixed right, never scrolls away */}
<div className="flex items-center gap-2 pl-3 flex-shrink-0">
2026-04-01 14:58:17 +07:00
{/* 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>
);
})}
2026-04-01 14:58:17 +07:00
{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 && (
2026-04-01 09:25:31 +07:00
<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>
);
}