Skip to main content

dioxus_mind_elixir/
view.rs

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/// Vertical alignment of the root and side groups.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum Alignment {
20    /// Center the root against the full branch group.
21    #[default]
22    Root,
23    /// Align side groups at their first nodes.
24    Nodes,
25}
26
27/// Properties of the read-only [`MindMap`] renderer.
28#[derive(Props, Clone, PartialEq)]
29pub struct MindMapProps {
30    /// Validated persistent document, reactive without remounting the map.
31    pub data: ReadSignal<MindMapData>,
32    /// Host CSS class.
33    #[props(default)]
34    pub class: String,
35    /// Host CSS, including inherited `--dme-*` variables.
36    #[props(default)]
37    pub style: String,
38    /// Explicit invalidation for host animations/position-only changes.
39    #[props(default)]
40    pub measurement_revision: u64,
41    /// Root alignment.
42    #[props(default)]
43    pub alignment: Alignment,
44    /// Optional explicit theme; omitted preserves ancestor CSS variables.
45    #[props(default)]
46    pub theme: Option<Theme>,
47    /// Optional per-view viewport control, separate from document history.
48    #[props(default)]
49    pub viewport: Option<Signal<Viewport>>,
50    /// Increment to center the root in the visible container.
51    #[props(default)]
52    pub center_revision: u64,
53    /// Reports recoverable errors.
54    #[props(default)]
55    pub on_error: EventHandler<MindMapError>,
56    /// Increment to fit all visible nodes into the container (zoom ≤ 100%).
57    #[props(default)]
58    pub fit_revision: u64,
59    /// Map a stored node image reference to a URL the renderer can load.
60    #[props(default)]
61    pub image_url: Option<Callback<String, String>>,
62}
63
64/// Render a reactive document without an editing controller.
65#[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/// Editable view of a shared checked document. Double-click a node to edit.
71/// Enter/Tab commit, Shift+Enter inserts a line break, Escape cancels. Blur
72/// cancels unless `blur_commits` is set. The draft is visible to the host via
73/// [`MindMapHandle::pending_edit`]. Wheel pans; Ctrl/Meta+wheel zooms.
74/// Viewport state belongs to this view, never to history.
75#[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        // Desktop queries cross the renderer boundary. Reject mixed snapshots
396        // if the canvas moved/resized/scaled during the batch. Resize/mount or
397        // document changes already restart this scoped resource.
398        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        // Merge paths with identical effective stroke, keeping DOM writes batched.
405        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((&center_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    // Deleting/replacing the edited node, or changing its text elsewhere, cancels
536    // this view's draft. Selection changes (which bump the revision) do not.
537    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                    // Wheel coordinates are measured against the view container,
627                    // not the possibly nested event target.
628                    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// Isolate high-frequency viewport updates: pan only updates this transform,
774// without rerunning MapBody or cloning the document/tree component props.
775#[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
799/// Main-branch color from the theme palette, by position among the root's children.
800fn 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    // Effective branch color (the node's own, else its main branch's palette
847    // color) as `--dme-branch-color`, for host styling of topics and markers.
848    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
1020/// Rows and approximate width (em) that fit `text`, so the inline editor keeps
1021/// the size of the topic instead of a large fixed box.
1022fn 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    // A pointer press selects before the editor blurs, and selection changes bump
1046    // the revision, so the revision must not invalidate the draft. Changes to the
1047    // edited node itself close the editor through the effect in `MapBody`.
1048    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    // The editor re-renders as it grows, so its value must follow the draft;
1062    // rendering the original text again would discard what was typed.
1063    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                    // Dioxus Desktop 0.7.10 returns null for successful focus.
1071                    // Only a received native focus event confirms success.
1072                    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                // Blur cancels by default; hosts that protect drafts opt into a
1100                // commit. Composition never blurs, so IME confirmation is safe.
1101                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            // The resource is cancelled on pointer movement, release, disable or unmount.
1147            if gesture.peek().as_ref() != Some(&current) {
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                        // The hit test used by commit decides before / after / child.
1191                        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}