Hoàn thành chức năng collaborative

This commit is contained in:
2026-04-01 14:58:17 +07:00
parent 89db1c7b5c
commit 9335e56322
13 changed files with 751 additions and 41 deletions
+3 -2
View File
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .routers import annotations, auth, pdfs from .routers import annotations, auth, pdfs, ws
@asynccontextmanager @asynccontextmanager
@@ -17,7 +17,7 @@ app = FastAPI(title="LMS API", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000"], # Next.js dev server allow_origins=["http://localhost:3000", "http://localhost:3001"],
allow_credentials=True, # required for cookies allow_credentials=True, # required for cookies
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -26,3 +26,4 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api") app.include_router(pdfs.router, prefix="/api")
app.include_router(annotations.router, prefix="/api") app.include_router(annotations.router, prefix="/api")
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
+63 -2
View File
@@ -18,6 +18,14 @@ def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
return pdf return pdf
def _any_pdf_or_404(db: Session, pdf_id: int) -> PDF:
"""Return PDF regardless of owner — any authenticated user may read annotations."""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/annotations/{pdf_id}/{page_number} ─────────────────────────────── # ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut) @router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
@@ -27,7 +35,7 @@ def get_annotation(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
_verify_pdf_ownership(db, pdf_id, current_user.id) _any_pdf_or_404(db, pdf_id)
ann = ( ann = (
db.query(Annotation) db.query(Annotation)
@@ -52,6 +60,59 @@ def get_annotation(
return ann return ann
# ── GET /api/annotations/{pdf_id}/{page_number}/all ───────────────────────────
# Returns a merged canvas_data whose "objects" array contains annotations from
# ALL users for the given page. Any authenticated user may call this.
@router.get("/{pdf_id}/{page_number}/all", response_model=AnnotationOut)
def get_all_annotations(
pdf_id: int,
page_number: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
rows = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.page_number == page_number,
)
.all()
)
if not rows:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
# Merge all objects arrays; use canvas metadata (background etc.) from latest record
latest = max(rows, key=lambda r: r.updated_at)
base: dict = dict(latest.canvas_data) if latest.canvas_data else {}
merged_objects: list = []
for row in rows:
for obj in (row.canvas_data or {}).get("objects", []):
# Tag each object with its owner so the frontend can avoid re-saving
# other users' objects under the current user's record (prevents duplicates).
obj_copy = dict(obj)
obj_copy["_owner_id"] = row.user_id
merged_objects.append(obj_copy)
base["objects"] = merged_objects
return AnnotationOut(
id=latest.id,
pdf_id=pdf_id,
page_number=page_number,
canvas_data=base,
updated_at=latest.updated_at,
)
# ── PUT /api/annotations/{pdf_id}/{page_number} ─────────────────────────────── # ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut) @router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
@@ -62,7 +123,7 @@ def upsert_annotation(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
_verify_pdf_ownership(db, pdf_id, current_user.id) _any_pdf_or_404(db, pdf_id)
ann = ( ann = (
db.query(Annotation) db.query(Annotation)
+14 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import os import os
@@ -84,3 +84,16 @@ def logout(response: Response):
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)): def me(current_user: User = Depends(get_current_user)):
return current_user return current_user
# ── GET /auth/token (return raw JWT for WebSocket auth) ─────────────────────
@router.get("/token")
def get_token(access_token: str | None = Cookie(default=None)):
"""
Returns the current JWT so the frontend can pass it as a WebSocket
query param (browsers can't send cookies on WS upgrade in all cases).
"""
if not access_token:
raise HTTPException(status_code=401, detail="Not authenticated.")
return {"access_token": access_token}
+28 -9
View File
@@ -30,6 +30,29 @@ def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
return pdf return pdf
def _any_or_404(db: Session, pdf_id: int) -> PDF:
"""Return PDF if it exists — any authenticated user may read it."""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
def _pdf_out(pdf: PDF, db: Session):
"""Build PDFOut including owner_username."""
from ..schemas import PDFOut as _PDFOut
owner = db.get(User, pdf.user_id)
data = {
"id": pdf.id,
"user_id": pdf.user_id,
"owner_username": owner.username if owner else "unknown",
"title": pdf.title,
"total_pages": pdf.total_pages,
"created_at": pdf.created_at,
}
return _PDFOut.model_validate(data)
# ── GET /api/pdfs ───────────────────────────────────────────────────────────── # ── GET /api/pdfs ─────────────────────────────────────────────────────────────
@router.get("", response_model=list[PDFOut]) @router.get("", response_model=list[PDFOut])
@@ -37,12 +60,8 @@ def list_pdfs(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return ( pdfs = db.query(PDF).order_by(PDF.created_at.desc()).all()
db.query(PDF) return [_pdf_out(p, db) for p in pdfs]
.filter(PDF.user_id == current_user.id)
.order_by(PDF.created_at.desc())
.all()
)
# ── POST /api/pdfs/upload ───────────────────────────────────────────────────── # ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
@@ -90,7 +109,7 @@ async def upload_pdfs(
db.commit() db.commit()
db.refresh(pdf) db.refresh(pdf)
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf))) results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=_pdf_out(pdf, db)))
return results return results
@@ -121,8 +140,8 @@ def serve_pdf(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Serve the raw PDF bytes — only to the owning user.""" """Serve the raw PDF bytes — any authenticated user may read."""
pdf = _own_or_404(db, pdf_id, current_user.id) pdf = _any_or_404(db, pdf_id)
file_path = Path(pdf.file_path) file_path = Path(pdf.file_path)
if not file_path.exists(): if not file_path.exists():
+172
View File
@@ -0,0 +1,172 @@
"""
WebSocket collaboration endpoint.
URL: ws://.../ws/pdf/{pdf_id}?token=<JWT>
Each PDF has its own "room". When a client connects it broadcasts a `presence`
event to the room. All annotation mutations are forwarded verbatim to every
other client in the room.
Message schema (JSON):
→ client sends:
{ "type": "object_add", "payload": <fabric object JSON> }
{ "type": "object_remove", "payload": { "obj_id": "<id>" } }
{ "type": "clear" }
{ "type": "cursor", "payload": { "x": 0, "y": 0, "page": 1 } }
{ "type": "ping" }
← server sends to ALL others in room:
same messages, with "user_id" / "username" / "color" injected
← server sends to ALL (including sender) on join/leave:
{ "type": "presence", "users": [ { "user_id", "username", "color" } ] }
"""
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from jose import JWTError
from ..security import decode_access_token
from ..database import get_db
from ..models import User
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Deterministic colour per user (hue based on user_id) ─────────────────────
def _user_color(user_id: int) -> str:
hue = (user_id * 61) % 360 # spread nicely around the wheel
return f"hsl({hue},70%,50%)"
# ── Room manager ──────────────────────────────────────────────────────────────
class _Room:
def __init__(self) -> None:
# websocket → user info dict
self._clients: dict[WebSocket, dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def join(self, ws: WebSocket, user_info: dict[str, Any]) -> None:
async with self._lock:
self._clients[ws] = user_info
await self._broadcast_presence()
async def leave(self, ws: WebSocket) -> None:
async with self._lock:
self._clients.pop(ws, None)
await self._broadcast_presence()
@property
def user_list(self) -> list[dict[str, Any]]:
"""Deduplicated by user_id — same user may have multiple WS connections."""
seen: set[int] = set()
result: list[dict[str, Any]] = []
for info in self._clients.values():
uid = info["user_id"]
if uid not in seen:
seen.add(uid)
result.append(info)
return result
async def broadcast(self, message: dict[str, Any], exclude: WebSocket | None = None) -> None:
"""Send message to every client except `exclude`."""
async with self._lock:
targets = [ws for ws in self._clients if ws is not exclude]
for ws in targets:
try:
await ws.send_json(message)
except Exception:
pass
async def broadcast_all(self, message: dict[str, Any]) -> None:
"""Send message to every client including sender."""
await self.broadcast(message, exclude=None)
async def _broadcast_presence(self) -> None:
await self.broadcast_all({"type": "presence", "users": self.user_list})
class _RoomManager:
def __init__(self) -> None:
self._rooms: dict[int, _Room] = {}
self._lock = asyncio.Lock()
async def get_or_create(self, pdf_id: int) -> _Room:
async with self._lock:
if pdf_id not in self._rooms:
self._rooms[pdf_id] = _Room()
return self._rooms[pdf_id]
async def cleanup(self, pdf_id: int) -> None:
async with self._lock:
room = self._rooms.get(pdf_id)
if room and not room._clients:
del self._rooms[pdf_id]
manager = _RoomManager()
# ── WebSocket endpoint ────────────────────────────────────────────────────────
@router.websocket("/ws/pdf/{pdf_id}")
async def pdf_collaboration(websocket: WebSocket, pdf_id: int):
# ── Auth: JWT passed as query param (cookies aren't reliably sent on WS) ──
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
try:
user_id = decode_access_token(token)
except JWTError:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
# ── Fetch username from DB ────────────────────────────────────────────────
db = next(get_db())
try:
user: User | None = db.get(User, user_id)
finally:
db.close()
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
color = _user_color(user_id)
user_info = {"user_id": user_id, "username": user.username, "color": color}
await websocket.accept()
room = await manager.get_or_create(pdf_id)
await room.join(websocket, user_info)
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
continue
# Inject sender identity and forward to all other clients
if msg_type in {"object_add", "object_remove", "clear", "cursor"}:
msg.update(user_info)
await room.broadcast(msg, exclude=websocket)
except WebSocketDisconnect:
pass
finally:
await room.leave(websocket)
await manager.cleanup(pdf_id)
+2
View File
@@ -51,6 +51,8 @@ class TokenPayload(BaseModel):
class PDFOut(BaseModel): class PDFOut(BaseModel):
id: int id: int
user_id: int
owner_username: str
title: str title: str
total_pages: int | None total_pages: int | None
created_at: datetime created_at: datetime
+4
View File
@@ -7,6 +7,10 @@ const nextConfig = {
source: "/api/:path*", source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`, destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/api/:path*`,
}, },
{
source: "/ws/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"}/ws/:path*`,
},
]; ];
}, },
}; };
+2 -2
View File
@@ -83,7 +83,7 @@ export default function DashboardPage() {
{/* ── Main content ───────────────────────────────────────────────── */} {/* ── Main content ───────────────────────────────────────────────── */}
<main className="max-w-6xl mx-auto px-4 py-8"> <main className="max-w-6xl mx-auto px-4 py-8">
<h2 className="text-xl font-bold mb-6 text-gray-800">My PDFs</h2> <h2 className="text-xl font-bold mb-6 text-gray-800">All PDFs</h2>
<UploadZone onUploaded={handleUploaded} /> <UploadZone onUploaded={handleUploaded} />
@@ -94,7 +94,7 @@ export default function DashboardPage() {
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{pdfs.map((pdf) => ( {pdfs.map((pdf) => (
<PDFCard key={pdf.id} pdf={pdf} onDelete={handleDelete} /> <PDFCard key={pdf.id} pdf={pdf} currentUserId={user!.id} onDelete={handleDelete} />
))} ))}
</div> </div>
)} )}
+11 -2
View File
@@ -3,6 +3,7 @@ import type { PDFItem } from "@/types";
interface Props { interface Props {
pdf: PDFItem; pdf: PDFItem;
currentUserId: number;
onDelete: (id: number) => void; 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 router = useRouter();
const isOwner = pdf.user_id === currentUserId;
return ( return (
<div <div
@@ -39,9 +41,15 @@ export default function PDFCard({ pdf, onDelete }: Props) {
{pdf.total_pages != null && ( {pdf.total_pages != null && (
<p className="text-xs text-gray-400">{pdf.total_pages} pages</p> <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> </div>
{/* Delete button — visible on hover */} {/* Delete button — only for owner, visible on hover */}
{isOwner && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -55,6 +63,7 @@ export default function PDFCard({ pdf, onDelete }: Props) {
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" /> 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> </svg>
</button> </button>
)}
</div> </div>
); );
} }
+255 -8
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Types // Types
@@ -113,6 +114,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [fitMode, setFitMode] = useState<FitMode>("width"); const [fitMode, setFitMode] = useState<FitMode>("width");
const [scrollMode, setScrollMode] = useState<ScrollMode>("single"); 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 // Ref for auto-scrolling thumbnail panel
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
const fitModeRef = useRef<FitMode>("width"); 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 // Generation counter — incremented on each renderAllPages call to abort stale runs
const renderAllPagesGenRef = useRef(0); 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 // Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { currentToolRef.current = tool; }, [tool]);
useEffect(() => { penColorRef.current = penColor; }, [penColor]); useEffect(() => { penColorRef.current = penColor; }, [penColor]);
@@ -139,6 +156,121 @@ export default function WorkbookViewer({ pdfId }: Props) {
} }
}, [currentPage, showThumbnails]); }, [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 // Re-render current page when fit mode changes — moved below renderAllPages definition
const generateThumbnails = useCallback(async () => { const generateThumbnails = useCallback(async () => {
@@ -173,6 +305,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return;
setRendering(true); setRendering(true);
renderingRef.current = true;
pendingRemoteEvents.current = [];
try { try {
// Cancel any in-progress render on the same canvas // Cancel any in-progress render on the same canvas
renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } });
@@ -215,7 +349,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!annotationData) { if (!annotationData) {
try { 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) { if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) {
annotationData = ann.canvas_data; annotationData = ann.canvas_data;
localAnnotations.current[pageNum] = annotationData; localAnnotations.current[pageNum] = annotationData;
@@ -231,6 +365,18 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (annotationData) { if (annotationData) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => { 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(); fc.renderAll();
skipObjectTracking.current = false; skipObjectTracking.current = false;
resolve(); resolve();
@@ -240,7 +386,34 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.renderAll(); fc.renderAll();
skipObjectTracking.current = false; 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 { } finally {
renderingRef.current = false;
setRendering(false); setRendering(false);
} }
}, },
@@ -319,7 +492,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
let annotationData: object | null = cached ?? null; let annotationData: object | null = cached ?? null;
if (!annotationData) { if (!annotationData) {
try { 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) { if (ann.canvas_data && Object.keys(ann.canvas_data).length > 0) {
annotationData = ann.canvas_data; annotationData = ann.canvas_data;
localAnnotations.current[i] = annotationData; localAnnotations.current[i] = annotationData;
@@ -328,7 +501,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
} }
if (annotationData) { if (annotationData) {
await new Promise<void>((resolve) => { 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 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("object:added", (e: any) => { fc.on("object:added", (e: any) => {
if (!skipObjectTracking.current) { if (skipObjectTracking.current) return;
addedObjects.current.push(e.target); 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 }); options.path.set({ opacity: 0.42 });
fc.renderAll(); 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 ─────────────────────────────────────────────────── // ── Init PDF.js ───────────────────────────────────────────────────
@@ -572,9 +778,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("mouse:down", (options: any) => { fc.on("mouse:down", (options: any) => {
if (!options.target) return; if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined;
fc.remove(options.target); fc.remove(options.target);
addedObjects.current = addedObjects.current.filter(o => o !== options.target); addedObjects.current = addedObjects.current.filter(o => o !== options.target);
fc.renderAll(); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); });
break; break;
} }
@@ -618,7 +826,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.isDrawingMode = false; fc.selection = false; fc.isDrawingMode = false; fc.selection = false;
fc.on("mouse:down", (options: any) => { fc.on("mouse:down", (options: any) => {
if (!options.target) return; if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined;
fc.remove(options.target); fc.renderAll(); fc.remove(options.target); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); break; }); break;
} }
}); });
@@ -641,7 +851,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current; const fc = fabricRef.current;
if (!fc) return; 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); await renderPageWithAnnotations(newPage);
setCurrentPage(newPage); setCurrentPage(newPage);
}, },
@@ -659,11 +870,21 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !isReady || saving) return; if (!fc || !isReady || saving) return;
setSaving(true); 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 { try {
await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData }); 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); setSaveNotice(true);
setTimeout(() => setSaveNotice(false), 2500); setTimeout(() => setSaveNotice(false), 2500);
} catch (err: unknown) { } catch (err: unknown) {
@@ -691,6 +912,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.clear(); fc.clear();
addedObjects.current = []; addedObjects.current = [];
fc.renderAll(); fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current);
}, []); }, []);
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -954,6 +1176,31 @@ export default function WorkbookViewer({ pdfId }: Props) {
{/* Save — fixed right, never scrolls away */} {/* Save — fixed right, never scrolls away */}
<div className="flex items-center gap-2 pl-3 flex-shrink-0"> <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 && ( {saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse"> Saved</span> <span className="text-xs text-green-600 font-medium animate-pulse"> Saved</span>
)} )}
+178
View File
@@ -0,0 +1,178 @@
/**
* useCollaboration
*
* Manages a WebSocket connection to the backend collaboration room for a PDF.
* The hook is intentionally "dumb" about canvas internals — callers supply
* callbacks that do the actual Fabric.js work.
*
* Protocol (all messages are JSON):
*
* → we send:
* { type: "object_add", payload: <fabric JSON object>, page: number }
* { type: "object_remove", payload: { obj_id: string }, page: number }
* { type: "clear", page: number }
* { type: "cursor", payload: { x: number, y: number }, page: number }
* { type: "ping" }
*
* ← we receive (same shapes, plus user_id / username / color injected by server):
* { type: "presence", users: CollabUser[] }
* { type: "object_add", ..., user_id, username, color }
* { type: "object_remove", ..., user_id, username, color }
* { type: "clear", ..., user_id, username, color }
* { type: "cursor", ..., user_id, username, color }
* { type: "pong" }
*/
import { useCallback, useEffect, useRef, useState } from "react";
export interface CollabUser {
user_id: number;
username: string;
color: string;
}
export interface RemoteEvent {
type: "object_add" | "object_remove" | "clear" | "cursor";
payload?: unknown;
page?: number;
user_id: number;
username: string;
color: string;
}
interface Options {
pdfId: number;
/** JWT access token — fetched from /api/auth/me or passed in */
token: string | null;
onEvent: (event: RemoteEvent) => void;
/**
* Called whenever a new peer joins the room (presence list grows).
* The host should respond by re-broadcasting all their current canvas objects
* so late-joining users see pre-existing annotations.
*/
onPeerJoined?: () => void;
}
const WS_BASE =
typeof window !== "undefined"
? (window.location.protocol === "https:" ? "wss" : "ws") +
"://" +
// Replace the port (or add one) to reach the backend directly on 8000
window.location.host.replace(/:\d+$/, "") + ":8000"
: "ws://localhost:8000";
const PING_INTERVAL = 25_000; // 25 s keepalive
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 wsRef = useRef<WebSocket | null>(null);
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const onPeerJoinedRef = useRef(onPeerJoined);
onPeerJoinedRef.current = onPeerJoined;
// Track previous user count to detect new peers joining
const prevUserCountRef = useRef(0);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const connect = useCallback(() => {
if (!token || !mountedRef.current) return;
const url = `${WS_BASE}/ws/pdf/${pdfId}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) { ws.close(); return; }
setConnected(true);
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, PING_INTERVAL);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data as string);
if (msg.type === "pong") return;
if (msg.type === "presence") {
const incoming = (msg.users ?? []) as CollabUser[];
setUsers(incoming);
// 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 (
prevUserCountRef.current > 0 &&
incoming.length > prevUserCountRef.current
) {
onPeerJoinedRef.current?.();
}
prevUserCountRef.current = incoming.length;
return;
}
onEventRef.current(msg as RemoteEvent);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
setConnected(false);
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
if (mountedRef.current) {
reconnectTimerRef.current = setTimeout(connect, RECONNECT_DELAY);
}
};
ws.onerror = () => ws.close();
}, [pdfId, token]);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
if (pingTimerRef.current) clearInterval(pingTimerRef.current);
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
/** Send an annotation event to all other clients in the room. */
const send = useCallback((msg: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}, []);
const sendObjectAdd = useCallback(
(fabricObject: object, page: number) =>
send({ type: "object_add", payload: fabricObject, page }),
[send]
);
const sendObjectRemove = useCallback(
(objId: string, page: number) =>
send({ type: "object_remove", payload: { obj_id: objId }, page }),
[send]
);
const sendClear = useCallback(
(page: number) => send({ type: "clear", page }),
[send]
);
const sendCursor = useCallback(
(x: number, y: number, page: number) =>
send({ type: "cursor", payload: { x, y }, page }),
[send]
);
return { users, connected, sendObjectAdd, sendObjectRemove, sendClear, sendCursor };
}
+2
View File
@@ -40,6 +40,8 @@ export const api = {
// Annotations // Annotations
getAnnotation: (pdfId: number, page: number) => getAnnotation: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`), request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`),
getAllAnnotations: (pdfId: number, page: number) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}/all`),
upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, { request<import("@/types").AnnotationData>(`/api/annotations/${pdfId}/${page}`, {
method: "PUT", method: "PUT",
+2
View File
@@ -7,6 +7,8 @@ export interface User {
export interface PDFItem { export interface PDFItem {
id: number; id: number;
user_id: number;
owner_username: string;
title: string; title: string;
total_pages: number | null; total_pages: number | null;
created_at: string; created_at: string;