Skip to content

Commit ea47fbe

Browse files
committed
feat(client): add Yew canvas with drawing tools and pan/zoom
Ship a WASM drawing UI with rectangle, ellipse, and freehand tools plus infinite canvas navigation via scroll zoom and shift-drag panning.
1 parent 2b4e6e8 commit ea47fbe

7 files changed

Lines changed: 491 additions & 0 deletions

File tree

crates/client/Cargo.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "excaildraw-client"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
authors.workspace = true
7+
description = "WebAssembly client for excaildraw"
8+
9+
[lib]
10+
crate-type = ["cdylib", "rlib"]
11+
12+
[dependencies]
13+
excaildraw-core = { path = "../core" }
14+
yew = { version = "0.21", features = ["csr"] }
15+
gloo = "0.11"
16+
gloo-events = "0.2"
17+
wasm-bindgen = "0.2"
18+
wasm-bindgen-futures = "0.4"
19+
js-sys = "0.3"
20+
web-sys = { version = "0.3", features = [
21+
"CanvasRenderingContext2d",
22+
"Document",
23+
"Element",
24+
"HtmlCanvasElement",
25+
"Window",
26+
"MouseEvent",
27+
"WheelEvent",
28+
"KeyboardEvent",
29+
"DomRect",
30+
"CssStyleDeclaration",
31+
] }
32+
serde = { workspace = true }
33+
serde_json = { workspace = true }
34+
uuid = { workspace = true, features = ["js"] }
35+
getrandom = { version = "0.3", features = ["wasm_js"] }

crates/client/Trunk.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[build]
2+
target = "index.html"
3+
dist = "../../dist"
4+
public_url = "/"
5+
6+
[watch]
7+
watch = ["src", "index.html"]

