initial commit

This commit is contained in:
Marc Planard 2023-10-08 13:25:48 +02:00
commit bf2272e70b
9 changed files with 419 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "lj_sketch_front"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
js-sys = "0.3.64"
log = "0.4.19"
wasm-bindgen = "0.2.87"
wasm-logger = "0.2.0"
web-sys = { version = "0.3.64", features = ["HtmlCanvasElement",
"CanvasRenderingContext2d",
"MouseEvent","DomRect"] }
yew = { version = "0.20.0", features = ["csr"] }

8
index.html Normal file
View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title>LJ Sketch</title>
<meta charset="UTF-8">
<link data-trunk rel="css" href="style.css"/>
</head>
</html>

136
src/canvas.rs Normal file
View File

@ -0,0 +1,136 @@
use yew::prelude::*;
use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d,
MouseEvent, EventTarget, Element, DomRect};
use wasm_bindgen::{JsValue, JsCast};
pub type Point = (f32, f32, String);
pub type Line = Vec<Point>;
#[derive(Clone,Debug,Properties,PartialEq)]
pub struct Sketch {
pub lines: Vec<Line>
}
#[derive(Debug)]
pub struct CanvasComp {
node_ref: NodeRef,
line: Line,
}
#[derive(Debug)]
pub enum CanvasMsg {
MoveTo(Point),
LineTo(Point),
Stroke,
}
#[derive(Clone, PartialEq, Properties, Debug)]
pub struct CanvasProps {
pub width: u32,
pub height: u32,
pub color: String,
pub sketch: Sketch,
pub on_newline: Callback<Line>
}
impl Component for CanvasComp {
type Message = Option<CanvasMsg>;
type Properties = CanvasProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
node_ref: NodeRef::default(),
line: vec![],
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let size = (props.width as f32 , props.height as f32);
let width : AttrValue = format!("{}", props.width).into();
let height : AttrValue = format!("{}", props.height).into();
let onmousedown = {
let color = props.color.clone();
ctx.link().callback(move |e: MouseEvent| {
let (x, y) = get_position(e, size);
Some(CanvasMsg::MoveTo((x, y, color.clone())))
})
};
let onmousemove = {
let color = props.color.clone();
ctx.link().callback(move |e: MouseEvent| {
match e.buttons() {
0 => None,
_ => {
let (x, y) = get_position(e, size);
Some(CanvasMsg::LineTo((x, y, color.clone())))
}
}
})
};
let onmouseup = ctx.link().callback(move |_| Some(CanvasMsg::Stroke));
html! {
<canvas {width} {height} {onmousedown} {onmousemove} {onmouseup}
ref={self.node_ref.clone()} />
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
let Some(msg) = msg else {
return false;
};
match msg {
CanvasMsg::MoveTo(point) => self.line = vec![ point ],
CanvasMsg::LineTo(point) => self.line.push(point),
CanvasMsg::Stroke => {
ctx.props().on_newline.emit(self.line.clone());
self.line.clear();
}
};
true
}
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
let canvas = self.node_ref.cast::<HtmlCanvasElement>().unwrap();
let ctx2d: CanvasRenderingContext2d = canvas
.get_context("2d").unwrap().unwrap()
.dyn_into().unwrap();
let (width, height) = (canvas.width(), canvas.height());
ctx2d.set_line_width(2.0);
ctx2d.set_fill_style(&JsValue::from_str("#000000"));
ctx2d.fill_rect(0.0, 0.0, width.into(), height.into());
for line in &ctx.props().sketch.lines {
draw_line(&ctx2d, line);
}
if !self.line.is_empty() {
draw_line(&ctx2d, &self.line);
}
}
}
fn draw_line(ctx: &CanvasRenderingContext2d, line: &Line) {
ctx.set_stroke_style(&JsValue::from_str(&line[0].2));
ctx.begin_path();
ctx.move_to(line[0].0.into(), line[0].1.into());
for (x, y, _) in &line[1..] {
ctx.line_to((*x).into(), (*y).into());
}
ctx.stroke();
}
fn get_position(e: MouseEvent, (w, h) : (f32, f32)) -> (f32, f32) {
let target = e.target().and_then(|event_target: EventTarget| {
event_target.dyn_into::<Element>().ok()
}).unwrap();
let rect: DomRect = target.get_bounding_client_rect();
let (width, height) = (rect.width(), rect.height());
let mouse_x = w * e.offset_x() as f32 / width as f32;
let mouse_y = h * e.offset_y() as f32 / height as f32;
(mouse_x, mouse_y)
}

60
src/colors.rs Normal file
View File

