hoàn thành chức năng hiện username annotation của từng user

This commit is contained in:
2026-04-01 20:13:48 +07:00
parent 386871bd4a
commit 217e2f9712
7 changed files with 376 additions and 10 deletions
+129 -9
View File
@@ -144,6 +144,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
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");
const [currentUsername, setCurrentUsername] = useState("");
const currentUsernameRef = useRef("");
const userMapRef = useRef<Map<number, string>>(new Map());
const [annotationTooltip, setAnnotationTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -177,8 +181,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
// 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 currentPageRef in sync; clear any lingering hover tooltip on page navigation
useEffect(() => { currentPageRef.current = currentPage; setAnnotationTooltip(null); }, [currentPage]);
// Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]);
@@ -186,6 +190,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]);
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => {
@@ -193,7 +198,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fullData: any = fc.toJSON(["_owner_id"]);
const fullData: any = fc.toJSON(["_owner_id", "_owner_username"]);
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ownObjects = ((fullData.objects as any[]) ?? []).filter(
@@ -229,6 +234,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
currentUserIdRef.current = u.id;
setUserRole(u.role);
userRoleRef.current = u.role;
setCurrentUsername(u.username);
currentUsernameRef.current = u.username;
userMapRef.current.set(u.id, u.username);
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
@@ -307,6 +315,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account
if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -317,7 +326,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
});
return;
}
}, []);
}, []);;
// When a new peer joins the room, broadcast all our own (non-remote) canvas
// objects so they receive our pre-existing annotations immediately.
@@ -358,6 +367,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
}, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]);
// Keep user id→name map updated as peers connect/disconnect
useEffect(() => {
collabUsers.forEach(u => userMapRef.current.set(u.user_id, u.username));
}, [collabUsers]);
// 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.
@@ -481,8 +495,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
addedObjects.current = [];
if (annotationData) {
const rawObjs: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => {
// Enforce custom properties — Fabric may not restore underscore-prefixed props
fc.getObjects().forEach((obj: any, i: number) => {
const raw = rawObjs[i];
if (raw) {
if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
}
});
// Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -493,6 +516,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -522,6 +546,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -620,8 +645,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
} catch { /* no annotation */ }
}
if (annotationData) {
const rawObjsCont: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => {
// Enforce custom properties — Fabric may not restore underscore-prefixed props
fc.getObjects().forEach((obj: any, i: number) => {
const raw = rawObjsCont[i];
if (raw) {
if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
}
});
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -632,6 +666,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -708,7 +743,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
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"]);
const json = (obj as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(json, currentPageRef.current);
});
}
@@ -721,11 +756,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
options.path.set({ opacity: 0.42 });
fc.renderAll();
}
// Tag with owner info for tooltip display
options.path._owner_id = currentUserIdRef.current;
options.path._owner_username = currentUsernameRef.current;
// 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"]);
const obj = (options.path as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(obj, currentPageRef.current);
}
// Sync unsaved canvas to Redis so late-joining users see this stroke
@@ -784,6 +822,61 @@ export default function WorkbookViewer({ pdfId }: Props) {
};
}, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]);
// ── Annotation hover tooltip (works in all modes including pan) ────────
// Uses scroll-container mousemove + Fabric findTarget() so pointer-events:none
// on the canvas wrapper doesn't block tooltip detection.
useEffect(() => {
if (!isReady) return;
const container = scrollContainerRef.current;
if (!container) return;
const resolveOwnerName = (obj: any): string | null => {
if (obj._owner_username) return obj._owner_username as string;
const ownerId: number | null = obj._owner_id ?? null;
if (ownerId === null) return null; // no owner info — don't attribute to current user
const uid = currentUserIdRef.current;
if (ownerId === uid) return currentUsernameRef.current || null;
return userMapRef.current.get(ownerId) ?? `User #${ownerId}`;
};
let lastTarget: any = null;
const handleMouseMove = (e: MouseEvent) => {
let found: any = null;
if (scrollModeRef.current === "single") {
const fc = fabricRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (fc) found = (fc as any).findTarget(e, false) ?? null;
} else {
for (const fc of pageFabricRefs.current) {
if (!fc) continue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hit = (fc as any).findTarget(e, false);
if (hit) { found = hit; break; }
}
}
if (found === lastTarget) return; // avoid redundant state updates
lastTarget = found;
if (found) {
const name = resolveOwnerName(found);
if (name) {
setAnnotationTooltip({ x: e.clientX, y: e.clientY, text: name });
} else {
setAnnotationTooltip(null);
}
} else {
setAnnotationTooltip(null);
}
};
const handleMouseLeave = () => { lastTarget = null; setAnnotationTooltip(null); };
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseleave", handleMouseLeave);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseleave", handleMouseLeave);
};
}, [isReady]);
// Re-render when switching scroll modes
useEffect(() => {
if (!isReady) return;
@@ -893,6 +986,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
fontFamily: "Arial, sans-serif",
padding: 4,
});
textObj._owner_id = currentUserIdRef.current;
textObj._owner_username = currentUsernameRef.current;
fc.add(textObj);
fc.setActiveObject(textObj);
textObj.enterEditing();
@@ -993,7 +1088,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
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"]);
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username"]);
await renderPageWithAnnotations(newPage);
setCurrentPage(newPage);
},
@@ -1011,8 +1106,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !isReady || saving) return;
setSaving(true);
// Include _owner_id in serialization so we can filter remote objects
const fullCanvasData = fc.toJSON(["_owner_id"]);
// Include _owner_id and _owner_username in serialization
const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username"]);
// 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
@@ -1124,6 +1219,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
{pdfTitle}
</span>
{/* Current user badge */}
{currentUsername && (
<span className="flex items-center gap-1 text-xs font-medium text-white flex-shrink-0 px-2 py-0.5 rounded-full"
style={{ background: penColor }}
title={`Đang đăng nhập: ${currentUsername} (${userRole})`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white opacity-80" />
{currentUsername}
</span>
)}
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Thumbnail toggle */}
@@ -1505,6 +1611,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div>
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */}
{/* Annotation owner tooltip */}
{annotationTooltip && (
<div
style={{ position: "fixed", left: annotationTooltip.x + 14, top: annotationTooltip.y + 14, zIndex: 9999 }}
className="pointer-events-none bg-gray-800 bg-opacity-90 text-white text-xs rounded-lg px-2.5 py-1 shadow-xl whitespace-nowrap flex items-center gap-1"
>
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
{annotationTooltip.text}
</div>
)}
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div
ref={colorPickerRef}