diff --git a/pub/index.html b/pub/index.html new file mode 100644 index 0000000..8446511 --- /dev/null +++ b/pub/index.html @@ -0,0 +1,27 @@ + + + + LJ Sketch + + + + + +
+
+
+ + +
+ +
+ + LJ Sketch +
+ + + + + diff --git a/pub/main.js b/pub/main.js new file mode 100644 index 0000000..c055ffc --- /dev/null +++ b/pub/main.js @@ -0,0 +1,130 @@ +const colors = [ + "#000000", + "#1D2B53", + "#7E2553", + "#008751", + "#AB5236", + "#5F574F", + "#C2C3C7", + "#FFF1E8", + "#FF004D", + "#FFA300", + "#FFEC27", + "#00E436", + "#29ADFF", + "#83769C", + "#FF77A8", + "#FFCCAA" +]; + +const initUI = (colorsDiv, selectedColor) => { + for (const color of colors) { + (color => { + const div = document.createElement("div"); + div.setAttribute("class", "color"); + div.setAttribute("style", "background: "+color+";"); + div.onclick = evt => { selectedColor.value = color; }; + colorsDiv.appendChild(div); + })(color); + } +}; + +//Get Mouse Position +const getMousePoint = (canvas, evt, color) => { + var rect = canvas.getBoundingClientRect(); + const x = Math.floor(canvas.width * (evt.clientX - rect.left) / rect.width); + const y = Math.floor(canvas.height * (evt.clientY - rect.top) / rect.width); + return { x, y, color }; +}; + +const updateLine = (ctx, line) => { + ctx.strokeStyle = line[0].color; + ctx.beginPath(); + ctx.moveTo(line[0].x, line[0].y); + for(let i=1; i < line.length; ++i) { + ctx.lineTo(line[i].x, line[i].y); + } + ctx.stroke(); +}; + +const updateCanvas = (canvas, ctx, lines) => { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + for(let line of lines) { + updateLine(ctx, line); + } +}; + +const initDrawing = (canvas, clearButton, selectedColor, ws) => { + const ctx = canvas.getContext("2d"); + ctx.lineWidth = 2; + let lines = []; + let currentLine = null; + + const resetCanvas = () => { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + resetCanvas(); + + clearButton.onclick = evt => { + lines = []; + resetCanvas(); + ws.send(JSON.stringify({ t: "clear" })); + }; + + canvas.addEventListener("mousedown", evt => { + const pt = getMousePoint(canvas, evt, selectedColor.value); + currentLine = [ pt ]; + ws.send(JSON.stringify({ ...pt, t: "moveTo" })); + }); + + canvas.addEventListener("mousemove", evt => { + if(evt.buttons === 1) { + const pt = getMousePoint(canvas, evt, selectedColor.value); + currentLine.push(pt); + updateLine(ctx, currentLine); + ws.send(JSON.stringify({ ...pt, t: "lineTo" })); + } + }); + + canvas.addEventListener("mouseup", evt => { + console.log(currentLine); + currentLine = null; + ws.send(JSON.stringify({ t: "stroke"})); + }); + + ws.addEventListener('message', function (event) { + let j = JSON.parse(event.data); + console.log(j); + if (j.t === "line") { + let line = j.line.map(a => ({ x: a[0], y: a[1], color: a[2] })); + lines.push(line); + updateCanvas(canvas, ctx, lines); + } else if (j.t == "clear") { + lines = []; + resetCanvas(); + } + }); +}; + + +const initWs = () => { + const socket = new WebSocket('ws://localhost:3000/ws'); + + socket.addEventListener('open', function (event) { + }); + + return socket; +}; + +window.onload = () => { + const colorsDiv = document.querySelector("#colors"); + const selectedColor = document.querySelector("#selectedColor"); + const clearButton = document.querySelector("#clearButton"); + const canvas = document.querySelector("#canvas"); + initUI(colorsDiv, selectedColor); + let ws = initWs(); + initDrawing(canvas, clearButton, selectedColor, ws); + +}; diff --git a/pub/style.css b/pub/style.css new file mode 100644 index 0000000..0ad422d --- /dev/null +++ b/pub/style.css @@ -0,0 +1,66 @@ +body { + margin: 0px; + padding: 0px; + background: #222; + color: #ddd; + font-family: monospace; +} + +#main { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + grid-auto-rows: minmax(100px, auto); + + position: absolute; + top: 0px; + right: 0px; + bottom: 0px; + left: 0px; + margin: 2em; +} + +#canvas { + margin: 0 auto; + display: block; + height: 100%; + aspect-ratio: 1/1; +} + +#canvas:hover { + cursor: crosshair; +} + +#toolbox { + display: inline-block; + margin: 0 auto; + width: 128px; + padding: 1em; +} + +.button2x { + width: 128px !important; + height: 128px !important; +} + +#toolbox > input { + display: inline-block; + width: 64px; + height: 64px; + font-size: 64px; +} + +#colors { +} + +.color { + display: inline-block; + width: 64px; + height: 64px; +} + +.footer { + display: block; + text-align: center; +} + diff --git a/src/gen_server.rs b/src/gen_server.rs index ff72696..38e336d 100644 --- a/src/gen_server.rs +++ b/src/gen_server.rs @@ -1,25 +1,25 @@ use std::collections::HashMap; use std::net::SocketAddr; -use tokio::sync::mpsc:: {Sender, Receiver}; -use crate::ws_client::Line; +use crate::line::Line; +use tokio::sync::mpsc::{self, Sender, Receiver}; #[derive(Debug, Clone)] pub enum GSMsg { NewClient((SocketAddr, Sender)), - NewLine(Line), DeleteClient(SocketAddr), + NewLine(Line), Clear } -pub struct State { - pub gs_tx: Sender +pub fn spawn() -> Sender { + let (tx, rx) = mpsc::channel(32); + tokio::spawn(gen_server(rx)); + tx } -pub async fn gen_server(mut rx: Receiver) { - let mut clients : HashMap> = - HashMap::new(); - - let mut lines : Vec = vec![]; +async fn gen_server(mut rx: Receiver) { + let mut clients: HashMap> = HashMap::new(); + let mut lines: Vec = vec![]; while let Some(msg) = rx.recv().await { match msg { @@ -36,8 +36,8 @@ pub async fn gen_server(mut rx: Receiver) { lines.push(line); }, GSMsg::DeleteClient(addr) => { - tracing::info!("Client {addr} removed"); clients.remove(&addr); + tracing::info!("Client {addr} removed"); }, GSMsg::Clear => { send_all(&mut clients, &GSMsg::Clear).await; @@ -52,8 +52,8 @@ async fn send_all( msg: &GSMsg ) { let mut to_remove : Vec = vec![]; - - for (addr, ref mut tx) in &mut *clients { + + for (addr, ref mut tx) in clients.iter() { let ret = tx .send(msg.clone()) .await; diff --git a/src/line.rs b/src/line.rs new file mode 100644 index 0000000..1698d47 --- /dev/null +++ b/src/line.rs @@ -0,0 +1,19 @@ +use geo::Simplify; + +pub type Line = Vec<(f32,f32,u32)>; + +pub fn simplify_line(line: &Line) -> Line { + if line.len() < 2 { + return line.to_vec(); + } + let color = line[0].2; + let linestring : geo::LineString = + line.iter() + .map(| (x, y, _) | (*x as f64, *y as f64 )) + .collect(); + let linestring = linestring.simplify(&4.0); + linestring.0.iter() + .map(| c | (c.x as f32, c.y as f32, color)) + .collect() +} + diff --git a/src/main.rs b/src/main.rs index 068f1c7..3dbbbcc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod gen_server; mod ws_client; +mod line; use axum::{ extract::{ @@ -11,20 +12,15 @@ use axum::{ Router, Extension }; +use axum::extract::connect_info::ConnectInfo; use std::{net::SocketAddr, path::PathBuf}; use tower_http::{ services::ServeDir, trace::{DefaultMakeSpan, TraceLayer}, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use axum::extract::connect_info::ConnectInfo; - -use std::sync::Arc; -use tokio::sync::{ - mpsc:: { self, Sender, Receiver }, - Mutex -}; -use gen_server::{State,GSMsg,gen_server}; +use tokio::sync::mpsc::Sender; +use gen_server::GSMsg; const LISTEN_ON : &str = "0.0.0.0:3000"; @@ -38,25 +34,19 @@ async fn main() { ) .with(tracing_subscriber::fmt::layer()) .init(); - - let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets"); - let (gs_tx, gs_rx) : (Sender, Receiver) = mpsc::channel(32); - - let state = Arc::new(Mutex::new(State { gs_tx })); - - tokio::spawn(gen_server(gs_rx)); + let gs_tx = gen_server::spawn(); + + let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("pub"); let app = Router::new() .fallback_service(ServeDir::new(assets_dir) .append_index_html_on_directories(true)) .route("/ws", get(ws_handler)) - .layer(Extension(state)) - .layer( - TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::default() - .include_headers(false)), - ); + .layer(Extension(gs_tx)) + .layer(TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::default() + .include_headers(false))); let addr : SocketAddr = LISTEN_ON.parse().unwrap(); @@ -69,7 +59,7 @@ async fn main() { async fn ws_handler( ws: WebSocketUpgrade, - Extension(state): Extension>>, + Extension(gs_tx): Extension>, user_agent: Option>, ConnectInfo(addr): ConnectInfo, ) -> impl IntoResponse { @@ -78,9 +68,6 @@ async fn ws_handler( } else { String::from("Unknown browser") }; - tracing::info!("`{user_agent}` at {addr} connected."); - // finalize the upgrade process by returning upgrade callback. - // we can customize the callback by sending additional info such as address. - ws.on_upgrade(move |socket| ws_client::handle_socket(socket, addr, state)) + tracing::info!("{addr} connected [{user_agent}]."); + ws.on_upgrade(move |socket| ws_client::handle_socket(socket, addr, gs_tx)) } - diff --git a/src/ws_client.rs b/src/ws_client.rs index fb663fb..93a677a 100644 --- a/src/ws_client.rs +++ b/src/ws_client.rs @@ -1,20 +1,14 @@ -use crate::gen_server::{State,GSMsg}; - use axum::extract::ws::{ Message, Message::Text, Message::Close, WebSocket }; use std::net::SocketAddr; -use std::sync::Arc; -use tokio::sync::{ - mpsc:: { self, Sender, Receiver }, - Mutex -}; +use tokio::sync::mpsc::{self, Sender}; use serde::{Serialize,Deserialize}; -use geo::Simplify; - use core::ops::ControlFlow; - +use crate::gen_server::GSMsg; +use crate::line::{Line,simplify_line}; + #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "t")] -pub enum JMsg { +enum JMsg { #[serde(rename = "clear")] Clear, #[serde(rename = "moveTo")] @@ -27,126 +21,118 @@ pub enum JMsg { Line { line: Vec<(f32,f32,String)> } } -pub type Line = Vec<(f32,f32,u32)>; - pub async fn handle_socket( mut socket: WebSocket, who: SocketAddr, - state: Arc> + gs_tx: Sender ) { - - let (c_tx, mut c_rx) : (Sender, Receiver) = mpsc::channel(32); - - { - state.lock() - .await - .gs_tx.send(GSMsg::NewClient((who, c_tx))) - .await.unwrap(); - } + let (c_tx, mut c_rx) = mpsc::channel(32); + gs_tx.send(GSMsg::NewClient((who, c_tx))).await.unwrap(); let mut line : Line = vec![]; loop { tokio::select! { Some(msg) = socket.recv() => { - match process_ws_msg(&state, &who, &mut line, msg).await { - ControlFlow::Break(()) => { return; }, + let Ok(msg) = msg else { + tracing::warn!("{who}: Error receiving packet: {msg:?}"); + continue; + }; + match process_ws_msg(&gs_tx, &who, &mut line, msg).await { + ControlFlow::Break(()) => break, ControlFlow::Continue(()) => {} } }, Some(msg) = c_rx.recv() => { - match msg { - GSMsg::NewLine(line) => { - socket.send(Message::Text(line_to_json(&line))) - .await.unwrap(); - }, - GSMsg::Clear => { - let msg = serde_json::to_string(&JMsg::Clear).unwrap(); - socket.send(Message::Text(msg)) - .await.unwrap(); - }, - msg => { - tracing::info!("{who} should not get this: {:?}", msg) - } - } + process_gs_msg(&mut socket, &who, msg).await }, else => { tracing::warn!("{who}: Connection lost unexpectedly."); - return; + break; } } } } +async fn process_gs_msg(socket: &mut WebSocket, who: &SocketAddr, msg: GSMsg) { + match msg { + GSMsg::NewLine(line) => { + socket.send(Message::Text(line_to_json(&line))).await.unwrap(); + }, + GSMsg::Clear => { + let msg = serde_json::to_string(&JMsg::Clear).unwrap(); + socket.send(Message::Text(msg)).await.unwrap(); + }, + msg => { + tracing::info!("{who} should not get this: {:?}", msg); + } + } +} + async fn process_ws_msg( - state: &Arc>, + gs_tx: &Sender, who: &SocketAddr, line: &mut Line, - msg: Result + msg: Message ) -> ControlFlow<(),()> { match msg { - Ok(Text(msg)) => { - let Ok(msg) : Result = serde_json::from_str(&msg) else { - tracing::warn!("{who}: Can't parse JSON: {:?}", msg); - return ControlFlow::Continue(()); - }; - tracing::debug!("{who}: '{:?}'", msg); - match msg { - JMsg::Clear => { - state.lock() - .await - .gs_tx.send(GSMsg::Clear) - .await.unwrap(); - line.clear(); - }, - JMsg::MoveTo { x, y, color } => { - *line = vec![ (x, y, parse_color(color)) ]; - }, - JMsg::LineTo { x, y, color } => { - line.push( (x, y, parse_color(color)) ); - }, - JMsg::Stroke => { - if line.len() > 1 { - state.lock() - .await - .gs_tx.send(GSMsg::NewLine(simplify_line(line))) - .await.unwrap(); + Text(text) => { + match serde_json::from_str(&text) { + Ok(json) => { + tracing::debug!("{who}: '{:?}'", json); + match handle_ws_msg(line, json) { + Ok(Some(req)) => gs_tx.send(req).await.unwrap(), + Ok(None) => {}, + Err(err) => { + tracing::warn!("{who}: message error: {err}"); + } } - *line = vec![]; }, - JMsg::Line{..} => { panic!("recieved a line message :/"); } + Err(err) => { + tracing::warn!("{who}: can't parse JSON: {err}"); + } } }, - Ok(Close(close)) => { + Close(close) => { tracing::info!("{who}: closing: {:?}", close); - state.lock() - .await - .gs_tx.send(GSMsg::DeleteClient(*who)) - .await.unwrap(); + gs_tx.send(GSMsg::DeleteClient(*who)).await.unwrap(); return ControlFlow::Break(()); }, _ => { - tracing::warn!("{who}: Can't handle message: {:?}", msg); + tracing::warn!("{who}: can't handle message: {:?}", msg); } } ControlFlow::Continue(()) } -fn simplify_line(line: &Line) -> Line { - if line.len() < 2 { - return line.to_vec(); - } - let color = line[0].2; - let linestring : geo::LineString = - line.iter() - .map(| (x, y, _) | (*x as f64, *y as f64 )) - .collect(); - let linestring = linestring.simplify(&4.0); - linestring.0.iter() - .map(| c | (c.x as f32, c.y as f32, color)) - .collect() +fn handle_ws_msg( + line: &mut Line, + msg: JMsg +) -> Result, &'static str> { + match msg { + JMsg::Clear => { + line.clear(); + return Ok(Some(GSMsg::Clear)); + }, + JMsg::MoveTo { x, y, color } => { + *line = vec![ (x, y, parse_color(color)?) ]; + }, + JMsg::LineTo { x, y, color } => { + line.push( (x, y, parse_color(color)?) ); + }, + JMsg::Stroke => { + if line.len() > 1 { + let line2 = simplify_line(line); + *line = vec![]; + return Ok(Some(GSMsg::NewLine(line2))); + } + }, + JMsg::Line{..} => { + tracing::warn!("recieved a line message O_o"); + } + }; + Ok(None) } - fn line_to_json(line: &Line) -> String { let line = line.iter() .map(| (x, y, c) | { @@ -156,6 +142,10 @@ fn line_to_json(line: &Line) -> String { serde_json::to_string(&JMsg::Line{ line }).unwrap() } -fn parse_color(s: String) -> u32 { - u32::from_str_radix(&s[1..], 16).unwrap() +fn parse_color(s: String) -> Result { + if s.len() != 7 || &s[0..1] != "#" { + Err("badly formated color.") + } else { + u32::from_str_radix(&s[1..], 16).map_err(|_| "unable to parse color") + } }