crates/client/index.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>excaildraw</title>
7+
<link data-trunk rel="rust" />
8+
<style>
9+
* { box-sizing: border-box; margin: 0; padding: 0; }
10+
html, body, #app { width: 100%; height: 100%; overflow: hidden; font-family: system-ui, sans-serif; }
11+
.app { display: flex; flex-direction: column; height: 100%; }
12+
.toolbar {
13+
display: flex; align-items: center; gap: 8px;
14+
padding: 8px 12px; background: #1e1e1e; color: #fff;
15+
border-bottom: 1px solid #333;
16+
}
17+
.brand { font-weight: 700; margin-right: 12px; letter-spacing: -0.02em; }
18+
.tool-btn {
19+
background: #2d2d2d; color: #fff; border: 1px solid #444;
20+
border-radius: 6px; padding: 6px 12px; cursor: pointer;
21+
}
22+
.tool-btn:hover { background: #3d3d3d; }
23+
.tool-btn.active { background: #6965db; border-color: #6965db; }
24+
.hint { margin-left: auto; opacity: 0.6; font-size: 12px; }
25+
.canvas { flex: 1; width: 100%; cursor: crosshair; display: block; }
26+
</style>
27+
</head>
28+
<body>
29+
<div id="app"></div>
30+
</body>
31+
</html>

crates/client/src/app.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use excaildraw_core::{Element, ElementType};
2+
use std::rc::Rc;
3+
use wasm_bindgen::JsCast;
4+
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, MouseEvent, WheelEvent};
5+
use yew::prelude::*;
6+
7+
use crate::canvas::render_scene;
8+
use crate::viewport::Viewport;
9+
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub enum Tool {
12+
Select,
13+
Rectangle,
14+
Ellipse,
15+
Freedraw,
16+
}
17+
18+
#[function_component(App)]
19+
pub fn app() -> Html {
20+
let canvas_ref = use_node_ref();
21+
let viewport = use_mut_ref(Viewport::default);
22+
let elements = use_mut_ref(Vec::<Element>::new);
23+
let tool = use_state(|| Tool::Rectangle);
24+
let drawing = use_mut_ref(|| None::<(f64, f64)>);
25+
let preview = use_mut_ref(|| None::<Element>);
26+
let panning = use_mut_ref(|| None::<(f64, f64)>);
27+
let freedraw_points = use_mut_ref(Vec::<f64>::new);
28+
29+
let frame = use_state(|| 0u32);
30+
31+
{
32+
let canvas_ref = canvas_ref.clone();
33+
let viewport = viewport.clone();
34+
let elements = elements.clone();
35+
let preview = preview.clone();
36+
let frame_id = *frame;
37+
use_effect_with(frame_id, move |_| {
38+
if let Some(canvas) = canvas_ref.cast::<HtmlCanvasElement>() {
39+
let rect = canvas.get_bounding_client_rect();
40+
let width = rect.width();
41+
let height = rect.height();
42+
canvas.set_width(width as u32);
43+
canvas.set_height(height as u32);
44+
if let Ok(Some(ctx)) = canvas
45+
.get_context("2d")
46+
.map(|c| c.map(|v| v.dyn_into::<CanvasRenderingContext2d>().ok()))
47+
.map(|o| o.flatten())
48+
{
49+
let vp = viewport.borrow();
50+
let els = elements.borrow();
51+
let prev = preview.borrow();
52+
render_scene(
53+
&ctx,
54+
&vp,
55+
width,
56+
height,
57+
&els,
58+
prev.as_ref(),
59+
);
60+
}
61+
}
62+
|| ()
63+
});
64+
}
65+
66+
let bump_frame = {
67+
let frame = frame.clone();
68+
Rc::new(move || frame.set(*frame + 1))
69+
};
70+
71+
let on_wheel = {
72+
let viewport = viewport.clone();
73+
let canvas_ref = canvas_ref.clone();
74+
let bump_frame = bump_frame.clone();
75+
Callback::from(move |e: WheelEvent| {
76+
e.prevent_default();
77+
let rect = canvas_ref
78+
.cast::<HtmlCanvasElement>()
79+
.map(|c| c.get_bounding_client_rect());
80+
if let Some(rect) = rect {
81+
let sx = e.client_x() as f64 - rect.left();
82+
let sy = e.client_y() as f64 - rect.top();
83+
viewport.borrow_mut().zoom_at(sx, sy, -e.delta_y());
84+
bump_frame();
85+
}
86+
})
87+
};
88+
89+
let on_mouse_down = {
90+
let viewport = viewport.clone();
91+
let canvas_ref = canvas_ref.clone();
92+
let tool = tool.clone();
93+
let drawing = drawing.clone();
94+
let preview = preview.clone();
95+
let panning = panning.clone();
96+
let freedraw_points = freedraw_points.clone();
97+
let bump_frame = bump_frame.clone();
98+
Callback::from(move |e: MouseEvent| {
99+
let rect = canvas_ref
100+
.cast::<HtmlCanvasElement>()
101+
.map(|c| c.get_bounding_client_rect());
102+
let Some(rect) = rect else { return };
103+
104+
let sx = e.client_x() as f64 - rect.left();
105+
let sy = e.client_y() as f64 - rect.top();
106+
107+
if e.button() == 1 || e.shift_key() {
108+
*panning.borrow_mut() = Some((sx, sy));
109+
return;
110+
}
111+
112+
let (wx, wy) = viewport.borrow().screen_to_world(sx, sy);
113+
match *tool {
114+
Tool::Select => {}
115+
Tool::Rectangle => {
116+
*drawing.borrow_mut() = Some((wx, wy));
117+
*preview.borrow_mut() =
118+
Some(Element::new(ElementType::Rectangle, wx, wy, 0.0, 0.0));
119+
}
120+
Tool::Ellipse => {
121+
*drawing.borrow_mut() = Some((wx, wy));
122+
*preview.borrow_mut() =
123+
Some(Element::new(ElementType::Ellipse, wx, wy, 0.0, 0.0));
124+
}
125+
Tool::Freedraw => {
126+
freedraw_points.borrow_mut().clear();
127+
freedraw_points.borrow_mut().extend([wx, wy]);
128+
let mut el = Element::new(ElementType::Freedraw, wx, wy, 0.0, 0.0);
129+
el.points = Some(freedraw_points.borrow().clone());
130+
*preview.borrow_mut() = Some(el);
131+
*drawing.borrow_mut() = Some((wx, wy));
132+
}
133+
}
134+
bump_frame();
135+
})
136+
};
137+
138+
let on_mouse_move = {
139+
let viewport = viewport.clone();
140+
let canvas_ref = canvas_ref.clone();
141+
let drawing = drawing.clone();
142+
let preview = preview.clone();
143+
let panning = panning.clone();
144+
let freedraw_points = freedraw_points.clone();
145+
let tool = tool.clone();
146+
let bump_frame = bump_frame.clone();
147+
Callback::from(move |e: MouseEvent| {
148+
let rect = canvas_ref
149+
.cast::<HtmlCanvasElement>()
150+
.map(|c| c.get_bounding_client_rect());
151+
let Some(rect) = rect else { return };
152+
153+
let sx = e.client_x() as f64 - rect.left();
154+
let sy = e.client_y() as f64 - rect.top();
155+
156+
if let Some((px, py)) = *panning.borrow() {
157+
viewport.borrow_mut().pan(sx - px, sy - py);
158+
*panning.borrow_mut() = Some((sx, sy));
159+
return;
160+
}
161+
162+
if drawing.borrow().is_none() {
163+
return;
164+
}
165+
166+
let (wx, wy) = viewport.borrow().screen_to_world(sx, sy);
167+
match *tool {
168+
Tool::Rectangle | Tool::Ellipse => {
169+
if let Some((sx0, sy0)) = *drawing.borrow() {
170+
if let Some(p) = preview.borrow_mut().as_mut() {
171+
p.x = sx0.min(wx);
172+
p.y = sy0.min(wy);
173+
p.width = (wx - sx0).abs();
174+
p.height = (wy - sy0).abs();
175+
}
176+
}
177+
}
178+
Tool::Freedraw => {
179+
freedraw_points.borrow_mut().extend([wx, wy]);
180+
if let Some(p) = preview.borrow_mut().as_mut() {
181+
p.points = Some(freedraw_points.borrow().clone());
182+
}
183+
}
184+
Tool::Select => {}
185+
}
186+
bump_frame();
187+
})
188+
};
189+
190+
let on_mouse_up = {
191+
let drawing = drawing.clone();
192+
let preview = preview.clone();
193+
let elements = elements.clone();
194+
let panning = panning.clone();
195+
let bump_frame = bump_frame.clone();
196+
Callback::from(move |_: MouseEvent| {
197+
if panning.borrow_mut().take().is_some() {
198+
return;
199+
}
200+
if let Some(mut el) = preview.borrow_mut().take() {
201+
if el.width.abs() > 1.0 || el.height.abs() > 1.0 || el.points.is_some() {
202+
el.bump_version();
203+
elements.borrow_mut().push(el);
204+
}
205+
}
206+
drawing.borrow_mut().take();
207+
bump_frame();
208+
})
209+
};
210+
211+
let set_tool = |t: Tool| {
212+
let tool = tool.clone();
213+
Callback::from(move |_| tool.set(t))
214+
};
215+
216+
html! {
217+
<div class="app">
218+
<header class="toolbar">
219+
<span class="brand">{"excaildraw"}</span>
220+
<button class={classes!("tool-btn", (*tool == Tool::Select).then_some("active"))}
221+
onclick={set_tool(Tool::Select)}>{"Select"}</button>
222+
<button class={classes!("tool-btn", (*tool == Tool::Rectangle).then_some("active"))}
223+
onclick={set_tool(Tool::Rectangle)}>{"Rectangle"}</button>
224+
<button class={classes!("tool-btn", (*tool == Tool::Ellipse).then_some("active"))}
225+
onclick={set_tool(Tool::Ellipse)}>{"Ellipse"}</button>
226+
<button class={classes!("tool-btn", (*tool == Tool::Freedraw).then_some("active"))}
227+
onclick={set_tool(Tool::Freedraw)}>{"Draw"}</button>
228+
<span class="hint">{"Scroll to zoom · Shift+drag to pan"}</span>
229+
</header>
230+
<canvas
231+
ref={canvas_ref}
232+
class="canvas"
233+
onwheel={on_wheel}
234+
onmousedown={on_mouse_down}
235+
onmousemove={on_mouse_move}
236+
onmouseup={on_mouse_up.clone()}
237+
onmouseleave={on_mouse_up}
238+
/>
239+
</div>
240+
}
241+
}

0 commit comments

Comments
 (0)