diff --git a/backend/app/main.py b/backend/app/main.py index adc6c21..c9fd14a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import annotations, auth, pdfs +from .routers import annotations, auth, pdfs, ws @asynccontextmanager @@ -17,7 +17,7 @@ app = FastAPI(title="LMS API", lifespan=lifespan) app.add_middleware( 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_methods=["*"], allow_headers=["*"], @@ -26,3 +26,4 @@ app.add_middleware( app.include_router(auth.router, prefix="/api") app.include_router(pdfs.router, prefix="/api") app.include_router(annotations.router, prefix="/api") +app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path) diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py index fa580e4..ff010c7 100644 --- a/backend/app/routers/annotations.py +++ b/backend/app/routers/annotations.py @@ -18,6 +18,14 @@ def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> 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} ─────────────────────────────── @router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut) @@ -27,7 +35,7 @@ def get_annotation( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - _verify_pdf_ownership(db, pdf_id, current_user.id) + _any_pdf_or_404(db, pdf_id) ann = ( db.query(Annotation) @@ -52,6 +60,59 @@ def get_annotation( 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} ─────────────────────────────── @router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut) @@ -62,7 +123,7 @@ def upsert_annotation( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - _verify_pdf_ownership(db, pdf_id, current_user.id) + _any_pdf_or_404(db, pdf_id) ann = ( db.query(Annotation) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9493258..3f63346 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 import os @@ -84,3 +84,16 @@ def logout(response: Response): @router.get("/me", response_model=UserOut) def me(current_user: User = Depends(get_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} diff --git a/backend/app/routers/pdfs.py b/backend/app/routers/pdfs.py index 10d0801..200a5b5 100644 --- a/backend/app/routers/pdfs.py +++ b/backend/app/routers/pdfs.py @@ -30,6 +30,29 @@ def _own_or_404(db: Session, pdf_id: int, user_id: int) -> 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 ───────────────────────────────────────────────────────────── @router.get("", response_model=list[PDFOut]) @@ -37,12 +60,8 @@ def list_pdfs( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - return ( - db.query(PDF) - .filter(PDF.user_id == current_user.id) - .order_by(PDF.created_at.desc()) - .all() - ) + pdfs = db.query(PDF).order_by(PDF.created_at.desc()).all() + return [_pdf_out(p, db) for p in pdfs] # ── POST /api/pdfs/upload ───────────────────────────────────────────────────── @@ -90,7 +109,7 @@ async def upload_pdfs( db.commit() 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 @@ -121,8 +140,8 @@ def serve_pdf( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """Serve the raw PDF bytes — only to the owning user.""" - pdf = _own_or_404(db, pdf_id, current_user.id) + """Serve the raw PDF bytes — any authenticated user may read.""" + pdf = _any_or_404(db, pdf_id) file_path = Path(pdf.file_path) if not file_path.exists(): diff --git a/backend/app/routers/ws.py b/backend/app/routers/ws.py new file mode 100644 index 0000000..3048865 --- /dev/null +++ b/backend/app/routers/ws.py @@ -0,0 +1,172 @@ +""" +WebSocket collaboration endpoint. + +URL: ws://.../ws/pdf/{pdf_id}?token= + +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": } + { "type": "object_remove", "payload": { "obj_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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6eea75a..effa71a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -51,6 +51,8 @@ class TokenPayload(BaseModel): class PDFOut(BaseModel): id: int + user_id: int + owner_username: str title: str total_pages: int | None created_at: datetime diff --git a/frontend/next.config.js b/frontend/next.config.js index 6eb445f..7bb50db 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -7,6 +7,10 @@ const nextConfig = { source: "/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*`, + }, ]; }, }; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 7dc668f..a52382e 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -83,7 +83,7 @@ export default function DashboardPage() { {/* ── Main content ───────────────────────────────────────────────── */}
-

My PDFs

+

All PDFs

@@ -94,7 +94,7 @@ export default function DashboardPage() { ) : (
{pdfs.map((pdf) => ( - + ))}
)} diff --git a/frontend/src/components/PDFCard.tsx b/frontend/src/components/PDFCard.tsx index 282f418..2997cb3 100644 --- a/frontend/src/components/PDFCard.tsx +++ b/frontend/src/components/PDFCard.tsx @@ -3,6 +3,7 @@ import type { PDFItem } from "@/types"; interface Props { pdf: PDFItem; + currentUserId: number; 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 isOwner = pdf.user_id === currentUserId; return (
{pdf.total_pages} pages

)} + {!isOwner && ( +

+ by {pdf.owner_username} +

+ )}
- {/* Delete button — visible on hover */} - + {/* Delete button — only for owner, visible on hover */} + {isOwner && ( + + )} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/WorkbookViewer.tsx b/frontend/src/components/WorkbookViewer.tsx index e55b6d0..b522aaa 100644 --- a/frontend/src/components/WorkbookViewer.tsx +++ b/frontend/src/components/WorkbookViewer.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; +import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration"; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -113,6 +114,15 @@ export default function WorkbookViewer({ pdfId }: Props) { const [fitMode, setFitMode] = useState("width"); const [scrollMode, setScrollMode] = useState("single"); + // Collaboration + const [collabToken, setCollabToken] = useState(null); + const currentPageRef = useRef(1); // mutable copy for collab callbacks + const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events + const currentUserIdRef = useRef(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([]); + // Ref for auto-scrolling thumbnail panel const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]); const fitModeRef = useRef("width"); @@ -125,6 +135,13 @@ export default function WorkbookViewer({ pdfId }: Props) { // Generation counter — incremented on each renderAllPages call to abort stale runs 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 useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { penColorRef.current = penColor; }, [penColor]); @@ -139,6 +156,121 @@ export default function WorkbookViewer({ pdfId }: Props) { } }, [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; + 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 const generateThumbnails = useCallback(async () => { @@ -173,6 +305,8 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!fc || !doc || !pdfCanvasRef.current || !scrollContainerRef.current) return; setRendering(true); + renderingRef.current = true; + pendingRemoteEvents.current = []; try { // Cancel any in-progress render on the same canvas renderTasksRef.current.forEach(t => { try { t.cancel(); } catch { /* ignore */ } }); @@ -215,7 +349,7 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!annotationData) { 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) { annotationData = ann.canvas_data; localAnnotations.current[pageNum] = annotationData; @@ -231,6 +365,18 @@ export default function WorkbookViewer({ pdfId }: Props) { if (annotationData) { await new Promise((resolve) => { 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(); skipObjectTracking.current = false; resolve(); @@ -240,7 +386,34 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.renderAll(); 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; + 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 { + renderingRef.current = false; setRendering(false); } }, @@ -319,7 +492,7 @@ export default function WorkbookViewer({ pdfId }: Props) { let annotationData: object | null = cached ?? null; if (!annotationData) { 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) { annotationData = ann.canvas_data; localAnnotations.current[i] = annotationData; @@ -328,7 +501,22 @@ export default function WorkbookViewer({ pdfId }: Props) { } if (annotationData) { await new Promise((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 } - // 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 fc.on("object:added", (e: any) => { - if (!skipObjectTracking.current) { - addedObjects.current.push(e.target); + if (skipObjectTracking.current) return; + 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 }); 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 ─────────────────────────────────────────────────── @@ -572,9 +778,11 @@ export default function WorkbookViewer({ pdfId }: Props) { // eslint-disable-next-line @typescript-eslint/no-explicit-any fc.on("mouse:down", (options: any) => { if (!options.target) return; + const collab_id = options.target.collab_id as string | undefined; fc.remove(options.target); addedObjects.current = addedObjects.current.filter(o => o !== options.target); fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } @@ -618,7 +826,9 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.isDrawingMode = false; fc.selection = false; fc.on("mouse:down", (options: any) => { if (!options.target) return; + const collab_id = options.target.collab_id as string | undefined; fc.remove(options.target); fc.renderAll(); + if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current); }); break; } }); @@ -641,7 +851,8 @@ export default function WorkbookViewer({ pdfId }: Props) { const fc = fabricRef.current; 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); setCurrentPage(newPage); }, @@ -659,11 +870,21 @@ export default function WorkbookViewer({ pdfId }: Props) { if (!fc || !isReady || saving) return; 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 { 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); setTimeout(() => setSaveNotice(false), 2500); } catch (err: unknown) { @@ -691,6 +912,7 @@ export default function WorkbookViewer({ pdfId }: Props) { fc.clear(); addedObjects.current = []; fc.renderAll(); + collabSendRef.current?.clear(currentPageRef.current); }, []); // ───────────────────────────────────────────────────────────────────────── @@ -954,6 +1176,31 @@ export default function WorkbookViewer({ pdfId }: Props) { {/* Save — fixed right, never scrolls away */}
+ + {/* Collaboration presence */} +
+ + {collabUsers.length > 0 && ( +
+ {collabUsers.slice(0, 5).map(u => ( + + {u.username[0]} + + ))} + {collabUsers.length > 5 && ( + + +{collabUsers.length - 5} + + )} +
+ )} +
+ {saveNotice && ( ✓ Saved )} diff --git a/frontend/src/hooks/useCollaboration.ts b/frontend/src/hooks/useCollaboration.ts new file mode 100644 index 0000000..ab54f19 --- /dev/null +++ b/frontend/src/hooks/useCollaboration.ts @@ -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: , 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([]); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(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 | null>(null); + const reconnectTimerRef = useRef | 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) => { + 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 }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9af923b..aedae1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -40,6 +40,8 @@ export const api = { // Annotations getAnnotation: (pdfId: number, page: number) => request(`/api/annotations/${pdfId}/${page}`), + getAllAnnotations: (pdfId: number, page: number) => + request(`/api/annotations/${pdfId}/${page}/all`), upsertAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) => request(`/api/annotations/${pdfId}/${page}`, { method: "PUT", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ee2241..b0b7497 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,8 @@ export interface User { export interface PDFItem { id: number; + user_id: number; + owner_username: string; title: string; total_pages: number | null; created_at: string;