feat(whiteboard-view): wave 9

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-14 00:41:09 -03:00
parent bf4f55aed1
commit d3790796df
10 changed files with 1181 additions and 0 deletions
Generated
+3
View File
@@ -2175,6 +2175,8 @@ dependencies = [
"image",
"log",
"notescraft-backlinks",
"notescraft-canvas",
"notescraft-diagrams",
"notescraft-editor",
"notescraft-engine",
"notescraft-graph",
@@ -2182,6 +2184,7 @@ dependencies = [
"notescraft-markdown",
"notescraft-templates",
"notescraft-themes",
"notescraft-whiteboard",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -0,0 +1,44 @@
//! Renders the whiteboard widget offscreen for visual review.
use notescraft_ui_egui::{
theme::Tokens,
whiteboard_view::{WhiteboardView, show_whiteboard},
};
use notescraft_whiteboard::{Element, IdGen, Whiteboard};
fn main() {
let output = std::env::args().nth(1).unwrap_or_else(|| "/tmp/notescraft-whiteboard.png".to_owned());
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else {
eprintln!("missing built-in Catppuccin Mocha theme");
std::process::exit(1);
};
let mut ids = IdGen::new();
let mut board = Whiteboard::new();
let mut rectangle = Element::rectangle(&mut ids, 140.0, 150.0, 220.0, 140.0);
"#89b4fa".clone_into(&mut rectangle.stroke_color);
board.add(rectangle);
board.add(Element::diamond(&mut ids, 450.0, 210.0, 140.0, 110.0));
board.add(Element::text(&mut ids, 180.0, 330.0, "Sketch your ideas"));
let state = (Tokens::new(theme), WhiteboardView::default(), board);
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 720.0)).with_max_steps(32).wgpu().build_ui_state(
|ui, state: &mut (Tokens, WhiteboardView, Whiteboard)| {
let _ = show_whiteboard(ui, &state.0, &mut state.1, &mut state.2);
},
state,
);
harness.run();
match harness.render() {
Ok(image) => match image.save(&output) {
Ok(()) => println!("wrote {output}"),
Err(error) => {
eprintln!("could not save {output}: {error}");
std::process::exit(1);
}
},
Err(error) => {
eprintln!("render failed: {error}");
std::process::exit(1);
}
}
}
+1
View File
@@ -31,6 +31,7 @@ pub mod theme;
pub mod tree;
#[cfg(test)]
mod tree_tests;
pub mod whiteboard_view;
// Remaining phase-3 scope: settings panel, graph and canvas renderers (other tasks).
+133
View File
@@ -0,0 +1,133 @@
//! Excalidraw-compatible whiteboard editor widget.
mod edit;
mod export;
mod geometry;
mod interaction;
mod render;
mod state;
pub use export::{export_png, export_svg};
pub use geometry::{element_bounds, hit_test, smooth_points, transform_point};
pub use interaction::show_whiteboard;
pub use state::{WhiteboardResponse, WhiteboardTool, WhiteboardView};
#[cfg(test)]
mod tests {
use notescraft_whiteboard::{Element, IdGen, Whiteboard};
use super::{WhiteboardTool, WhiteboardView, export_svg, show_whiteboard, smooth_points, transform_point};
use crate::theme::Tokens;
#[test]
fn transform_rotation_round_trips() {
let point = [12.0, -3.0];
let center = [4.0, 9.0];
let transformed = transform_point(point, center, std::f64::consts::FRAC_PI_2);
assert!((transformed[0] - 16.0).abs() < 1e-9);
assert!((transformed[1] - 17.0).abs() < 1e-9);
}
#[test]
fn freedraw_smoothing_is_bounded_and_keeps_endpoints() {
let points = vec![[0.0, 0.0], [2.0, 4.0], [4.0, 0.0]];
let smooth = smooth_points(&points).unwrap_or_default();
assert_eq!(smooth.first(), points.first());
assert_eq!(smooth.last(), points.last());
assert!(smooth.len() <= points.len() * 4);
let hostile = vec![[0.0, 0.0]; 200_000];
assert!(smooth_points(&hostile).is_none());
}
#[test]
fn svg_escapes_text_and_renders_seeded_rough_shapes() {
let mut ids = IdGen::new();
let mut board = Whiteboard::new();
let mut rect = Element::rectangle(&mut ids, 0.0, 0.0, 80.0, 40.0);
rect.stroke_color = "#222222".to_owned();
board.add(rect);
board.add(Element::text(&mut ids, 4.0, 5.0, "<hello & \"world\">"));
let svg = export_svg(&board);
assert!(svg.contains("&lt;hello &amp; &quot;world&quot;&gt;"));
assert_eq!(svg.matches("<path ").count(), 2);
assert_eq!(svg.matches("<text ").count(), 1);
assert_eq!(svg, export_svg(&board));
}
#[test]
fn undo_redo_restore_identical_board_json() {
let mut view = WhiteboardView::default();
let mut board = Whiteboard::new();
view.snapshot(&board);
let before = board.to_string_pretty().ok();
let mut ids = IdGen::new();
board.add(Element::rectangle(&mut ids, 2.0, 3.0, 4.0, 5.0));
view.snapshot(&board);
let after = board.to_string_pretty().ok();
view.undo(&mut board);
assert_eq!(board.to_string_pretty().ok(), before);
view.redo(&mut board);
assert_eq!(board.to_string_pretty().ok(), after);
}
#[test]
fn hostile_nan_transform_stays_finite() {
let transformed = transform_point([f64::NAN, f64::INFINITY], [1.0, 2.0], f64::NAN);
assert!(transformed.iter().all(|value| value.is_finite()));
}
#[test]
fn pointer_gestures_create_each_supported_element_kind() {
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else { return };
let tokens = Tokens::new(theme);
let ctx = egui::Context::default();
let mut view = WhiteboardView::default();
let mut board = Whiteboard::new();
draw_frame(&ctx, &tokens, &mut view, &mut board, Vec::new());
for (step, (tool, expected)) in [
(WhiteboardTool::Rectangle, "rectangle"),
(WhiteboardTool::Diamond, "diamond"),
(WhiteboardTool::Ellipse, "ellipse"),
(WhiteboardTool::Arrow, "arrow"),
(WhiteboardTool::Line, "line"),
(WhiteboardTool::Freedraw, "freedraw"),
(WhiteboardTool::Text, "text"),
]
.into_iter()
.enumerate()
{
view.tool = tool;
let offset = f32::from(u16::try_from(step).unwrap_or(0)) * 70.0;
let start = egui::pos2(180.0 + offset, 280.0);
let end = egui::pos2(start.x + 30.0, start.y + 24.0);
draw_frame(
&ctx,
&tokens,
&mut view,
&mut board,
vec![
egui::Event::PointerMoved(start),
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::default() },
],
);
draw_frame(&ctx, &tokens, &mut view, &mut board, vec![egui::Event::PointerMoved(end)]);
draw_frame(
&ctx,
&tokens,
&mut view,
&mut board,
vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::default() }],
);
assert_eq!(board.elements.last().map(Element::type_name), Some(expected));
}
}
fn draw_frame(ctx: &egui::Context, tokens: &Tokens, view: &mut WhiteboardView, board: &mut Whiteboard, events: Vec<egui::Event>) {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))), events, ..egui::RawInput::default() };
let mut output = ctx.run_ui(input, |ui| {
let _ = show_whiteboard(ui, tokens, view, board);
});
output.textures_delta.clear();
}
}
+331
View File
@@ -0,0 +1,331 @@
//! Keyboard commands, edits and pointer-driven element creation.
use egui::{Key, Modifiers, Response};
use notescraft_whiteboard::{Element, ElementKind, Whiteboard};
use super::{
geometry::{element_bounds, hit_test, smooth_points},
render::{to_document, to_screen},
state::{Draft, Transform, WhiteboardTool, WhiteboardView},
};
const DEFAULT_SHAPE_SIZE: f64 = 72.0;
const MAX_DRAW_POINTS: usize = 10_000;
pub(super) fn handle_keys(ctx: &egui::Context, view: &mut WhiteboardView, board: &mut Whiteboard) {
ctx.input_mut(|input| {
let command = input.modifiers.command;
if input.consume_key(Modifiers::NONE, Key::Escape) {
view.selected_ids.clear();
view.draft = None;
}
for (key, tool) in [
(Key::V, WhiteboardTool::Select),
(Key::R, WhiteboardTool::Rectangle),
(Key::D, WhiteboardTool::Diamond),
(Key::O, WhiteboardTool::Ellipse),
(Key::A, WhiteboardTool::Arrow),
(Key::L, WhiteboardTool::Line),
(Key::P, WhiteboardTool::Freedraw),
(Key::T, WhiteboardTool::Text),
(Key::E, WhiteboardTool::Eraser),
(Key::H, WhiteboardTool::Hand),
] {
if input.consume_key(Modifiers::NONE, key) {
view.tool = tool;
}
}
if command && input.consume_key(Modifiers::COMMAND, Key::Z) {
view.undo(board);
}
if command && input.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z) {
view.redo(board);
}
if command && input.consume_key(Modifiers::COMMAND, Key::G) {
group_selected(view, board);
}
if command && input.consume_key(Modifiers::COMMAND, Key::D) {
duplicate_selected(view, board);
}
if command && input.consume_key(Modifiers::COMMAND, Key::CloseBracket) {
reorder_selected(view, board, true);
}
if command && input.consume_key(Modifiers::COMMAND, Key::OpenBracket) {
reorder_selected(view, board, false);
}
if input.consume_key(Modifiers::SHIFT, Key::Num1) {
fit_board(view, board);
}
if input.consume_key(Modifiers::NONE, Key::Delete) || input.consume_key(Modifiers::NONE, Key::Backspace) {
delete_selected(view, board);
}
});
}
pub(super) fn handle_pointer(response: &Response, view: &mut WhiteboardView, board: &mut Whiteboard) {
let pointer = response.interact_pointer_pos();
if response.drag_started()
&& let Some(position) = pointer
{
let point = to_document(view, position);
if view.tool == WhiteboardTool::Select {
if let Some(transform) = transform_at(view, board, position, point) {
view.transform = Some(transform);
} else {
select_at(view, board, point, response.ctx.input(|input| input.modifiers.shift));
if !view.selected_ids.is_empty() {
view.drag_anchor = Some(point);
}
}
} else if view.tool == WhiteboardTool::Hand {
view.drag_anchor = Some(point);
} else {
view.draft = Some(Draft { start: point, points: vec![point] });
}
}
if response.dragged()
&& let Some(position) = pointer
{
let point = to_document(view, position);
if view.tool == WhiteboardTool::Select && view.transform.is_some() {
apply_transform(view, board, point);
} else if view.tool == WhiteboardTool::Select || view.tool == WhiteboardTool::Hand {
if let Some(last) = view.drag_anchor.replace(point) {
let [point_x, point_y] = point;
let [last_x, last_y] = last;
if view.tool == WhiteboardTool::Hand {
let [pan_x, pan_y] = view.pan;
view.pan = [pan_x + point_x - last_x, pan_y + point_y - last_y];
} else {
move_selected(view, board, [point_x - last_x, point_y - last_y]);
}
}
} else if let Some(draft) = &mut view.draft {
if view.tool == WhiteboardTool::Freedraw && draft.points.len() < MAX_DRAW_POINTS {
draft.points.push(point);
} else if let Some(last) = draft.points.last_mut() {
*last = point;
}
}
}
if response.clicked()
&& let Some(position) = pointer
{
let point = to_document(view, position);
match view.tool {
WhiteboardTool::Select => select_at(view, board, point, response.ctx.input(|input| input.modifiers.shift)),
WhiteboardTool::Eraser | WhiteboardTool::Hand => {}
_ => {
let draft = Draft { start: point, points: vec![point] };
finish_draft(view, board, &draft);
}
}
}
if response.clicked()
&& view.tool == WhiteboardTool::Eraser
&& let Some(position) = pointer
&& let Some(id) = top_hit(board, to_document(view, position))
{
board.delete(&id);
view.changed = true;
}
if response.drag_stopped() {
view.drag_anchor = None;
view.transform = None;
if let Some(draft) = view.draft.take() {
finish_draft(view, board, &draft);
}
}
}
fn transform_at(view: &WhiteboardView, board: &Whiteboard, screen: egui::Pos2, document: [f64; 2]) -> Option<Transform> {
let id = view.selected_ids.first()?;
let element = board.elements.iter().find(|element| &element.id == id)?;
let bounds = element_bounds(element)?;
let corners = [
(to_screen(view, [bounds.x, bounds.y]), [-1.0, -1.0]),
(to_screen(view, [bounds.x + bounds.width, bounds.y]), [1.0, -1.0]),
(to_screen(view, [bounds.x, bounds.y + bounds.height]), [-1.0, 1.0]),
(to_screen(view, [bounds.x + bounds.width, bounds.y + bounds.height]), [1.0, 1.0]),
];
if let Some((_, sides)) = corners.iter().find(|(handle, _)| handle.distance(screen) < 14.0) {
return Some(Transform::Resize {
id: id.clone(),
start: document,
origin: [element.x, element.y],
size: [element.width, element.height],
sides: *sides,
});
}
let center = [element.x + element.width / 2.0, element.y + element.height / 2.0];
let rotate_handle = to_screen(view, [bounds.x + bounds.width / 2.0, bounds.y - 20.0 / view.zoom]);
let [document_x, document_y] = document;
let [center_x, center_y] = center;
(rotate_handle.distance(screen) < 14.0).then(|| Transform::Rotate {
id: id.clone(),
center,
start_angle: (document_y - center_y).atan2(document_x - center_x),
base_angle: element.angle,
})
}
fn apply_transform(view: &mut WhiteboardView, board: &mut Whiteboard, point: [f64; 2]) {
let Some(transform) = &view.transform else { return };
let id = match transform {
Transform::Resize { id, .. } | Transform::Rotate { id, .. } => id,
};
let Some(element) = board.elements.iter_mut().find(|element| &element.id == id) else { return };
match transform {
Transform::Resize { start, origin, size, sides, .. } => {
let [point_x, point_y] = point;
let [start_x, start_y] = *start;
let [origin_x, origin_y] = *origin;
let [width, height] = *size;
let [side_x, side_y] = *sides;
let dx = point_x - start_x;
let dy = point_y - start_y;
element.x = origin_x + if side_x < 0.0 { dx } else { 0.0 };
element.y = origin_y + if side_y < 0.0 { dy } else { 0.0 };
element.width = (width + dx * side_x).max(1.0);
element.height = (height + dy * side_y).max(1.0);
}
Transform::Rotate { center, start_angle, base_angle, .. } => {
let [point_x, point_y] = point;
let [center_x, center_y] = *center;
element.angle = base_angle + (point_y - center_y).atan2(point_x - center_x) - start_angle;
}
}
element.version = element.version.saturating_add(1);
view.changed = true;
}
fn finish_draft(view: &mut WhiteboardView, board: &mut Whiteboard, draft: &Draft) {
let [x1, y1] = draft.start;
let [x2, y2] = draft.points.last().copied().unwrap_or(draft.start);
let x = x1.min(x2);
let y = y1.min(y2);
let width = (x2 - x1).abs().max(if (x2 - x1).abs() < 1.0 { DEFAULT_SHAPE_SIZE } else { 0.0 });
let height = (y2 - y1).abs().max(if (y2 - y1).abs() < 1.0 { DEFAULT_SHAPE_SIZE } else { 0.0 });
let element = match view.tool {
WhiteboardTool::Rectangle => Some(Element::rectangle(&mut view.ids, x, y, width, height)),
WhiteboardTool::Diamond => Some(Element::diamond(&mut view.ids, x, y, width, height)),
WhiteboardTool::Ellipse => Some(Element::ellipse(&mut view.ids, x, y, width, height)),
WhiteboardTool::Arrow => Some(Element::arrow(&mut view.ids, x, y, width, height)),
WhiteboardTool::Line => Some(Element::line(&mut view.ids, x, y, width, height)),
WhiteboardTool::Freedraw => Some(freehand(view, draft)),
WhiteboardTool::Text => Some(Element::text(&mut view.ids, x, y, "Text")),
_ => None,
};
if let Some(mut element) = element {
element.stroke_color.clone_from(&view.stroke_color);
element.stroke_width = view.stroke_width;
view.selected_ids = vec![element.id.clone()];
board.add(element);
view.changed = true;
}
}
fn freehand(view: &mut WhiteboardView, draft: &Draft) -> Element {
let points = smooth_points(&draft.points).unwrap_or_default();
let min_x = points.iter().map(|[x, _]| *x).fold(f64::INFINITY, f64::min);
let min_y = points.iter().map(|[_, y]| *y).fold(f64::INFINITY, f64::min);
let max_x = points.iter().map(|[x, _]| *x).fold(f64::NEG_INFINITY, f64::max);
let max_y = points.iter().map(|[_, y]| *y).fold(f64::NEG_INFINITY, f64::max);
let local = points.into_iter().map(|[x, y]| [x - min_x, y - min_y]).collect::<Vec<_>>();
let pressures = vec![0.5; local.len()];
let mut element = Element::new(view.ids.next_id(), ElementKind::Freedraw { points: local, pressures });
element.seed = view.ids.next_seed();
element.x = min_x;
element.y = min_y;
element.width = max_x - min_x;
element.height = max_y - min_y;
element
}
fn top_hit(board: &Whiteboard, point: [f64; 2]) -> Option<String> {
board.elements.iter().rev().find(|element| hit_test(element, point, 6.0)).map(|element| element.id.clone())
}
fn select_at(view: &mut WhiteboardView, board: &Whiteboard, point: [f64; 2], additive: bool) {
if let Some(id) = top_hit(board, point) {
if additive && view.selected_ids.contains(&id) {
view.selected_ids.retain(|selected| selected != &id);
} else if additive {
view.selected_ids.push(id);
} else {
view.selected_ids = vec![id];
}
} else if !additive {
view.selected_ids.clear();
}
}
fn move_selected(view: &mut WhiteboardView, board: &mut Whiteboard, delta: [f64; 2]) {
let [delta_x, delta_y] = delta;
let mut moved = false;
for element in board.elements.iter_mut().filter(|element| view.selected_ids.contains(&element.id) && !element.locked && !element.is_deleted) {
element.x += delta_x;
element.y += delta_y;
element.version = element.version.saturating_add(1);
moved = true;
}
view.changed |= moved;
}
fn delete_selected(view: &mut WhiteboardView, board: &mut Whiteboard) {
for id in &view.selected_ids {
if board.delete(id).is_some() {
view.changed = true;
}
}
}
fn duplicate_selected(view: &mut WhiteboardView, board: &mut Whiteboard) {
let clones = board.elements.iter().filter(|element| view.selected_ids.contains(&element.id) && !element.is_deleted).cloned().collect::<Vec<_>>();
view.selected_ids.clear();
for mut element in clones {
element.id = view.ids.next_id();
element.seed = view.ids.next_seed();
element.x += 16.0;
element.y += 16.0;
view.selected_ids.push(element.id.clone());
board.add(element);
}
view.changed = true;
}
fn group_selected(view: &mut WhiteboardView, board: &mut Whiteboard) {
if view.selected_ids.len() < 2 {
return;
}
let group = view.ids.next_id();
for element in board.elements.iter_mut().filter(|element| view.selected_ids.contains(&element.id)) {
element.group_ids.push(group.clone());
}
view.changed = true;
}
fn reorder_selected(view: &mut WhiteboardView, board: &mut Whiteboard, forward: bool) {
let Some(id) = view.selected_ids.first() else { return };
let Some(index) = board.elements.iter().position(|element| &element.id == id) else { return };
if forward {
if index.saturating_add(1) < board.elements.len() {
board.elements.swap(index, index + 1);
view.changed = true;
}
} else if index > 0 {
board.elements.swap(index, index - 1);
view.changed = true;
}
}
fn fit_board(view: &mut WhiteboardView, board: &Whiteboard) {
if let Some(bounds) = board.bounding_box() {
let [canvas_width, canvas_height] = view.canvas_size;
let available_width = (canvas_width - 96.0).max(1.0);
let available_height = (canvas_height - 96.0).max(1.0);
view.zoom = (available_width / bounds.width.max(1.0)).min(available_height / bounds.height.max(1.0)).clamp(0.1, 8.0);
let margin = 48.0 / view.zoom;
view.pan = [margin - bounds.x, margin - bounds.y];
}
}
@@ -0,0 +1,86 @@
//! Pure SVG output and bounds hook for raster export.
use std::fmt::Write as _;
use notescraft_whiteboard::{Element, ElementKind, Whiteboard, rough};
use crate::whiteboard_view::geometry::{element_bounds, safe_coordinate, smooth_points};
const MIN_VIEW_SIZE: f64 = 1.0;
/// Returns SVG for visible board elements using deterministic rough outlines.
#[must_use]
pub fn export_svg(board: &Whiteboard) -> String {
let bounds = board.bounding_box().unwrap_or_default();
let width = bounds.width.max(MIN_VIEW_SIZE);
let height = bounds.height.max(MIN_VIEW_SIZE);
let mut svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{:.2} {:.2} {:.2} {:.2}\">", bounds.x, bounds.y, width, height);
for element in board.elements.iter().filter(|element| !element.is_deleted) {
match &element.kind {
ElementKind::Text { text, font_size, .. } => {
let font_size = safe_coordinate(*font_size);
let _ = write!(
svg,
"<text x=\"{:.2}\" y=\"{:.2}\" fill=\"{}\" font-size=\"{:.2}\">{}</text>",
safe_coordinate(element.x),
safe_coordinate(element.y + font_size),
escape(&element.stroke_color),
font_size,
escape(text)
);
}
ElementKind::Freedraw { points, .. } => {
if let Some(path) = smooth_points(points) {
write_path(&mut svg, element, &path, false);
}
}
ElementKind::Image { .. } | ElementKind::Frame { .. } | ElementKind::Unknown { .. } => {}
_ => {
let closed = matches!(&element.kind, ElementKind::Rectangle | ElementKind::Ellipse | ElementKind::Diamond);
for points in rough::outline(element, rough::Style::HandDrawn) {
let path = points.into_iter().map(|[x, y]| [f64::from(x), f64::from(y)]).collect::<Vec<_>>();
write_path(&mut svg, element, &path, closed);
}
}
}
}
svg.push_str("</svg>");
svg
}
/// Returns visible element bounds for a future raster renderer.
#[must_use]
pub fn export_png(board: &Whiteboard) -> Option<notescraft_whiteboard::Bounds> {
board.elements.iter().filter(|element| !element.is_deleted).filter_map(element_bounds).reduce(notescraft_whiteboard::Bounds::union)
}
fn write_path(svg: &mut String, element: &Element, points: &[[f64; 2]], close: bool) {
let Some([first_x, first_y]) = points.first().copied() else { return };
let _ = write!(svg, "<path d=\"M {first_x:.2} {first_y:.2}");
for [x, y] in points.iter().copied().skip(1) {
let _ = write!(svg, " L {x:.2} {y:.2}");
}
if close {
svg.push_str(" Z");
}
let stroke = if element.stroke_color.is_empty() { "currentColor" } else { element.stroke_color.as_str() };
let _ = write!(svg, "\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"/>", escape(stroke), safe_coordinate(element.stroke_width));
}
fn escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
escaped.push_str(match character {
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
'"' => "&quot;",
'\'' => "&apos;",
_ => {
escaped.push(character);
continue;
}
});
}
escaped
}
@@ -0,0 +1,121 @@
//! Whiteboard coordinate transforms, hit testing and freehand smoothing.
use notescraft_whiteboard::{Bounds, Element, ElementKind};
const MAX_COORDINATE: f64 = 1.0e9;
const FREEHAND_LIMIT: usize = 10_000;
/// Rotates `point` around `center`, mapping non-finite inputs to zero.
#[must_use]
pub fn transform_point(point: [f64; 2], center: [f64; 2], angle: f64) -> [f64; 2] {
let [px, py] = point.map(sanitize);
let [cx, cy] = center.map(sanitize);
let (sin, cos) = sanitize(angle).sin_cos();
let (dx, dy) = (px - cx, py - cy);
[sanitize(cx + dx * cos - dy * sin), sanitize(cy + dx * sin + dy * cos)]
}
/// Computes rotated axis-aligned bounds for one element.
#[must_use]
pub fn element_bounds(element: &Element) -> Option<Bounds> {
let points = match &element.kind {
ElementKind::Line { points } | ElementKind::Arrow { points, .. } | ElementKind::Freedraw { points, .. } => points.iter().copied().take(FREEHAND_LIMIT),
_ => {
let width = sanitize(element.width);
let height = sanitize(element.height);
return bounds_of(
[[element.x, element.y], [element.x + width, element.y], [element.x + width, element.y + height], [element.x, element.y + height]]
.into_iter()
.map(|point| rotate_element_point(element, point)),
);
}
};
let points = points.map(|[x, y]| rotate_element_point(element, [element.x + x, element.y + y]));
bounds_of(points)
}
/// Tests whether a document-space point lies inside an element's bounds.
#[must_use]
pub fn hit_test(element: &Element, point: [f64; 2], tolerance: f64) -> bool {
if element.is_deleted || element.locked {
return false;
}
let Some(bounds) = element_bounds(element) else { return false };
let [x, y] = point.map(sanitize);
let margin = sanitize(tolerance).max(0.0);
x >= bounds.x - margin && x <= bounds.x + bounds.width + margin && y >= bounds.y - margin && y <= bounds.y + bounds.height + margin
}
/// Smooths a freehand path with a bounded Catmull-Rom interpolation.
///
/// Paths above 10,000 samples are returned unchanged, avoiding an allocation
/// multiplier for hostile files while keeping original data intact.
#[must_use]
pub fn smooth_points(points: &[[f64; 2]]) -> Option<Vec<[f64; 2]>> {
if points.len() > FREEHAND_LIMIT {
return None;
}
let points = points.iter().map(|[x, y]| [sanitize(*x), sanitize(*y)]).collect::<Vec<_>>();
if points.len() < 3 {
return Some(points);
}
let mut smooth = Vec::with_capacity(points.len().saturating_mul(4));
if let Some(first) = points.first() {
smooth.push(*first);
}
for index in 0..points.len().saturating_sub(1) {
let p1 = points.get(index).copied().unwrap_or([0.0, 0.0]);
let p0 = points.get(index.saturating_sub(1)).copied().unwrap_or(p1);
let p2 = points.get(index + 1).copied().unwrap_or([0.0, 0.0]);
let p3 = points.get(index + 2).copied().unwrap_or(p2);
for step in 1..=3 {
let t = f64::from(step) / 3.0;
smooth.push(catmull(p0, p1, p2, p3, t));
}
}
Some(smooth)
}
fn catmull(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
let t2 = t * t;
let t3 = t2 * t;
let interpolate = |a: f64, b: f64, c: f64, d: f64| {
sanitize(0.5 * ((2.0 * b) + (-a + c) * t + (2.0 * a - 5.0 * b + 4.0 * c - d) * t2 + (-a + 3.0 * b - 3.0 * c + d) * t3))
};
let [x0, y0] = p0;
let [x1, y1] = p1;
let [x2, y2] = p2;
let [x3, y3] = p3;
[interpolate(x0, x1, x2, x3), interpolate(y0, y1, y2, y3)]
}
fn rotate_element_point(element: &Element, point: [f64; 2]) -> [f64; 2] {
let center = [element.x + element.width / 2.0, element.y + element.height / 2.0];
transform_point(point, center, element.angle)
}
fn bounds_of(points: impl Iterator<Item = [f64; 2]>) -> Option<Bounds> {
let mut points = points;
let first = points.next()?;
let mut min = first;
let mut max = first;
for point in points {
let [min_x, min_y] = min;
let [max_x, max_y] = max;
let [point_x, point_y] = point;
min = [min_x.min(point_x), min_y.min(point_y)];
max = [max_x.max(point_x), max_y.max(point_y)];
}
let [min_x, min_y] = min;
let [max_x, max_y] = max;
Some(Bounds { x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y })
}
fn sanitize(value: f64) -> f64 {
if value.is_finite() { value.clamp(-MAX_COORDINATE, MAX_COORDINATE) } else { 0.0 }
}
/// Sanitizes one potentially hostile document coordinate for painting.
pub(crate) fn safe_coordinate(value: f64) -> f64 {
sanitize(value)
}
@@ -0,0 +1,165 @@
//! Whiteboard canvas, floating tools and style/export controls.
use egui::{Align2, Area, Color32, Id, Order, Rect, Sense, Stroke};
use notescraft_whiteboard::{ElementKind, Whiteboard};
use super::{
edit::{handle_keys, handle_pointer},
render::paint_board,
state::{WhiteboardResponse, WhiteboardTool, WhiteboardView, strings},
};
use crate::{
icons, motion,
theme::{Tokens, color},
};
const TOOL_SIZE: f32 = 44.0;
const ICON_SIZE: f32 = 20.0;
/// Shows one interactive Excalidraw-compatible whiteboard surface.
pub fn show_whiteboard(ui: &mut egui::Ui, tokens: &Tokens, view: &mut WhiteboardView, board: &mut Whiteboard) -> WhiteboardResponse {
install_fonts(ui.ctx());
view.changed = false;
view.export_requested = false;
view.snapshot(board);
if !ui.ctx().egui_wants_keyboard_input() {
handle_keys(ui.ctx(), view, board);
}
let canvas = ui.max_rect();
view.screen_origin = [f64::from(canvas.left()), f64::from(canvas.top())];
view.canvas_size = [f64::from(canvas.width()), f64::from(canvas.height())];
let response = ui.interact(canvas, Id::new("notescraft-whiteboard-canvas"), Sense::click_and_drag());
handle_pointer(&response, view, board);
zoom_input(ui.ctx(), view, canvas);
ui.painter().rect_filled(canvas, 0.0, color(tokens.theme.colors.canvas));
paint_board(&ui.painter().with_clip_rect(canvas), tokens, view, board);
show_toolbar(ui.ctx(), tokens, view);
show_style_panel(ui.ctx(), tokens, view, board);
show_text_editor(ui.ctx(), tokens, view, board);
if view.changed {
view.snapshot(board);
}
WhiteboardResponse { changed: view.changed, selected_ids: view.selected_ids.clone(), export_requested: view.export_requested }
}
fn install_fonts(ctx: &egui::Context) {
let id = Id::new("notescraft-whiteboard-fonts-installed");
if !ctx.data(|data| data.get_temp::<bool>(id).unwrap_or(false)) {
crate::fonts::install(ctx);
ctx.data_mut(|data| data.insert_temp(id, true));
}
}
fn show_toolbar(ctx: &egui::Context, tokens: &Tokens, view: &mut WhiteboardView) {
Area::new(Id::new("notescraft-whiteboard-tools")).order(Order::Foreground).anchor(Align2::CENTER_TOP, [0.0, 12.0]).show(ctx, |ui| {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(6.0).show(ui, |ui| {
ui.horizontal(|ui| {
for tool in WhiteboardTool::ALL {
tool_button(ui, tokens, view, tool);
}
});
});
});
}
fn tool_button(ui: &mut egui::Ui, tokens: &Tokens, view: &mut WhiteboardView, tool: WhiteboardTool) {
let (rect, response) = ui.allocate_exact_size(egui::vec2(TOOL_SIZE, TOOL_SIZE), Sense::click());
let active = view.tool == tool;
let hover = motion::hover(ui.ctx(), ("whiteboard-tool", tool as u8), &response);
let bounce = motion::spring(ui.ctx(), ("whiteboard-tool-bounce", tool as u8), if active { 1.0 } else { 0.0 });
let background =
if active { color(tokens.theme.colors.accent) } else { motion::mix(color(tokens.theme.colors.surface), color(tokens.theme.colors.surface_2), hover) };
ui.painter().rect_filled(rect, tokens.radius_small(), background);
let tint = if active { color(tokens.theme.colors.on_accent) } else { color(tokens.theme.colors.text) };
let icon_rect = Rect::from_center_size(rect.center(), egui::vec2(ICON_SIZE, ICON_SIZE) * (1.0 + bounce * 0.04));
icons::paint(ui, tool.icon(), icon_rect, tint);
let clicked = response.clicked();
response.on_hover_text(tool.label());
if clicked {
view.tool = tool;
}
}
fn show_style_panel(ctx: &egui::Context, tokens: &Tokens, view: &mut WhiteboardView, board: &mut Whiteboard) {
Area::new(Id::new("notescraft-whiteboard-style")).order(Order::Foreground).anchor(Align2::RIGHT_TOP, [-12.0, 72.0]).show(ctx, |ui| {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(12.0).show(ui, |ui| {
ui.label(strings::STROKE);
ui.horizontal(|ui| {
for candidate in ["#1e1e1e", "#e03131", "#2f9e44", "#1971c2", "#9c36b5"] {
let tint = color_from_hex(candidate);
let (rect, response) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), Sense::click());
ui.painter().circle_filled(rect.center(), 8.0, tint);
if view.stroke_color == candidate {
ui.painter().circle_stroke(rect.center(), 10.0, Stroke::new(1.0, color(tokens.theme.colors.accent)));
}
if response.clicked() {
apply_style(view, board, candidate, None);
}
}
});
let mut width = crate::style::narrow_f32(view.stroke_width);
if ui.add(egui::Slider::new(&mut width, 1.0..=8.0).text(strings::STROKE)).changed() {
apply_style(view, board, &view.stroke_color.clone(), Some(f64::from(width)));
}
ui.horizontal(|ui| {
if ui.selectable_label(view.style == notescraft_whiteboard::rough::Style::HandDrawn, strings::HAND_DRAWN).clicked() {
view.style = notescraft_whiteboard::rough::Style::HandDrawn;
}
if ui.selectable_label(view.style == notescraft_whiteboard::rough::Style::Clean, strings::CLEAN).clicked() {
view.style = notescraft_whiteboard::rough::Style::Clean;
}
});
if ui.button(strings::EXPORT).clicked() {
view.export_requested = true;
}
});
});
}
fn apply_style(view: &mut WhiteboardView, board: &mut Whiteboard, color: &str, width: Option<f64>) {
color.clone_into(&mut view.stroke_color);
view.stroke_width = width.unwrap_or(view.stroke_width);
for element in board.elements.iter_mut().filter(|element| view.selected_ids.contains(&element.id) && !element.is_deleted) {
element.stroke_color.clone_from(&view.stroke_color);
element.stroke_width = view.stroke_width;
element.version = element.version.saturating_add(1);
view.changed = true;
}
}
fn show_text_editor(ctx: &egui::Context, tokens: &Tokens, view: &mut WhiteboardView, board: &mut Whiteboard) {
let Some(id) = view.selected_ids.first() else { return };
let Some(element) = board.elements.iter_mut().find(|element| &element.id == id) else { return };
let ElementKind::Text { text, .. } = &mut element.kind else { return };
let point = super::render::to_screen(view, [element.x, element.y]);
Area::new(Id::new("notescraft-whiteboard-text-editor")).order(Order::Foreground).fixed_pos(point).show(ctx, |ui| {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(tokens.radius_small()).show(ui, |ui| {
if ui.add(egui::TextEdit::singleline(text).desired_width(180.0)).changed() {
element.version = element.version.saturating_add(1);
view.changed = true;
}
});
});
}
fn zoom_input(ctx: &egui::Context, view: &mut WhiteboardView, canvas: Rect) {
let (scroll, position) = ctx.input(|input| (input.smooth_scroll_delta.y, input.pointer.hover_pos()));
if scroll.abs() > 0.0
&& let Some(position) = position.filter(|point| canvas.contains(*point))
{
let document = super::render::to_document(view, position);
let [document_x, document_y] = document;
let [origin_x, origin_y] = view.screen_origin;
view.zoom = (view.zoom * (1.0 + f64::from(scroll.signum()) * 0.08)).clamp(0.1, 8.0);
view.pan = [(f64::from(position.x) - origin_x) / view.zoom - document_x, (f64::from(position.y) - origin_y) / view.zoom - document_y];
}
}
fn color_from_hex(value: &str) -> Color32 {
let digits = value.strip_prefix('#').unwrap_or("000000");
let red = u8::from_str_radix(digits.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
let green = u8::from_str_radix(digits.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
let blue = u8::from_str_radix(digits.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
Color32::from_rgb(red, green, blue)
}
@@ -0,0 +1,96 @@
//! Theme-aware painting of whiteboard elements and selection bounds.
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke};
use notescraft_whiteboard::{Element, ElementKind, Whiteboard, rough};
use super::{
geometry::{element_bounds, safe_coordinate},
state::WhiteboardView,
};
use crate::{
style::narrow_f32,
theme::{Tokens, color},
};
const HANDLE_SIZE: f32 = 7.0;
pub(super) fn paint_board(painter: &Painter, tokens: &Tokens, view: &WhiteboardView, board: &Whiteboard) {
let accent = color(tokens.theme.colors.accent);
for element in board.elements.iter().filter(|element| !element.is_deleted) {
paint_element(painter, tokens, view, element);
if view.selected_ids.contains(&element.id)
&& let Some(bounds) = element_bounds(element)
{
let rect = screen_rect(view, bounds.x, bounds.y, bounds.width, bounds.height);
painter.rect_stroke(rect, 4.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
for point in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] {
painter.rect_filled(Rect::from_center_size(point, egui::vec2(HANDLE_SIZE, HANDLE_SIZE)), 2.0, accent);
}
let rotate = Pos2::new(rect.center().x, rect.top() - 20.0);
painter.line_segment([Pos2::new(rect.center().x, rect.top()), rotate], Stroke::new(1.0, accent));
painter.circle_filled(rotate, HANDLE_SIZE * 0.5, accent);
}
}
}
fn paint_element(painter: &Painter, tokens: &Tokens, view: &WhiteboardView, element: &Element) {
let stroke = if element.stroke_color.is_empty() {
color(tokens.theme.colors.text)
} else {
parse_color(&element.stroke_color).unwrap_or_else(|| color(tokens.theme.colors.text))
};
let width = narrow_f32(element.stroke_width.max(0.5) * view.zoom);
match &element.kind {
ElementKind::Text { text, font_size, .. } => {
let point = to_screen(view, [safe_coordinate(element.x), safe_coordinate(element.y)]);
painter.text(point, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(narrow_f32(safe_coordinate(*font_size) * view.zoom)), stroke);
}
ElementKind::Freedraw { points, .. } => {
if let Some(points) = super::smooth_points(points) {
let points = points.into_iter().map(|[x, y]| to_screen(view, [element.x + x, element.y + y])).collect::<Vec<_>>();
if points.len() > 1 {
painter.add(Shape::line(points, Stroke::new(width, stroke)));
}
}
}
ElementKind::Image { .. } | ElementKind::Frame { .. } | ElementKind::Unknown { .. } => {}
_ => {
let closed = matches!(&element.kind, ElementKind::Rectangle | ElementKind::Ellipse | ElementKind::Diamond);
for path in rough::outline(element, view.style) {
let points = path.into_iter().map(|[x, y]| to_screen(view, [f64::from(x), f64::from(y)])).collect::<Vec<_>>();
if points.len() > 1 {
let shape = if closed { Shape::closed_line(points, Stroke::new(width, stroke)) } else { Shape::line(points, Stroke::new(width, stroke)) };
painter.add(shape);
}
}
}
}
}
pub(super) fn to_screen(view: &WhiteboardView, point: [f64; 2]) -> Pos2 {
let [point_x, point_y] = point;
let [origin_x, origin_y] = view.screen_origin;
let [pan_x, pan_y] = view.pan;
egui::pos2(narrow_f32(origin_x + (point_x + pan_x) * view.zoom), narrow_f32(origin_y + (point_y + pan_y) * view.zoom))
}
pub(super) fn to_document(view: &WhiteboardView, point: Pos2) -> [f64; 2] {
let [origin_x, origin_y] = view.screen_origin;
let [pan_x, pan_y] = view.pan;
[(f64::from(point.x) - origin_x) / view.zoom - pan_x, (f64::from(point.y) - origin_y) / view.zoom - pan_y]
}
pub(super) fn screen_rect(view: &WhiteboardView, x: f64, y: f64, width: f64, height: f64) -> Rect {
Rect::from_two_pos(to_screen(view, [x, y]), to_screen(view, [x + width, y + height]))
}
fn parse_color(value: &str) -> Option<Color32> {
let digits = value.strip_prefix('#')?;
if digits.len() != 6 {
return None;
}
let red = u8::from_str_radix(digits.get(0..2)?, 16).ok()?;
let green = u8::from_str_radix(digits.get(2..4)?, 16).ok()?;
let blue = u8::from_str_radix(digits.get(4..6)?, 16).ok()?;
Some(Color32::from_rgb(red, green, blue))
}
+201
View File
@@ -0,0 +1,201 @@
//! Persistent interaction state for the whiteboard widget.
use std::collections::VecDeque;
use notescraft_whiteboard::{IdGen, Whiteboard, rough::Style};
/// User-facing labels used by the whiteboard controls.
pub(super) mod strings {
pub(crate) const SELECT: &str = "Select";
pub(crate) const RECTANGLE: &str = "Rectangle";
pub(crate) const DIAMOND: &str = "Diamond";
pub(crate) const ELLIPSE: &str = "Ellipse";
pub(crate) const ARROW: &str = "Arrow";
pub(crate) const LINE: &str = "Line";
pub(crate) const PEN: &str = "Pen";
pub(crate) const TEXT: &str = "Text";
pub(crate) const ERASER: &str = "Eraser";
pub(crate) const HAND: &str = "Hand";
pub(crate) const CLEAN: &str = "Clean";
pub(crate) const HAND_DRAWN: &str = "Hand-drawn";
pub(crate) const EXPORT: &str = "Export SVG";
pub(crate) const STROKE: &str = "Stroke";
}
/// Tool selected in the floating whiteboard palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WhiteboardTool {
/// Select, move and resize elements.
#[default]
Select,
/// Draw rectangles.
Rectangle,
/// Draw diamonds.
Diamond,
/// Draw ellipses.
Ellipse,
/// Draw arrows.
Arrow,
/// Draw straight lines.
Line,
/// Draw smoothed freehand strokes.
Freedraw,
/// Place text.
Text,
/// Mark elements deleted.
Eraser,
/// Pan the canvas.
Hand,
}
impl WhiteboardTool {
pub(super) const ALL: [Self; 10] =
[Self::Select, Self::Rectangle, Self::Diamond, Self::Ellipse, Self::Arrow, Self::Line, Self::Freedraw, Self::Text, Self::Eraser, Self::Hand];
pub(super) const fn label(self) -> &'static str {
match self {
Self::Select => strings::SELECT,
Self::Rectangle => strings::RECTANGLE,
Self::Diamond => strings::DIAMOND,
Self::Ellipse => strings::ELLIPSE,
Self::Arrow => strings::ARROW,
Self::Line => strings::LINE,
Self::Freedraw => strings::PEN,
Self::Text => strings::TEXT,
Self::Eraser => strings::ERASER,
Self::Hand => strings::HAND,
}
}
pub(super) const fn icon(self) -> &'static str {
match self {
Self::Select | Self::Freedraw => "pencil",
Self::Rectangle => "square",
Self::Diamond => "star",
Self::Ellipse => "circle-check",
Self::Arrow => "arrow-up",
Self::Line => "minus",
Self::Text => "file-text",
Self::Eraser => "x",
Self::Hand => "panel-left",
}
}
}
/// Selection and drawing result returned from one widget frame.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WhiteboardResponse {
/// True when this frame changed the whiteboard.
pub changed: bool,
/// IDs of currently selected live elements.
pub selected_ids: Vec<String>,
/// True when export was requested by the user.
pub export_requested: bool,
}
/// State retained between calls to [`super::show_whiteboard`].
#[derive(Debug, Clone)]
pub struct WhiteboardView {
/// Active tool.
pub tool: WhiteboardTool,
/// Roughness style, hand-drawn by default.
pub style: Style,
/// Selected element IDs.
pub selected_ids: Vec<String>,
/// Document to screen scale.
pub zoom: f64,
/// Document-space pan offset.
pub pan: [f64; 2],
pub(super) screen_origin: [f64; 2],
pub(super) canvas_size: [f64; 2],
/// Current stroke colour.
pub stroke_color: String,
/// Current stroke width.
pub stroke_width: f64,
pub(super) ids: IdGen,
pub(super) undo: VecDeque<String>,
pub(super) redo: VecDeque<String>,
pub(super) last_snapshot: Option<String>,
pub(super) changed: bool,
pub(super) export_requested: bool,
pub(super) draft: Option<Draft>,
pub(super) drag_anchor: Option<[f64; 2]>,
pub(super) transform: Option<Transform>,
}
#[derive(Debug, Clone)]
pub(super) struct Draft {
pub(super) start: [f64; 2],
pub(super) points: Vec<[f64; 2]>,
}
#[derive(Debug, Clone)]
pub(super) enum Transform {
Resize { id: String, start: [f64; 2], origin: [f64; 2], size: [f64; 2], sides: [f64; 2] },
Rotate { id: String, center: [f64; 2], start_angle: f64, base_angle: f64 },
}
impl Default for WhiteboardView {
fn default() -> Self {
Self {
tool: WhiteboardTool::Select,
style: Style::HandDrawn,
selected_ids: Vec::new(),
zoom: 1.0,
pan: [0.0, 0.0],
screen_origin: [0.0, 0.0],
canvas_size: [1.0, 1.0],
stroke_color: String::new(),
stroke_width: 2.0,
ids: IdGen::new(),
undo: VecDeque::new(),
redo: VecDeque::new(),
last_snapshot: None,
changed: false,
export_requested: false,
draft: None,
drag_anchor: None,
transform: None,
}
}
}
impl WhiteboardView {
pub(super) fn snapshot(&mut self, board: &Whiteboard) {
let Ok(snapshot) = board.to_string_pretty() else { return };
if self.last_snapshot.as_ref() == Some(&snapshot) {
return;
}
if let Some(previous) = self.last_snapshot.replace(snapshot) {
self.undo.push_back(previous);
while self.undo.len() > 100 {
self.undo.pop_front();
}
self.redo.clear();
}
}
pub(super) fn undo(&mut self, board: &mut Whiteboard) {
let Some(snapshot) = self.undo.pop_back() else { return };
if let Ok(current) = board.to_string_pretty() {
self.redo.push_back(current);
}
if let Ok(restored) = Whiteboard::from_str(&snapshot) {
*board = restored;
}
self.last_snapshot = Some(snapshot);
self.changed = true;
}
pub(super) fn redo(&mut self, board: &mut Whiteboard) {
let Some(snapshot) = self.redo.pop_back() else { return };
if let Ok(current) = board.to_string_pretty() {
self.undo.push_back(current);
}
if let Ok(restored) = Whiteboard::from_str(&snapshot) {
*board = restored;
}
self.last_snapshot = Some(snapshot);
self.changed = true;
}
}