feat(whiteboard-view): wave 9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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("<hello & "world">"));
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
'&' => "&",
|
||||
'<' => "<",
|
||||
'>' => ">",
|
||||
'"' => """,
|
||||
'\'' => "'",
|
||||
_ => {
|
||||
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))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user