mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 16:33:11 +07:00
Hoàn thành chức năng collaborative
This commit is contained in:
@@ -3,6 +3,7 @@ import type { PDFItem } from "@/types";
|
||||
|
||||
interface Props {
|
||||
pdf: PDFItem;
|
||||
currentUserId: number;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
@@ -14,8 +15,9 @@ function formatDate(iso: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export default function PDFCard({ pdf, onDelete }: Props) {
|
||||
export default function PDFCard({ pdf, currentUserId, onDelete }: Props) {
|
||||
const router = useRouter();
|
||||
const isOwner = pdf.user_id === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,22 +41,29 @@ export default function PDFCard({ pdf, onDelete }: Props) {
|
||||
{pdf.total_pages != null && (
|
||||
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<p className="mt-1.5 text-[10px] text-blue-500 font-medium truncate">
|
||||
by {pdf.owner_username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(pdf.id);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
||||
>
|
||||
<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-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Delete button — only for owner, visible on hover */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(pdf.id);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
||||
>
|
||||
<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-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
@@ -113,6 +114,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
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
|
||||
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
|
||||
const renderingRef = useRef(false);
|
||||
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
|
||||
|
||||
// Ref for auto-scrolling thumbnail panel
|
||||
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const fitModeRef = useRef<FitMode>("width");
|
||||
@@ -125,6 +135,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
// 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 } | null>(null);
|
||||
|
||||
// Keep currentPageRef in sync
|
||||
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
|
||||
|
||||
// Keep mutable refs in sync
|
||||
useEffect(() => { currentToolRef.current = tool; }, [tool]);
|
||||
useEffect(() => { penColorRef.current = penColor; }, [penColor]);
|
||||
@@ -139,6 +156,121 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
}
|
||||
}, [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; }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 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[]) => {
|
||||
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 = false;
|
||||
obj.evented = false;
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Collaboration hook
|
||||
const { users: collabUsers, connected: collabConnected, sendObjectAdd, sendObjectRemove, sendClear } =
|
||||
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 };
|
||||
}, [sendObjectAdd, sendObjectRemove, sendClear]);
|
||||
|
||||
// Re-render current page when fit mode changes — moved below renderAllPages definition
|
||||
|
||||
const generateThumbnails = useCallback(async () => {
|
||||
@@ -173,6 +305,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
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 */ } });
|
||||
@@ -215,7 +349,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
|
||||
if (!annotationData) {
|
||||
try {
|
||||
const ann = await api.getAnnotation(pdfId, pageNum);
|
||||
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;
|
||||
@@ -231,6 +365,18 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
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) {
|
||||
// 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 = false;
|
||||
obj.evented = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
fc.renderAll();
|
||||
skipObjectTracking.current = false;
|
||||
resolve();
|
||||
@@ -240,7 +386,34 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
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[]) => {
|
||||
objects.forEach((obj: any) => {
|
||||
obj.collab_id = objJson.collab_id;
|
||||
obj._isRemote = true;
|
||||
obj.selectable = false;
|
||||
obj.evented = false;
|
||||
fc.add(obj);
|
||||
});
|
||||
fc.renderAll();
|
||||
skipRemoteRef.current = false;
|
||||
skipObjectTracking.current = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
renderingRef.current = false;
|
||||
setRendering(false);
|
||||
}
|
||||
},
|
||||
@@ -319,7 +492,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
let annotationData: object | null = cached ?? null;
|
||||
if (!annotationData) {
|
||||
try {
|
||||
const ann = await api.getAnnotation(pdfId, i);
|
||||
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;
|
||||
@@ -328,7 +501,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
}
|
||||
if (annotationData) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fc.loadFromJSON(annotationData, () => { fc.renderAll(); resolve(); });
|
||||
fc.loadFromJSON(annotationData, () => {
|
||||
// Mark objects from other users as remote
|
||||
const uid = currentUserIdRef.current;
|
||||
if (uid !== null) {
|
||||
// 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 = false;
|
||||
obj.evented = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
fc.renderAll();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -386,11 +574,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
fc.wrapperEl.style.pointerEvents = "none"; // start in pan mode
|
||||
}
|
||||
|
||||
// Track added objects for undo (skip objects loaded from JSON)
|
||||
// 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) {
|
||||
addedObjects.current.push(e.target);
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -401,6 +600,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init PDF.js ───────────────────────────────────────────────────
|
||||
@@ -572,9 +778,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fc.on("mouse:down", (options: any) => {
|
||||
if (!options.target) return;
|
||||
const collab_id = options.target.collab_id as string | undefined;
|
||||
fc.remove(options.target);
|
||||
addedObjects.current = addedObjects.current.filter(o => o !== options.target);
|
||||
fc.renderAll();
|
||||
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -618,7 +826,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
fc.isDrawingMode = false; fc.selection = false;
|
||||
fc.on("mouse:down", (options: any) => {
|
||||
if (!options.target) return;
|
||||
const collab_id = options.target.collab_id as string | undefined;
|
||||
fc.remove(options.target); fc.renderAll();
|
||||
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
|
||||
}); break;
|
||||
}
|
||||
});
|
||||
@@ -641,7 +851,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
|
||||
const fc = fabricRef.current;
|
||||
if (!fc) return;
|
||||
localAnnotations.current[currentPage] = fc.toJSON();
|
||||
// Preserve _owner_id so remote-object detection works on cache hits
|
||||
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id"]);
|
||||
await renderPageWithAnnotations(newPage);
|
||||
setCurrentPage(newPage);
|
||||
},
|
||||
@@ -659,11 +870,21 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
if (!fc || !isReady || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
const canvasData = fc.toJSON();
|
||||
// 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 });
|
||||
localAnnotations.current[currentPage] = canvasData;
|
||||
// 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) {
|
||||
@@ -691,6 +912,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
fc.clear();
|
||||
addedObjects.current = [];
|
||||
fc.renderAll();
|
||||
collabSendRef.current?.clear(currentPageRef.current);
|
||||
}, []);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -954,6 +1176,31 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
||||
|
||||
{/* 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 => (
|
||||
<span
|
||||
key={u.user_id}
|
||||
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"
|
||||
>
|
||||
{u.username[0]}
|
||||
</span>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user