feat: convert Mermaid diagrams to native DrawingElements instead of raster overlays

Mermaid SVG is now parsed into interactive shapes (rectangles, arrows,
text) that can be selected, moved, and resized. Removes the MermaidOverlay
system entirely in favor of first-class drawing elements.
This commit is contained in:
David Ibia
2026-02-10 17:05:45 +01:00
parent 519d1f2459
commit 9e9d33eb84
7 changed files with 518 additions and 166 deletions

View File

@@ -224,6 +224,15 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
height: Option<f32>, height: Option<f32>,
}, },
ExportCanvas {
#[serde(default)]
session_id: Option<String>,
path: String,
#[serde(default)]
scale: Option<f32>,
#[serde(default)]
background: Option<String>,
},
Batch { Batch {
requests: Vec<AgentRequest>, requests: Vec<AgentRequest>,
}, },
@@ -292,6 +301,14 @@ pub enum AgentResponse {
session_id: String, session_id: String,
overlay_id: String, overlay_id: String,
svg_source: String, svg_source: String,
#[serde(default)]
element_ids: Vec<String>,
},
CanvasExported {
session_id: String,
path: String,
width: u32,
height: u32,
}, },
BatchResult { BatchResult {
results: Vec<AgentResponse>, results: Vec<AgentResponse>,
@@ -323,14 +340,6 @@ pub enum DrawingCommand {
Clear { Clear {
session_id: String, session_id: String,
}, },
RenderMermaid {
session_id: String,
overlay_id: String,
mermaid_source: String,
svg_source: String,
position: Pos2,
size: Vec2,
},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@ use super::protocol::{
DrawingCommand, GuiEvent, SessionCommand, DrawingCommand, GuiEvent, SessionCommand,
}; };
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle}; use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
use crate::export::ExportData;
use crate::session::{SessionCreator, SessionStore}; use crate::session::{SessionCreator, SessionStore};
use anyhow::Result; use anyhow::Result;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -11,10 +12,9 @@ use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, mpsc, RwLock}; use tokio::sync::{broadcast, mpsc, RwLock};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use usvg::Tree;
const EVENT_CHANNEL_CAPACITY: usize = 64; const EVENT_CHANNEL_CAPACITY: usize = 64;
static OVERLAY_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); static MERMAID_RENDER_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct AgentServer { pub struct AgentServer {
sessions: Arc<RwLock<SessionStore>>, sessions: Arc<RwLock<SessionStore>>,
@@ -237,7 +237,7 @@ async fn process_single_request(
.unwrap_or(0), .unwrap_or(0),
}; };
store.add_session(info.clone(), None); store.add_session(info.clone(), None, None);
drop(store); drop(store);
let _ = session_command_tx.send(SessionCommand::Create { let _ = session_command_tx.send(SessionCommand::Create {
@@ -690,8 +690,8 @@ async fn process_single_request(
mermaid_source, mermaid_source,
x, x,
y, y,
width, width: _,
height, height: _,
} => { } => {
let sid = { let sid = {
let store = sessions.read().await; let store = sessions.read().await;
@@ -714,47 +714,104 @@ async fn process_single_request(
} }
}; };
let options = usvg::Options::default(); let elements = match crate::svg::svg_to_drawing_elements(
let tree = match Tree::from_str(&svg_source, &options) { &svg_source,
Ok(tree) => tree, x.unwrap_or(0.0),
y.unwrap_or(0.0),
) {
Ok(elements) => elements,
Err(e) => { Err(e) => {
return AgentResponse::Error { return AgentResponse::Error {
message: format!("Failed to parse Mermaid SVG: {}", e), message: format!("Failed to convert Mermaid SVG: {}", e),
} }
} }
}; };
let natural_size = tree.size();
let overlay_id = format!( let overlay_id = format!(
"mermaid_{}", "mermaid_group_{}",
OVERLAY_ID_COUNTER.fetch_add(1, Ordering::SeqCst) MERMAID_RENDER_COUNTER.fetch_add(1, Ordering::SeqCst)
);
let position = egui::pos2(x.unwrap_or(0.0), y.unwrap_or(0.0));
let size = egui::vec2(
width.unwrap_or(natural_size.width()),
height.unwrap_or(natural_size.height()),
); );
let mut element_ids = Vec::with_capacity(elements.len());
{
let mut store = sessions.write().await;
for element in &elements {
element_ids.push(element.id.clone());
store.add_drawing_element(&sid, element.clone());
if command_tx if command_tx
.send(DrawingCommand::RenderMermaid { .send(DrawingCommand::Create {
session_id: sid.clone(), session_id: sid.clone(),
overlay_id: overlay_id.clone(), element: element.clone(),
mermaid_source,
svg_source: svg_source.clone(),
position,
size,
}) })
.is_err() .is_err()
{ {
return AgentResponse::Error { return AgentResponse::Error {
message: "Failed to enqueue Mermaid render command".to_string(), message: "Failed to send drawing command".to_string(),
}; };
} }
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
session_id: sid.clone(),
element: element.clone(),
});
}
}
AgentResponse::MermaidRendered { AgentResponse::MermaidRendered {
session_id: sid, session_id: sid,
overlay_id, overlay_id,
svg_source, svg_source,
element_ids,
}
}
AgentRequest::ExportCanvas {
session_id,
path,
scale,
background,
} => {
let export_scale = scale.unwrap_or(2.0);
if !(export_scale.is_finite() && export_scale > 0.0) {
return AgentResponse::Error {
message: "scale must be a positive finite value".to_string(),
};
}
let background_color = background
.as_deref()
.and_then(parse_hex_color)
.map(color32_to_skia)
.unwrap_or_else(|| tiny_skia::Color::from_rgba8(30, 30, 30, 255));
let (sid, svg_source, drawing_elements) = {
let store = sessions.read().await;
match store.get_export_data(session_id.as_deref()) {
Some(data) => data,
None => {
return AgentResponse::Error {
message: "No session found".to_string(),
}
}
}
};
let export_data = ExportData {
svg_source,
drawing_elements,
background_color,
};
match crate::export::export_canvas_to_png(&export_data, &path, export_scale) {
Ok((width, height)) => AgentResponse::CanvasExported {
session_id: sid,
path,
width,
height,
},
Err(e) => AgentResponse::Error {
message: format!("Failed to export canvas: {}", e),
},
} }
} }
AgentRequest::Batch { .. } => AgentResponse::Error { AgentRequest::Batch { .. } => AgentResponse::Error {
@@ -763,6 +820,10 @@ async fn process_single_request(
} }
} }
fn color32_to_skia(c: egui::Color32) -> tiny_skia::Color {
tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a())
}
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String { fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
let description = crate::element_tree::ElementTree { let description = crate::element_tree::ElementTree {
root: element.clone(), root: element.clone(),

View File

@@ -3,14 +3,14 @@ use crate::canvas::{CanvasInteraction, CanvasState};
use crate::clipboard::ClipboardManager; use crate::clipboard::ClipboardManager;
use crate::command_palette::{CommandId, CommandPalette}; use crate::command_palette::{CommandId, CommandPalette};
use crate::drawing::{ use crate::drawing::{
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection, draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
Tool,
}; };
use crate::export::{self, ExportData};
use crate::history::{ChangeSource, HistoryTree, NodeId}; use crate::history::{ChangeSource, HistoryTree, NodeId};
use crate::mermaid::render_mermaid_to_svg; use crate::mermaid::render_mermaid_to_svg;
use crate::persistence::{self, SavedSession, SavedWorkspace}; use crate::persistence::{self, SavedSession, SavedWorkspace};
use crate::session::{MermaidOverlay, Session, SessionCreator, SessionStore}; use crate::session::{Session, SessionCreator, SessionStore};
use crate::svg::{parse_svg, SvgRenderer}; use crate::svg::{parse_svg, SvgRenderer};
use egui::{Color32, ColorImage, TextureOptions}; use egui::{Color32, ColorImage, TextureOptions};
use std::path::Path; use std::path::Path;
@@ -21,6 +21,12 @@ use tokio::sync::{broadcast, mpsc, RwLock};
const AGENT_PORT: u16 = 9876; const AGENT_PORT: u16 = 9876;
const MIN_SHAPE_SIZE: f32 = 5.0; const MIN_SHAPE_SIZE: f32 = 5.0;
type SessionExportSnapshot = (
String,
Vec<DrawingElement>,
Option<String>,
);
pub struct AgCanvasApp { pub struct AgCanvasApp {
sessions: Vec<Session>, sessions: Vec<Session>,
active_session_idx: usize, active_session_idx: usize,
@@ -112,7 +118,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
let mut store = sessions_handle.write().await; let mut store = sessions_handle.write().await;
store.add_session(info_clone.clone(), None); store.add_session(info_clone.clone(), None, None);
store.set_active(&info_clone.id); store.set_active(&info_clone.id);
}); });
let _ = event_tx.send(GuiEvent::SessionCreated { let _ = event_tx.send(GuiEvent::SessionCreated {
@@ -205,12 +211,13 @@ impl AgCanvasApp {
let sessions_handle = self.sessions_handle.clone(); let sessions_handle = self.sessions_handle.clone();
let event_tx = self.event_tx.clone(); let event_tx = self.event_tx.clone();
let tree_clone = tree.clone(); let tree_clone = tree.clone();
let svg_source = svg_data.to_string();
let sid = session_id.clone(); let sid = session_id.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
let mut store = sessions_handle.write().await; let mut store = sessions_handle.write().await;
store.update_tree(&sid, Some(tree_clone)); store.update_tree(&sid, Some(tree_clone), Some(svg_source));
}); });
let _ = event_tx.send(GuiEvent::SvgLoaded { let _ = event_tx.send(GuiEvent::SvgLoaded {
session_id, session_id,
@@ -256,7 +263,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
let mut store = sessions_handle.write().await; let mut store = sessions_handle.write().await;
store.update_tree(&sid, None); store.update_tree(&sid, None, None);
}); });
let _ = event_tx.send(GuiEvent::SvgCleared { session_id }); let _ = event_tx.send(GuiEvent::SvgCleared { session_id });
}); });
@@ -291,44 +298,7 @@ impl AgCanvasApp {
self.status_message = Some((message, std::time::Instant::now())); self.status_message = Some((message, std::time::Instant::now()));
} }
fn render_mermaid_overlays_to_texture(&mut self, ctx: &egui::Context) { fn handle_mermaid_render(&mut self, _ctx: &egui::Context) {
let ppp = ctx.pixels_per_point();
let scale = self.active_session().canvas_state.zoom.max(1.0) * ppp;
let session = self.active_session_mut();
let session_id = session.id.clone();
for overlay in &mut session.mermaid_overlays {
if overlay.texture.is_some() {
continue;
}
if let Ok(pixmap) = overlay.renderer.render(scale) {
let size = [pixmap.width() as usize, pixmap.height() as usize];
let pixels: Vec<Color32> = pixmap
.pixels()
.iter()
.map(|p| {
Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())
})
.collect();
let image = ColorImage { size, pixels };
overlay.texture = Some(ctx.load_texture(
format!(
"mermaid-{}-{}-{}-{}",
session_id,
overlay.id,
overlay.mermaid_source.len(),
overlay.svg_source.len()
),
image,
TextureOptions::LINEAR,
));
}
}
}
fn handle_mermaid_render(&mut self, ctx: &egui::Context) {
let source = self.mermaid_input.trim().to_string(); let source = self.mermaid_input.trim().to_string();
if source.is_empty() { if source.is_empty() {
self.set_status("Mermaid input is empty".to_string()); self.set_status("Mermaid input is empty".to_string());
@@ -336,11 +306,23 @@ impl AgCanvasApp {
} }
match render_mermaid_to_svg(&source) { match render_mermaid_to_svg(&source) {
Ok(svg_string) => { Ok(svg_source) => {
self.show_mermaid_dialog = false; self.show_mermaid_dialog = false;
self.mermaid_input.clear(); self.mermaid_input.clear();
self.load_svg_data(&svg_string, ctx);
self.set_status("Rendered Mermaid diagram".to_string()); match crate::svg::svg_to_drawing_elements(&svg_source, 0.0, 0.0) {
Ok(elements) => {
let count = elements.len();
self.active_session_mut().drawing_elements.extend(elements);
self.active_session_mut()
.record_edit("Paste Mermaid", crate::history::ChangeSource::Human);
self.sync_drawing_elements_to_store();
self.set_status(format!("Rendered Mermaid diagram ({} elements)", count));
}
Err(e) => {
self.set_status(format!("Failed to convert Mermaid: {}", e));
}
}
} }
Err(e) => { Err(e) => {
self.set_status(format!("Mermaid error: {}", e)); self.set_status(format!("Mermaid error: {}", e));
@@ -402,38 +384,6 @@ impl AgCanvasApp {
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None }); .record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
} }
} }
DrawingCommand::RenderMermaid {
session_id,
overlay_id,
mermaid_source,
svg_source,
position,
size,
} => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
match parse_svg(&svg_source) {
Ok((_, usvg_tree)) => {
let overlay = MermaidOverlay {
id: overlay_id,
mermaid_source,
svg_source,
renderer: SvgRenderer::new(usvg_tree),
texture: None,
position,
size,
};
session.mermaid_overlays.push(overlay);
session.record_edit(
"Agent: Render Mermaid",
ChangeSource::Agent { name: None },
);
}
Err(e) => {
tracing::warn!("Failed to parse Mermaid overlay SVG: {}", e);
}
}
}
}
} }
} }
} }
@@ -480,23 +430,47 @@ impl AgCanvasApp {
fn sync_drawing_elements_to_store(&self) { fn sync_drawing_elements_to_store(&self) {
let sessions_handle = self.sessions_handle.clone(); let sessions_handle = self.sessions_handle.clone();
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self let session_snapshots: Vec<SessionExportSnapshot> = self
.sessions .sessions
.iter() .iter()
.map(|s| (s.id.clone(), s.drawing_elements.clone())) .map(|s| (s.id.clone(), s.drawing_elements.clone(), s.svg_source.clone()))
.collect(); .collect();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
let mut store = sessions_handle.write().await; let mut store = sessions_handle.write().await;
for (sid, elements) in elements_by_session { for (sid, elements, svg_source) in session_snapshots {
store.update_drawing_elements(&sid, elements); store.update_drawing_elements(&sid, elements);
store.update_export_layers(&sid, svg_source);
} }
}); });
}); });
} }
fn export_canvas_png(&mut self) {
let session = self.active_session();
let session_id = session.id.clone();
let export_data = ExportData {
svg_source: session.svg_source.clone(),
drawing_elements: session.drawing_elements.clone(),
background_color: tiny_skia::Color::from_rgba8(30, 30, 30, 255),
};
let mut path = std::env::temp_dir();
path.push(format!("canvas_export_{}.png", session_id));
let path_str = path.to_string_lossy().to_string();
match export::export_canvas_to_png(&export_data, &path_str, 2.0) {
Ok((width, height)) => {
self.set_status(format!("Exported PNG: {} ({}x{})", path_str, width, height));
}
Err(e) => {
self.set_status(format!("Export failed: {}", e));
}
}
}
fn build_saved_workspace(&self) -> SavedWorkspace { fn build_saved_workspace(&self) -> SavedWorkspace {
let sessions: Vec<SavedSession> = self let sessions: Vec<SavedSession> = self
.sessions .sessions
@@ -557,6 +531,7 @@ impl AgCanvasApp {
let info = session.info(); let info = session.info();
let tree_clone = session.element_tree.clone(); let tree_clone = session.element_tree.clone();
let svg_source_clone = saved.svg_source.clone();
self.sessions.push(session); self.sessions.push(session);
let sessions_handle = self.sessions_handle.clone(); let sessions_handle = self.sessions_handle.clone();
@@ -565,7 +540,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
let mut store = sessions_handle.write().await; let mut store = sessions_handle.write().await;
store.add_session(info_clone, tree_clone); store.add_session(info_clone, tree_clone, svg_source_clone);
}); });
}); });
} }
@@ -619,6 +594,7 @@ impl AgCanvasApp {
CommandId::ClearCanvas => self.clear_canvas(), CommandId::ClearCanvas => self.clear_canvas(),
CommandId::PasteSvg => self.handle_paste(ctx), CommandId::PasteSvg => self.handle_paste(ctx),
CommandId::PasteMermaid => self.show_mermaid_dialog = true, CommandId::PasteMermaid => self.show_mermaid_dialog = true,
CommandId::ExportPng => self.export_canvas_png(),
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select, CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan, CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle, CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
@@ -931,6 +907,7 @@ impl eframe::App for AgCanvasApp {
let mut toggle_history = false; let mut toggle_history = false;
let mut undo = false; let mut undo = false;
let mut redo = false; let mut redo = false;
let mut export_png = false;
let mut tool_switch: Option<Tool> = None; let mut tool_switch: Option<Tool> = None;
let palette_open = self.command_palette.visible; let palette_open = self.command_palette.visible;
@@ -962,6 +939,9 @@ impl eframe::App for AgCanvasApp {
undo = true; undo = true;
} }
} }
if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::E) {
export_png = true;
}
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
delete_selected = true; delete_selected = true;
} }
@@ -1011,6 +991,9 @@ impl eframe::App for AgCanvasApp {
if toggle_history { if toggle_history {
self.show_history_panel = !self.show_history_panel; self.show_history_panel = !self.show_history_panel;
} }
if export_png {
self.export_canvas_png();
}
if delete_selected if delete_selected
&& !self.show_text_input && !self.show_text_input
&& !self.show_mermaid_dialog && !self.show_mermaid_dialog
@@ -1102,14 +1085,6 @@ impl eframe::App for AgCanvasApp {
{ {
self.render_svg_to_texture(ctx); self.render_svg_to_texture(ctx);
} }
if self
.active_session()
.mermaid_overlays
.iter()
.any(|overlay| overlay.texture.is_none())
{
self.render_mermaid_overlays_to_texture(ctx);
}
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { egui::menu::bar(ui, |ui| {
@@ -1133,6 +1108,11 @@ impl eframe::App for AgCanvasApp {
ui.close_menu(); ui.close_menu();
} }
ui.separator(); ui.separator();
if ui.button("Export as PNG (Cmd+Shift+E)").clicked() {
self.export_canvas_png();
ui.close_menu();
}
ui.separator();
if ui.button("Clear Canvas").clicked() { if ui.button("Clear Canvas").clicked() {
self.clear_canvas(); self.clear_canvas();
ui.close_menu(); ui.close_menu();
@@ -1577,21 +1557,6 @@ impl eframe::App for AgCanvasApp {
let offset = self.active_session().canvas_state.offset; let offset = self.active_session().canvas_state.offset;
let zoom = self.active_session().canvas_state.zoom; let zoom = self.active_session().canvas_state.zoom;
for overlay in &self.active_session().mermaid_overlays {
if let Some(texture) = &overlay.texture {
let screen_pos =
canvas_to_screen(overlay.position, canvas_center, offset, zoom);
let screen_size = overlay.size * zoom;
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
painter.image(
texture.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
}
}
draw_elements( draw_elements(
&painter, &painter,
&self.active_session().drawing_elements, &self.active_session().drawing_elements,
@@ -1621,7 +1586,6 @@ impl eframe::App for AgCanvasApp {
); );
if self.active_session().svg_texture.is_none() if self.active_session().svg_texture.is_none()
&& self.active_session().mermaid_overlays.is_empty()
&& self.active_session().drawing_elements.is_empty() && self.active_session().drawing_elements.is_empty()
{ {
painter.text( painter.text(

View File

@@ -6,7 +6,7 @@ mod tool;
pub use boolean::BooleanOpType; pub use boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle}; pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{ pub use render::{
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection, draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
find_handle_at_screen_pos, screen_to_canvas, screen_to_canvas,
}; };
pub use tool::{DragState, Tool}; pub use tool::{DragState, Tool};

View File

@@ -77,16 +77,6 @@ pub struct SessionInfo {
pub created_at: i64, pub created_at: i64,
} }
pub struct MermaidOverlay {
pub id: String,
pub mermaid_source: String,
pub svg_source: String,
pub renderer: SvgRenderer,
pub texture: Option<TextureHandle>,
pub position: egui::Pos2,
pub size: egui::Vec2,
}
pub struct Session { pub struct Session {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -98,7 +88,6 @@ pub struct Session {
pub description_text: String, pub description_text: String,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
pub mermaid_overlays: Vec<MermaidOverlay>,
pub selected_element_ids: Vec<String>, pub selected_element_ids: Vec<String>,
pub active_tool: Tool, pub active_tool: Tool,
pub drag_state: DragState, pub drag_state: DragState,
@@ -121,7 +110,6 @@ impl Session {
svg_source: None, svg_source: None,
description_text: String::new(), description_text: String::new(),
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
mermaid_overlays: Vec::new(),
selected_element_ids: Vec::new(), selected_element_ids: Vec::new(),
active_tool: Tool::default(), active_tool: Tool::default(),
drag_state: DragState::default(), drag_state: DragState::default(),
@@ -157,7 +145,6 @@ impl Session {
self.svg_source = None; self.svg_source = None;
self.description_text.clear(); self.description_text.clear();
self.drawing_elements.clear(); self.drawing_elements.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.drag_state = DragState::default(); self.drag_state = DragState::default();
self.canvas_state.reset(); self.canvas_state.reset();
@@ -206,7 +193,6 @@ impl Session {
self.svg_renderer = None; self.svg_renderer = None;
self.svg_texture = None; self.svg_texture = None;
self.description_text.clear(); self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.drag_state = DragState::default(); self.drag_state = DragState::default();
} }
@@ -219,7 +205,6 @@ impl Session {
self.svg_renderer = None; self.svg_renderer = None;
self.svg_texture = None; self.svg_texture = None;
self.description_text.clear(); self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.drag_state = DragState::default(); self.drag_state = DragState::default();
true true
@@ -236,7 +221,6 @@ impl Session {
self.svg_renderer = None; self.svg_renderer = None;
self.svg_texture = None; self.svg_texture = None;
self.description_text.clear(); self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.drag_state = DragState::default(); self.drag_state = DragState::default();
true true
@@ -250,9 +234,12 @@ impl Session {
pub struct SessionData { pub struct SessionData {
pub info: SessionInfo, pub info: SessionInfo,
pub tree: Option<ElementTree>, pub tree: Option<ElementTree>,
pub svg_source: Option<String>,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
} }
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
#[derive(Default)] #[derive(Default)]
pub struct SessionStore { pub struct SessionStore {
sessions: HashMap<String, SessionData>, sessions: HashMap<String, SessionData>,
@@ -265,13 +252,19 @@ impl SessionStore {
Self::default() Self::default()
} }
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) { pub fn add_session(
&mut self,
info: SessionInfo,
tree: Option<ElementTree>,
svg_source: Option<String>,
) {
let id = info.id.clone(); let id = info.id.clone();
self.sessions.insert( self.sessions.insert(
id.clone(), id.clone(),
SessionData { SessionData {
info, info,
tree, tree,
svg_source,
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
}, },
); );
@@ -293,11 +286,17 @@ impl SessionStore {
} }
} }
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) { pub fn update_tree(
&mut self,
session_id: &str,
tree: Option<ElementTree>,
svg_source: Option<String>,
) {
if let Some(data) = self.sessions.get_mut(session_id) { if let Some(data) = self.sessions.get_mut(session_id) {
data.info.has_svg = tree.is_some(); data.info.has_svg = tree.is_some();
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count); data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
data.tree = tree; data.tree = tree;
data.svg_source = svg_source;
} }
} }
@@ -316,6 +315,12 @@ impl SessionStore {
} }
} }
pub fn update_export_layers(&mut self, session_id: &str, svg_source: Option<String>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.svg_source = svg_source;
}
}
pub fn get_drawing_elements( pub fn get_drawing_elements(
&self, &self,
session_id: Option<&str>, session_id: Option<&str>,
@@ -327,6 +332,14 @@ impl SessionStore {
Some((id, &data.drawing_elements)) Some((id, &data.drawing_elements))
} }
pub fn get_export_data(&self, session_id: Option<&str>) -> Option<ExportSessionData> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
Some((id, data.svg_source.clone(), data.drawing_elements.clone()))
}
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool { pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
if let Some(data) = self.sessions.get_mut(session_id) { if let Some(data) = self.sessions.get_mut(session_id) {
data.drawing_elements.push(element); data.drawing_elements.push(element);

View File

@@ -0,0 +1,303 @@
use anyhow::Result;
use egui::{Color32, Pos2, Vec2};
use usvg::tiny_skia_path::PathSegment;
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
/// Convert an SVG string into editable DrawingElements.
/// `offset_x` and `offset_y` shift all elements (for positioning on canvas).
pub fn svg_to_drawing_elements(
svg_source: &str,
offset_x: f32,
offset_y: f32,
) -> Result<Vec<DrawingElement>> {
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
let tree = usvg::Tree::from_str(svg_source, &options)?;
let mut elements = Vec::new();
walk_group(tree.root(), offset_x, offset_y, &mut elements);
Ok(elements)
}
fn walk_group(group: &usvg::Group, ox: f32, oy: f32, elements: &mut Vec<DrawingElement>) {
let (gx, gy) = extract_group_translate(group);
let next_ox = ox + gx;
let next_oy = oy + gy;
for node in group.children() {
match node {
usvg::Node::Group(g) => walk_group(g, next_ox, next_oy, elements),
usvg::Node::Path(path) => {
if let Some(element) = convert_path(path, next_ox, next_oy) {
elements.push(element);
}
}
usvg::Node::Text(text) => {
if let Some(element) = convert_text(text, next_ox, next_oy) {
elements.push(element);
}
}
usvg::Node::Image(_) => {}
}
}
}
fn extract_group_translate(group: &usvg::Group) -> (f32, f32) {
let t = group.transform();
(t.tx, t.ty)
}
fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option<DrawingElement> {
let bbox = path.bounding_box();
// Skip degenerate paths (zero area AND zero length — truly empty)
if bbox.width() <= 0.0 && bbox.height() <= 0.0 {
return None;
}
if is_background_rect(path) {
return None;
}
// Skip tiny marker definition fragments
if bbox.width() < 1.0 && bbox.height() < 1.0 {
return None;
}
let segments: Vec<_> = path.data().segments().collect();
if segments.is_empty() {
return None;
}
let style = extract_style(path);
if is_rect_path(&segments) {
return Some(DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
size: Vec2::new(bbox.width(), bbox.height()),
},
style,
));
}
if is_line_path(&segments) {
let (start, end) = extract_line_endpoints(&segments)?;
let shape = if is_stroke_only(path) {
Shape::Arrow {
start: Pos2::new(start.0 + ox, start.1 + oy),
end: Pos2::new(end.0 + ox, end.1 + oy),
}
} else {
Shape::Line {
start: Pos2::new(start.0 + ox, start.1 + oy),
end: Pos2::new(end.0 + ox, end.1 + oy),
}
};
return Some(DrawingElement::new(shape, style));
}
let aspect = bbox.width() / bbox.height().max(0.001);
if (0.9..=1.1).contains(&aspect) && has_curves(&segments) {
return Some(DrawingElement::new(
Shape::Ellipse {
center: Pos2::new(
bbox.left() + bbox.width() * 0.5 + ox,
bbox.top() + bbox.height() * 0.5 + oy,
),
radii: Vec2::new(bbox.width() * 0.5, bbox.height() * 0.5),
},
style,
));
}
None
}
fn convert_text(text: &usvg::Text, ox: f32, oy: f32) -> Option<DrawingElement> {
let content = extract_text_content(text);
if content.trim().is_empty() {
return None;
}
let bbox = text.bounding_box();
let font_size = extract_font_size(text);
let fill_color = extract_text_color(text);
Some(DrawingElement::new(
Shape::Text {
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
content,
font_size,
},
ShapeStyle {
fill: None,
stroke_color: fill_color,
stroke_width: 0.0,
},
))
}
fn extract_text_content(text: &usvg::Text) -> String {
text.chunks()
.iter()
.map(|chunk| chunk.text())
.collect::<Vec<_>>()
.join("")
}
fn extract_font_size(text: &usvg::Text) -> f32 {
text.chunks()
.iter()
.flat_map(|chunk| chunk.spans().iter())
.next()
.map(|span| span.font_size().get())
.unwrap_or(16.0)
}
fn extract_text_color(text: &usvg::Text) -> Color32 {
for chunk in text.chunks() {
for span in chunk.spans() {
if let Some(fill) = span.fill() {
if let usvg::Paint::Color(c) = fill.paint() {
return Color32::from_rgb(c.red, c.green, c.blue);
}
}
}
}
Color32::WHITE
}
fn extract_style(path: &usvg::Path) -> ShapeStyle {
let fill = path.fill().and_then(|f| match f.paint() {
usvg::Paint::Color(c) => Some(Color32::from_rgb(c.red, c.green, c.blue)),
_ => None,
});
let (stroke_color, stroke_width) = path
.stroke()
.map(|s| {
let color = match s.paint() {
usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue),
_ => Color32::WHITE,
};
(color, s.width().get())
})
.unwrap_or((Color32::WHITE, 2.0));
ShapeStyle {
fill,
stroke_color,
stroke_width,
}
}
fn is_background_rect(path: &usvg::Path) -> bool {
let Some(fill) = path.fill() else {
return false;
};
let usvg::Paint::Color(c) = fill.paint() else {
return false;
};
if !(c.red > 240 && c.green > 240 && c.blue > 240) {
return false;
}
let bbox = path.bounding_box();
bbox.left().abs() < 1.0 && bbox.top().abs() < 1.0
}
fn is_rect_path(segments: &[PathSegment]) -> bool {
let has_close = segments.iter().any(|s| matches!(s, PathSegment::Close));
if !has_close {
return false;
}
let line_count = segments
.iter()
.filter(|s| matches!(s, PathSegment::LineTo(_)))
.count();
line_count >= 3
}
fn is_line_path(segments: &[PathSegment]) -> bool {
!segments.iter().any(|s| matches!(s, PathSegment::Close))
}
fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> {
let mut first: Option<(f32, f32)> = None;
let mut last: Option<(f32, f32)> = None;
for segment in segments {
let point = match segment {
PathSegment::MoveTo(p) => (p.x, p.y),
PathSegment::LineTo(p) => (p.x, p.y),
PathSegment::QuadTo(_, p) => (p.x, p.y),
PathSegment::CubicTo(_, _, p) => (p.x, p.y),
PathSegment::Close => continue,
};
if first.is_none() {
first = Some(point);
}
last = Some(point);
}
match (first, last) {
(Some(a), Some(b)) => Some((a, b)),
_ => None,
}
}
fn has_curves(segments: &[PathSegment]) -> bool {
segments
.iter()
.any(|s| matches!(s, PathSegment::QuadTo(_, _) | PathSegment::CubicTo(_, _, _)))
}
fn is_stroke_only(path: &usvg::Path) -> bool {
path.fill().is_none() && path.stroke().is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_simple_mermaid_svg() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70" viewBox="0 0 100 70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" rx="3" ry="3" fill="#F8FAFF" stroke="#C7D2E5" stroke-width="1"/><text x="50" y="40" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#1C2430"><tspan x="50" dy="0">Hello</tspan></text></svg>"##;
let elements = svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse svg");
assert!(
elements.len() >= 2,
"Expected at least 2 elements, got {}",
elements.len()
);
let has_rect = elements
.iter()
.any(|element| matches!(element.shape, Shape::Rectangle { .. }));
assert!(has_rect, "Expected a rectangle element");
let has_text = elements
.iter()
.any(|element| matches!(element.shape, Shape::Text { .. }));
assert!(has_text, "Expected a text element");
}
#[test]
fn applies_offset() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" fill="#F8FAFF" stroke="#C7D2E5"/></svg>"##;
let elements =
svg_to_drawing_elements(svg, 100.0, 200.0).expect("converter should parse svg");
if let Some(element) = elements.first() {
let rect = element.bounding_rect();
assert!(rect.min.x >= 100.0, "Expected x offset applied");
assert!(rect.min.y >= 200.0, "Expected y offset applied");
}
}
}

View File

@@ -1,5 +1,7 @@
mod converter;
mod parser; mod parser;
mod renderer; mod renderer;
pub use converter::svg_to_drawing_elements;
pub use parser::parse_svg; pub use parser::parse_svg;
pub use renderer::SvgRenderer; pub use renderer::SvgRenderer;