mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:53:10 +07:00
colaborative tích hợp redis để đồng bộ toàn bộ annotation
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Redis helper — returns a connected client or None when Redis is unavailable.
|
||||||
|
|
||||||
|
Key namespace design (prevents cross-file / cross-user data leakage):
|
||||||
|
|
||||||
|
ann:{pdf_id}:{page_number}:{user_id}
|
||||||
|
|
||||||
|
Every segment is mandatory, so:
|
||||||
|
- User A opening file 1 never touches User A's data on file 2
|
||||||
|
- User A opening file 1 never touches User B's data on file 1
|
||||||
|
- Data for page 3 never affects page 7
|
||||||
|
|
||||||
|
TTL: 24 h — temp entry expires automatically if the user never returns.
|
||||||
|
On permanent save the entry is deleted immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import redis as redis_lib
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_client: Optional[redis_lib.Redis] = None
|
||||||
|
_warned = False # log the "unavailable" warning only once
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> Optional[redis_lib.Redis]:
|
||||||
|
"""Return a live Redis client, or None if Redis is unreachable."""
|
||||||
|
global _client, _warned
|
||||||
|
|
||||||
|
if _client is not None:
|
||||||
|
try:
|
||||||
|
_client.ping()
|
||||||
|
return _client
|
||||||
|
except Exception:
|
||||||
|
_client = None # connection dropped — try to reconnect below
|
||||||
|
|
||||||
|
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
try:
|
||||||
|
r: redis_lib.Redis = redis_lib.from_url(
|
||||||
|
url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=2,
|
||||||
|
socket_timeout=2,
|
||||||
|
)
|
||||||
|
r.ping()
|
||||||
|
_client = r
|
||||||
|
_warned = False
|
||||||
|
logger.info("Redis connected: %s", url)
|
||||||
|
return _client
|
||||||
|
except Exception as exc:
|
||||||
|
if not _warned:
|
||||||
|
logger.warning(
|
||||||
|
"Redis unavailable (%s) — temp annotation cache disabled. "
|
||||||
|
"Set REDIS_URL or start a local Redis instance to enable it.",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
_warned = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Key helpers — centralised so there's one place to change the format.
|
||||||
|
ANN_TTL = 86_400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
def ann_temp_key(pdf_id: int, page_number: int, user_id: int) -> str:
|
||||||
|
"""Fully-namespaced Redis key for one user's unsaved canvas on one PDF page."""
|
||||||
|
return f"ann:{pdf_id}:{page_number}:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def ann_temp_page_pattern(pdf_id: int, page_number: int) -> str:
|
||||||
|
"""Glob pattern to list all users' temp entries for a given page."""
|
||||||
|
return f"ann:{pdf_id}:{page_number}:*"
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, status as http_status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
from ..models import Annotation, PDF, User
|
from ..models import Annotation, PDF, User
|
||||||
|
from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis
|
||||||
from ..schemas import AnnotationIn, AnnotationOut
|
from ..schemas import AnnotationIn, AnnotationOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/annotations", tags=["annotations"])
|
router = APIRouter(prefix="/annotations", tags=["annotations"])
|
||||||
@@ -82,7 +84,35 @@ def get_all_annotations(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
# Build user_id → canvas_data from DB (permanent saves)
|
||||||
|
# Key = pdf_id + page_number + user_id → completely isolated per file/page/user
|
||||||
|
user_data: dict[int, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
user_data[row.user_id] = dict(row.canvas_data) if row.canvas_data else {}
|
||||||
|
|
||||||
|
# Overlay with Redis temp data — unsaved strokes written by path:created / clear.
|
||||||
|
# If a user has BOTH a DB record and a temp entry, Redis wins (more recent).
|
||||||
|
r = get_redis()
|
||||||
|
if r is not None:
|
||||||
|
try:
|
||||||
|
pattern = ann_temp_page_pattern(pdf_id, page_number)
|
||||||
|
temp_keys: list[str] = r.keys(pattern)
|
||||||
|
for key in temp_keys:
|
||||||
|
# key format: ann:{pdf_id}:{page_number}:{user_id}
|
||||||
|
try:
|
||||||
|
uid = int(key.split(":")[-1])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raw = r.get(key)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
user_data[uid] = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass # Redis hiccup — fall through with DB data only
|
||||||
|
|
||||||
|
if not user_data:
|
||||||
return AnnotationOut(
|
return AnnotationOut(
|
||||||
id=0,
|
id=0,
|
||||||
pdf_id=pdf_id,
|
pdf_id=pdf_id,
|
||||||
@@ -91,28 +121,50 @@ def get_all_annotations(
|
|||||||
updated_at=datetime.now(timezone.utc),
|
updated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge all objects arrays; use canvas metadata (background etc.) from latest record
|
# Canvas-level metadata (version, background…) comes from the latest DB record
|
||||||
latest = max(rows, key=lambda r: r.updated_at)
|
latest = max(rows, key=lambda row: row.updated_at) if rows else None
|
||||||
base: dict = dict(latest.canvas_data) if latest.canvas_data else {}
|
base: dict = dict(latest.canvas_data) if latest and latest.canvas_data else {}
|
||||||
|
|
||||||
merged_objects: list = []
|
merged_objects: list = []
|
||||||
for row in rows:
|
for uid, canvas in user_data.items():
|
||||||
for obj in (row.canvas_data or {}).get("objects", []):
|
for obj in (canvas 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 = dict(obj)
|
||||||
obj_copy["_owner_id"] = row.user_id
|
obj_copy["_owner_id"] = uid
|
||||||
merged_objects.append(obj_copy)
|
merged_objects.append(obj_copy)
|
||||||
base["objects"] = merged_objects
|
base["objects"] = merged_objects
|
||||||
|
|
||||||
return AnnotationOut(
|
return AnnotationOut(
|
||||||
id=latest.id,
|
id=latest.id if latest else 0,
|
||||||
pdf_id=pdf_id,
|
pdf_id=pdf_id,
|
||||||
page_number=page_number,
|
page_number=page_number,
|
||||||
canvas_data=base,
|
canvas_data=base,
|
||||||
updated_at=latest.updated_at,
|
updated_at=latest.updated_at if latest else datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /api/annotations/{pdf_id}/{page_number}/temp ──────────────────────────
|
||||||
|
# Writes unsaved canvas state to Redis so every subsequent /all call includes it.
|
||||||
|
# Called automatically (debounced) by the frontend after each stroke.
|
||||||
|
|
||||||
|
@router.put("/{pdf_id}/{page_number}/temp", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||||
|
def upsert_temp_annotation(
|
||||||
|
pdf_id: int,
|
||||||
|
page_number: int,
|
||||||
|
body: AnnotationIn,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_any_pdf_or_404(db, pdf_id)
|
||||||
|
r = get_redis()
|
||||||
|
if r is None:
|
||||||
|
return # Redis unavailable — silently skip
|
||||||
|
key = ann_temp_key(pdf_id, page_number, current_user.id)
|
||||||
|
try:
|
||||||
|
r.setex(key, ANN_TTL, json.dumps(body.canvas_data))
|
||||||
|
except Exception:
|
||||||
|
pass # non-critical
|
||||||
|
|
||||||
|
|
||||||
# ── 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)
|
||||||
@@ -149,4 +201,13 @@ def upsert_annotation(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ann)
|
db.refresh(ann)
|
||||||
|
|
||||||
|
# Data is now in DB — remove the Redis temp entry so /all doesn't double-count
|
||||||
|
r = get_redis()
|
||||||
|
if r is not None:
|
||||||
|
try:
|
||||||
|
r.delete(ann_temp_key(pdf_id, page_number, current_user.id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ann
|
return ann
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ async def pdf_collaboration(websocket: WebSocket, pdf_id: int):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Inject sender identity and forward to all other clients
|
# Inject sender identity and forward to all other clients
|
||||||
if msg_type in {"object_add", "object_remove", "clear", "cursor"}:
|
if msg_type in {"object_add", "object_remove", "clear", "cursor", "color_sync"}:
|
||||||
msg.update(user_info)
|
msg.update(user_info)
|
||||||
await room.broadcast(msg, exclude=websocket)
|
await room.broadcast(msg, exclude=websocket)
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ python-jose[cryptography]==3.3.0
|
|||||||
pydantic[email]==2.9.2
|
pydantic[email]==2.9.2
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
alembic==1.14.1
|
alembic==1.14.1
|
||||||
|
redis>=5.0
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- lms_net
|
- lms_net
|
||||||
|
|
||||||
|
# ── Redis (temp annotation cache) ────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --save "" --appendonly no # in-memory only, no disk writes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- lms_net
|
||||||
|
|
||||||
# ── FastAPI backend ───────────────────────────────────────────────────────────
|
# ── FastAPI backend ───────────────────────────────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
image: ${BACKEND_IMAGE:-lms-backend:latest}
|
image: ${BACKEND_IMAGE:-lms-backend:latest}
|
||||||
@@ -28,12 +41,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db}
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-lms_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lms_db}
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||||
JWT_ALGORITHM: HS256
|
JWT_ALGORITHM: HS256
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
UPLOAD_DIR: /uploads
|
UPLOAD_DIR: /uploads
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
volumes:
|
volumes:
|
||||||
- pdf_uploads:/uploads
|
- pdf_uploads:/uploads
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
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";
|
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
|
||||||
|
// Each color is visually distinct to avoid confusion between collaborators.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#e63946", "#9d0208", "#ff6b6b", // Red
|
||||||
|
"#f4572a", "#a23b17", "#ff9472", // Orange-red
|
||||||
|
"#f7b731", "#a07800", "#ffe066", // Yellow
|
||||||
|
"#80b918", "#4a6c0a", "#b5e853", // Yellow-green
|
||||||
|
"#2dc653", "#0a7a2e", "#6ee08a", // Green
|
||||||
|
"#00b4d8", "#005f73", "#48cae4", // Cyan
|
||||||
|
"#4361ee", "#1a237e", "#7b9cff", // Blue
|
||||||
|
"#7209b7", "#3a0068", "#b56aff", // Purple
|
||||||
|
"#e040fb", "#880e62", "#f48fff", // Magenta
|
||||||
|
"#ff6392", "#a3003f", "#ffadc5", // Rose
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function userIdToPresetColor(id: number): string {
|
||||||
|
return PRESET_COLORS[id % PRESET_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Types
|
// Types
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -122,6 +145,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
|
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
|
||||||
const renderingRef = useRef(false);
|
const renderingRef = useRef(false);
|
||||||
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
|
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
|
||||||
|
// True once api.me() has resolved and pen color has been set from user id
|
||||||
|
const [userColorReady, setUserColorReady] = useState(false);
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||||
|
const [colorPickerPos, setColorPickerPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const colorBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Ref for auto-scrolling thumbnail panel
|
// Ref for auto-scrolling thumbnail panel
|
||||||
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
@@ -137,7 +168,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
|
|
||||||
// Collab: stable ref to send functions (set after hook initializes)
|
// Collab: stable ref to send functions (set after hook initializes)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any } | null>(null);
|
const collabSendRef = useRef<{ objectAdd: any; objectRemove: any; clear: any; colorSync: (c: string) => void } | null>(null);
|
||||||
|
|
||||||
|
// Temp-cache sync: debounce unsaved canvas state to Redis (1.5 s after last stroke)
|
||||||
|
const tempSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncTempCanvasRef = useRef<(page: number) => void>(() => {});
|
||||||
|
|
||||||
// Keep currentPageRef in sync
|
// Keep currentPageRef in sync
|
||||||
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
|
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
|
||||||
@@ -149,6 +185,27 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
|
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
|
||||||
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
|
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
|
||||||
|
|
||||||
|
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
|
||||||
|
useEffect(() => {
|
||||||
|
syncTempCanvasRef.current = (page: number) => {
|
||||||
|
const fc = fabricRef.current;
|
||||||
|
if (!fc) return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const fullData: any = fc.toJSON(["_owner_id"]);
|
||||||
|
const uid = currentUserIdRef.current;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ownObjects = ((fullData.objects as any[]) ?? []).filter(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(o: any) => o._owner_id == null || o._owner_id === uid
|
||||||
|
);
|
||||||
|
const canvasData = { ...fullData, objects: ownObjects };
|
||||||
|
if (tempSyncTimerRef.current) clearTimeout(tempSyncTimerRef.current);
|
||||||
|
tempSyncTimerRef.current = setTimeout(() => {
|
||||||
|
api.upsertTempAnnotation(pdfId, page, { canvas_data: canvasData }).catch(() => {});
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
}, [pdfId]);
|
||||||
|
|
||||||
// Auto-scroll thumbnail sidebar to keep current page visible
|
// Auto-scroll thumbnail sidebar to keep current page visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showThumbnails) {
|
if (showThumbnails) {
|
||||||
@@ -166,9 +223,32 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
|
|
||||||
// Fetch current user id — used to distinguish own from remote objects when merging /all
|
// Fetch current user id — used to distinguish own from remote objects when merging /all
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.me().then((u) => { currentUserIdRef.current = u.id; }).catch(() => {});
|
api.me().then((u) => {
|
||||||
|
currentUserIdRef.current = u.id;
|
||||||
|
// Assign deterministic color from preset palette based on user id
|
||||||
|
const assigned = userIdToPresetColor(u.id);
|
||||||
|
setPenColor(assigned);
|
||||||
|
penColorRef.current = assigned;
|
||||||
|
setUserColorReady(true); // unblocks the color-sync broadcast
|
||||||
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close color picker when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!colorPickerOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node) &&
|
||||||
|
colorBtnRef.current && !colorBtnRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
setColorPickerPos(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [colorPickerOpen]);
|
||||||
|
|
||||||
// Handle incoming remote annotation events
|
// Handle incoming remote annotation events
|
||||||
const onRemoteEvent = useCallback((event: RemoteEvent) => {
|
const onRemoteEvent = useCallback((event: RemoteEvent) => {
|
||||||
const fabric = fabricNSRef.current;
|
const fabric = fabricNSRef.current;
|
||||||
@@ -260,16 +340,49 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
const fc = fabricRef.current;
|
const fc = fabricRef.current;
|
||||||
if (fc) broadcastCanvas(fc, currentPageRef.current);
|
if (fc) broadcastCanvas(fc, currentPageRef.current);
|
||||||
}
|
}
|
||||||
|
// Also broadcast our current pen color so the new peer can disable our swatch
|
||||||
|
collabSendRef.current?.colorSync(penColorRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Collaboration hook
|
// Collaboration hook
|
||||||
const { users: collabUsers, connected: collabConnected, sendObjectAdd, sendObjectRemove, sendClear } =
|
const { users: collabUsers, connected: collabConnected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendColorSync } =
|
||||||
useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined });
|
useCollaboration({ pdfId, token: collabToken, onEvent: onRemoteEvent, onPeerJoined: handlePeerJoined });
|
||||||
|
|
||||||
// Keep send functions accessible in stable refs (used in Fabric event handlers)
|
// Keep send functions accessible in stable refs (used in Fabric event handlers)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear };
|
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
|
||||||
}, [sendObjectAdd, sendObjectRemove, sendClear]);
|
}, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]);
|
||||||
|
|
||||||
|
// Broadcast pen color only when BOTH WS is connected AND user color has been fetched.
|
||||||
|
// This prevents both users broadcasting the same default "#e63946" before api.me() resolves.
|
||||||
|
// Also re-broadcasts whenever the user manually picks a new color.
|
||||||
|
useEffect(() => {
|
||||||
|
if (collabConnected && userColorReady) {
|
||||||
|
sendColorSync(penColor);
|
||||||
|
}
|
||||||
|
}, [collabConnected, userColorReady, penColor, sendColorSync]);
|
||||||
|
|
||||||
|
// If our color collides with a peer's color, pick the first free preset color and rebroadcast.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collabConnected || !userColorReady) return;
|
||||||
|
const uid = currentUserIdRef.current;
|
||||||
|
if (uid == null) return;
|
||||||
|
|
||||||
|
const taken = new Set(
|
||||||
|
Object.entries(peerColors)
|
||||||
|
.filter(([id]) => Number(id) !== uid)
|
||||||
|
.map(([, c]) => c.toLowerCase())
|
||||||
|
);
|
||||||
|
const current = penColor.toLowerCase();
|
||||||
|
if (!taken.has(current)) return;
|
||||||
|
|
||||||
|
const replacement = PRESET_COLORS.find(c => !taken.has(c.toLowerCase())) ?? penColor;
|
||||||
|
if (replacement.toLowerCase() === current) return;
|
||||||
|
|
||||||
|
setPenColor(replacement);
|
||||||
|
penColorRef.current = replacement;
|
||||||
|
sendColorSync(replacement);
|
||||||
|
}, [collabConnected, userColorReady, peerColors, penColor, sendColorSync]);
|
||||||
|
|
||||||
// Re-render current page when fit mode changes — moved below renderAllPages definition
|
// Re-render current page when fit mode changes — moved below renderAllPages definition
|
||||||
|
|
||||||
@@ -607,6 +720,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
const obj = (options.path as any).toJSON(["collab_id"]);
|
const obj = (options.path as any).toJSON(["collab_id"]);
|
||||||
collabSendRef.current?.objectAdd(obj, currentPageRef.current);
|
collabSendRef.current?.objectAdd(obj, currentPageRef.current);
|
||||||
}
|
}
|
||||||
|
// Sync unsaved canvas to Redis so late-joining users see this stroke
|
||||||
|
syncTempCanvasRef.current(currentPageRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Object removed (eraser / undo) — sync temp cache
|
||||||
|
fc.on("object:removed", () => {
|
||||||
|
if (skipObjectTracking.current || skipRemoteRef.current) return;
|
||||||
|
syncTempCanvasRef.current(currentPageRef.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Init PDF.js ───────────────────────────────────────────────────
|
// ── Init PDF.js ───────────────────────────────────────────────────
|
||||||
@@ -883,6 +1004,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData });
|
await api.upsertAnnotation(pdfId, currentPage, { canvas_data: canvasData });
|
||||||
|
// Data is now in DB — cancel any pending debounce (backend deletes Redis key too)
|
||||||
|
if (tempSyncTimerRef.current) { clearTimeout(tempSyncTimerRef.current); tempSyncTimerRef.current = null; }
|
||||||
// Cache the full display state (including remote objects) so the page looks correct on revisit
|
// Cache the full display state (including remote objects) so the page looks correct on revisit
|
||||||
localAnnotations.current[currentPage] = fullCanvasData;
|
localAnnotations.current[currentPage] = fullCanvasData;
|
||||||
setSaveNotice(true);
|
setSaveNotice(true);
|
||||||
@@ -913,6 +1036,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
addedObjects.current = [];
|
addedObjects.current = [];
|
||||||
fc.renderAll();
|
fc.renderAll();
|
||||||
collabSendRef.current?.clear(currentPageRef.current);
|
collabSendRef.current?.clear(currentPageRef.current);
|
||||||
|
// Push empty canvas to Redis so other users see the clear immediately on next load
|
||||||
|
syncTempCanvasRef.current(currentPageRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1129,12 +1254,22 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
</ToolBtn>
|
</ToolBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{/* Color picker button */}
|
||||||
type="color"
|
<button
|
||||||
value={penColor}
|
ref={colorBtnRef}
|
||||||
onChange={(e) => setPenColor(e.target.value)}
|
onClick={() => {
|
||||||
title="Color"
|
if (colorPickerOpen) {
|
||||||
className="w-7 h-7 rounded cursor-pointer border border-gray-300 p-0.5 bg-white flex-shrink-0"
|
setColorPickerOpen(false);
|
||||||
|
setColorPickerPos(null);
|
||||||
|
} else {
|
||||||
|
const rect = colorBtnRef.current!.getBoundingClientRect();
|
||||||
|
setColorPickerPos({ top: rect.bottom + 6, left: rect.left });
|
||||||
|
setColorPickerOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Pen color"
|
||||||
|
className="w-7 h-7 rounded-full border-2 border-white shadow flex-shrink-0 hover:scale-110 transition-transform"
|
||||||
|
style={{ background: penColor, outline: `2px solid ${penColor}`, outlineOffset: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -1294,6 +1429,40 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */}
|
||||||
|
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
ref={colorPickerRef}
|
||||||
|
style={{ position: "fixed", top: colorPickerPos.top, left: colorPickerPos.left, zIndex: 9999 }}
|
||||||
|
className="bg-white rounded-xl shadow-2xl border border-gray-200 p-3"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] text-gray-400 mb-2 font-medium uppercase tracking-wide">Chọn màu bút</p>
|
||||||
|
<div className="grid grid-cols-6 gap-1.5">
|
||||||
|
{PRESET_COLORS.map((c) => {
|
||||||
|
const takenByPeer = Object.entries(peerColors)
|
||||||
|
.filter(([uid]) => Number(uid) !== currentUserIdRef.current)
|
||||||
|
.some(([, pc]) => pc.toLowerCase() === c.toLowerCase());
|
||||||
|
const isSelected = penColor.toLowerCase() === c.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
disabled={takenByPeer}
|
||||||
|
onClick={() => { setPenColor(c); setColorPickerOpen(false); setColorPickerPos(null); }}
|
||||||
|
style={{ background: c }}
|
||||||
|
className={[
|
||||||
|
"w-7 h-7 rounded-full border-2 transition-transform",
|
||||||
|
isSelected ? "border-white shadow-lg scale-110 ring-2 ring-offset-1 ring-gray-400" : "border-transparent hover:scale-110",
|
||||||
|
takenByPeer ? "opacity-25 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
title={takenByPeer ? "Màu này đã được người dùng khác chọn" : c}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface CollabUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteEvent {
|
export interface RemoteEvent {
|
||||||
type: "object_add" | "object_remove" | "clear" | "cursor";
|
type: "object_add" | "object_remove" | "clear" | "cursor" | "color_sync";
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
page?: number;
|
page?: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -67,6 +67,7 @@ const RECONNECT_DELAY = 3_000; // 3 s reconnect on unexpected close
|
|||||||
export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) {
|
export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Options) {
|
||||||
const [users, setUsers] = useState<CollabUser[]>([]);
|
const [users, setUsers] = useState<CollabUser[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [peerColors, setPeerColors] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const onEventRef = useRef(onEvent);
|
const onEventRef = useRef(onEvent);
|
||||||
@@ -101,9 +102,21 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(e.data as string);
|
const msg = JSON.parse(e.data as string);
|
||||||
if (msg.type === "pong") return;
|
if (msg.type === "pong") return;
|
||||||
|
if (msg.type === "color_sync") {
|
||||||
|
const { user_id, color } = msg as { user_id: number; color: string };
|
||||||
|
setPeerColors(prev => ({ ...prev, [user_id]: color }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === "presence") {
|
if (msg.type === "presence") {
|
||||||
const incoming = (msg.users ?? []) as CollabUser[];
|
const incoming = (msg.users ?? []) as CollabUser[];
|
||||||
setUsers(incoming);
|
setUsers(incoming);
|
||||||
|
// Update peer color map from presence payload (includes our own entry).
|
||||||
|
setPeerColors(
|
||||||
|
incoming.reduce<Record<number, string>>((acc, u) => {
|
||||||
|
acc[u.user_id] = u.color;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
// If someone new joined (count increased) and we're already in the room,
|
// 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.
|
// notify the caller so they can re-broadcast their current canvas objects.
|
||||||
if (
|
if (
|
||||||
@@ -174,5 +187,10 @@ export function useCollaboration({ pdfId, token, onEvent, onPeerJoined }: Option
|
|||||||
[send]
|
[send]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { users, connected, sendObjectAdd, sendObjectRemove, sendClear, sendCursor };
|
const sendColorSync = useCallback(
|
||||||
|
(color: string) => send({ type: "color_sync", color }),
|
||||||
|
[send]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { users, connected, peerColors, sendObjectAdd, sendObjectRemove, sendClear, sendCursor, sendColorSync };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,9 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
upsertTempAnnotation: (pdfId: number, page: number, body: { canvas_data: object }) =>
|
||||||
|
request<void>(`/api/annotations/${pdfId}/${page}/temp`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ start_backend() {
|
|||||||
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \
|
ACCESS_TOKEN_EXPIRE_MINUTES="$ACCESS_TOKEN_EXPIRE_MINUTES" \
|
||||||
UPLOAD_DIR="$UPLOAD_DIR" \
|
UPLOAD_DIR="$UPLOAD_DIR" \
|
||||||
|
REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" \
|
||||||
${VENV}uvicorn main:app \
|
${VENV}uvicorn main:app \
|
||||||
--host 0.0.0.0 \
|
--host 0.0.0.0 \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
|||||||
Reference in New Issue
Block a user