@ -0,0 +1,60 @@
pub const COLORS : [&str; 16] = [
"#000000", "#1D2B53", "#7E2553", "#008751",
"#AB5236", "#5F574F", "#C2C3C7", "#FFF1E8",
"#FF004D", "#FFA300", "#FFEC27", "#00E436",
"#29ADFF", "#83769C", "#FF77A8", "#FFCCAA"
];
#[derive(Clone,Copy,PartialEq,Debug)]
pub struct Color([u8 ; 3]);
impl From<Color> for String {
fn from(color: Color) -> String {
format!("#{:02x}{:02x}{:02x}", color.0[0], color.0[1], color.0[2])
}
}
impl TryFrom<String> for Color {
type Error = &'static str;
fn try_from(s: String) -> Result<Color, Self::Error> {
let ("#", rest) = s[..].split_at(1) else {
return Err("Badly formated color: should start with '#'");
};
if rest.len() != 6 {
return Err("Badly formated color");
}
let Ok(r) = u8::from_str_radix(&rest[0..2], 16) else {
return Err("Badly formated color");
};
let Ok(g) = u8::from_str_radix(&rest[2..4], 16) else {
return Err("Badly formated color");
};
let Ok(b) = u8::from_str_radix(&rest[4..6], 16) else {
return Err("Badly formated color");
};
Ok(Color([r,g,b]))
}
}
#[test]
fn test_string_to_color() {
let c : Result<Color, _> = "#ff0042".to_string().try_into();
assert_eq!(c, Ok(Color([0xff, 0x00, 0x42])));
let c : Result<Color, _> = "ff0042".to_string().try_into();
assert_eq!(c, Err("Badly formated color: should start with '#'"));
let c : Result<Color, _> = "#ff004242".to_string().try_into();
assert_eq!(c, Err("Badly formated color"));
let c : Result<Color, _> = "#ffg042".to_string().try_into();
assert_eq!(c, Err("Badly formated color"));
}
#[test]
fn test_color_to_string() {
let s : String = Color([0xff, 0x00, 0x42]).into();
assert_eq!(s, "#ff0042");
}

44
src/draw_app.rs Normal file
View File

@ -0,0 +1,44 @@
use yew::prelude::*;
use crate::canvas::{CanvasComp,Line,Sketch};
use crate::toolbox::ToolboxComp;
#[function_component]
pub fn DrawApp() -> Html {
let sketch_handle = use_state(|| Sketch{ lines: vec![] });
let sketch = (*sketch_handle).clone();
let color_handle = use_state(|| String::from("#ffffff"));
let color = (*color_handle).clone();
let on_changecolor = Callback::from(move | color: String | {
color_handle.set(color);
});
let on_clearcanvas = {
let sketch_handle = sketch_handle.clone();
Callback::from(move | _: () | {
sketch_handle.set(Sketch { lines: vec![] });
})
};
let on_newline = Callback::from(move | line: Line | {
let mut s = (*sketch_handle).clone();
s.lines.push(line);
sketch_handle.set(s);
});
html! {
<div class="drawApp">
<ToolboxComp {on_changecolor} {on_clearcanvas}
color={color.clone()} />
<div>
<CanvasComp width=1024 height=1024
{sketch} {on_newline}
color={color.clone()} />
<span class="footer">{"LJ Sketch"}</span>
</div>
</div>
}
}

21
src/main.rs Normal file
View File

@ -0,0 +1,21 @@
mod canvas;
mod toolbox;
mod draw_app;
mod colors;
use yew::prelude::*;
use draw_app::DrawApp;
#[function_component]
pub fn App() -> Html {
html! {
<div>
<DrawApp />
</div>
}
}
fn main() {
wasm_logger::init(wasm_logger::Config::default());
yew::Renderer::<App>::new().render();
}

51
src/toolbox.rs Normal file
View File

@ -0,0 +1,51 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement};
use crate::colors::COLORS;
#[derive(PartialEq, Properties, Clone)]
pub struct ToolboxProps {
pub color: String,
pub on_changecolor: Callback<String>,
pub on_clearcanvas: Callback<()>
}
#[function_component]
pub fn ToolboxComp(props: &ToolboxProps) -> Html {
let onchange = {
let parent_onchange = props.on_changecolor.clone();
Callback::from(move | event: Event | {
if let Some(input) = event.target_dyn_into::<HtmlInputElement>() {
parent_onchange.emit(input.value());
}
})
};
let onclearcanvas = {
let parent_onclearcanvas = props.on_clearcanvas.clone();
Callback::from(move | _event: MouseEvent | {
parent_onclearcanvas.emit(());
})
};
html! {
<div class="toolbox">
{ for COLORS.iter().map(| &color | {
let style = format!("background: {};", color);
let onclick = {
let parent_onchange = props.on_changecolor.clone();
Callback::from(move | _event | {
parent_onchange.emit(color.to_string());
})
};
html! { <div class="color" style={style} {onclick} /> }
}) }
<input type="color" class="button2x" {onchange}
value={props.color.clone()} />
<input type="button" class="button2x" onclick={onclearcanvas}
value="" />
</div>
}
}

82
style.css Normal file
View File

@ -0,0 +1,82 @@
body {
margin: 0px;
padding: 0px;
background: #222;
color: #ddd;
font-family: monospace;
}
.drawApp {
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;
}
.drawApp > * > canvas {
margin: 0 auto;
display: block;
height: 100%;
aspect-ratio: 1/1;
}
.drawApp > * > canvas:hover {
cursor: crosshair;
}
.drawApp > .toolbox {
display: inline-block;
margin: 0 auto;
width: 128px;
padding: 1em;
}
.drawApp > .toolbox > input {
display: inline-block;
width: 64px;
height: 64px;
font-size: 64px;
}
.drawApp > .toolbox > .button2x {
width: 128px !important;
height: 128px !important;
}
.drawApp > .toolbox > .color {
display: inline-block;
width: 64px;
height: 64px;
}
.drawApp > * > .footer {
display: block;
text-align: center;
}
#errorBox {
border: thick solid #990000;
background: #dd0000;
position: fixed;
bottom: 0px;
right: 0px;
text-align: center;
padding: 1em;
font-size: x-large;
}
.visible {
display: block;
}
.invisible {
display: none;
}