mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 18:13:10 +07:00
hoàn thành chức năng hiện username annotation của từng user
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user