diff --git a/Cargo.lock b/Cargo.lock index d3b8d3e..1103d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/ui-egui/examples/whiteboard_view.rs b/crates/ui-egui/examples/whiteboard_view.rs new file mode 100644 index 0000000..b1afd83 --- /dev/null +++ b/crates/ui-egui/examples/whiteboard_view.rs @@ -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); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e12d18c..9ab4744 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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). diff --git a/crates/ui-egui/src/whiteboard_view.rs b/crates/ui-egui/src/whiteboard_view.rs new file mode 100644 index 0000000..213916e --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view.rs @@ -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, "")); + let svg = export_svg(&board); + assert!(svg.contains("<hello & "world">")); + assert_eq!(svg.matches(") { + 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(); + } +} diff --git a/crates/ui-egui/src/whiteboard_view/edit.rs b/crates/ui-egui/src/whiteboard_view/edit.rs new file mode 100644 index 0000000..02f18f7 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/edit.rs @@ -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 { + 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::>(); + 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 { + 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::>(); + 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]; + } +} diff --git a/crates/ui-egui/src/whiteboard_view/export.rs b/crates/ui-egui/src/whiteboard_view/export.rs new file mode 100644 index 0000000..e247aa9 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/export.rs @@ -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!("", 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, + "{}", + 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::>(); + write_path(&mut svg, element, &path, closed); + } + } + } + } + svg.push_str(""); + svg +} + +/// Returns visible element bounds for a future raster renderer. +#[must_use] +pub fn export_png(board: &Whiteboard) -> Option { + 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, "", 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 { + '&' => "&", + '<' => "<", + '>' => ">", + '"' => """, + '\'' => "'", + _ => { + escaped.push(character); + continue; + } + }); + } + escaped +} diff --git a/crates/ui-egui/src/whiteboard_view/geometry.rs b/crates/ui-egui/src/whiteboard_view/geometry.rs new file mode 100644 index 0000000..97c3262 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/geometry.rs @@ -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 { + 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> { + if points.len() > FREEHAND_LIMIT { + return None; + } + let points = points.iter().map(|[x, y]| [sanitize(*x), sanitize(*y)]).collect::>(); + 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) -> Option { + 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) +} diff --git a/crates/ui-egui/src/whiteboard_view/interaction.rs b/crates/ui-egui/src/whiteboard_view/interaction.rs new file mode 100644 index 0000000..373bf00 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/interaction.rs @@ -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::(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) { + 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) +} diff --git a/crates/ui-egui/src/whiteboard_view/render.rs b/crates/ui-egui/src/whiteboard_view/render.rs new file mode 100644 index 0000000..b288931 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/render.rs @@ -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::>(); + 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::>(); + 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 { + 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)) +} diff --git a/crates/ui-egui/src/whiteboard_view/state.rs b/crates/ui-egui/src/whiteboard_view/state.rs new file mode 100644 index 0000000..07bb9f7 --- /dev/null +++ b/crates/ui-egui/src/whiteboard_view/state.rs @@ -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, + /// 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, + /// 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, + pub(super) redo: VecDeque, + pub(super) last_snapshot: Option, + pub(super) changed: bool, + pub(super) export_requested: bool, + pub(super) draft: Option, + pub(super) drag_anchor: Option<[f64; 2]>, + pub(super) transform: Option, +} + +#[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; + } +}