Skip to main content

dioxus_mind_elixir/
view.rs

1use crate::plugins::{
2    self, Capability, Command, Locale,
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}
57
58/// Render a reactive document without an editing controller.
59#[component]
60pub fn MindMap(props: MindMapProps) -> Element {
61    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 } }
62}
63
64/// Editable view of a shared checked document. Double-click a node to edit.
65/// Enter/Tab commit, Shift+Enter inserts a line break, Escape cancels. Wheel pans;
66/// Ctrl/Meta+wheel zooms. Viewport state belongs to this view, never to history.
67#[component]
68pub fn EditableMindMap(
69    handle: MindMapHandle,
70    #[props(default)] class: String,
71    #[props(default)] style: String,
72    #[props(default)] measurement_revision: u64,
73    #[props(default)] alignment: Alignment,
74    #[props(default)] theme: Option<Theme>,
75    #[props(default)] viewport: Option<Signal<Viewport>>,
76    #[props(default)] center_revision: u64,
77    #[props(default)] on_error: EventHandler<MindMapError>,
78    #[props(default)] locale: Locale,
79) -> Element {
80    let document = handle.document();
81    match document {
82        Ok(document) => {
83            rsx! { MapBody { document, handle: Some(handle), locale, class, style, measurement_revision, alignment, theme, viewport_control: viewport, center_revision, on_error } }
84        }
85        Err(error) => rsx! { p { role: "alert", "{error}" } },
86    }
87}
88
89#[derive(Clone, PartialEq)]
90struct EditSession {
91    id: String,
92    original: String,
93    revision: u64,
94}
95#[derive(Clone, PartialEq)]
96struct NodeDrag {
97    start: Point,
98    current: Point,
99    source: Option<String>,
100    active: bool,
101    revision: u64,
102}
103#[derive(Clone, Copy)]
104struct ViewContext {
105    mounts: Signal<Mounts>,
106    resize: Signal<u64>,
107    editor: Signal<Option<EditSession>>,
108    handle: Option<MindMapHandle>,
109    on_error: EventHandler<MindMapError>,
110    view_id: usize,
111    gesture: Signal<Option<NodeDrag>>,
112    menu: Signal<Option<Point>>,
113    locale: Locale,
114    suppress_click: Signal<bool>,
115    gesture_epoch: Signal<u64>,
116}
117impl ViewContext {
118    fn resized(mut self) {
119        let next = self.resize.peek().saturating_add(1);
120        self.resize.set(next);
121    }
122    fn report(self, result: Result<(), MindMapError>) {
123        if let Err(error) = result {
124            self.on_error.call(error);
125        }
126    }
127    fn select(self, id: &str) {
128        if let Some(handle) = self.handle {
129            self.report(handle.select(&[id.to_owned()]));
130        }
131    }
132    fn click_select(self, id: &str, toggle: bool) {
133        if let Some(handle) = self.handle {
134            let result = (|| {
135                let mut ids = if toggle && handle.has_plugin(Capability::MultiSelect) {
136                    handle.selection()?
137                } else {
138                    Vec::new()
139                };
140                if let Some(index) = ids.iter().position(|item| item == id) {
141                    ids.remove(index);
142                } else {
143                    ids.push(id.into());
144                }
145                handle.select(&ids)
146            })();
147            self.report(result);
148        }
149    }
150    fn keyboard(self, event: KeyboardEvent, node: &Node) {
151        if event.is_composing() {
152            return;
153        }
154        let Some(handle) = self.handle else {
155            return;
156        };
157        if !handle.has_plugin(Capability::Keyboard) {
158            return;
159        }
160        let modifiers = event.modifiers();
161        let primary = modifiers.ctrl() || modifiers.meta();
162        let command = match event.key() {
163            Key::Enter if primary => Some(Command::Parent),
164            Key::Enter if modifiers.shift() => Some(Command::SiblingBefore),
165            Key::Enter => Some(Command::SiblingAfter),
166            Key::Tab => Some(Command::Child),
167            Key::Delete | Key::Backspace => Some(Command::Delete),
168            Key::Character(ref c) if primary && c.eq_ignore_ascii_case("z") => {
169                Some(if modifiers.shift() {
170                    Command::Redo
171                } else {
172                    Command::Undo
173                })
174            }
175            Key::Character(ref c) if primary && c.eq_ignore_ascii_case("y") => Some(Command::Redo),
176            _ => None,
177        };
178        if let Some(command) = command {
179            event.prevent_default();
180            event.stop_propagation();
181            self.report((|| {
182                if handle.selection()?.is_empty() {
183                    handle.select(std::slice::from_ref(&node.id))?;
184                }
185                plugins::execute(handle, command, self.locale)
186            })());
187        } else if matches!(
188            event.key(),
189            Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight
190        ) {
191            event.prevent_default();
192            event.stop_propagation();
193            let elements = self.mounts.peek().clone();
194            let id = node.id.clone();
195            let key = event.key();
196            spawn(async move {
197                let result = async {
198                    let revision = handle.revision()?;
199                    let origin = elements.get(&id).ok_or(MindMapError::StaleOperation)?;
200                    let origin = platform::measure(origin).await?;
201                    let x = origin.x + origin.width / 2.0;
202                    let y = origin.y + origin.height / 2.0;
203                    let mut best = None::<(f64, String)>;
204                    for (candidate, element) in &elements {
205                        if candidate == &id {
206                            continue;
207                        }
208                        let rect = platform::measure(element).await?;
209                        let dx = rect.x + rect.width / 2.0 - x;
210                        let dy = rect.y + rect.height / 2.0 - y;
211                        let (forward, cross) = match key {
212                            Key::ArrowLeft => (-dx, dy.abs()),
213                            Key::ArrowRight => (dx, dy.abs()),
214                            Key::ArrowUp => (-dy, dx.abs()),
215                            _ => (dy, dx.abs()),
216                        };
217                        if forward > 1.0 {
218                            let score = forward + cross * 3.0;
219                            if best.as_ref().is_none_or(|(previous, _)| score < *previous) {
220                                best = Some((score, candidate.clone()));
221                            }
222                        }
223                    }
224                    if handle.revision()? != revision {
225                        return Err(MindMapError::StaleOperation);
226                    }
227                    if let Some((_, id)) = best {
228                        handle.claim_editor(self.view_id)?;
229                        handle.select(&[id])?;
230                    }
231                    Ok(())
232                }
233                .await;
234                self.report(result);
235            });
236        }
237    }
238    fn start_edit(mut self, node: &Node) {
239        if let Some(handle) = self.handle {
240            let result = (|| {
241                if handle.is_read_only()? {
242                    return Err(MindMapError::ReadOnly);
243                }
244                handle.select(std::slice::from_ref(&node.id))?;
245                handle.claim_editor(self.view_id)?;
246                self.editor.set(Some(EditSession {
247                    id: node.id.clone(),
248                    original: node.topic.clone(),
249                    revision: handle.revision()?,
250                }));
251                Ok(())
252            })();
253            self.report(result);
254        }
255    }
256}
257
258#[component]
259fn MapBody(
260    document: MindMapData,
261    #[props(default)] handle: Option<MindMapHandle>,
262    class: String,
263    style: String,
264    measurement_revision: u64,
265    alignment: Alignment,
266    #[props(default)] theme: Option<Theme>,
267    #[props(default)] viewport_control: Option<Signal<Viewport>>,
268    #[props(default)] center_revision: u64,
269    on_error: EventHandler<MindMapError>,
270    #[props(default)] locale: Locale,
271) -> Element {
272    let mounts = use_signal(Mounts::new);
273    let canvas = use_signal(|| None::<Rc<MountedData>>);
274    let scale_probe = use_signal(|| None::<Rc<MountedData>>);
275    let mut container = use_signal(|| None::<Rc<MountedData>>);
276    let resize = use_signal(|| 0u64);
277    let editor = use_signal(|| None::<EditSession>);
278    let mut gesture = use_signal(|| None::<NodeDrag>);
279    let mut menu = use_signal(|| None::<Point>);
280    let suppress_click = use_signal(|| false);
281    let mut gesture_epoch = use_signal(|| 0u64);
282    let context = ViewContext {
283        mounts,
284        resize,
285        editor,
286        handle,
287        on_error,
288        view_id: dioxus::core::current_scope_id().0,
289        gesture,
290        menu,
291        locale,
292        suppress_click,
293        gesture_epoch,
294    };
295    use_context_provider(|| context);
296    let local_viewport = use_signal(Viewport::default);
297    let mut viewport = viewport_control.unwrap_or(local_viewport);
298    let mut drag = use_signal(|| None::<Point>);
299    let mut measurement_error = use_signal(|| None::<MindMapError>);
300    let mut input = use_signal(|| (document.clone(), measurement_revision, theme.clone()));
301    use_effect(use_reactive(
302        (&document, &measurement_revision, &theme),
303        move |(document, revision, theme)| {
304            input.set((document, revision, theme));
305        },
306    ));
307    let geometry = use_resource(move || async move {
308        let (document, _, theme) = input.read().clone();
309        let elements = mounts.read().clone();
310        let element = canvas.read().clone();
311        let probe = scale_probe.read().clone();
312        let _ = resize();
313        document.validate()?;
314        let (Some(element), Some(probe)) = (element, probe) else {
315            return Ok(Vec::new());
316        };
317        let scale = platform::measure(&probe).await?.width / 100.0;
318        if !scale.is_finite() || scale <= 0.0 {
319            return Err(MindMapError::InvalidGeometry);
320        }
321        let canvas_rect = platform::measure(&element).await?;
322        let mut pending = vec![(
323            &document.node_data,
324            None::<String>,
325            document.direction,
326            None::<String>,
327        )];
328        let mut rectangles = BTreeMap::new();
329        let mut edges = Vec::new();
330        while let Some((node, parent, direction, color)) = pending.pop() {
331            let Some(element) = elements.get(&node.id) else {
332                return Ok(Vec::new());
333            };
334            rectangles.insert(
335                node.id.clone(),
336                local(platform::measure(element).await?, canvas_rect, scale),
337            );
338            let color = node.branch_color.clone().or(color);
339            if let Some(parent_id) = parent {
340                edges.push((parent_id, node.id.clone(), direction, color.clone()));
341            }
342            if node.expanded {
343                for (index, child) in node.children.iter().enumerate().rev() {
344                    let child_direction = if node.id == document.node_data.id
345                        && document.direction == LayoutDirection::Side
346                    {
347                        match child.direction {
348                            Some(NodeDirection::Left) => LayoutDirection::Left,
349                            Some(NodeDirection::Right) => LayoutDirection::Right,
350                            None => assigned_side(&document.node_data.children, &child.id),
351                        }
352                    } else {
353                        direction
354                    };
355                    let child_color = if node.id == document.node_data.id {
356                        theme
357                            .as_ref()
358                            .and_then(|theme| {
359                                if theme.palette.is_empty() {
360                                    None
361                                } else {
362                                    theme.palette.get(index % theme.palette.len()).cloned()
363                                }
364                            })
365                            .or_else(|| color.clone())
366                    } else {
367                        color.clone()
368                    };
369                    pending.push((child, Some(node.id.clone()), child_direction, child_color));
370                }
371            }
372        }
373        // Desktop queries cross the renderer boundary. Reject mixed snapshots
374        // if the canvas moved/resized/scaled during the batch. Resize/mount or
375        // document changes already restart this scoped resource.
376        let final_canvas = platform::measure(&element).await?;
377        let final_scale = platform::measure(&probe).await?.width / 100.0;
378        if final_canvas != canvas_rect || (final_scale - scale).abs() > 0.0001 {
379            context.resized();
380            return Ok(Vec::new());
381        }
382        // Merge paths with identical effective stroke, keeping DOM writes batched.
383        let mut groups = BTreeMap::<Option<String>, String>::new();
384        for (parent_id, child_id, direction, color) in edges {
385            if let (Some(parent), Some(child)) =
386                (rectangles.get(&parent_id), rectangles.get(&child_id))
387            {
388                let path = branch(
389                    *parent,
390                    *child,
391                    direction,
392                    parent_id == document.node_data.id,
393                    canvas_rect.height / scale,
394                );
395                let group = groups.entry(color).or_default();
396                group.push_str(&path);
397                group.push(' ');
398            }
399        }
400        Ok::<_, MindMapError>(groups.into_iter().collect::<Vec<_>>())
401    });
402    use_effect(move || {
403        let error = match &*geometry.read() {
404            Some(Err(error)) => Some(error.clone()),
405            _ => None,
406        };
407        if *measurement_error.peek() != error {
408            measurement_error.set(error.clone());
409            if let Some(error) = error {
410                on_error.call(error);
411            }
412        }
413    });
414    let mut center_request = use_signal(|| 0u64);
415    use_effect(use_reactive((&center_revision,), move |(revision,)| {
416        center_request.set(revision)
417    }));
418    let _center = use_resource(move || async move {
419        let request = center_request();
420        if request == 0 {
421            return;
422        }
423        let root_id = input.read().0.node_data.id.clone();
424        let elements = mounts.read().clone();
425        let view_element = container.read().clone();
426        let Some(root) = elements.get(&root_id) else {
427            return;
428        };
429        let Some(view_element) = view_element else {
430            return;
431        };
432        let before = *viewport.peek();
433        let result = async {
434            let root = platform::measure(root).await?;
435            let view = platform::measure(&view_element).await?;
436            let target = before.to_canvas(Point {
437                x: root.x + root.width / 2.0 - view.x,
438                y: root.y + root.height / 2.0 - view.y,
439            })?;
440            let mut next = before;
441            next.center(
442                target,
443                Point {
444                    x: view.width,
445                    y: view.height,
446                },
447            )?;
448            if *viewport.peek() == before {
449                viewport.set(next);
450            }
451            Ok(())
452        }
453        .await;
454        context.report(result);
455    });
456    // Deleting/replacing the edited node, or changing it elsewhere, cancels this
457    // view's draft. No stale asynchronous result may restore that editor.
458    use_effect(move || {
459        let current = input.read();
460        let session = context.editor.read().clone();
461        if let Some(session) = session {
462            let exists = current
463                .0
464                .node_data
465                .find(&session.id)
466                .is_some_and(|node| node.topic == session.original);
467            let writable = match handle {
468                Some(handle) => {
469                    matches!(handle.is_read_only(), Ok(false))
470                        && matches!(handle.owns_editor(context.view_id), Ok(true))
471                        && matches!(handle.revision(), Ok(revision) if revision == session.revision)
472                }
473                None => false,
474            };
475            if !exists || !writable {
476                let mut editor = context.editor;
477                editor.set(None);
478            }
479        }
480    });
481    use_effect(move || {
482        if let Some(handle) = handle {
483            let revision = handle.revision();
484            let node_enabled = handle.has_plugin(Capability::DragDrop)
485                && matches!(handle.is_read_only(), Ok(false));
486            let select_enabled = handle.has_plugin(Capability::MultiSelect);
487            let stale = gesture.peek().as_ref().is_some_and(|current| {
488                revision != Ok(current.revision)
489                    || if current.source.is_some() {
490                        !node_enabled
491                    } else {
492                        !select_enabled
493                    }
494            });
495            if stale {
496                gesture.set(None);
497            }
498            if !handle.has_plugin(Capability::ContextMenu) {
499                menu.set(None);
500            }
501        }
502    });
503    let selection = match handle {
504        Some(handle) => handle.selection(),
505        None => Ok(Vec::new()),
506    };
507    let selection = match selection {
508        Ok(selection) => selection,
509        Err(error) => return rsx! { p { role: "alert", "{error}" } },
510    };
511    let paths = match &*geometry.read() {
512        Some(Ok(paths)) => paths.clone(),
513        _ => Vec::new(),
514    };
515    let direction = match document.direction {
516        LayoutDirection::Left => "left",
517        LayoutDirection::Right => "right",
518        LayoutDirection::Side => "side",
519        LayoutDirection::Down => "down",
520    };
521    let align = if alignment == Alignment::Nodes {
522        "nodes"
523    } else {
524        "root"
525    };
526    let style = format!(
527        "{}{style}",
528        theme.as_ref().map(Theme::css).unwrap_or_default()
529    );
530    let root = &document.node_data;
531    let validation = document.validate();
532    rsx! {
533        style { "{STYLESHEET}" }
534        div {
535            class: "dme-map {class}", style,
536            role: "group", aria_label: "Mind map", tabindex: "0",
537            "data-layout": direction, "data-alignment": align, "data-compact": document.compact,
538            onmounted: move |event| container.set(Some(event.data())),
539            onwheel: move |event| {
540                event.prevent_default();
541                let delta = event.delta().strip_units();
542                let result = if event.modifiers().ctrl() || event.modifiers().meta() {
543                    // Wheel coordinates are measured against the view container,
544                    // not the possibly nested event target.
545                    let at = event.client_coordinates();
546                    let element = container.read().clone();
547                    spawn(async move {
548                        if let Some(element) = element {
549                            let result = async {
550                                let rect = platform::measure(&element).await?;
551                                let mut next = *viewport.peek();
552                                next.zoom_at(next.scale * (-delta.y * 0.002).exp(), Point { x: at.x - rect.x, y: at.y - rect.y })?;
553                                viewport.set(next); Ok(())
554                            }.await;
555                            context.report(result);
556                        }
557                    });
558                    Ok(())
559                } else {
560                    let mut next = *viewport.peek();
561                    let result = next.pan(Point { x: -delta.x, y: -delta.y });
562                    if result.is_ok() { viewport.set(next); } result
563                };
564                context.report(result);
565            },
566            onpointerdown: move |event| {
567                if event.trigger_button() == Some(MouseButton::Primary) {
568                    let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
569                    menu.set(None);
570                    if event.modifiers().shift() && handle.is_some_and(|h| h.has_plugin(Capability::MultiSelect)) {
571                        {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);}
572                        if let Some(handle)=handle {
573                            match handle.select(&[]).and_then(|_|handle.revision()) {
574                                Ok(revision)=>gesture.set(Some(NodeDrag {start: point,current:point,source:None,active:false,revision})),
575                                Err(error)=>context.on_error.call(error),
576                            }
577                        }
578                    } else { drag.set(Some(point)); }
579                    if let Some(handle) = handle { context.report(handle.select(&[])); }
580                }
581            },
582            onpointermove: move |event| {
583                let at = event.client_coordinates(); let point = Point { x: at.x,y:at.y };
584                let current_gesture = gesture.peek().clone();
585                if let Some(mut current) = current_gesture {
586                    current.current = point; current.active = current.active || gesture::is_drag(current.start,point);
587                    gesture.set(Some(current));
588                    return;
589                }
590                let previous = *drag.peek();
591                if let Some(previous) = previous {
592                    let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
593                    let mut next = *viewport.peek();
594                    let result = next.pan(Point { x: point.x - previous.x, y: point.y - previous.y });
595                    if result.is_ok() { viewport.set(next); drag.set(Some(point)); } context.report(result);
596                }
597            },
598            onpointerup: move |_| {
599                drag.set(None); let current = gesture.peek().clone(); gesture.set(None);
600                if let (Some(current),Some(handle)) = (current,handle) {
601                    if current.active {
602                        let mut suppress_click = suppress_click; suppress_click.set(true);
603                        let epoch=*gesture_epoch.peek();
604                        let ids = handle.selection();
605                        let elements = mounts.peek().clone(); let revision = handle.revision();
606                        spawn(async move {
607                            let result = async {
608                                let revision = revision?; let data = handle.document()?; let mut selected = Vec::new(); let mut placement = None;
609                                for (id,mounted) in elements {
610                                    let r = platform::measure(&mounted).await?;
611                                    let rect = HitRect {x:r.x,y:r.y,width:r.width,height:r.height};
612                                    if current.source.is_some() {
613                                        if rect.contains(current.current) && current.source.as_ref() != Some(&id) {
614                                            placement = Some(gesture::drop_placement(&id,rect,current.current,data.direction,viewport.peek().scale)?);
615                                        }
616                                    } else if rect.intersects(current.start,current.current) { selected.push(id); }
617                                }
618                                if handle.revision()? != revision || revision != current.revision || *gesture_epoch.peek()!=epoch { return Err(MindMapError::StaleOperation); }
619                                if current.source.is_some() {
620                                    if !handle.has_plugin(Capability::DragDrop) { return Err(MindMapError::StaleOperation); }
621                                    if let Some(placement) = placement { handle.apply(Operation::Move {ids:ids?,placement})?; }
622                                } else {
623                                    if !handle.has_plugin(Capability::MultiSelect) { return Err(MindMapError::StaleOperation); }
624                                    handle.select(&selected)?;
625                                }
626                                Ok(())
627                            }.await; context.report(result);
628                        });
629                    }
630                }
631            },
632            onpointercancel: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
633            onpointerleave: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
634            onkeydown: move |event| {
635                if event.key() == Key::Escape { gesture.set(None); menu.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} }
636                if event.key() == Key::Home && !event.is_composing() { event.prevent_default(); viewport.set(Viewport::default()); }
637            },
638            if let Err(error) = validation { p { class: "dme-error", role: "alert", "{error}" } }
639            else {
640                CanvasTransform { viewport, canvas, scale_probe,
641
642                    svg { class: "dme-links", "aria-hidden": "true",
643                        for (color, path) in paths {
644                            path { class: "dme-link", d: path, style: color.map(|color| format!("stroke:{color}")) }
645                        }
646                    }
647                    if document.direction == LayoutDirection::Down {
648                        div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
649                        if root.expanded {
650                            div { class: "dme-main dme-down",
651                                for child in &root.children { NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Down, selection: selection.clone() } }
652                            }
653                        }
654                    } else {
655                        div { class: "dme-main dme-left",
656                            if root.expanded {
657                                for child in root.children.iter().filter(|node| side(document.direction, &root.children, &node.id) == LayoutDirection::Left) {
658                                    NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Left, selection: selection.clone() }
659                                }
660                            }
661                        }
662                        div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
663                        div { class: "dme-main dme-right",
664                            if root.expanded {
665                                for child in root.children.iter().filter(|node| side(document.direction, &root.children, &node.id) == LayoutDirection::Right) {
666                                    NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Right, selection: selection.clone() }
667                                }
668                            }
669                        }
670                    }
671                }
672            }
673            GestureOverlay { gesture, locale, mounts, viewport, container, handle }
674            if let (Some(at),Some(handle)) = (menu(),handle) {
675                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),
676                    onpointerdown: move |event| event.stop_propagation(),
677                    match handle.plugin_commands() {
678                        Ok(commands) => rsx! { for command in commands {
679                            button { r#type: "button", role: "menuitem", onclick: move |_| { menu.set(None); context.report(plugins::execute(handle,command,locale)); }, "{locale.label(command)}" }
680                        } },
681                        Err(error) => rsx! { p {role: "alert", "{error}"} },
682                    }
683                }
684            }
685            if let Some(error) = measurement_error() { p { class: "dme-error", role: "alert", "{error}" } }
686        }
687    }
688}
689
690// Isolate high-frequency viewport updates: pan only updates this transform,
691// without rerunning MapBody or cloning the document/tree component props.
692#[component]
693fn CanvasTransform(
694    viewport: Signal<Viewport>,
695    mut canvas: Signal<Option<Rc<MountedData>>>,
696    mut scale_probe: Signal<Option<Rc<MountedData>>>,
697    children: Element,
698) -> Element {
699    let context = use_context::<ViewContext>();
700    let view = viewport();
701    if let Err(error) = view.validate() {
702        return rsx! { p { class: "dme-error", role: "alert", "{error}" } };
703    }
704    let transform = format!(
705        "transform:translate({}px,{}px) scale({});",
706        view.translation.x, view.translation.y, view.scale
707    );
708    rsx! { div { class: "dme-canvas", style: transform, "data-scale": "{view.scale}",
709        onmounted: move |event| canvas.set(Some(event.data())),
710        onresize: move |_| context.resized(),
711        div { class: "dme-scale-probe", aria_hidden: "true", onmounted: move |event| scale_probe.set(Some(event.data())) }
712        {children}
713    } }
714}
715
716fn assigned_side(children: &[Node], id: &str) -> LayoutDirection {
717    let (mut left, mut right) = (0usize, 0usize);
718    for child in children {
719        let direction = match child.direction {
720            Some(NodeDirection::Left) => LayoutDirection::Left,
721            Some(NodeDirection::Right) => LayoutDirection::Right,
722            None if left <= right => LayoutDirection::Left,
723            None => LayoutDirection::Right,
724        };
725        if child.id == id {
726            return direction;
727        }
728        if direction == LayoutDirection::Left {
729            left += 1;
730        } else {
731            right += 1;
732        }
733    }
734    LayoutDirection::Right
735}
736fn side(direction: LayoutDirection, children: &[Node], id: &str) -> LayoutDirection {
737    if direction == LayoutDirection::Side {
738        assigned_side(children, id)
739    } else {
740        direction
741    }
742}
743
744#[component]
745fn NodeView(node: Node, direction: LayoutDirection, selection: Vec<String>) -> Element {
746    let context = use_context::<ViewContext>();
747    let class = match direction {
748        LayoutDirection::Left => "dme-branch dme-left",
749        LayoutDirection::Down => "dme-branch dme-down",
750        _ => "dme-branch dme-right",
751    };
752    rsx! {
753        div { class, onresize: move |_| context.resized(),
754            TopicView { key: "{node.id}", node: node.clone(), root: false, direction, selected: selection.contains(&node.id) }
755            if node.expanded && !node.children.is_empty() {
756                div { class: "dme-children", onresize: move |_| context.resized(),
757                    for child in node.children { NodeView { key: "{child.id}", node: child, direction, selection: selection.clone() } }
758                }
759            }
760        }
761    }
762}
763
764#[component]
765fn TopicView(
766    node: Node,
767    root: bool,
768    #[props(default)] direction: LayoutDirection,
769    selected: bool,
770) -> Element {
771    let mut context = use_context::<ViewContext>();
772    let mut mounts = context.mounts;
773    let mut own_element = use_signal(|| None::<Rc<MountedData>>);
774    let cleanup_id = node.id.clone();
775    use_drop(move || {
776        if let Ok(mut mounts) = mounts.try_write() {
777            mounts.remove(&cleanup_id);
778        }
779    });
780    let mut focused = use_signal(|| false);
781    let mount_id = node.id.clone();
782    let click_id = node.id.clone();
783    let drag_id = node.id.clone();
784    let context_id = node.id.clone();
785    let edit_node = node.clone();
786    let keyboard_node = node.clone();
787    let collapse_id = node.id.clone();
788    let expanded = node.expanded;
789    let editing = context
790        .editor
791        .read()
792        .as_ref()
793        .is_some_and(|session| session.id == node.id);
794    let style = node.style.clone().unwrap_or_default();
795    let flow = match direction {
796        LayoutDirection::Left => "left",
797        LayoutDirection::Down => "down",
798        _ => "right",
799    };
800    rsx! {
801        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,
802            tabindex: "0", role: "treeitem", aria_selected: selected,
803            aria_expanded: if node.children.is_empty() { None } else { Some(expanded.to_string()) },
804            font_size: style.font_size, font_family: style.font_family, color: style.color, background: style.background,
805            font_weight: style.font_weight, width: style.width, border: style.border, text_decoration: style.text_decoration,
806            onmounted: move |event| {
807                own_element.set(Some(event.data()));
808                mounts.write().insert(mount_id.clone(), event.data());
809                if selected && !editing {
810                    spawn(async move {
811                        if let Err(error) = event.data().set_focus(true).await {
812                            if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
813                        }
814                    });
815                }
816            },
817            onfocus: move |_| focused.set(true),
818            onblur: move |_| focused.set(false),
819            onresize: move |_| context.resized(),
820            onpointerdown: move |event| {
821                event.stop_propagation();
822                context.suppress_click.set(false);
823                if event.trigger_button() == Some(MouseButton::Primary) && !event.modifiers().shift() && !event.modifiers().ctrl() && !event.modifiers().meta() {
824                    if let Some(handle) = context.handle {
825                        if handle.has_plugin(Capability::DragDrop) && matches!(handle.is_read_only(),Ok(false)) && !root && !editing {
826                            if !handle.selection().is_ok_and(|ids| ids.contains(&drag_id)) { context.select(&drag_id); }
827                            let at=event.client_coordinates(); let at=Point{x:at.x,y:at.y};
828                            let next=context.gesture_epoch.peek().wrapping_add(1);context.gesture_epoch.set(next);
829                            match handle.revision() {
830                                Ok(revision)=>context.gesture.set(Some(NodeDrag {start:at,current:at,source:Some(drag_id.clone()),active:false,revision})),
831                                Err(error)=>context.on_error.call(error),
832                            }
833                        }
834                    }
835                }
836            },
837            oncontextmenu: move |event| {
838                if context.handle.is_some_and(|h| h.has_plugin(Capability::ContextMenu)) {
839                    event.prevent_default(); event.stop_propagation(); context.select(&context_id);
840                    let at=event.client_coordinates(); context.menu.set(Some(Point{x:at.x,y:at.y}));
841                }
842            },
843            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()); },
844            ondoubleclick: move |event| { event.stop_propagation(); context.start_edit(&edit_node); },
845            onkeydown: move |event| {
846                if event.is_composing() { return; }
847                if event.key() == Key::F2 { event.prevent_default(); event.stop_propagation(); context.start_edit(&keyboard_node); } else { context.keyboard(event, &keyboard_node); }
848            },
849            if editing {
850                if let Some(session) = context.editor.read().clone() {
851                    TopicEditor { key: "{session.id}-{session.revision}", session }
852                }
853            } else { "{node.topic}" }
854            if selected && !editing {
855                span { class: "dme-focus-marker", aria_hidden: "true",
856                    onmounted: move |_| async move {
857                        if let Some(handle) = context.handle {
858                            if matches!(handle.owns_editor(context.view_id), Ok(true)) {
859                                let element = own_element.peek().clone();
860                                if let Some(element) = element {
861                                    if let Err(error) = element.set_focus(true).await {
862                                        if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
863                                    }
864                                }
865                            }
866                        }
867                    }
868                }
869            }
870            if !root && !editing && !node.children.is_empty() && context.handle.is_some() {
871                button { class: "dme-collapse", r#type: "button", "data-expanded": expanded, aria_expanded: expanded, aria_label: match (context.locale,expanded) {(Locale::Zh,true)=>"折叠",(Locale::Zh,false)=>"展开",(_,true)=>"Collapse",(_,false)=>"Expand"},
872                    onpointerdown: move |event| event.stop_propagation(),
873                    onclick: move |event| {
874                        event.stop_propagation();
875                        if let Some(handle) = context.handle { context.report(handle.apply(Operation::SetExpanded { id: collapse_id.clone(), expanded: !expanded }).map(|_| ())); }
876                    },
877                    ondoubleclick: move |event| event.stop_propagation(),
878                    onkeydown: move |event| event.stop_propagation(),
879                    span { class: "dme-collapse-icon", aria_hidden: "true" }
880                }
881            }
882        }
883    }
884}
885
886#[component]
887fn TopicEditor(session: EditSession) -> Element {
888    let context = use_context::<ViewContext>();
889    let mut draft = use_signal(|| session.original.clone());
890    let mut composing = use_signal(|| false);
891    let mut composition_ended = use_signal(|| false);
892    let mut finished = use_signal(|| false);
893    let mut focused = use_signal(|| false);
894    let mut editor = context.editor;
895    let original = session.original.clone();
896    let commit = move || -> Result<(), MindMapError> {
897        let handle = context.handle.ok_or(MindMapError::StaleOperation)?;
898        if handle.revision()? != session.revision {
899            return Err(MindMapError::StaleOperation);
900        }
901        let value = draft.peek().clone();
902        if value.trim().is_empty() || value == original {
903            return Ok(());
904        }
905        handle.apply(Operation::SetTopic {
906            id: session.id.clone(),
907            topic: value,
908        })?;
909        Ok(())
910    };
911    rsx! {
912        textarea { class: "dme-editor", autofocus: true, aria_label: "Edit node", rows: "3", value: session.original,
913            onmounted: move |event| async move {
914                if let Err(error) = event.data().set_focus(true).await {
915                    // Dioxus Desktop 0.7.10 returns null for successful focus.
916                    // Only a received native focus event confirms success.
917                    if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
918                }
919            },
920            onfocus: move |_| focused.set(true),
921            oninput: move |event| { draft.set(event.value()); context.resized(); },
922            oncompositionstart: move |_| { composing.set(true); composition_ended.set(false); },
923            oncompositionend: move |_| { composing.set(false); composition_ended.set(true); },
924            onkeyup: move |_| composition_ended.set(false),
925            onclick: move |event| event.stop_propagation(),
926            onwheel: move |event| event.stop_propagation(),
927            onkeydown: move |event| {
928                event.stop_propagation();
929                if composing() || composition_ended() || event.is_composing() { return; }
930                let cancel = event.key() == Key::Escape;
931                let submit = event.key() == Key::Tab || (event.key() == Key::Enter && !event.modifiers().shift());
932                if cancel || submit {
933                    event.prevent_default(); finished.set(true);
934                    if submit { context.report(commit()); }
935                    editor.set(None);
936
937                }
938            },
939            onblur: move |_| {
940                // Keep the explicit commit/cancel contract; blur cancels the draft
941                // and never creates a hidden history operation during IME events.
942                if !finished() { editor.set(None); }
943            },
944        }
945    }
946}
947
948#[component]
949fn GestureOverlay(
950    gesture: Signal<Option<NodeDrag>>,
951    locale: Locale,
952    mounts: Signal<Mounts>,
953    mut viewport: Signal<Viewport>,
954    container: Signal<Option<Rc<MountedData>>>,
955    handle: Option<MindMapHandle>,
956) -> Element {
957    let edge = use_resource(move || async move {
958        let current = gesture.read().clone();
959        let enabled = handle.is_some_and(|handle| handle.has_plugin(Capability::EdgePan));
960        let Some(current) =
961            current.filter(|current| current.active && current.source.is_some() && enabled)
962        else {
963            return Ok::<(), MindMapError>(());
964        };
965        let mounted = container.peek().clone();
966        let Some(mounted) = mounted else {
967            return Ok::<(), MindMapError>(());
968        };
969        loop {
970            let rect = platform::measure(&mounted).await?;
971            let delta = gesture::edge_pan(
972                HitRect {
973                    x: rect.x,
974                    y: rect.y,
975                    width: rect.width,
976                    height: rect.height,
977                },
978                current.current,
979            )?;
980            if delta == Point::default() {
981                return Ok::<(), MindMapError>(());
982            }
983            // The resource is cancelled on pointer movement, release, disable or unmount.
984            if gesture.peek().as_ref() != Some(&current) {
985                return Ok::<(), MindMapError>(());
986            }
987            let mut next = *viewport.peek();
988            next.pan(delta)?;
989            viewport.set(next);
990            futures_timer::Delay::new(std::time::Duration::from_millis(16)).await;
991        }
992    });
993    let target = use_resource(move || async move {
994        let current = gesture.read().clone();
995        let elements = mounts.peek().clone();
996        let Some(current) = current.filter(|current| current.active && current.source.is_some())
997        else {
998            return Ok(None);
999        };
1000        for (id, mounted) in elements {
1001            if current.source.as_ref() == Some(&id) {
1002                continue;
1003            }
1004            let r = platform::measure(&mounted).await?;
1005            let rect = HitRect {
1006                x: r.x,
1007                y: r.y,
1008                width: r.width,
1009                height: r.height,
1010            };
1011            if rect.contains(current.current) {
1012                return Ok::<_, MindMapError>(Some((id, rect, current.current)));
1013            }
1014        }
1015        Ok(None)
1016    });
1017    rsx! {
1018        if let Some(Err(error))=&*edge.read() {span {class:"dme-error",role:"alert","{error}"}}
1019        if let Some(current)=gesture() {
1020            if current.active {
1021                if current.source.is_some() {
1022                    div {class:"dme-drag-status",role:"status", if locale==Locale::Zh {"拖动节点 · Esc 取消"} else {"Moving branches · Esc to cancel"}}
1023                    if let Some(Ok(Some((id,rect,at))))=&*target.read() {
1024                        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),
1025                            span {class:"dme-drop-label", if locale==Locale::Zh {"释放以移动"} else {"Drop to move"}}
1026                        }
1027                        // The hit test used by commit decides before / after / child.
1028                        span {class:"dme-drag-point",style:format!("position:fixed;left:{}px;top:{}px",at.x,at.y)}
1029                    }
1030                    if let Some(Err(error))=&*target.read() {span {class:"dme-error",role:"alert","{error}"}}
1031                } else {
1032                    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())}
1033                }
1034            }
1035        }
1036    }
1037}