pinakes-ui: improve graph rendering; fix panic
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6d1af08cd1133fb2efefccdefa7ad3e36a6a6964
This commit is contained in:
parent
55ee55fb31
commit
b4ffd56460
1 changed files with 476 additions and 97 deletions
|
|
@ -1,9 +1,8 @@
|
|||
//! Graph visualization component for markdown note connections.
|
||||
//!
|
||||
//! Renders a force-directed graph showing connections between notes.
|
||||
//! Uses a simple SVG-based rendering approach (no D3.js dependency).
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse};
|
||||
|
||||
|
|
@ -92,7 +91,7 @@ pub fn GraphView(
|
|||
"No linked notes found. Start creating links between your notes!"
|
||||
}
|
||||
} else {
|
||||
GraphSvg {
|
||||
ForceDirectedGraph {
|
||||
nodes: graph.nodes.clone(),
|
||||
edges: graph.edges.clone(),
|
||||
selected_node: selected_node.clone(),
|
||||
|
|
@ -126,118 +125,498 @@ pub fn GraphView(
|
|||
}
|
||||
}
|
||||
|
||||
/// SVG-based graph rendering.
|
||||
/// Node with physics simulation state
|
||||
#[derive(Clone, Debug)]
|
||||
struct PhysicsNode {
|
||||
id: String,
|
||||
label: String,
|
||||
title: Option<String>,
|
||||
link_count: usize,
|
||||
backlink_count: usize,
|
||||
x: f64,
|
||||
y: f64,
|
||||
vx: f64,
|
||||
vy: f64,
|
||||
}
|
||||
|
||||
/// Force-directed graph with physics simulation
|
||||
#[component]
|
||||
fn GraphSvg(
|
||||
fn ForceDirectedGraph(
|
||||
nodes: Vec<GraphNodeResponse>,
|
||||
edges: Vec<GraphEdgeResponse>,
|
||||
selected_node: Signal<Option<String>>,
|
||||
on_node_click: EventHandler<String>,
|
||||
on_node_double_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Simple circular layout for nodes
|
||||
let node_count = nodes.len();
|
||||
let width: f64 = 800.0;
|
||||
let height: f64 = 600.0;
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
let radius = (width.min(height) / 2.0) - 60.0;
|
||||
// Physics parameters (adjustable via controls)
|
||||
let mut repulsion_strength = use_signal(|| 1000.0f64);
|
||||
let mut link_strength = use_signal(|| 0.5f64);
|
||||
let mut link_distance = use_signal(|| 100.0f64);
|
||||
let mut center_strength = use_signal(|| 0.1f64);
|
||||
let mut damping = use_signal(|| 0.8f64);
|
||||
let mut show_controls = use_signal(|| false);
|
||||
let mut simulation_active = use_signal(|| true);
|
||||
|
||||
// Calculate node positions in a circle
|
||||
let positions: Vec<(f64, f64)> = (0..node_count)
|
||||
.map(|i| {
|
||||
let angle = (i as f64 / node_count as f64) * 2.0 * std::f64::consts::PI;
|
||||
let x = center_x + radius * angle.cos();
|
||||
let y = center_y + radius * angle.sin();
|
||||
(x, y)
|
||||
})
|
||||
.collect();
|
||||
// View state
|
||||
let mut zoom = use_signal(|| 1.0f64);
|
||||
let mut pan_x = use_signal(|| 0.0f64);
|
||||
let mut pan_y = use_signal(|| 0.0f64);
|
||||
let mut is_dragging_canvas = use_signal(|| false);
|
||||
let mut drag_start_x = use_signal(|| 0.0f64);
|
||||
let mut drag_start_y = use_signal(|| 0.0f64);
|
||||
let mut dragged_node = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Create a map from node id to position
|
||||
let id_to_pos: std::collections::HashMap<&str, (f64, f64)> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.id.as_str(), positions[i]))
|
||||
.collect();
|
||||
// Initialize physics nodes with random positions
|
||||
let mut physics_nodes = use_signal(|| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let angle = rand::random::<f64>() * 2.0 * std::f64::consts::PI;
|
||||
let radius = 100.0 + rand::random::<f64>() * 200.0;
|
||||
PhysicsNode {
|
||||
id: n.id.clone(),
|
||||
label: n.label.clone(),
|
||||
title: n.title.clone(),
|
||||
link_count: n.link_count as usize,
|
||||
backlink_count: n.backlink_count as usize,
|
||||
x: radius * angle.cos(),
|
||||
y: radius * angle.sin(),
|
||||
vx: 0.0,
|
||||
vy: 0.0,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
// Animation loop
|
||||
let edges_for_sim = edges.clone();
|
||||
use_future(move || {
|
||||
let edges_for_sim = edges_for_sim.clone();
|
||||
async move {
|
||||
loop {
|
||||
// Check simulation state
|
||||
let is_active = *simulation_active.peek();
|
||||
let is_dragging = dragged_node.peek().is_some();
|
||||
|
||||
if is_active && !is_dragging {
|
||||
let mut nodes = physics_nodes.write();
|
||||
let node_count = nodes.len();
|
||||
|
||||
if node_count > 0 {
|
||||
// Read physics parameters each frame
|
||||
let rep_strength = *repulsion_strength.peek();
|
||||
let link_strength_val = *link_strength.peek();
|
||||
let link_distance = *link_distance.peek();
|
||||
let center_strength_val = *center_strength.peek();
|
||||
let damping_val = *damping.peek();
|
||||
|
||||
// Apply forces
|
||||
for i in 0..node_count {
|
||||
let mut fx = 0.0;
|
||||
let mut fy = 0.0;
|
||||
|
||||
// Repulsion between all nodes
|
||||
for j in 0..node_count {
|
||||
if i != j {
|
||||
let dx = nodes[i].x - nodes[j].x;
|
||||
let dy = nodes[i].y - nodes[j].y;
|
||||
let dist_sq = (dx * dx + dy * dy).max(1.0);
|
||||
let dist = dist_sq.sqrt();
|
||||
let force = rep_strength / dist_sq;
|
||||
fx += (dx / dist) * force;
|
||||
fy += (dy / dist) * force;
|
||||
}
|
||||
}
|
||||
|
||||
// Center force (pull towards origin)
|
||||
fx -= nodes[i].x * center_strength_val;
|
||||
fy -= nodes[i].y * center_strength_val;
|
||||
|
||||
// Store force temporarily
|
||||
nodes[i].vx = fx;
|
||||
nodes[i].vy = fy;
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for edge in &edges_for_sim {
|
||||
if let (Some(i), Some(j)) = (
|
||||
nodes.iter().position(|n| n.id == edge.source),
|
||||
nodes.iter().position(|n| n.id == edge.target),
|
||||
) {
|
||||
let dx = nodes[j].x - nodes[i].x;
|
||||
let dy = nodes[j].y - nodes[i].y;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||
let force = (dist - link_distance) * link_strength_val;
|
||||
|
||||
let fx = (dx / dist) * force;
|
||||
let fy = (dy / dist) * force;
|
||||
|
||||
nodes[i].vx += fx;
|
||||
nodes[i].vy += fy;
|
||||
nodes[j].vx -= fx;
|
||||
nodes[j].vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions with velocity and damping
|
||||
let mut total_kinetic_energy = 0.0;
|
||||
for node in nodes.iter_mut() {
|
||||
node.x += node.vx * 0.01;
|
||||
node.y += node.vy * 0.01;
|
||||
node.vx *= damping_val;
|
||||
node.vy *= damping_val;
|
||||
|
||||
// Calculate kinetic energy (1/2 * m * v^2, assume m=1)
|
||||
let speed_sq = node.vx * node.vx + node.vy * node.vy;
|
||||
total_kinetic_energy += speed_sq;
|
||||
}
|
||||
|
||||
// If total kinetic energy is below threshold, pause simulation
|
||||
let avg_kinetic_energy = total_kinetic_energy / node_count as f64;
|
||||
if avg_kinetic_energy < 0.01 {
|
||||
simulation_active.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for ~16ms (60 FPS)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let selected = selected_node.read();
|
||||
let current_zoom = *zoom.read();
|
||||
let current_pan_x = *pan_x.read();
|
||||
let current_pan_y = *pan_y.read();
|
||||
|
||||
// Create id to position map
|
||||
let nodes_read = physics_nodes.read();
|
||||
let id_to_pos: HashMap<&str, (f64, f64)> = nodes_read
|
||||
.iter()
|
||||
.map(|n| (n.id.as_str(), (n.x, n.y)))
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
svg {
|
||||
class: "graph-svg",
|
||||
width: "{width}",
|
||||
height: "{height}",
|
||||
view_box: "0 0 {width} {height}",
|
||||
div { class: "graph-svg-container",
|
||||
// Zoom and physics controls
|
||||
div { class: "graph-zoom-controls",
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Zoom In",
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom.read() * 1.2).min(5.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
"+"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Zoom Out",
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
"−"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Reset View",
|
||||
onclick: move |_| {
|
||||
zoom.set(1.0);
|
||||
pan_x.set(0.0);
|
||||
pan_y.set(0.0);
|
||||
},
|
||||
"⊙"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Physics Settings",
|
||||
onclick: move |_| {
|
||||
let current = *show_controls.read();
|
||||
show_controls.set(!current);
|
||||
},
|
||||
"⚙"
|
||||
}
|
||||
}
|
||||
|
||||
// Draw edges first (so they appear behind nodes)
|
||||
g { class: "graph-edges",
|
||||
for edge in &edges {
|
||||
if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
|
||||
id_to_pos.get(edge.source.as_str()),
|
||||
id_to_pos.get(edge.target.as_str()),
|
||||
)
|
||||
{
|
||||
line {
|
||||
class: "graph-edge edge-type-{edge.link_type}",
|
||||
x1: "{x1}",
|
||||
y1: "{y1}",
|
||||
x2: "{x2}",
|
||||
y2: "{y2}",
|
||||
stroke: "#888",
|
||||
stroke_width: "1",
|
||||
marker_end: "url(#arrowhead)",
|
||||
// Physics control panel
|
||||
if *show_controls.read() {
|
||||
div { class: "physics-controls-panel",
|
||||
h4 { "Physics Settings" }
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Repulsion Strength" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "100",
|
||||
max: "5000",
|
||||
step: "100",
|
||||
value: "{*repulsion_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
repulsion_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*repulsion_strength.read():.0}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Link Strength" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.1",
|
||||
max: "2.0",
|
||||
step: "0.1",
|
||||
value: "{*link_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
link_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*link_strength.read():.1}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Link Distance" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "50",
|
||||
max: "300",
|
||||
step: "10",
|
||||
value: "{*link_distance.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
link_distance.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*link_distance.read():.0}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Center Gravity" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.01",
|
||||
max: "0.5",
|
||||
step: "0.01",
|
||||
value: "{*center_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
center_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*center_strength.read():.2}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Damping" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.5",
|
||||
max: "0.95",
|
||||
step: "0.05",
|
||||
value: "{*damping.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
damping.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*damping.read():.2}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Simulation Status" }
|
||||
span {
|
||||
style: if *simulation_active.read() {
|
||||
"color: #4ade80;"
|
||||
} else {
|
||||
"color: #94a3b8;"
|
||||
},
|
||||
if *simulation_active.read() {
|
||||
"Running"
|
||||
} else {
|
||||
"Paused (settled)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| {
|
||||
simulation_active.set(true);
|
||||
},
|
||||
disabled: *simulation_active.read(),
|
||||
"Restart Simulation"
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| {
|
||||
repulsion_strength.set(1000.0);
|
||||
link_strength.set(0.5);
|
||||
link_distance.set(100.0);
|
||||
center_strength.set(0.1);
|
||||
damping.set(0.8);
|
||||
simulation_active.set(true);
|
||||
},
|
||||
"Reset to Defaults"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow marker definition
|
||||
defs {
|
||||
marker {
|
||||
id: "arrowhead",
|
||||
marker_width: "10",
|
||||
marker_height: "7",
|
||||
ref_x: "10",
|
||||
ref_y: "3.5",
|
||||
orient: "auto",
|
||||
polygon { points: "0 0, 10 3.5, 0 7", fill: "#888" }
|
||||
}
|
||||
}
|
||||
// SVG canvas - fills available space
|
||||
svg {
|
||||
class: "graph-svg",
|
||||
style: "width: 100%; height: 100%;",
|
||||
view_box: "-1000 -1000 2000 2000",
|
||||
onmousedown: move |evt| {
|
||||
// Check if clicking on background (not a node)
|
||||
is_dragging_canvas.set(true);
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
},
|
||||
onmousemove: move |evt| {
|
||||
if *is_dragging_canvas.read() {
|
||||
let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom;
|
||||
let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom;
|
||||
pan_x.set(current_pan_x + dx);
|
||||
pan_y.set(current_pan_y + dy);
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
g { class: "graph-nodes",
|
||||
for (i , node) in nodes.iter().enumerate() {
|
||||
{
|
||||
let (x, y) = positions[i];
|
||||
let node_id = node.id.clone();
|
||||
let node_id2 = node.id.clone();
|
||||
let label = node.label.clone();
|
||||
let is_selected = selected.as_ref() == Some(&node.id);
|
||||
let node_size = 8.0 + (node.link_count + node.backlink_count) as f64 * 2.0;
|
||||
let node_size = node_size.min(30.0);
|
||||
// Handle node dragging
|
||||
if let Some(ref node_id) = *dragged_node.read() {
|
||||
let mut nodes = physics_nodes.write();
|
||||
if let Some(node) = nodes.iter_mut().find(|n| &n.id == node_id) {
|
||||
let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom * 2.0;
|
||||
let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom * 2.0;
|
||||
node.x += dx;
|
||||
node.y += dy;
|
||||
// Reset velocity when dragging
|
||||
node.vx = 0.0;
|
||||
node.vy = 0.0;
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
}
|
||||
}
|
||||
},
|
||||
onmouseup: move |_| {
|
||||
is_dragging_canvas.set(false);
|
||||
dragged_node.set(None);
|
||||
},
|
||||
onmouseleave: move |_| {
|
||||
is_dragging_canvas.set(false);
|
||||
dragged_node.set(None);
|
||||
},
|
||||
onwheel: move |evt| {
|
||||
let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 };
|
||||
let new_zoom = (*zoom.read() * delta).max(0.1).min(5.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
|
||||
rsx! {
|
||||
g {
|
||||
class: if is_selected { "graph-node selected" } else { "graph-node" },
|
||||
onclick: move |_| on_node_click.call(node_id.clone()),
|
||||
ondoubleclick: move |_| on_node_double_click.call(node_id2.clone()),
|
||||
// Transform group for zoom and pan
|
||||
g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})",
|
||||
|
||||
|
||||
circle {
|
||||
cx: "{x}",
|
||||
cy: "{y}",
|
||||
r: "{node_size}",
|
||||
fill: if is_selected { "#2196f3" } else { "#4caf50" },
|
||||
stroke: if is_selected { "#1565c0" } else { "#388e3c" },
|
||||
stroke_width: "2",
|
||||
// Draw edges first
|
||||
g { class: "graph-edges",
|
||||
for edge in &edges {
|
||||
if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
|
||||
id_to_pos.get(edge.source.as_str()),
|
||||
id_to_pos.get(edge.target.as_str()),
|
||||
)
|
||||
{
|
||||
line {
|
||||
class: "graph-edge edge-type-{edge.link_type}",
|
||||
x1: "{x1}",
|
||||
y1: "{y1}",
|
||||
x2: "{x2}",
|
||||
y2: "{y2}",
|
||||
stroke: "#666",
|
||||
stroke_width: "{1.5 / current_zoom}",
|
||||
stroke_opacity: "0.6",
|
||||
marker_end: "url(#arrowhead)",
|
||||
}
|
||||
text {
|
||||
x: "{x}",
|
||||
y: "{y + node_size + 15.0}",
|
||||
text_anchor: "middle",
|
||||
font_size: "12",
|
||||
fill: "#333",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow marker definition
|
||||
defs {
|
||||
marker {
|
||||
id: "arrowhead",
|
||||
marker_width: "10",
|
||||
marker_height: "7",
|
||||
ref_x: "9",
|
||||
ref_y: "3.5",
|
||||
orient: "auto",
|
||||
polygon { points: "0 0, 10 3.5, 0 7", fill: "#666", fill_opacity: "0.6" }
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
g { class: "graph-nodes",
|
||||
for node in nodes_read.iter() {
|
||||
{
|
||||
let node_id = node.id.clone();
|
||||
let node_id2 = node.id.clone();
|
||||
let node_id3 = node.id.clone();
|
||||
let display_text = node.title.as_ref().unwrap_or(&node.label).clone();
|
||||
let is_selected = selected.as_ref() == Some(&node.id);
|
||||
|
||||
// Node size based on connections
|
||||
let total_links = node.link_count + node.backlink_count;
|
||||
let node_radius = 8.0 + (total_links as f64 * 1.5).min(20.0);
|
||||
let scaled_radius = node_radius / current_zoom;
|
||||
|
||||
rsx! {
|
||||
g {
|
||||
class: if is_selected { "graph-node selected" } else { "graph-node" },
|
||||
style: "cursor: pointer;",
|
||||
onclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
on_node_click.call(node_id.clone());
|
||||
},
|
||||
ondoubleclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
on_node_double_click.call(node_id2.clone());
|
||||
},
|
||||
onmousedown: move |evt| {
|
||||
evt.stop_propagation();
|
||||
dragged_node.set(Some(node_id3.clone()));
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
},
|
||||
|
||||
circle {
|
||||
cx: "{node.x}",
|
||||
cy: "{node.y}",
|
||||
r: "{scaled_radius}",
|
||||
fill: if is_selected { "#2196f3" } else { "#4caf50" },
|
||||
stroke: if is_selected { "#1565c0" } else { "#2e7d32" },
|
||||
stroke_width: "{2.0 / current_zoom}",
|
||||
}
|
||||
text {
|
||||
x: "{node.x}",
|
||||
y: "{node.y + scaled_radius + 15.0 / current_zoom}",
|
||||
text_anchor: "middle",
|
||||
font_size: "{12.0 / current_zoom}",
|
||||
fill: "#333",
|
||||
pointer_events: "none",
|
||||
"{display_text}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -261,7 +640,7 @@ fn NodeDetailsPanel(
|
|||
div { class: "node-details-panel",
|
||||
div { class: "node-details-header",
|
||||
h3 { "{node.label}" }
|
||||
button { class: "close-btn", onclick: move |_| on_close.call(()), "\u{2715}" }
|
||||
button { class: "close-btn", onclick: move |_| on_close.call(()), "×" }
|
||||
}
|
||||
div { class: "node-details-content",
|
||||
if let Some(ref title) = node.title {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue