mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 13:03:10 +07:00
colaborative tích hợp redis để đồng bộ toàn bộ annotation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user