1use crate::plugins::{
2 self, Capability, Command, Labels,
3 gesture::{self, HitRect},
4};
5use crate::{
6 LayoutDirection, MindMapData, MindMapError, MindMapHandle, Node, NodeDirection, Operation,
7 Point, STYLESHEET, Theme, Viewport,
8 generate_branch::{branch, local},
9 platform,
10};
11use dioxus::html::input_data::MouseButton;
12use dioxus::prelude::*;
13use std::{collections::BTreeMap, rc::Rc};
14
15type Mounts = BTreeMap<String, Rc<MountedData>>;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum Alignment {
20 #[default]
22 Root,
23 Nodes,
25}
26
27#[derive(Props, Clone, PartialEq)]
29pub struct MindMapProps {
30 pub data: ReadSignal<MindMapData>,
32 #[props(default)]
34 pub class: String,
35 #[props(default)]
37 pub style: String,
38 #[props(default)]
40 pub measurement_revision: u64,
41 #[props(default)]
43 pub alignment: Alignment,
44 #[props(default)]
46 pub theme: Option<Theme>,
47 #[props(default)]
49 pub viewport: Option<Signal<Viewport>>,
50 #[props(default)]
52 pub center_revision: u64,
53 #[props(default)]
55 pub on_error: EventHandler<MindMapError>,
56 #[props(default)]
58 pub fit_revision: u64,
59 #[props(default)]
61 pub image_url: Option<Callback<String, String>>,
62}
63
64#[component]
66pub fn MindMap(props: MindMapProps) -> Element {
67 rsx! { MapBody { document: props.data.read().clone(), class: props.class, style: props.style, measurement_revision: props.measurement_revision, alignment: props.alignment, theme: props.theme, viewport_control: props.viewport, center_revision: props.center_revision, on_error: props.on_error, fit_revision: props.fit_revision, image_url: props.image_url } }
68}
69
70#[component]
76pub fn EditableMindMap(
77 handle: MindMapHandle,
78 #[props(default)] class: String,
79 #[props(default)] style: String,
80 #[props(default)] measurement_revision: u64,
81 #[props(default)] alignment: Alignment,
82 #[props(default)] theme: Option<Theme>,
83 #[props(default)] viewport: Option<Signal<Viewport>>,
84 #[props(default)] center_revision: u64,
85 #[props(default)] on_error: EventHandler<MindMapError>,
86 #[props(default)] labels: Labels,
87 #[props(default)] fit_revision: u64,
88 #[props(default)] image_url: Option<Callback<String, String>>,
89 #[props(default)] blur_commits: bool,
90) -> Element {
91 let document = handle.document();
92 match document {
93 Ok(document) => {
94 rsx! { MapBody { document, handle: Some(handle), labels, class, style, measurement_revision, alignment, theme, viewport_control: viewport, center_revision, on_error, fit_revision, image_url, blur_commits } }
95 }
96 Err(error) => rsx! { p { role: "alert", "{error}" } },
97 }
98}
99
100#[derive(Clone, PartialEq)]
101struct EditSession {
102 id: String,
103 original: String,
104 revision: u64,
105}
106#[derive(Clone, PartialEq)]
107struct NodeDrag {
108 start: Point,
109 current: Point,
110 source: Option<String>,
111 active: bool,
112 revision: u64,
113}
114#[derive(Clone, Copy)]
115struct ViewContext {
116 mounts: Signal<Mounts>,
117 resize: Signal<u64>,
118 editor: Signal<Option<EditSession>>,
119 handle: Option<MindMapHandle>,
120 on_error: EventHandler<MindMapError>,
121 view_id: usize,
122 gesture: Signal<Option<NodeDrag>>,
123 menu: Signal<Option<Point>>,
124 labels: Signal<Labels>,
125 suppress_click: Signal<bool>,
126 gesture_epoch: Signal<u64>,
127 image_url: Option<Callback<String, String>>,
128 blur_commits: bool,
129}
130impl ViewContext {
131 fn resized(mut self) {
132 let next = self.resize.peek().saturating_add(1);
133 self.resize.set(next);
134 }
135 fn report(self, result: Result<(), MindMapError>) {
136 if let Err(error) = result {
137 self.on_error.call(error);
138 }
139 }
140 fn select(self, id: &str) {
141 if let Some(handle) = self.handle {
142 self.report(handle.select(&[id.to_owned()]));
143 }
144 }
145 fn click_select(self, id: &str, toggle: bool) {
146 if let Some(handle) = self.handle {
147 let result = (|| {
148 let mut ids = if toggle && handle.has_plugin(Capability::MultiSelect) {
149 handle.selection()?
150 } else {
151 Vec::new()
152 };
153 if let Some(index) = ids.iter().position(|item| item == id) {
154 ids.remove(index);
155 } else {
156 ids.push(id.into());
157 }
158 handle.select(&ids)
159 })();
160 self.report(result);
161 }
162 }
163 fn keyboard(self, event: KeyboardEvent, node: &Node) {
164 if event.is_composing() {
165 return;
166 }
167 let Some(handle) = self.handle else {
168 return;
169 };
170 if !handle.has_plugin(Capability::Keyboard) {
171 return;
172 }
173 let modifiers = event.modifiers();
174 let primary = modifiers.ctrl() || modifiers.meta();
175 let command = match event.key() {
176 Key::Enter if primary => Some(Command::Parent),
177 Key::Enter if modifiers.shift() => Some(Command::SiblingBefore),
178 Key::Enter => Some(Command::SiblingAfter),
179 Key::Tab => Some(Command::Child),
180 Key::Delete | Key::Backspace => Some(Command::Delete),
181 Key::Character(ref c) if primary && c.eq_ignore_ascii_case("z") => {
182 Some(if modifiers.shift() {
183 Command::Redo
184 } else {
185 Command::Undo
186 })
187 }
188 Key::Character(ref c) if primary && c.eq_ignore_ascii_case("y") => Some(Command::Redo),
189 _ => None,
190 };
191 if let Some(command) = command {
192 event.prevent_default();
193 event.stop_propagation();
194 self.report((|| {
195 if handle.selection()?.is_empty() {
196 handle.select(std::slice::from_ref(&node.id))?;
197 }
198 plugins::execute(handle, command, &self.labels.peek().new_topic)
199 })());
200 } else if matches!(
201 event.key(),
202 Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight
203 ) {
204 event.prevent_default();
205 event.stop_propagation();
206 let elements = self.mounts.peek().clone();
207 let id = node.id.clone();
208 let key = event.key();
209 spawn(async move {
210 let result = async {
211 let revision = handle.revision()?;
212 let origin = elements.get(&id).ok_or(MindMapError::StaleOperation)?;
213 let origin = platform::measure(origin).await?;
214 let x = origin.x + origin.width / 2.0;
215 let y = origin.y + origin.height / 2.0;
216 let mut best = None::<(f64, String)>;
217 for (candidate, element) in &elements {
218 if candidate == &id {
219 continue;
220 }
221 let rect = platform::measure(element).await?;
222 let dx = rect.x + rect.width / 2.0 - x;
223 let dy = rect.y + rect.height / 2.0 - y;
224 let (forward, cross) = match key {
225 Key::ArrowLeft => (-dx, dy.abs()),
226 Key::ArrowRight => (dx, dy.abs()),
227 Key::ArrowUp => (-dy, dx.abs()),
228 _ => (dy, dx.abs()),
229 };
230 if forward > 1.0 {
231 let score = forward + cross * 3.0;
232 if best.as_ref().is_none_or(|(previous, _)| score < *previous) {
233 best = Some((score, candidate.clone()));
234 }
235 }
236 }
237 if handle.revision()? != revision {
238 return Err(MindMapError::StaleOperation);
239 }
240 if let Some((_, id)) = best {
241 handle.claim_editor(self.view_id)?;
242 handle.select(&[id])?;
243 }
244 Ok(())
245 }
246 .await;
247 self.report(result);
248 });
249 }
250 }
251 fn start_edit(mut self, node: &Node) {
252 if let Some(handle) = self.handle {
253 let result = (|| {
254 if handle.is_read_only()? {
255 return Err(MindMapError::ReadOnly);
256 }
257 handle.select(std::slice::from_ref(&node.id))?;
258 handle.begin_edit(self.view_id, &node.id, &node.topic)?;
259 self.editor.set(Some(EditSession {
260 id: node.id.clone(),
261 original: node.topic.clone(),
262 revision: handle.revision()?,
263 }));
264 Ok(())
265 })();
266 self.report(result);
267 }
268 }
269}
270
271#[component]
272fn MapBody(
273 document: MindMapData,
274 #[props(default)] handle: Option<MindMapHandle>,
275 class: String,
276 style: String,
277 measurement_revision: u64,
278 alignment: Alignment,
279 #[props(default)] theme: Option<Theme>,
280 #[props(default)] viewport_control: Option<Signal<Viewport>>,
281 #[props(default)] center_revision: u64,
282 on_error: EventHandler<MindMapError>,
283 #[props(default)] labels: Labels,
284 #[props(default)] fit_revision: u64,
285 #[props(default)] image_url: Option<Callback<String, String>>,
286 #[props(default)] blur_commits: bool,
287) -> Element {
288 let mounts = use_signal(Mounts::new);
289 let canvas = use_signal(|| None::<Rc<MountedData>>);
290 let scale_probe = use_signal(|| None::<Rc<MountedData>>);
291 let mut container = use_signal(|| None::<Rc<MountedData>>);
292 let resize = use_signal(|| 0u64);
293 let editor = use_signal(|| None::<EditSession>);
294 let mut gesture = use_signal(|| None::<NodeDrag>);
295 let mut menu = use_signal(|| None::<Point>);
296 let suppress_click = use_signal(|| false);
297 let mut gesture_epoch = use_signal(|| 0u64);
298 let mut label_signal = use_signal(|| labels.clone());
299 if *label_signal.peek() != labels {
300 label_signal.set(labels.clone());
301 }
302 let context = ViewContext {
303 mounts,
304 resize,
305 editor,
306 handle,
307 on_error,
308 view_id: dioxus::core::current_scope_id().0,
309 gesture,
310 menu,
311 labels: label_signal,
312 suppress_click,
313 gesture_epoch,
314 image_url,
315 blur_commits,
316 };
317 use_context_provider(|| context);
318 let local_viewport = use_signal(Viewport::default);
319 let mut viewport = viewport_control.unwrap_or(local_viewport);
320 let mut drag = use_signal(|| None::<Point>);
321 let mut measurement_error = use_signal(|| None::<MindMapError>);
322 let mut input = use_signal(|| (document.clone(), measurement_revision, theme.clone()));
323 use_effect(use_reactive(
324 (&document, &measurement_revision, &theme),
325 move |(document, revision, theme)| {
326 input.set((document, revision, theme));
327 },
328 ));
329 let geometry = use_resource(move || async move {
330 let (document, _, theme) = input.read().clone();
331 let elements = mounts.read().clone();
332 let element = canvas.read().clone();
333 let probe = scale_probe.read().clone();
334 let _ = resize();
335 document.validate()?;
336 let (Some(element), Some(probe)) = (element, probe) else {
337 return Ok(Vec::new());
338 };
339 let scale = platform::measure(&probe).await?.width / 100.0;
340 if !scale.is_finite() || scale <= 0.0 {
341 return Err(MindMapError::InvalidGeometry);
342 }
343 let canvas_rect = platform::measure(&element).await?;
344 let mut pending = vec![(
345 &document.node_data,
346 None::<String>,
347 document.direction,
348 None::<String>,
349 )];
350 let mut rectangles = BTreeMap::new();
351 let mut edges = Vec::new();
352 while let Some((node, parent, direction, color)) = pending.pop() {
353 let Some(element) = elements.get(&node.id) else {
354 return Ok(Vec::new());
355 };
356 rectangles.insert(
357 node.id.clone(),
358 local(platform::measure(element).await?, canvas_rect, scale),
359 );
360 let color = node.branch_color.clone().or(color);
361 if let Some(parent_id) = parent {
362 edges.push((parent_id, node.id.clone(), direction, color.clone()));
363 }
364 if node.expanded {
365 for (index, child) in node.children.iter().enumerate().rev() {
366 let child_direction = if node.id == document.node_data.id
367 && document.direction == LayoutDirection::Side
368 {
369 match child.direction {
370 Some(NodeDirection::Left) => LayoutDirection::Left,
371 Some(NodeDirection::Right) => LayoutDirection::Right,
372 None => assigned_side(&document.node_data.children, &child.id),
373 }
374 } else {
375 direction
376 };
377 let child_color = if node.id == document.node_data.id {
378 theme
379 .as_ref()
380 .and_then(|theme| {
381 if theme.palette.is_empty() {
382 None
383 } else {
384 theme.palette.get(index % theme.palette.len()).cloned()
385 }
386 })
387 .or_else(|| color.clone())
388 } else {
389 color.clone()
390 };
391 pending.push((child, Some(node.id.clone()), child_direction, child_color));
392 }
393 }
394 }
395 let final_canvas = platform::measure(&element).await?;
399 let final_scale = platform::measure(&probe).await?.width / 100.0;
400 if final_canvas != canvas_rect || (final_scale - scale).abs() > 0.0001 {
401 context.resized();
402 return Ok(Vec::new());
403 }
404 let mut groups = BTreeMap::<Option<String>, String>::new();
406 for (parent_id, child_id, direction, color) in edges {
407 if let (Some(parent), Some(child)) =
408 (rectangles.get(&parent_id), rectangles.get(&child_id))
409 {
410 let path = branch(
411 *parent,
412 *child,
413 direction,
414 parent_id == document.node_data.id,
415 canvas_rect.height / scale,
416 );
417 let group = groups.entry(color).or_default();
418 group.push_str(&path);
419 group.push(' ');
420 }
421 }
422 Ok::<_, MindMapError>(groups.into_iter().collect::<Vec<_>>())
423 });
424 use_effect(move || {
425 let error = match &*geometry.read() {
426 Some(Err(error)) => Some(error.clone()),
427 _ => None,
428 };
429 if *measurement_error.peek() != error {
430 measurement_error.set(error.clone());
431 if let Some(error) = error {
432 on_error.call(error);
433 }
434 }
435 });
436 let mut center_request = use_signal(|| 0u64);
437 use_effect(use_reactive((¢er_revision,), move |(revision,)| {
438 center_request.set(revision)
439 }));
440 let _center = use_resource(move || async move {
441 let request = center_request();
442 if request == 0 {
443 return;
444 }
445 let root_id = input.read().0.node_data.id.clone();
446 let elements = mounts.read().clone();
447 let view_element = container.read().clone();
448 let Some(root) = elements.get(&root_id) else {
449 return;
450 };
451 let Some(view_element) = view_element else {
452 return;
453 };
454 let before = *viewport.peek();
455 let result = async {
456 let root = platform::measure(root).await?;
457 let view = platform::measure(&view_element).await?;
458 let target = before.to_canvas(Point {
459 x: root.x + root.width / 2.0 - view.x,
460 y: root.y + root.height / 2.0 - view.y,
461 })?;
462 let mut next = before;
463 next.center(
464 target,
465 Point {
466 x: view.width,
467 y: view.height,
468 },
469 )?;
470 if *viewport.peek() == before {
471 viewport.set(next);
472 }
473 Ok(())
474 }
475 .await;
476 context.report(result);
477 });
478 let mut fit_request = use_signal(|| 0u64);
479 use_effect(use_reactive((&fit_revision,), move |(revision,)| {
480 fit_request.set(revision)
481 }));
482 let _fit = use_resource(move || async move {
483 let request = fit_request();
484 if request == 0 {
485 return;
486 }
487 let elements = mounts.peek().clone();
488 let view_element = container.peek().clone();
489 let Some(view_element) = view_element else {
490 return;
491 };
492 let before = *viewport.peek();
493 let result = async {
494 let view = platform::measure(&view_element).await?;
495 let mut bounds: Option<(f64, f64, f64, f64)> = None;
496 for element in elements.values() {
497 let r = platform::measure(element).await?;
498 bounds = Some(match bounds {
499 None => (r.x, r.y, r.x + r.width, r.y + r.height),
500 Some((x0, y0, x1, y1)) => (
501 x0.min(r.x),
502 y0.min(r.y),
503 x1.max(r.x + r.width),
504 y1.max(r.y + r.height),
505 ),
506 });
507 }
508 let Some((x0, y0, x1, y1)) = bounds else {
509 return Ok(());
510 };
511 let a = before.to_canvas(Point {
512 x: x0 - view.x,
513 y: y0 - view.y,
514 })?;
515 let b = before.to_canvas(Point {
516 x: x1 - view.x,
517 y: y1 - view.y,
518 })?;
519 let next = gesture::fit(
520 a,
521 b,
522 Point {
523 x: view.width,
524 y: view.height,
525 },
526 )?;
527 if *viewport.peek() == before {
528 viewport.set(next);
529 }
530 Ok(())
531 }
532 .await;
533 context.report(result);
534 });
535 use_effect(move || {
538 let current = input.read();
539 let session = context.editor.read().clone();
540 if let Some(session) = session {
541 let exists = current
542 .0
543 .node_data
544 .find(&session.id)
545 .is_some_and(|node| node.topic == session.original);
546 let writable = match handle {
547 Some(handle) => {
548 matches!(handle.is_read_only(), Ok(false))
549 && handle.edits(context.view_id, &session.id)
550 }
551 None => false,
552 };
553 if !exists || !writable {
554 let mut editor = context.editor;
555 editor.set(None);
556 if let Some(handle) = handle {
557 if handle.edits(context.view_id, &session.id) {
558 context.report(handle.cancel_pending());
559 }
560 }
561 }
562 }
563 });
564 use_effect(move || {
565 if let Some(handle) = handle {
566 let revision = handle.revision();
567 let node_enabled = handle.has_plugin(Capability::DragDrop)
568 && matches!(handle.is_read_only(), Ok(false));
569 let select_enabled = handle.has_plugin(Capability::MultiSelect);
570 let stale = gesture.peek().as_ref().is_some_and(|current| {
571 revision != Ok(current.revision)
572 || if current.source.is_some() {
573 !node_enabled
574 } else {
575 !select_enabled
576 }
577 });
578 if stale {
579 gesture.set(None);
580 }
581 if !handle.has_plugin(Capability::ContextMenu) {
582 menu.set(None);
583 }
584 }
585 });
586 let selection = match handle {
587 Some(handle) => handle.selection(),
588 None => Ok(Vec::new()),
589 };
590 let selection = match selection {
591 Ok(selection) => selection,
592 Err(error) => return rsx! { p { role: "alert", "{error}" } },
593 };
594 let paths = match &*geometry.read() {
595 Some(Ok(paths)) => paths.clone(),
596 _ => Vec::new(),
597 };
598 let direction = match document.direction {
599 LayoutDirection::Left => "left",
600 LayoutDirection::Right => "right",
601 LayoutDirection::Side => "side",
602 LayoutDirection::Down => "down",
603 };
604 let align = if alignment == Alignment::Nodes {
605 "nodes"
606 } else {
607 "root"
608 };
609 let style = format!(
610 "{}{style}",
611 theme.as_ref().map(Theme::css).unwrap_or_default()
612 );
613 let root = &document.node_data;
614 let validation = document.validate();
615 rsx! {
616 style { "{STYLESHEET}" }
617 div {
618 class: "dme-map {class}", style,
619 role: "group", aria_label: "{label_signal.read().map}", tabindex: "0",
620 "data-layout": direction, "data-alignment": align, "data-compact": document.compact,
621 onmounted: move |event| container.set(Some(event.data())),
622 onwheel: move |event| {
623 event.prevent_default();
624 let delta = event.delta().strip_units();
625 let result = if event.modifiers().ctrl() || event.modifiers().meta() {
626 let at = event.client_coordinates();
629 let element = container.read().clone();
630 spawn(async move {
631 if let Some(element) = element {
632 let result = async {
633 let rect = platform::measure(&element).await?;
634 let mut next = *viewport.peek();
635 next.zoom_at(next.scale * (-delta.y * 0.002).exp(), Point { x: at.x - rect.x, y: at.y - rect.y })?;
636 viewport.set(next); Ok(())
637 }.await;
638 context.report(result);
639 }
640 });
641 Ok(())
642 } else {
643 let mut next = *viewport.peek();
644 let result = next.pan(Point { x: -delta.x, y: -delta.y });
645 if result.is_ok() { viewport.set(next); } result
646 };
647 context.report(result);
648 },
649 onpointerdown: move |event| {
650 if event.trigger_button() == Some(MouseButton::Primary) {
651 let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
652 menu.set(None);
653 if event.modifiers().shift() && handle.is_some_and(|h| h.has_plugin(Capability::MultiSelect)) {
654 {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);}
655 if let Some(handle)=handle {
656 match handle.select(&[]).and_then(|_|handle.revision()) {
657 Ok(revision)=>gesture.set(Some(NodeDrag {start: point,current:point,source:None,active:false,revision})),
658 Err(error)=>context.on_error.call(error),
659 }
660 }
661 } else { drag.set(Some(point)); }
662 if let Some(handle) = handle { context.report(handle.select(&[])); }
663 }
664 },
665 onpointermove: move |event| {
666 let at = event.client_coordinates(); let point = Point { x: at.x,y:at.y };
667 let current_gesture = gesture.peek().clone();
668 if let Some(mut current) = current_gesture {
669 current.current = point; current.active = current.active || gesture::is_drag(current.start,point);
670 gesture.set(Some(current));
671 return;
672 }
673 let previous = *drag.peek();
674 if let Some(previous) = previous {
675 let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
676 let mut next = *viewport.peek();
677 let result = next.pan(Point { x: point.x - previous.x, y: point.y - previous.y });
678 if result.is_ok() { viewport.set(next); drag.set(Some(point)); } context.report(result);
679 }
680 },
681 onpointerup: move |_| {
682 drag.set(None); let current = gesture.peek().clone(); gesture.set(None);
683 if let (Some(current),Some(handle)) = (current,handle) {
684 if current.active {
685 let mut suppress_click = suppress_click; suppress_click.set(true);
686 let epoch=*gesture_epoch.peek();
687 let ids = handle.selection();
688 let elements = mounts.peek().clone(); let revision = handle.revision();
689 spawn(async move {
690 let result = async {
691 let revision = revision?; let data = handle.document()?; let mut selected = Vec::new(); let mut placement = None;
692 for (id,mounted) in elements {
693 let r = platform::measure(&mounted).await?;
694 let rect = HitRect {x:r.x,y:r.y,width:r.width,height:r.height};
695 if current.source.is_some() {
696 if rect.contains(current.current) && current.source.as_ref() != Some(&id) {
697 placement = Some(gesture::drop_placement(&id,rect,current.current,data.direction,viewport.peek().scale)?);
698 }
699 } else if rect.intersects(current.start,current.current) { selected.push(id); }
700 }
701 if handle.revision()? != revision || revision != current.revision || *gesture_epoch.peek()!=epoch { return Err(MindMapError::StaleOperation); }
702 if current.source.is_some() {
703 if !handle.has_plugin(Capability::DragDrop) { return Err(MindMapError::StaleOperation); }
704 if let Some(placement) = placement { handle.apply(Operation::Move {ids:ids?,placement})?; }
705 } else {
706 if !handle.has_plugin(Capability::MultiSelect) { return Err(MindMapError::StaleOperation); }
707 handle.select(&selected)?;
708 }
709 Ok(())
710 }.await; context.report(result);
711 });
712 }
713 }
714 },
715 onpointercancel: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
716 onpointerleave: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
717 onkeydown: move |event| {
718 if event.key() == Key::Escape { gesture.set(None); menu.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} }
719 if event.key() == Key::Home && !event.is_composing() { event.prevent_default(); viewport.set(Viewport::default()); }
720 },
721 if let Err(error) = validation { p { class: "dme-error", role: "alert", "{error}" } }
722 else {
723 CanvasTransform { viewport, canvas, scale_probe,
724
725 svg { class: "dme-links", "aria-hidden": "true",
726 for (color, path) in paths {
727 path { class: "dme-link", d: path, style: color.map(|color| format!("stroke:{color}")) }
728 }
729 }
730 if document.direction == LayoutDirection::Down {
731 div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
732 if root.expanded {
733 div { class: "dme-main dme-down",
734 for (index, child) in root.children.iter().enumerate() { NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Down, selection: selection.clone(), color: palette_color(theme.as_ref(), index) } }
735 }
736 }
737 } else {
738 div { class: "dme-main dme-left",
739 if root.expanded {
740 for (index, child) in root.children.iter().enumerate().filter(|(_, node)| side(document.direction, &root.children, &node.id) == LayoutDirection::Left) {
741 NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Left, selection: selection.clone(), color: palette_color(theme.as_ref(), index) }
742 }
743 }
744 }
745 div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
746 div { class: "dme-main dme-right",
747 if root.expanded {
748 for (index, child) in root.children.iter().enumerate().filter(|(_, node)| side(document.direction, &root.children, &node.id) == LayoutDirection::Right) {
749 NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Right, selection: selection.clone(), color: palette_color(theme.as_ref(), index) }
750 }
751 }
752 }
753 }
754 }
755 }
756 GestureOverlay { gesture, labels: label_signal, mounts, viewport, container, handle }
757 if let (Some(at),Some(handle)) = (menu(),handle) {
758 div { class: "dme-context-menu", role: "menu", style: format!("position:fixed;left:clamp(8px,{}px,calc(100vw - var(--dme-menu-width,200px) - 8px));top:clamp(8px,{}px,40vh)",at.x,at.y),
759 onpointerdown: move |event| event.stop_propagation(),
760 match handle.plugin_commands() {
761 Ok(commands) => rsx! { for command in commands {
762 button { r#type: "button", role: "menuitem", onclick: move |_| { menu.set(None); let topic = label_signal.peek().new_topic.clone(); context.report(plugins::execute(handle,command,&topic)); }, "{label_signal.read().command(command)}" }
763 } },
764 Err(error) => rsx! { p {role: "alert", "{error}"} },
765 }
766 }
767 }
768 if let Some(error) = measurement_error() { p { class: "dme-error", role: "alert", "{error}" } }
769 }
770 }
771}
772
773#[component]
776fn CanvasTransform(
777 viewport: Signal<Viewport>,
778 mut canvas: Signal<Option<Rc<MountedData>>>,
779 mut scale_probe: Signal<Option<Rc<MountedData>>>,
780 children: Element,
781) -> Element {
782 let context = use_context::<ViewContext>();
783 let view = viewport();
784 if let Err(error) = view.validate() {
785 return rsx! { p { class: "dme-error", role: "alert", "{error}" } };
786 }
787 let transform = format!(
788 "transform:translate({}px,{}px) scale({});",
789 view.translation.x, view.translation.y, view.scale
790 );
791 rsx! { div { class: "dme-canvas", style: transform, "data-scale": "{view.scale}",
792 onmounted: move |event| canvas.set(Some(event.data())),
793 onresize: move |_| context.resized(),
794 div { class: "dme-scale-probe", aria_hidden: "true", onmounted: move |event| scale_probe.set(Some(event.data())) }
795 {children}
796 } }
797}
798
799fn palette_color(theme: Option<&Theme>, index: usize) -> Option<String> {
801 let palette = &theme?.palette;
802 palette.get(index % palette.len().max(1)).cloned()
803}
804
805fn assigned_side(children: &[Node], id: &str) -> LayoutDirection {
806 let (mut left, mut right) = (0usize, 0usize);
807 for child in children {
808 let direction = match child.direction {
809 Some(NodeDirection::Left) => LayoutDirection::Left,
810 Some(NodeDirection::Right) => LayoutDirection::Right,
811 None if left <= right => LayoutDirection::Left,
812 None => LayoutDirection::Right,
813 };
814 if child.id == id {
815 return direction;
816 }
817 if direction == LayoutDirection::Left {
818 left += 1;
819 } else {
820 right += 1;
821 }
822 }
823 LayoutDirection::Right
824}
825fn side(direction: LayoutDirection, children: &[Node], id: &str) -> LayoutDirection {
826 if direction == LayoutDirection::Side {
827 assigned_side(children, id)
828 } else {
829 direction
830 }
831}
832
833#[component]
834fn NodeView(
835 node: Node,
836 direction: LayoutDirection,
837 selection: Vec<String>,
838 #[props(default)] color: Option<String>,
839) -> Element {
840 let context = use_context::<ViewContext>();
841 let class = match direction {
842 LayoutDirection::Left => "dme-branch dme-left",
843 LayoutDirection::Down => "dme-branch dme-down",
844 _ => "dme-branch dme-right",
845 };
846 let color = node
849 .branch_color
850 .clone()
851 .or(color)
852 .filter(|c| !c.contains([';', '{', '}', '"']));
853 let branch_style = color.as_ref().map(|c| format!("--dme-branch-color:{c}"));
854 rsx! {
855 div { class, style: branch_style, onresize: move |_| context.resized(),
856 TopicView { key: "{node.id}", node: node.clone(), root: false, direction, selected: selection.contains(&node.id) }
857 if node.expanded && !node.children.is_empty() {
858 div { class: "dme-children", onresize: move |_| context.resized(),
859 for child in node.children { NodeView { key: "{child.id}", node: child, direction, selection: selection.clone(), color: color.clone() } }
860 }
861 }
862 }
863 }
864}
865
866#[component]
867fn TopicView(
868 node: Node,
869 root: bool,
870 #[props(default)] direction: LayoutDirection,
871 selected: bool,
872) -> Element {
873 let mut context = use_context::<ViewContext>();
874 let mut mounts = context.mounts;
875 let mut own_element = use_signal(|| None::<Rc<MountedData>>);
876 let cleanup_id = node.id.clone();
877 use_drop(move || {
878 if let Ok(mut mounts) = mounts.try_write() {
879 mounts.remove(&cleanup_id);
880 }
881 });
882 let mut focused = use_signal(|| false);
883 let mount_id = node.id.clone();
884 let click_id = node.id.clone();
885 let drag_id = node.id.clone();
886 let context_id = node.id.clone();
887 let edit_node = node.clone();
888 let keyboard_node = node.clone();
889 let collapse_id = node.id.clone();
890 let expanded = node.expanded;
891 let editing = context
892 .editor
893 .read()
894 .as_ref()
895 .is_some_and(|session| session.id == node.id);
896 let style = node.style.clone().unwrap_or_default();
897 let image = node.image.clone().map(|image| {
898 let src = match context.image_url {
899 Some(resolve) => resolve.call(image.url.clone()),
900 None => image.url.clone(),
901 };
902 (src, image)
903 });
904 let mut broken = use_signal(|| None::<String>);
905 let has_note = node.note.is_some();
906 let has_image = image.is_some();
907 let image_width = image
908 .as_ref()
909 .map(|(_, image)| format!("--dme-image-width:{}px", image.width));
910 let flow = match direction {
911 LayoutDirection::Left => "left",
912 LayoutDirection::Down => "down",
913 _ => "right",
914 };
915 rsx! {
916 div { key: "{node.id}-{editing}", class: "dme-topic", autofocus: selected && !editing, "data-node-id": "{node.id}", "data-root": root, "data-selected": selected, "data-flow": flow,
917 tabindex: "0", role: "treeitem", aria_selected: selected,
918 aria_expanded: if node.children.is_empty() { None } else { Some(expanded.to_string()) },
919 font_size: style.font_size, font_family: style.font_family, color: style.color, background: style.background,
920 font_weight: style.font_weight, width: style.width, border: style.border, text_decoration: style.text_decoration,
921 "data-image": has_image, "data-note": has_note, style: image_width,
922 onmounted: move |event| {
923 own_element.set(Some(event.data()));
924 mounts.write().insert(mount_id.clone(), event.data());
925 if selected && !editing {
926 spawn(async move {
927 if let Err(error) = event.data().set_focus(true).await {
928 if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
929 }
930 });
931 }
932 },
933 onfocus: move |_| focused.set(true),
934 onblur: move |_| focused.set(false),
935 onresize: move |_| context.resized(),
936 onpointerdown: move |event| {
937 event.stop_propagation();
938 context.suppress_click.set(false);
939 if event.trigger_button() == Some(MouseButton::Primary) && !event.modifiers().shift() && !event.modifiers().ctrl() && !event.modifiers().meta() {
940 if let Some(handle) = context.handle {
941 if handle.has_plugin(Capability::DragDrop) && matches!(handle.is_read_only(),Ok(false)) && !root && !editing {
942 if !handle.selection().is_ok_and(|ids| ids.contains(&drag_id)) { context.select(&drag_id); }
943 let at=event.client_coordinates(); let at=Point{x:at.x,y:at.y};
944 let next=context.gesture_epoch.peek().wrapping_add(1);context.gesture_epoch.set(next);
945 match handle.revision() {
946 Ok(revision)=>context.gesture.set(Some(NodeDrag {start:at,current:at,source:Some(drag_id.clone()),active:false,revision})),
947 Err(error)=>context.on_error.call(error),
948 }
949 }
950 }
951 }
952 },
953 oncontextmenu: move |event| {
954 if context.handle.is_some_and(|h| h.has_plugin(Capability::ContextMenu)) {
955 event.prevent_default(); event.stop_propagation(); context.select(&context_id);
956 let at=event.client_coordinates(); context.menu.set(Some(Point{x:at.x,y:at.y}));
957 }
958 },
959 onclick: move |event| { event.stop_propagation(); if *context.suppress_click.peek() {context.suppress_click.set(false); return;} context.click_select(&click_id,event.modifiers().ctrl() || event.modifiers().meta() || event.modifiers().shift()); },
960 ondoubleclick: move |event| { event.stop_propagation(); context.start_edit(&edit_node); },
961 onkeydown: move |event| {
962 if event.is_composing() { return; }
963 if event.key() == Key::F2 { event.prevent_default(); event.stop_propagation(); context.start_edit(&keyboard_node); } else { context.keyboard(event, &keyboard_node); }
964 },
965 if let Some((src, image)) = image {
966 if broken.read().as_deref() == Some(src.as_str()) {
967 span { class: "dme-image-missing", role: "img", aria_label: image.alt.clone().unwrap_or_default(),
968 style: format!("width:{}px;height:{}px", image.width, image.height),
969 "{context.labels.read().image_unavailable}"
970 }
971 } else {
972 img { class: "dme-image", src: "{src}", alt: image.alt.clone().unwrap_or_default(), draggable: "false",
973 width: "{image.width}", height: "{image.height}",
974 style: format!("width:{}px;height:{}px", image.width, image.height),
975 onerror: move |_| broken.set(Some(src.clone())),
976 onload: move |_| context.resized(),
977 }
978 }
979 }
980 if editing {
981 if let Some(session) = context.editor.read().clone() {
982 TopicEditor { key: "{session.id}-{session.revision}", session }
983 }
984 } else { span { class: "dme-topic-text", "{node.topic}" } }
985 if has_note && !editing {
986 span { class: "dme-note-marker", aria_label: "{context.labels.read().has_note}", title: "{context.labels.read().has_note}" }
987 }
988 if selected && !editing {
989 span { class: "dme-focus-marker", aria_hidden: "true",
990 onmounted: move |_| async move {
991 if let Some(handle) = context.handle {
992 if matches!(handle.owns_editor(context.view_id), Ok(true)) {
993 let element = own_element.peek().clone();
994 if let Some(element) = element {
995 if let Err(error) = element.set_focus(true).await {
996 if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
997 }
998 }
999 }
1000 }
1001 }
1002 }
1003 }
1004 if !root && !editing && !node.children.is_empty() && context.handle.is_some() {
1005 button { class: "dme-collapse", r#type: "button", "data-expanded": expanded, aria_expanded: expanded, aria_label: if expanded { context.labels.read().collapse.clone() } else { context.labels.read().expand.clone() },
1006 onpointerdown: move |event| event.stop_propagation(),
1007 onclick: move |event| {
1008 event.stop_propagation();
1009 if let Some(handle) = context.handle { context.report(handle.apply(Operation::SetExpanded { id: collapse_id.clone(), expanded: !expanded }).map(|_| ())); }
1010 },
1011 ondoubleclick: move |event| event.stop_propagation(),
1012 onkeydown: move |event| event.stop_propagation(),
1013 span { class: "dme-collapse-icon", aria_hidden: "true" }
1014 }
1015 }
1016 }
1017 }
1018}
1019
1020fn editor_size(text: &str) -> (usize, f64) {
1023 let rows = text.split('\n').count().max(1);
1024 let width = text
1025 .split('\n')
1026 .map(|line| {
1027 line.chars()
1028 .map(|c| if c.is_ascii() { 0.6 } else { 1.0 })
1029 .sum::<f64>()
1030 })
1031 .fold(1.0_f64, f64::max);
1032 (rows, width + 0.5)
1033}
1034
1035#[component]
1036fn TopicEditor(session: EditSession) -> Element {
1037 let context = use_context::<ViewContext>();
1038 let mut draft = use_signal(|| session.original.clone());
1039 let mut composing = use_signal(|| false);
1040 let mut composition_ended = use_signal(|| false);
1041 let mut finished = use_signal(|| false);
1042 let mut focused = use_signal(|| false);
1043 let mut editor = context.editor;
1044 let edited = use_signal(|| session.id.clone());
1045 let commit = move || -> Result<(), MindMapError> {
1049 let handle = context.handle.ok_or(MindMapError::StaleOperation)?;
1050 if !handle.edits(context.view_id, &edited.peek()) {
1051 return Ok(());
1052 }
1053 handle.update_draft(context.view_id, draft.peek().clone());
1054 handle.commit_pending().map(|_| ())
1055 };
1056 let discard = move || {
1057 if let Some(handle) = context.handle {
1058 context.report(handle.cancel_pending());
1059 }
1060 };
1061 let text = draft.read().clone();
1064 let (rows, width) = editor_size(&text);
1065 rsx! {
1066 textarea { class: "dme-editor", autofocus: true, aria_label: "{context.labels.read().edit_topic}", rows: "{rows}", value: "{text}",
1067 style: "--dme-editor-width:{width}em",
1068 onmounted: move |event| async move {
1069 if let Err(error) = event.data().set_focus(true).await {
1070 if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
1073 }
1074 },
1075 onfocus: move |_| focused.set(true),
1076 oninput: move |event| {
1077 let value = event.value();
1078 if let Some(handle) = context.handle { handle.update_draft(context.view_id, value.clone()); }
1079 draft.set(value); context.resized();
1080 },
1081 oncompositionstart: move |_| { composing.set(true); composition_ended.set(false); },
1082 oncompositionend: move |_| { composing.set(false); composition_ended.set(true); },
1083 onkeyup: move |_| composition_ended.set(false),
1084 onclick: move |event| event.stop_propagation(),
1085 onwheel: move |event| event.stop_propagation(),
1086 onkeydown: move |event| {
1087 event.stop_propagation();
1088 if composing() || composition_ended() || event.is_composing() { return; }
1089 let cancel = event.key() == Key::Escape;
1090 let submit = event.key() == Key::Tab || (event.key() == Key::Enter && !event.modifiers().shift());
1091 if cancel || submit {
1092 event.prevent_default(); finished.set(true);
1093 if submit { context.report(commit()); } else { discard(); }
1094 editor.set(None);
1095
1096 }
1097 },
1098 onblur: move |_| {
1099 if !finished() {
1102 finished.set(true);
1103 if context.blur_commits { context.report(commit()); } else { discard(); }
1104 editor.set(None);
1105 }
1106 },
1107 }
1108 }
1109}
1110
1111#[component]
1112fn GestureOverlay(
1113 gesture: Signal<Option<NodeDrag>>,
1114 labels: Signal<Labels>,
1115 mounts: Signal<Mounts>,
1116 mut viewport: Signal<Viewport>,
1117 container: Signal<Option<Rc<MountedData>>>,
1118 handle: Option<MindMapHandle>,
1119) -> Element {
1120 let edge = use_resource(move || async move {
1121 let current = gesture.read().clone();
1122 let enabled = handle.is_some_and(|handle| handle.has_plugin(Capability::EdgePan));
1123 let Some(current) =
1124 current.filter(|current| current.active && current.source.is_some() && enabled)
1125 else {
1126 return Ok::<(), MindMapError>(());
1127 };
1128 let mounted = container.peek().clone();
1129 let Some(mounted) = mounted else {
1130 return Ok::<(), MindMapError>(());
1131 };
1132 loop {
1133 let rect = platform::measure(&mounted).await?;
1134 let delta = gesture::edge_pan(
1135 HitRect {
1136 x: rect.x,
1137 y: rect.y,
1138 width: rect.width,
1139 height: rect.height,
1140 },
1141 current.current,
1142 )?;
1143 if delta == Point::default() {
1144 return Ok::<(), MindMapError>(());
1145 }
1146 if gesture.peek().as_ref() != Some(¤t) {
1148 return Ok::<(), MindMapError>(());
1149 }
1150 let mut next = *viewport.peek();
1151 next.pan(delta)?;
1152 viewport.set(next);
1153 futures_timer::Delay::new(std::time::Duration::from_millis(16)).await;
1154 }
1155 });
1156 let target = use_resource(move || async move {
1157 let current = gesture.read().clone();
1158 let elements = mounts.peek().clone();
1159 let Some(current) = current.filter(|current| current.active && current.source.is_some())
1160 else {
1161 return Ok(None);
1162 };
1163 for (id, mounted) in elements {
1164 if current.source.as_ref() == Some(&id) {
1165 continue;
1166 }
1167 let r = platform::measure(&mounted).await?;
1168 let rect = HitRect {
1169 x: r.x,
1170 y: r.y,
1171 width: r.width,
1172 height: r.height,
1173 };
1174 if rect.contains(current.current) {
1175 return Ok::<_, MindMapError>(Some((id, rect, current.current)));
1176 }
1177 }
1178 Ok(None)
1179 });
1180 rsx! {
1181 if let Some(Err(error))=&*edge.read() {span {class:"dme-error",role:"alert","{error}"}}
1182 if let Some(current)=gesture() {
1183 if current.active {
1184 if current.source.is_some() {
1185 div {class:"dme-drag-status",role:"status", "{labels.read().moving}"}
1186 if let Some(Ok(Some((id,rect,at))))=&*target.read() {
1187 div {class:"dme-drop-target", "data-drop-id":id.clone(), style:format!("position:fixed;left:{}px;top:{}px;width:{}px;height:{}px;--dme-drop-band:{}px;",rect.x,rect.y,rect.width,rect.height,12.0*viewport.peek().scale),
1188 span {class:"dme-drop-label", "{labels.read().drop_here}"}
1189 }
1190 span {class:"dme-drag-point",style:format!("position:fixed;left:{}px;top:{}px",at.x,at.y)}
1192 }
1193 if let Some(Err(error))=&*target.read() {span {class:"dme-error",role:"alert","{error}"}}
1194 } else {
1195 div {class:"dme-marquee",style:format!("position:fixed;left:{}px;top:{}px;width:{}px;height:{}px",current.start.x.min(current.current.x),current.start.y.min(current.current.y),(current.current.x-current.start.x).abs(),(current.current.y-current.start.y).abs())}
1196 }
1197 }
1198 }
1199 }
1200}