mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
Hoàn thành chức năng collaborative
This commit is contained in:
+3
-2
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,22 +41,29 @@ 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 */}
|
||||||
<button
|
{isOwner && (
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onDelete(pdf.id);
|
e.stopPropagation();
|
||||||
}}
|
onDelete(pdf.id);
|
||||||
title="Delete"
|
}}
|
||||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
title="Delete"
|
||||||
>
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition bg-white/80 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-lg p-1.5 shadow"
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
</svg>
|
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" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user