colaborative tích hợp redis để đồng bộ toàn bộ annotation

This commit is contained in:
2026-04-01 18:29:56 +07:00
parent 9335e56322
commit 8546d41a26
9 changed files with 372 additions and 26 deletions
+180 -11
View File
@@ -1,10 +1,33 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
// ─────────────────────────────────────────────────────────────────────────────
// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
// Each color is visually distinct to avoid confusion between collaborators.
// ─────────────────────────────────────────────────────────────────────────────
const PRESET_COLORS = [
"#e63946", "#9d0208", "#ff6b6b", // Red
"#f4572a", "#a23b17", "#ff9472", // Orange-red
"#f7b731", "#a07800", "#ffe066", // Yellow
"#80b918", "#4a6c0a", "#b5e853", // Yellow-green
"#2dc653", "#0a7a2e", "#6ee08a", // Green
"#00b4d8", "#005f73", "#48cae4", // Cyan
"#4361ee", "#1a237e", "#7b9cff", // Blue
"#7209b7", "#3a0068", "#b56aff", // Purple
"#e040fb", "#880e62", "#f48fff", // Magenta
"#ff6392", "#a3003f", "#ffadc5", // Rose
] as const;
function userIdToPresetColor(id: number): string {
return PRESET_COLORS[id % PRESET_COLORS.length];
}
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
@@ -122,6 +145,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
// True once api.me() has resolved and pen color has been set from user id
const [userColorReady, setUserColorReady] = useState(false);
// Color picker
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [colorPickerPos, setColorPickerPos] = useState<{ top: number; left: number } | null>(null);
const colorBtnRef = useRef<HTMLButtonElement>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
// Ref for auto-scrolling thumbnail panel
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
@@ -137,7 +168,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
// 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);
const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any; colorSync: (c: string) => void } | null>(null);
// Temp-cache sync: debounce unsaved canvas state to Redis (1.5 s after last stroke)
const tempSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncTempCanvasRef = useRef<(page: number) => void>(() => {});
// Keep currentPageRef in sync
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
@@ -149,6 +185,27 @@ export default function WorkbookViewer({ pdfId }: Props) {
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) {
@@ -166,9 +223,32 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Fetch current user id — used to distinguish own from remote objects when merging /all
useEffect(() => {
api.me().then((u) => { currentUserIdRef.current = u.id; }).catch(() => {});
api.me().then((u) => {
currentUserIdRef.current = u.id;
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
penColorRef.current = assigned;
setUserColorReady(true); // unblocks the color-sync broadcast
}).catch(() => {});
}, []);
// Close color picker when clicking outside
useEffect(() => {
if (!colorPickerOpen) return;
const handler = (e: MouseEvent) => {
if (
colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node) &&
colorBtnRef.current && !colorBtnRef.current.contains(e.target as Node)
) {
setColorPickerOpen(false);
setColorPickerPos(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [colorPickerOpen]);
// Handle incoming remote annotation events
const onRemoteEvent = useCallback((event: RemoteEvent) => {
const fabric = fabricNSRef.current;
@@ -260,16 +340,49 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (fc) broadcastCanvas(fc, currentPageRef.current);
}
// Also broadcast our current pen color so the new peer can disable our swatch
collabSendRef.current?.colorSync(penColorRef.current);
}, []);
// Collaboration hook
const { users: collabUsers, connected: collabConnected, sendObjectAdd, sendObjectRemove, sendClear } =
const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } =
useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined });
// Keep send functions accessible in stable refs (used in Fabric event handlers)
useEffect(() => {
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear };
}, [sendObjectAdd, sendObjectRemove, sendClear]);
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
}, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]);
// Broadcast pen color only when BOTH WS is connected AND user color has been fetched.
// This prevents both users broadcasting the same default "#e63946" before api.me() resolves.
// Also re-broadcasts whenever the user manually picks a new color.
useEffect(() => {
if (collabConnected && userColorReady) {
sendColorSync(penColor);
}
}, [collabConnected, userColorReady, penColor, sendColorSync]);
// If our color collides with a peer's color, pick the first free preset color and rebroadcast.
useEffect(() => {
if (!collabConnected || !userColorReady) return;
const uid = currentUserIdRef.current;
if (uid == null) return;
const taken = new Set(
Object.entries(peerColors)
.filter(([id]) => Number(id) !== uid)
.map(([, c]) => c.toLowerCase())
);
const current = penColor.toLowerCase();
if (!taken.has(current)) return;
const replacement = PRESET_COLORS.find(c => !taken.has(c.toLowerCase())) ?? penColor;
if (replacement.toLowerCase() === current) return;
setPenColor(replacement);
penColorRef.current = replacement;
sendColorSync(replacement);
}, [collabConnected, userColorReady, peerColors, penColor, sendColorSync]);
// Re-render current page when fit mode changes — moved below renderAllPages definition
@@ -607,6 +720,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
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 ───────────────────────────────────────────────────
@@ -883,6 +1004,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
try {
await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData });
// Data is now in DB — cancel any pending debounce (backend deletes Redis key too)
if (tempSyncTimerRef.current) { clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = null; }
// Cache the full display state (including remote objects) so the page looks correct on revisit
localAnnotations.current[currentPage] = fullCanvasData;
setSaveNotice(true);
@@ -913,6 +1036,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
addedObjects.current = [];
fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current);
// Push empty canvas to Redis so other users see the clear immediately on next load
syncTempCanvasRef.current(currentPageRef.current);
}, []);
// ─────────────────────────────────────────────────────────────────────────
@@ -1129,12 +1254,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
</ToolBtn>
</div>
<input
type="color"
value={penColor}
onChange={(e) => setPenColor(e.target.value)}
title="Color"
className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white flex-shrink-0"
{/* Color picker button */}
<button
ref={colorBtnRef}
onClick={() => {
if (colorPickerOpen) {
setColorPickerOpen(false);
setColorPickerPos(null);
} else {
const rect = colorBtnRef.current!.getBoundingClientRect();
setColorPickerPos({ top: rect.bottom + 6, left: rect.left });
setColorPickerOpen(true);
}
}}
title="Pen color"
className="w-7 h-7 rounded-full border-2 border-white shadow flex-shrink-0 hover:scale-110 transition-transform"
style={{ background: penColor, outline: `2px solid ${penColor}`, outlineOffset: 2 }}
/>
<input
@@ -1294,6 +1429,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
</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>
);
}
+20 -2
View File
@@ -32,7 +32,7 @@ export interface CollabUser {
}
export interface RemoteEvent {
type: "object_add" | "object_remove" | "clear" | "cursor";
type: "object_add" | "object_remove" | "clear" | "cursor" | "color_sync";
payload?: unknown;
page?: number;
user_id: number;
@@ -67,6 +67,7 @@ const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close
export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) {
const [users, setUsers] = useState<CollabUser[]>([]);
const [connected, setConnected] = useState(false);
const [peerColors, setPeerColors] = useState<Record<number, string>>({});
const wsRef = useRef<WebSocket | null>(null);
const onEventRef = useRef(onEvent);
@@ -101,9 +102,21 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option
try {
const msg = JSON.parse(e.data as string);
if (msg.type === "pong") return;
if (msg.type === "color_sync") {
const { user_id, color } = msg as { user_id: number; color: string };
setPeerColors(prev => ({ ...prev, [user_id]: color }));
return;
}
if (msg.type === "presence") {
const incoming = (msg.users ?? []) as CollabUser[];
setUsers(incoming);
// Update peer color map from presence payload (includes our own entry).
setPeerColors(
incoming.reduce<Record<number, string>>((acc, u) => {
acc[u.user_id] = u.color;
return acc;
}, {})
);
// If someone new joined (count increased) and we're already in the room,
// notify the caller so they can re-broadcast their current canvas objects.
if (
@@ -174,5 +187,10 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option
[send]
);
return { users, connected, sendObjectAdd, sendObjectRemove, sendClear, sendCursor };
const sendColorSync = useCallback(
(color: string) => send({ type: "color_sync", color }),
[send]
);
return { users, connected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendCursor, sendColorSync };
}
+5
View File
@@ -47,4 +47,9 @@ export const api = {
method: "PUT",
body: JSON.stringify(body),
}),
upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<void>(`/api/annotations/${pdfId}/${page}/temp`, {
method: "PUT",
body: JSON.stringify(body),
}),
};