Some checks failed
BotServer CI / build (push) Failing after 1m34s
Split 20+ files over 1000 lines into focused subdirectories for better maintainability and code organization. All changes maintain backward compatibility through re-export wrappers. Major splits: - attendance/llm_assist.rs (2074→7 modules) - basic/keywords/face_api.rs → face_api/ (7 modules) - basic/keywords/file_operations.rs → file_ops/ (8 modules) - basic/keywords/hear_talk.rs → hearing/ (6 modules) - channels/wechat.rs → wechat/ (10 modules) - channels/youtube.rs → youtube/ (5 modules) - contacts/mod.rs → contacts_api/ (6 modules) - core/bootstrap/mod.rs → bootstrap/ (5 modules) - core/shared/admin.rs → admin_*.rs (5 modules) - designer/canvas.rs → canvas_api/ (6 modules) - designer/mod.rs → designer_api/ (6 modules) - docs/handlers.rs → handlers_api/ (11 modules) - drive/mod.rs → drive_handlers.rs, drive_types.rs - learn/mod.rs → types.rs - main.rs → main_module/ (7 modules) - meet/webinar.rs → webinar_api/ (8 modules) - paper/mod.rs → (10 modules) - security/auth.rs → auth_api/ (7 modules) - security/passkey.rs → (4 modules) - sources/mod.rs → sources_api/ (5 modules) - tasks/mod.rs → task_api/ (5 modules) Stats: 38,040 deletions, 1,315 additions across 318 files Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
896 lines
28 KiB
Rust
896 lines
28 KiB
Rust
use axum::{
|
|
extract::{
|
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
Path, State,
|
|
},
|
|
response::IntoResponse,
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use futures::{SinkExt, StreamExt};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::{broadcast, RwLock};
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::shared::state::AppState;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum ShapeType {
|
|
Rectangle,
|
|
Ellipse,
|
|
Line,
|
|
Arrow,
|
|
Freehand,
|
|
Text,
|
|
Image,
|
|
Sticky,
|
|
Polygon,
|
|
Triangle,
|
|
Diamond,
|
|
Star,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum StrokeStyle {
|
|
Solid,
|
|
Dashed,
|
|
Dotted,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Point {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Color {
|
|
pub r: u8,
|
|
pub g: u8,
|
|
pub b: u8,
|
|
pub a: f32,
|
|
}
|
|
|
|
impl Color {
|
|
pub fn to_rgba(&self) -> String {
|
|
format!("rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a)
|
|
}
|
|
|
|
pub fn from_hex(hex: &str) -> Option<Self> {
|
|
let hex = hex.trim_start_matches('#');
|
|
if hex.len() != 6 && hex.len() != 8 {
|
|
return None;
|
|
}
|
|
|
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
|
let a = if hex.len() == 8 {
|
|
u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255.0
|
|
} else {
|
|
1.0
|
|
};
|
|
|
|
Some(Color { r, g, b, a })
|
|
}
|
|
}
|
|
|
|
impl Default for Color {
|
|
fn default() -> Self {
|
|
Color {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
a: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ShapeStyle {
|
|
pub stroke_color: Color,
|
|
pub fill_color: Option<Color>,
|
|
pub stroke_width: f64,
|
|
pub stroke_style: StrokeStyle,
|
|
pub opacity: f32,
|
|
pub font_size: Option<f64>,
|
|
pub font_family: Option<String>,
|
|
}
|
|
|
|
impl Default for ShapeStyle {
|
|
fn default() -> Self {
|
|
ShapeStyle {
|
|
stroke_color: Color::default(),
|
|
fill_color: None,
|
|
stroke_width: 2.0,
|
|
stroke_style: StrokeStyle::Solid,
|
|
opacity: 1.0,
|
|
font_size: None,
|
|
font_family: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Shape {
|
|
pub id: Uuid,
|
|
pub shape_type: ShapeType,
|
|
pub points: Vec<Point>,
|
|
pub style: ShapeStyle,
|
|
pub text: Option<String>,
|
|
pub image_url: Option<String>,
|
|
pub rotation: f64,
|
|
pub z_index: i32,
|
|
pub locked: bool,
|
|
pub created_by: Uuid,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CursorPosition {
|
|
pub user_id: Uuid,
|
|
pub username: String,
|
|
pub color: Color,
|
|
pub position: Point,
|
|
pub last_update: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Selection {
|
|
pub user_id: Uuid,
|
|
pub shape_ids: Vec<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum WhiteboardOperation {
|
|
AddShape { shape: Shape },
|
|
UpdateShape { shape: Shape },
|
|
DeleteShape { shape_id: Uuid },
|
|
MoveShapes { shape_ids: Vec<Uuid>, delta: Point },
|
|
ResizeShape { shape_id: Uuid, bounds: ShapeBounds },
|
|
RotateShape { shape_id: Uuid, angle: f64 },
|
|
BringToFront { shape_id: Uuid },
|
|
SendToBack { shape_id: Uuid },
|
|
LockShape { shape_id: Uuid },
|
|
UnlockShape { shape_id: Uuid },
|
|
GroupShapes { shape_ids: Vec<Uuid>, group_id: Uuid },
|
|
UngroupShapes { group_id: Uuid },
|
|
Clear,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ShapeBounds {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub width: f64,
|
|
pub height: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UndoRedoEntry {
|
|
pub id: Uuid,
|
|
pub operation: WhiteboardOperation,
|
|
pub inverse_operation: WhiteboardOperation,
|
|
pub user_id: Uuid,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum WhiteboardMessage {
|
|
Join {
|
|
user_id: Uuid,
|
|
username: String,
|
|
color: Color,
|
|
},
|
|
Leave {
|
|
user_id: Uuid,
|
|
},
|
|
Operation {
|
|
user_id: Uuid,
|
|
operation: WhiteboardOperation,
|
|
},
|
|
CursorMove {
|
|
cursor: CursorPosition,
|
|
},
|
|
Select {
|
|
selection: Selection,
|
|
},
|
|
Deselect {
|
|
user_id: Uuid,
|
|
},
|
|
Undo {
|
|
user_id: Uuid,
|
|
},
|
|
Redo {
|
|
user_id: Uuid,
|
|
},
|
|
RequestSync,
|
|
FullSync {
|
|
shapes: Vec<Shape>,
|
|
cursors: Vec<CursorPosition>,
|
|
selections: Vec<Selection>,
|
|
},
|
|
Ping,
|
|
Pong,
|
|
Error {
|
|
message: String,
|
|
},
|
|
UserJoined {
|
|
user_id: Uuid,
|
|
username: String,
|
|
color: Color,
|
|
},
|
|
UserLeft {
|
|
user_id: Uuid,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct WhiteboardState {
|
|
pub id: Uuid,
|
|
pub conversation_id: Uuid,
|
|
pub name: String,
|
|
pub shapes: HashMap<Uuid, Shape>,
|
|
pub cursors: HashMap<Uuid, CursorPosition>,
|
|
pub selections: HashMap<Uuid, Selection>,
|
|
pub undo_stack: Vec<UndoRedoEntry>,
|
|
pub redo_stack: Vec<UndoRedoEntry>,
|
|
pub max_undo_history: usize,
|
|
pub next_z_index: i32,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl WhiteboardState {
|
|
pub fn new(id: Uuid, conversation_id: Uuid, name: String) -> Self {
|
|
let now = Utc::now();
|
|
WhiteboardState {
|
|
id,
|
|
conversation_id,
|
|
name,
|
|
shapes: HashMap::new(),
|
|
cursors: HashMap::new(),
|
|
selections: HashMap::new(),
|
|
undo_stack: Vec::new(),
|
|
redo_stack: Vec::new(),
|
|
max_undo_history: 100,
|
|
next_z_index: 0,
|
|
created_at: now,
|
|
updated_at: now,
|
|
}
|
|
}
|
|
|
|
pub fn apply_operation(
|
|
&mut self,
|
|
operation: WhiteboardOperation,
|
|
user_id: Uuid,
|
|
) -> Result<Option<WhiteboardOperation>, String> {
|
|
let inverse = self.compute_inverse(&operation)?;
|
|
|
|
match operation {
|
|
WhiteboardOperation::AddShape { shape } => {
|
|
let mut shape = shape;
|
|
shape.z_index = self.next_z_index;
|
|
self.next_z_index += 1;
|
|
self.shapes.insert(shape.id, shape);
|
|
}
|
|
WhiteboardOperation::UpdateShape { shape } => {
|
|
if !self.shapes.contains_key(&shape.id) {
|
|
return Err("Shape not found".to_string());
|
|
}
|
|
self.shapes.insert(shape.id, shape);
|
|
}
|
|
WhiteboardOperation::DeleteShape { shape_id } => {
|
|
if self.shapes.remove(&shape_id).is_none() {
|
|
return Err("Shape not found".to_string());
|
|
}
|
|
for selection in self.selections.values_mut() {
|
|
selection.shape_ids.retain(|id| *id != shape_id);
|
|
}
|
|
}
|
|
WhiteboardOperation::MoveShapes { shape_ids, delta } => {
|
|
for shape_id in &shape_ids {
|
|
if let Some(shape) = self.shapes.get_mut(shape_id) {
|
|
for point in &mut shape.points {
|
|
point.x += delta.x;
|
|
point.y += delta.y;
|
|
}
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
}
|
|
WhiteboardOperation::ResizeShape { shape_id, bounds } => {
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
if shape.points.len() >= 2 {
|
|
shape.points[0] = Point {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
};
|
|
shape.points[1] = Point {
|
|
x: bounds.x + bounds.width,
|
|
y: bounds.y + bounds.height,
|
|
};
|
|
}
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::RotateShape { shape_id, angle } => {
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
shape.rotation = angle;
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::BringToFront { shape_id } => {
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
shape.z_index = self.next_z_index;
|
|
self.next_z_index += 1;
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::SendToBack { shape_id } => {
|
|
let min_z = self.shapes.values().map(|s| s.z_index).min().unwrap_or(0) - 1;
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
shape.z_index = min_z;
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::LockShape { shape_id } => {
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
shape.locked = true;
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::UnlockShape { shape_id } => {
|
|
if let Some(shape) = self.shapes.get_mut(&shape_id) {
|
|
shape.locked = false;
|
|
shape.updated_at = Utc::now();
|
|
}
|
|
}
|
|
WhiteboardOperation::GroupShapes { .. } => {}
|
|
WhiteboardOperation::UngroupShapes { .. } => {}
|
|
WhiteboardOperation::Clear => {
|
|
self.shapes.clear();
|
|
self.selections.clear();
|
|
self.next_z_index = 0;
|
|
}
|
|
}
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
if let Some(inv) = inverse.clone() {
|
|
let entry = UndoRedoEntry {
|
|
id: Uuid::new_v4(),
|
|
operation: self.reconstruct_operation(&inv),
|
|
inverse_operation: inv,
|
|
user_id,
|
|
timestamp: Utc::now(),
|
|
};
|
|
self.undo_stack.push(entry);
|
|
if self.undo_stack.len() > self.max_undo_history {
|
|
self.undo_stack.remove(0);
|
|
}
|
|
self.redo_stack.clear();
|
|
}
|
|
|
|
Ok(inverse)
|
|
}
|
|
|
|
fn compute_inverse(&self, operation: &WhiteboardOperation) -> Result<Option<WhiteboardOperation>, String> {
|
|
match operation {
|
|
WhiteboardOperation::AddShape { shape } => {
|
|
Ok(Some(WhiteboardOperation::DeleteShape { shape_id: shape.id }))
|
|
}
|
|
WhiteboardOperation::UpdateShape { shape } => {
|
|
if let Some(old_shape) = self.shapes.get(&shape.id) {
|
|
Ok(Some(WhiteboardOperation::UpdateShape {
|
|
shape: old_shape.clone(),
|
|
}))
|
|
} else {
|
|
Err("Shape not found".to_string())
|
|
}
|
|
}
|
|
WhiteboardOperation::DeleteShape { shape_id } => {
|
|
if let Some(shape) = self.shapes.get(shape_id) {
|
|
Ok(Some(WhiteboardOperation::AddShape {
|
|
shape: shape.clone(),
|
|
}))
|
|
} else {
|
|
Err("Shape not found".to_string())
|
|
}
|
|
}
|
|
WhiteboardOperation::MoveShapes { shape_ids, delta } => {
|
|
Ok(Some(WhiteboardOperation::MoveShapes {
|
|
shape_ids: shape_ids.clone(),
|
|
delta: Point {
|
|
x: -delta.x,
|
|
y: -delta.y,
|
|
},
|
|
}))
|
|
}
|
|
WhiteboardOperation::RotateShape { shape_id, .. } => {
|
|
if let Some(shape) = self.shapes.get(shape_id) {
|
|
Ok(Some(WhiteboardOperation::RotateShape {
|
|
shape_id: *shape_id,
|
|
angle: shape.rotation,
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
WhiteboardOperation::LockShape { shape_id } => {
|
|
Ok(Some(WhiteboardOperation::UnlockShape {
|
|
shape_id: *shape_id,
|
|
}))
|
|
}
|
|
WhiteboardOperation::UnlockShape { shape_id } => {
|
|
Ok(Some(WhiteboardOperation::LockShape {
|
|
shape_id: *shape_id,
|
|
}))
|
|
}
|
|
WhiteboardOperation::Clear => {
|
|
Ok(None)
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn reconstruct_operation(&self, inverse: &WhiteboardOperation) -> WhiteboardOperation {
|
|
inverse.clone()
|
|
}
|
|
|
|
pub fn undo(&mut self, user_id: Uuid) -> Option<WhiteboardOperation> {
|
|
let user_entries: Vec<_> = self
|
|
.undo_stack
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| e.user_id == user_id)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
|
|
if let Some(&last_idx) = user_entries.last() {
|
|
let entry = self.undo_stack.remove(last_idx);
|
|
let inverse = entry.inverse_operation.clone();
|
|
let _ = self.apply_operation_without_history(inverse.clone());
|
|
self.redo_stack.push(entry);
|
|
Some(inverse)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn redo(&mut self, user_id: Uuid) -> Option<WhiteboardOperation> {
|
|
let user_entries: Vec<_> = self
|
|
.redo_stack
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| e.user_id == user_id)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
|
|
if let Some(&last_idx) = user_entries.last() {
|
|
let entry = self.redo_stack.remove(last_idx);
|
|
let operation = entry.operation.clone();
|
|
let _ = self.apply_operation_without_history(operation.clone());
|
|
self.undo_stack.push(entry);
|
|
Some(operation)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn apply_operation_without_history(&mut self, operation: WhiteboardOperation) -> Result<(), String> {
|
|
match operation {
|
|
WhiteboardOperation::AddShape { shape } => {
|
|
self.shapes.insert(shape.id, shape);
|
|
}
|
|
WhiteboardOperation::UpdateShape { shape } => {
|
|
self.shapes.insert(shape.id, shape);
|
|
}
|
|
WhiteboardOperation::DeleteShape { shape_id } => {
|
|
self.shapes.remove(&shape_id);
|
|
}
|
|
WhiteboardOperation::MoveShapes { shape_ids, delta } => {
|
|
for shape_id in &shape_ids {
|
|
if let Some(shape) = self.shapes.get_mut(shape_id) {
|
|
for point in &mut shape.points {
|
|
point.x += delta.x;
|
|
point.y += delta.y;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
self.updated_at = Utc::now();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_cursor(&mut self, cursor: CursorPosition) {
|
|
self.cursors.insert(cursor.user_id, cursor);
|
|
}
|
|
|
|
pub fn remove_cursor(&mut self, user_id: &Uuid) {
|
|
self.cursors.remove(user_id);
|
|
}
|
|
|
|
pub fn set_selection(&mut self, selection: Selection) {
|
|
self.selections.insert(selection.user_id, selection);
|
|
}
|
|
|
|
pub fn clear_selection(&mut self, user_id: &Uuid) {
|
|
self.selections.remove(user_id);
|
|
}
|
|
|
|
pub fn get_shapes_sorted(&self) -> Vec<&Shape> {
|
|
let mut shapes: Vec<_> = self.shapes.values().collect();
|
|
shapes.sort_by_key(|s| s.z_index);
|
|
shapes
|
|
}
|
|
|
|
pub fn to_sync_message(&self) -> WhiteboardMessage {
|
|
WhiteboardMessage::FullSync {
|
|
shapes: self.shapes.values().cloned().collect(),
|
|
cursors: self.cursors.values().cloned().collect(),
|
|
selections: self.selections.values().cloned().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct WhiteboardManager {
|
|
whiteboards: Arc<RwLock<HashMap<Uuid, WhiteboardState>>>,
|
|
broadcast_channels: Arc<RwLock<HashMap<Uuid, broadcast::Sender<WhiteboardMessage>>>>,
|
|
}
|
|
|
|
impl Default for WhiteboardManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl WhiteboardManager {
|
|
pub fn new() -> Self {
|
|
WhiteboardManager {
|
|
whiteboards: Arc::new(RwLock::new(HashMap::new())),
|
|
broadcast_channels: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub async fn create_whiteboard(
|
|
&self,
|
|
conversation_id: Uuid,
|
|
name: String,
|
|
) -> Uuid {
|
|
let whiteboard_id = Uuid::new_v4();
|
|
let state = WhiteboardState::new(whiteboard_id, conversation_id, name);
|
|
|
|
let (tx, _) = broadcast::channel(1024);
|
|
|
|
{
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
whiteboards.insert(whiteboard_id, state);
|
|
}
|
|
|
|
{
|
|
let mut channels = self.broadcast_channels.write().await;
|
|
channels.insert(whiteboard_id, tx);
|
|
}
|
|
|
|
whiteboard_id
|
|
}
|
|
|
|
pub async fn get_whiteboard(&self, whiteboard_id: &Uuid) -> Option<WhiteboardState> {
|
|
let whiteboards = self.whiteboards.read().await;
|
|
whiteboards.get(whiteboard_id).cloned()
|
|
}
|
|
|
|
pub async fn delete_whiteboard(&self, whiteboard_id: &Uuid) -> bool {
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
let mut channels = self.broadcast_channels.write().await;
|
|
|
|
channels.remove(whiteboard_id);
|
|
whiteboards.remove(whiteboard_id).is_some()
|
|
}
|
|
|
|
pub async fn subscribe(&self, whiteboard_id: &Uuid) -> Option<broadcast::Receiver<WhiteboardMessage>> {
|
|
let channels = self.broadcast_channels.read().await;
|
|
channels.get(whiteboard_id).map(|tx| tx.subscribe())
|
|
}
|
|
|
|
pub async fn broadcast(&self, whiteboard_id: &Uuid, message: WhiteboardMessage) {
|
|
let channels = self.broadcast_channels.read().await;
|
|
if let Some(tx) = channels.get(whiteboard_id) {
|
|
let _ = tx.send(message);
|
|
}
|
|
}
|
|
|
|
pub async fn apply_operation(
|
|
&self,
|
|
whiteboard_id: &Uuid,
|
|
operation: WhiteboardOperation,
|
|
user_id: Uuid,
|
|
) -> Result<(), String> {
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
let whiteboard = whiteboards
|
|
.get_mut(whiteboard_id)
|
|
.ok_or("Whiteboard not found")?;
|
|
|
|
whiteboard.apply_operation(operation.clone(), user_id)?;
|
|
|
|
drop(whiteboards);
|
|
|
|
self.broadcast(
|
|
whiteboard_id,
|
|
WhiteboardMessage::Operation { user_id, operation },
|
|
)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn undo(&self, whiteboard_id: &Uuid, user_id: Uuid) -> Option<WhiteboardOperation> {
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
if let Some(whiteboard) = whiteboards.get_mut(whiteboard_id) {
|
|
whiteboard.undo(user_id)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub async fn redo(&self, whiteboard_id: &Uuid, user_id: Uuid) -> Option<WhiteboardOperation> {
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
if let Some(whiteboard) = whiteboards.get_mut(whiteboard_id) {
|
|
whiteboard.redo(user_id)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub async fn update_cursor(&self, whiteboard_id: &Uuid, cursor: CursorPosition) {
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
if let Some(whiteboard) = whiteboards.get_mut(whiteboard_id) {
|
|
whiteboard.update_cursor(cursor.clone());
|
|
}
|
|
drop(whiteboards);
|
|
|
|
self.broadcast(whiteboard_id, WhiteboardMessage::CursorMove { cursor })
|
|
.await;
|
|
}
|
|
|
|
pub async fn user_join(
|
|
&self,
|
|
whiteboard_id: &Uuid,
|
|
user_id: Uuid,
|
|
username: String,
|
|
color: Color,
|
|
) {
|
|
self.broadcast(
|
|
whiteboard_id,
|
|
WhiteboardMessage::UserJoined {
|
|
user_id,
|
|
username,
|
|
color,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn user_leave(&self, whiteboard_id: &Uuid, user_id: Uuid) {
|
|
{
|
|
let mut whiteboards = self.whiteboards.write().await;
|
|
if let Some(whiteboard) = whiteboards.get_mut(whiteboard_id) {
|
|
whiteboard.remove_cursor(&user_id);
|
|
whiteboard.clear_selection(&user_id);
|
|
}
|
|
}
|
|
|
|
self.broadcast(whiteboard_id, WhiteboardMessage::UserLeft { user_id })
|
|
.await;
|
|
}
|
|
}
|
|
|
|
impl Clone for WhiteboardState {
|
|
fn clone(&self) -> Self {
|
|
WhiteboardState {
|
|
id: self.id,
|
|
conversation_id: self.conversation_id,
|
|
name: self.name.clone(),
|
|
shapes: self.shapes.clone(),
|
|
cursors: self.cursors.clone(),
|
|
selections: self.selections.clone(),
|
|
undo_stack: self.undo_stack.clone(),
|
|
redo_stack: self.redo_stack.clone(),
|
|
max_undo_history: self.max_undo_history,
|
|
next_z_index: self.next_z_index,
|
|
created_at: self.created_at,
|
|
updated_at: self.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/whiteboard/:id/ws", get(whiteboard_websocket))
|
|
.route("/whiteboard/create/:conversation_id", get(create_whiteboard))
|
|
}
|
|
|
|
async fn create_whiteboard(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(conversation_id): Path<Uuid>,
|
|
) -> impl IntoResponse {
|
|
let manager = state
|
|
.extensions
|
|
.get::<WhiteboardManager>()
|
|
.await
|
|
.unwrap_or_else(|| Arc::new(WhiteboardManager::new()));
|
|
|
|
let whiteboard_id = manager
|
|
.create_whiteboard(conversation_id, "New Whiteboard".to_string())
|
|
.await;
|
|
|
|
axum::Json(serde_json::json!({
|
|
"whiteboard_id": whiteboard_id,
|
|
"conversation_id": conversation_id
|
|
}))
|
|
}
|
|
|
|
async fn whiteboard_websocket(
|
|
ws: WebSocketUpgrade,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(whiteboard_id): Path<Uuid>,
|
|
) -> impl IntoResponse {
|
|
ws.on_upgrade(move |socket| handle_whiteboard_socket(socket, state, whiteboard_id))
|
|
}
|
|
|
|
async fn handle_whiteboard_socket(
|
|
socket: WebSocket,
|
|
state: Arc<AppState>,
|
|
whiteboard_id: Uuid,
|
|
) {
|
|
let manager = state
|
|
.extensions
|
|
.get::<WhiteboardManager>()
|
|
.await
|
|
.unwrap_or_else(|| Arc::new(WhiteboardManager::new()));
|
|
|
|
let receiver = match manager.subscribe(&whiteboard_id).await {
|
|
Some(rx) => rx,
|
|
None => {
|
|
log::error!("Whiteboard {} not found", whiteboard_id);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let (mut sender, mut ws_receiver) = socket.split();
|
|
let mut broadcast_receiver = receiver;
|
|
|
|
let user_id = Uuid::new_v4();
|
|
let username = format!("User-{}", &user_id.to_string()[..8]);
|
|
let user_color = Color {
|
|
r: (user_id.as_u128() % 200 + 55) as u8,
|
|
g: ((user_id.as_u128() >> 8) % 200 + 55) as u8,
|
|
b: ((user_id.as_u128() >> 16) % 200 + 55) as u8,
|
|
a: 1.0,
|
|
};
|
|
|
|
manager
|
|
.user_join(&whiteboard_id, user_id, username.clone(), user_color.clone())
|
|
.await;
|
|
|
|
if let Some(whiteboard) = manager.get_whiteboard(&whiteboard_id).await {
|
|
let sync_msg = whiteboard.to_sync_message();
|
|
if let Ok(json) = serde_json::to_string(&sync_msg) {
|
|
let _ = sender.send(Message::Text(json.into())).await;
|
|
}
|
|
}
|
|
|
|
let manager_clone = manager.clone();
|
|
let whiteboard_id_clone = whiteboard_id;
|
|
|
|
let send_task = tokio::spawn(async move {
|
|
while let Ok(msg) = broadcast_receiver.recv().await {
|
|
if let Ok(json) = serde_json::to_string(&msg) {
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let receive_task = tokio::spawn(async move {
|
|
while let Some(result) = ws_receiver.next().await {
|
|
match result {
|
|
Ok(Message::Text(text)) => {
|
|
if let Ok(msg) = serde_json::from_str::<WhiteboardMessage>(&text) {
|
|
match msg {
|
|
WhiteboardMessage::Operation { operation, .. } => {
|
|
let _ = manager_clone
|
|
.apply_operation(&whiteboard_id_clone, operation, user_id)
|
|
.await;
|
|
}
|
|
WhiteboardMessage::CursorMove { mut cursor } => {
|
|
cursor.user_id = user_id;
|
|
cursor.username = username.clone();
|
|
cursor.color = user_color.clone();
|
|
cursor.last_update = Utc::now();
|
|
manager_clone.update_cursor(&whiteboard_id_clone, cursor).await;
|
|
}
|
|
WhiteboardMessage::Undo { .. } => {
|
|
if let Some(op) = manager_clone.undo(&whiteboard_id_clone, user_id).await {
|
|
manager_clone
|
|
.broadcast(
|
|
&whiteboard_id_clone,
|
|
WhiteboardMessage::Operation {
|
|
user_id,
|
|
operation: op,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
WhiteboardMessage::Redo { .. } => {
|
|
if let Some(op) = manager_clone.redo(&whiteboard_id_clone, user_id).await {
|
|
manager_clone
|
|
.broadcast(
|
|
&whiteboard_id_clone,
|
|
WhiteboardMessage::Operation {
|
|
user_id,
|
|
operation: op,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
WhiteboardMessage::Select { mut selection } => {
|
|
selection.user_id = user_id;
|
|
manager_clone
|
|
.broadcast(
|
|
&whiteboard_id_clone,
|
|
WhiteboardMessage::Select { selection },
|
|
)
|
|
.await;
|
|
}
|
|
WhiteboardMessage::Deselect { .. } => {
|
|
manager_clone
|
|
.broadcast(
|
|
&whiteboard_id_clone,
|
|
WhiteboardMessage::Deselect { user_id },
|
|
)
|
|
.await;
|
|
}
|
|
WhiteboardMessage::RequestSync => {
|
|
if let Some(whiteboard) =
|
|
manager_clone.get_whiteboard(&whiteboard_id_clone).await
|
|
{
|
|
manager_clone
|
|
.broadcast(&whiteboard_id_clone, whiteboard.to_sync_message())
|
|
.await;
|
|
}
|
|
}
|
|
WhiteboardMessage::Ping => {
|
|
manager_clone
|
|
.broadcast(&whiteboard_id_clone, WhiteboardMessage::Pong)
|
|
.await;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(Message::Close(_)) => break,
|
|
Err(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let _ = tokio::join!(send_task, receive_task);
|
|
|
|
manager
|
|
.user_leave(&whiteboard_id, user_id)
|
|
.await;
|
|
}
|