refactoring + add pub

This commit is contained in:
Marc Planard 2023-08-08 14:14:34 +02:00
parent 7ca48a2448
commit e235fcc749
7 changed files with 351 additions and 132 deletions

27
pub/index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>LJ Sketch</title>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<div id="main">
<div id="toolbox">
<div id="colors"></div>
<input type="color" id="selectedColor"
class="button2x" value="#ffffff"></input>
<input type="button" id="clearButton"
class="button2x" value="♻"></input>
</div>
<div>
<canvas id="canvas" width="1024" height="1024"></canvas>
<span class="footer">LJ Sketch</span>
</div>
<script src="main.js"></script>
</body>
</html>

130
pub/main.js Normal file
View File

@ -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);
};

66
pub/style.css Normal file
View File

@ -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;
}

View File

@ -1,25 +1,25 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::sync::mpsc:: {Sender, Receiver}; use crate::line::Line;
use crate::ws_client::Line; use tokio::sync::mpsc::{self, Sender, Receiver};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum GSMsg { pub enum GSMsg {
NewClient((SocketAddr, Sender<GSMsg>)), NewClient((SocketAddr, Sender<GSMsg>)),
NewLine(Line),
DeleteClient(SocketAddr), DeleteClient(SocketAddr),
NewLine(Line),
Clear Clear
} }
pub struct State { pub fn spawn() -> Sender<GSMsg> {
pub gs_tx: Sender<GSMsg> let (tx, rx) = mpsc::channel(32);
tokio::spawn(gen_server(rx));
tx
} }
pub async fn gen_server(mut rx: Receiver<GSMsg>) { async fn gen_server(mut rx: Receiver<GSMsg>) {
let mut clients : HashMap<SocketAddr, Sender<GSMsg>> = let mut clients: HashMap<SocketAddr, Sender<GSMsg>> = HashMap::new();
HashMap::new(); let mut lines: Vec<Line> = vec![];
let mut lines : Vec<Line> = vec![];
while let Some(msg) = rx.recv().await { while let Some(msg) = rx.recv().await {
match msg { match msg {
@ -36,8 +36,8 @@ pub async fn gen_server(mut rx: Receiver<GSMsg>) {
lines.push(line); lines.push(line);
}, },
GSMsg::DeleteClient(addr) => { GSMsg::DeleteClient(addr) => {
tracing::info!("Client {addr} removed");
clients.remove(&addr); clients.remove(&addr);
tracing::info!("Client {addr} removed");
}, },
GSMsg::Clear => { GSMsg::Clear => {
send_all(&mut clients, &GSMsg::Clear).await; send_all(&mut clients, &GSMsg::Clear).await;
@ -52,8 +52,8 @@ async fn send_all(
msg: &GSMsg msg: &GSMsg
) { ) {
let mut to_remove : Vec<SocketAddr> = vec![]; let mut to_remove : Vec<SocketAddr> = vec![];
for (addr, ref mut tx) in &mut *clients { for (addr, ref mut tx) in clients.iter() {
let ret = tx let ret = tx
.send(msg.clone()) .send(msg.clone())
.await; .await;

19
src/line.rs Normal file
View File

@ -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()
}

View File

@ -1,5 +1,6 @@
mod gen_server; mod gen_server;
mod ws_client; mod ws_client;
mod line;
use axum::{ use axum::{
extract::{ extract::{
@ -11,20 +12,15 @@ use axum::{
Router, Router,
Extension Extension
}; };
use axum::extract::connect_info::ConnectInfo;
use std::{net::SocketAddr, path::PathBuf}; use std::{net::SocketAddr, path::PathBuf};
use tower_http::{ use tower_http::{
services::ServeDir, services::ServeDir,
trace::{DefaultMakeSpan, TraceLayer}, trace::{DefaultMakeSpan, TraceLayer},
}; };
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use axum::extract::connect_info::ConnectInfo; use tokio::sync::mpsc::Sender;
use gen_server::GSMsg;
use std::sync::Arc;
use tokio::sync::{
mpsc:: { self, Sender, Receiver },
Mutex
};
use gen_server::{State,GSMsg,gen_server};
const LISTEN_ON : &str = "0.0.0.0:3000"; const LISTEN_ON : &str = "0.0.0.0:3000";
@ -38,25 +34,19 @@ async fn main() {
) )
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets");
let (gs_tx, gs_rx) : (Sender<GSMsg>, Receiver<GSMsg>) = mpsc::channel(32); let gs_tx = gen_server::spawn();
let state = Arc::new(Mutex::new(State { gs_tx })); let assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("pub");
tokio::spawn(gen_server(gs_rx));
let app = Router::new() let app = Router::new()
.fallback_service(ServeDir::new(assets_dir) .fallback_service(ServeDir::new(assets_dir)
.append_index_html_on_directories(true)) .append_index_html_on_directories(true))
.route("/ws", get(ws_handler)) .route("/ws", get(ws_handler))
.layer(Extension(state)) .layer(Extension(gs_tx))
.layer( .layer(TraceLayer::new_for_http()
TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::default()
.make_span_with(DefaultMakeSpan::default() .include_headers(false)));
.include_headers(false)),
);
let addr : SocketAddr = LISTEN_ON.parse().unwrap(); let addr : SocketAddr = LISTEN_ON.parse().unwrap();
@ -69,7 +59,7 @@ async fn main() {
async fn ws_handler( async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
Extension(state): Extension<Arc<Mutex<State>>>, Extension(gs_tx): Extension<Sender<GSMsg>>,
user_agent: Option<TypedHeader<axum::headers::UserAgent>>, user_agent: Option<TypedHeader<axum::headers::UserAgent>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -78,9 +68,6 @@ async fn ws_handler(
} else { } else {
String::from("Unknown browser") String::from("Unknown browser")
}; };
tracing::info!("`{user_agent}` at {addr} connected."); tracing::info!("{addr} connected [{user_agent}].");
// finalize the upgrade process by returning upgrade callback. ws.on_upgrade(move |socket| ws_client::handle_socket(socket, addr, gs_tx))
// we can customize the callback by sending additional info such as address.
ws.on_upgrade(move |socket| ws_client::handle_socket(socket, addr, state))
} }

View File

@ -1,20 +1,14 @@
use crate::gen_server::{State,GSMsg};
use axum::extract::ws::{ Message, Message::Text, Message::Close, WebSocket }; use axum::extract::ws::{ Message, Message::Text, Message::Close, WebSocket };
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{
mpsc:: { self, Sender, Receiver },
Mutex
};
use serde::{Serialize,Deserialize}; use serde::{Serialize,Deserialize};
use geo::Simplify;
use core::ops::ControlFlow; use core::ops::ControlFlow;
use crate::gen_server::GSMsg;
use crate::line::{Line,simplify_line};
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "t")] #[serde(tag = "t")]
pub enum JMsg { enum JMsg {
#[serde(rename = "clear")] #[serde(rename = "clear")]
Clear, Clear,
#[serde(rename = "moveTo")] #[serde(rename = "moveTo")]
@ -27,126 +21,118 @@ pub enum JMsg {
Line { line: Vec<(f32,f32,String)> } Line { line: Vec<(f32,f32,String)> }
} }
pub type Line = Vec<(f32,f32,u32)>;
pub async fn handle_socket( pub async fn handle_socket(
mut socket: WebSocket, mut socket: WebSocket,
who: SocketAddr, who: SocketAddr,
state: Arc<Mutex<State>> gs_tx: Sender<GSMsg>
) { ) {
let (c_tx, mut c_rx) = mpsc::channel(32);
let (c_tx, mut c_rx) : (Sender<GSMsg>, Receiver<GSMsg>) = mpsc::channel(32); gs_tx.send(GSMsg::NewClient((who, c_tx))).await.unwrap();
{
state.lock()
.await
.gs_tx.send(GSMsg::NewClient((who, c_tx)))
.await.unwrap();
}
let mut line : Line = vec![]; let mut line : Line = vec![];
loop { loop {
tokio::select! { tokio::select! {
Some(msg) = socket.recv() => { Some(msg) = socket.recv() => {
match process_ws_msg(&state, &who, &mut line, msg).await { let Ok(msg) = msg else {
ControlFlow::Break(()) => { return; }, tracing::warn!("{who}: Error receiving packet: {msg:?}");
continue;
};
match process_ws_msg(&gs_tx, &who, &mut line, msg).await {
ControlFlow::Break(()) => break,
ControlFlow::Continue(()) => {} ControlFlow::Continue(()) => {}
} }
}, },
Some(msg) = c_rx.recv() => { Some(msg) = c_rx.recv() => {
match msg { process_gs_msg(&mut socket, &who, msg).await
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)
}
}
}, },
else => { else => {
tracing::warn!("{who}: Connection lost unexpectedly."); 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( async fn process_ws_msg(
state: &Arc<Mutex<State>>, gs_tx: &Sender<GSMsg>,
who: &SocketAddr, who: &SocketAddr,
line: &mut Line, line: &mut Line,
msg: Result<Message,axum::Error> msg: Message
) -> ControlFlow<(),()> { ) -> ControlFlow<(),()> {
match msg { match msg {
Ok(Text(msg)) => { Text(text) => {
let Ok(msg) : Result<JMsg,_> = serde_json::from_str(&msg) else { match serde_json::from_str(&text) {
tracing::warn!("{who}: Can't parse JSON: {:?}", msg); Ok(json) => {
return ControlFlow::Continue(()); tracing::debug!("{who}: '{:?}'", json);
}; match handle_ws_msg(line, json) {
tracing::debug!("{who}: '{:?}'", msg); Ok(Some(req)) => gs_tx.send(req).await.unwrap(),
match msg { Ok(None) => {},
JMsg::Clear => { Err(err) => {
state.lock() tracing::warn!("{who}: message error: {err}");
.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();
} }
*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); tracing::info!("{who}: closing: {:?}", close);
state.lock() gs_tx.send(GSMsg::DeleteClient(*who)).await.unwrap();
.await
.gs_tx.send(GSMsg::DeleteClient(*who))
.await.unwrap();
return ControlFlow::Break(()); return ControlFlow::Break(());
}, },
_ => { _ => {
tracing::warn!("{who}: Can't handle message: {:?}", msg); tracing::warn!("{who}: can't handle message: {:?}", msg);
} }
} }
ControlFlow::Continue(()) ControlFlow::Continue(())
} }
fn simplify_line(line: &Line) -> Line { fn handle_ws_msg(
if line.len() < 2 { line: &mut Line,
return line.to_vec(); msg: JMsg
} ) -> Result<Option<GSMsg>, &'static str> {
let color = line[0].2; match msg {
let linestring : geo::LineString = JMsg::Clear => {
line.iter() line.clear();
.map(| (x, y, _) | (*x as f64, *y as f64 )) return Ok(Some(GSMsg::Clear));
.collect(); },
let linestring = linestring.simplify(&4.0); JMsg::MoveTo { x, y, color } => {
linestring.0.iter() *line = vec![ (x, y, parse_color(color)?) ];
.map(| c | (c.x as f32, c.y as f32, color)) },
.collect() 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 { fn line_to_json(line: &Line) -> String {
let line = line.iter() let line = line.iter()
.map(| (x, y, c) | { .map(| (x, y, c) | {
@ -156,6 +142,10 @@ fn line_to_json(line: &Line) -> String {
serde_json::to_string(&JMsg::Line{ line }).unwrap() serde_json::to_string(&JMsg::Line{ line }).unwrap()
} }
fn parse_color(s: String) -> u32 { fn parse_color(s: String) -> Result<u32, &'static str> {
u32::from_str_radix(&s[1..], 16).unwrap() 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")
}
} }