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    /// 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)] locale: Locale,
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), locale, 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    locale: Locale,
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.locale)
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)] locale: Locale,
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 context = ViewContext {
299        mounts,
300        resize,
301        editor,
302        handle,
303        on_error,
304        view_id: dioxus::core::current_scope_id().0,
305        gesture,
306        menu,
307        locale,
308        suppress_click,
309        gesture_epoch,
310        image_url,
311        blur_commits,
312    };
313    use_context_provider(|| context);
314    let local_viewport = use_signal(Viewport::default);
315    let mut viewport = viewport_control.unwrap_or(local_viewport);
316    let mut drag = use_signal(|| None::<Point>);
317    let mut measurement_error = use_signal(|| None::<MindMapError>);
318    let mut input = use_signal(|| (document.clone(), measurement_revision, theme.clone()));
319    use_effect(use_reactive(
320        (&document, &measurement_revision, &theme),
321        move |(document, revision, theme)| {
322            input.set((document, revision, theme));
323        },
324    ));
325    let geometry = use_resource(move || async move {
326        let (document, _, theme) = input.read().clone();
327        let elements = mounts.read().clone();
328        let element = canvas.read().clone();
329        let probe = scale_probe.read().clone();
330        let _ = resize();
331        document.validate()?;
332        let (Some(element), Some(probe)) = (element, probe) else {
333            return Ok(Vec::new());
334        };
335        let scale = platform::measure(&probe).await?.width / 100.0;
336        if !scale.is_finite() || scale <= 0.0 {
337            return Err(MindMapError::InvalidGeometry);
338        }
339        let canvas_rect = platform::measure(&element).await?;
340        let mut pending = vec![(
341            &document.node_data,
342            None::<String>,
343            document.direction,
344            None::<String>,
345        )];
346        let mut rectangles = BTreeMap::new();
347        let mut edges = Vec::new();
348        while let Some((node, parent, direction, color)) = pending.pop() {
349            let Some(element) = elements.get(&node.id) else {
350                return Ok(Vec::new());
351            };
352            rectangles.insert(
353                node.id.clone(),
354                local(platform::measure(element).await?, canvas_rect, scale),
355            );
356            let color = node.branch_color.clone().or(color);
357            if let Some(parent_id) = parent {
358                edges.push((parent_id, node.id.clone(), direction, color.clone()));
359            }
360            if node.expanded {
361                for (index, child) in node.children.iter().enumerate().rev() {
362                    let child_direction = if node.id == document.node_data.id
363                        && document.direction == LayoutDirection::Side
364                    {
365                        match child.direction {
366                            Some(NodeDirection::Left) => LayoutDirection::Left,
367                            Some(NodeDirection::Right) => LayoutDirection::Right,
368                            None => assigned_side(&document.node_data.children, &child.id),
369                        }
370                    } else {
371                        direction
372                    };
373                    let child_color = if node.id == document.node_data.id {
374                        theme
375                            .as_ref()
376                            .and_then(|theme| {
377                                if theme.palette.is_empty() {
378                                    None
379                                } else {
380                                    theme.palette.get(index % theme.palette.len()).cloned()
381                                }
382                            })
383                            .or_else(|| color.clone())
384                    } else {
385                        color.clone()
386                    };
387                    pending.push((child, Some(node.id.clone()), child_direction, child_color));
388                }
389            }
390        }
391        // Desktop queries cross the renderer boundary. Reject mixed snapshots
392        // if the canvas moved/resized/scaled during the batch. Resize/mount or
393        // document changes already restart this scoped resource.
394        let final_canvas = platform::measure(&element).await?;
395        let final_scale = platform::measure(&probe).await?.width / 100.0;
396        if final_canvas != canvas_rect || (final_scale - scale).abs() > 0.0001 {
397            context.resized();
398            return Ok(Vec::new());
399        }
400        // Merge paths with identical effective stroke, keeping DOM writes batched.
401        let mut groups = BTreeMap::<Option<String>, String>::new();
402        for (parent_id, child_id, direction, color) in edges {
403            if let (Some(parent), Some(child)) =
404                (rectangles.get(&parent_id), rectangles.get(&child_id))
405            {
406                let path = branch(
407                    *parent,
408                    *child,
409                    direction,
410                    parent_id == document.node_data.id,
411                    canvas_rect.height / scale,
412                );
413                let group = groups.entry(color).or_default();
414                group.push_str(&path);
415                group.push(' ');
416            }
417        }
418        Ok::<_, MindMapError>(groups.into_iter().collect::<Vec<_>>())
419    });
420    use_effect(move || {
421        let error = match &*geometry.read() {
422            Some(Err(error)) => Some(error.clone()),
423            _ => None,
424        };
425        if *measurement_error.peek() != error {
426            measurement_error.set(error.clone());
427            if let Some(error) = error {
428                on_error.call(error);
429            }
430        }
431    });
432    let mut center_request = use_signal(|| 0u64);
433    use_effect(use_reactive((&center_revision,), move |(revision,)| {
434        center_request.set(revision)
435    }));
436    let _center = use_resource(move || async move {
437        let request = center_request();
438        if request == 0 {
439            return;
440        }
441        let root_id = input.read().0.node_data.id.clone();
442        let elements = mounts.read().clone();
443        let view_element = container.read().clone();
444        let Some(root) = elements.get(&root_id) else {
445            return;
446        };
447        let Some(view_element) = view_element else {
448            return;
449        };
450        let before = *viewport.peek();
451        let result = async {
452            let root = platform::measure(root).await?;
453            let view = platform::measure(&view_element).await?;
454            let target = before.to_canvas(Point {
455                x: root.x + root.width / 2.0 - view.x,
456                y: root.y + root.height / 2.0 - view.y,
457            })?;
458            let mut next = before;
459            next.center(
460                target,
461                Point {
462                    x: view.width,
463                    y: view.height,
464                },
465            )?;
466            if *viewport.peek() == before {
467                viewport.set(next);
468            }
469            Ok(())
470        }
471        .await;
472        context.report(result);
473    });
474    let mut fit_request = use_signal(|| 0u64);
475    use_effect(use_reactive((&fit_revision,), move |(revision,)| {
476        fit_request.set(revision)
477    }));
478    let _fit = use_resource(move || async move {
479        let request = fit_request();
480        if request == 0 {
481            return;
482        }
483        let elements = mounts.peek().clone();
484        let view_element = container.peek().clone();
485        let Some(view_element) = view_element else {
486            return;
487        };
488        let before = *viewport.peek();
489        let result = async {
490            let view = platform::measure(&view_element).await?;
491            let mut bounds: Option<(f64, f64, f64, f64)> = None;
492            for element in elements.values() {
493                let r = platform::measure(element).await?;
494                bounds = Some(match bounds {
495                    None => (r.x, r.y, r.x + r.width, r.y + r.height),
496                    Some((x0, y0, x1, y1)) => (
497                        x0.min(r.x),
498                        y0.min(r.y),
499                        x1.max(r.x + r.width),
500                        y1.max(r.y + r.height),
501                    ),
502                });
503            }
504            let Some((x0, y0, x1, y1)) = bounds else {
505                return Ok(());
506            };
507            let a = before.to_canvas(Point {
508                x: x0 - view.x,
509                y: y0 - view.y,
510            })?;
511            let b = before.to_canvas(Point {
512                x: x1 - view.x,
513                y: y1 - view.y,
514            })?;
515            let next = gesture::fit(
516                a,
517                b,
518                Point {
519                    x: view.width,
520                    y: view.height,
521                },
522            )?;
523            if *viewport.peek() == before {
524                viewport.set(next);
525            }
526            Ok(())
527        }
528        .await;
529        context.report(result);
530    });
531    // Deleting/replacing the edited node, or changing it elsewhere, cancels this
532    // view's draft. No stale asynchronous result may restore that editor.
533    use_effect(move || {
534        let current = input.read();
535        let session = context.editor.read().clone();
536        if let Some(session) = session {
537            let exists = current
538                .0
539                .node_data
540                .find(&session.id)
541                .is_some_and(|node| node.topic == session.original);
542            let writable = match handle {
543                Some(handle) => {
544                    matches!(handle.is_read_only(), Ok(false))
545                        && matches!(handle.owns_editor(context.view_id), Ok(true))
546                        && matches!(handle.revision(), Ok(revision) if revision == session.revision)
547                        && handle.edits(context.view_id, &session.id)
548                }
549                None => false,
550            };
551            if !exists || !writable {
552                let mut editor = context.editor;
553                editor.set(None);
554                if let Some(handle) = handle {
555                    if handle.edits(context.view_id, &session.id) {
556                        context.report(handle.cancel_pending());
557                    }
558                }
559            }
560        }
561    });
562    use_effect(move || {
563        if let Some(handle) = handle {
564            let revision = handle.revision();
565            let node_enabled = handle.has_plugin(Capability::DragDrop)
566                && matches!(handle.is_read_only(), Ok(false));
567            let select_enabled = handle.has_plugin(Capability::MultiSelect);
568            let stale = gesture.peek().as_ref().is_some_and(|current| {
569                revision != Ok(current.revision)
570                    || if current.source.is_some() {
571                        !node_enabled
572                    } else {
573                        !select_enabled
574                    }
575            });
576            if stale {
577                gesture.set(None);
578            }
579            if !handle.has_plugin(Capability::ContextMenu) {
580                menu.set(None);
581            }
582        }
583    });
584    let selection = match handle {
585        Some(handle) => handle.selection(),
586        None => Ok(Vec::new()),
587    };
588    let selection = match selection {
589        Ok(selection) => selection,
590        Err(error) => return rsx! { p { role: "alert", "{error}" } },
591    };
592    let paths = match &*geometry.read() {
593        Some(Ok(paths)) => paths.clone(),
594        _ => Vec::new(),
595    };
596    let direction = match document.direction {
597        LayoutDirection::Left => "left",
598        LayoutDirection::Right => "right",
599        LayoutDirection::Side => "side",
600        LayoutDirection::Down => "down",
601    };
602    let align = if alignment == Alignment::Nodes {
603        "nodes"
604    } else {
605        "root"
606    };
607    let style = format!(
608        "{}{style}",
609        theme.as_ref().map(Theme::css).unwrap_or_default()
610    );
611    let root = &document.node_data;
612    let validation = document.validate();
613    rsx! {
614        style { "{STYLESHEET}" }
615        div {
616            class: "dme-map {class}", style,
617            role: "group", aria_label: "Mind map", tabindex: "0",
618            "data-layout": direction, "data-alignment": align, "data-compact": document.compact,
619            onmounted: move |event| container.set(Some(event.data())),
620            onwheel: move |event| {
621                event.prevent_default();
622                let delta = event.delta().strip_units();
623                let result = if event.modifiers().ctrl() || event.modifiers().meta() {
624                    // Wheel coordinates are measured against the view container,
625                    // not the possibly nested event target.
626                    let at = event.client_coordinates();
627                    let element = container.read().clone();
628                    spawn(async move {
629                        if let Some(element) = element {
630                            let result = async {
631                                let rect = platform::measure(&element).await?;
632                                let mut next = *viewport.peek();
633                                next.zoom_at(next.scale * (-delta.y * 0.002).exp(), Point { x: at.x - rect.x, y: at.y - rect.y })?;
634                                viewport.set(next); Ok(())
635                            }.await;
636                            context.report(result);
637                        }
638                    });
639                    Ok(())
640                } else {
641                    let mut next = *viewport.peek();
642                    let result = next.pan(Point { x: -delta.x, y: -delta.y });
643                    if result.is_ok() { viewport.set(next); } result
644                };
645                context.report(result);
646            },
647            onpointerdown: move |event| {
648                if event.trigger_button() == Some(MouseButton::Primary) {
649                    let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
650                    menu.set(None);
651                    if event.modifiers().shift() && handle.is_some_and(|h| h.has_plugin(Capability::MultiSelect)) {
652                        {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);}
653                        if let Some(handle)=handle {
654                            match handle.select(&[]).and_then(|_|handle.revision()) {
655                                Ok(revision)=>gesture.set(Some(NodeDrag {start: point,current:point,source:None,active:false,revision})),
656                                Err(error)=>context.on_error.call(error),
657                            }
658                        }
659                    } else { drag.set(Some(point)); }
660                    if let Some(handle) = handle { context.report(handle.select(&[])); }
661                }
662            },
663            onpointermove: move |event| {
664                let at = event.client_coordinates(); let point = Point { x: at.x,y:at.y };
665                let current_gesture = gesture.peek().clone();
666                if let Some(mut current) = current_gesture {
667                    current.current = point; current.active = current.active || gesture::is_drag(current.start,point);
668                    gesture.set(Some(current));
669                    return;
670                }
671                let previous = *drag.peek();
672                if let Some(previous) = previous {
673                    let at = event.client_coordinates(); let point = Point { x: at.x, y: at.y };
674                    let mut next = *viewport.peek();
675                    let result = next.pan(Point { x: point.x - previous.x, y: point.y - previous.y });
676                    if result.is_ok() { viewport.set(next); drag.set(Some(point)); } context.report(result);
677                }
678            },
679            onpointerup: move |_| {
680                drag.set(None); let current = gesture.peek().clone(); gesture.set(None);
681                if let (Some(current),Some(handle)) = (current,handle) {
682                    if current.active {
683                        let mut suppress_click = suppress_click; suppress_click.set(true);
684                        let epoch=*gesture_epoch.peek();
685                        let ids = handle.selection();
686                        let elements = mounts.peek().clone(); let revision = handle.revision();
687                        spawn(async move {
688                            let result = async {
689                                let revision = revision?; let data = handle.document()?; let mut selected = Vec::new(); let mut placement = None;
690                                for (id,mounted) in elements {
691                                    let r = platform::measure(&mounted).await?;
692                                    let rect = HitRect {x:r.x,y:r.y,width:r.width,height:r.height};
693                                    if current.source.is_some() {
694                                        if rect.contains(current.current) && current.source.as_ref() != Some(&id) {
695                                            placement = Some(gesture::drop_placement(&id,rect,current.current,data.direction,viewport.peek().scale)?);
696                                        }
697                                    } else if rect.intersects(current.start,current.current) { selected.push(id); }
698                                }
699                                if handle.revision()? != revision || revision != current.revision || *gesture_epoch.peek()!=epoch { return Err(MindMapError::StaleOperation); }
700                                if current.source.is_some() {
701                                    if !handle.has_plugin(Capability::DragDrop) { return Err(MindMapError::StaleOperation); }
702                                    if let Some(placement) = placement { handle.apply(Operation::Move {ids:ids?,placement})?; }
703                                } else {
704                                    if !handle.has_plugin(Capability::MultiSelect) { return Err(MindMapError::StaleOperation); }
705                                    handle.select(&selected)?;
706                                }
707                                Ok(())
708                            }.await; context.report(result);
709                        });
710                    }
711                }
712            },
713            onpointercancel: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
714            onpointerleave: move |_| { drag.set(None); gesture.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} },
715            onkeydown: move |event| {
716                if event.key() == Key::Escape { gesture.set(None); menu.set(None); {let next=gesture_epoch.peek().wrapping_add(1);gesture_epoch.set(next);} }
717                if event.key() == Key::Home && !event.is_composing() { event.prevent_default(); viewport.set(Viewport::default()); }
718            },
719            if let Err(error) = validation { p { class: "dme-error", role: "alert", "{error}" } }
720            else {
721                CanvasTransform { viewport, canvas, scale_probe,
722
723                    svg { class: "dme-links", "aria-hidden": "true",
724                        for (color, path) in paths {
725                            path { class: "dme-link", d: path, style: color.map(|color| format!("stroke:{color}")) }
726                        }
727                    }
728                    if document.direction == LayoutDirection::Down {
729                        div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
730                        if root.expanded {
731                            div { class: "dme-main dme-down",
732                                for child in &root.children { NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Down, selection: selection.clone() } }
733                            }
734                        }
735                    } else {
736                        div { class: "dme-main dme-left",
737                            if root.expanded {
738                                for child in root.children.iter().filter(|node| side(document.direction, &root.children, &node.id) == LayoutDirection::Left) {
739                                    NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Left, selection: selection.clone() }
740                                }
741                            }
742                        }
743                        div { class: "dme-root", TopicView { key: "{root.id}", node: root.clone(), root: true, selected: selection.contains(&root.id) } }
744                        div { class: "dme-main dme-right",
745                            if root.expanded {
746                                for child in root.children.iter().filter(|node| side(document.direction, &root.children, &node.id) == LayoutDirection::Right) {
747                                    NodeView { key: "{child.id}", node: child.clone(), direction: LayoutDirection::Right, selection: selection.clone() }
748                                }
749                            }
750                        }
751                    }
752                }
753            }
754            GestureOverlay { gesture, locale, mounts, viewport, container, handle }
755            if let (Some(at),Some(handle)) = (menu(),handle) {
756                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),
757                    onpointerdown: move |event| event.stop_propagation(),
758                    match handle.plugin_commands() {
759                        Ok(commands) => rsx! { for command in commands {
760                            button { r#type: "button", role: "menuitem", onclick: move |_| { menu.set(None); context.report(plugins::execute(handle,command,locale)); }, "{locale.label(command)}" }
761                        } },
762                        Err(error) => rsx! { p {role: "alert", "{error}"} },
763                    }
764                }
765            }
766            if let Some(error) = measurement_error() { p { class: "dme-error", role: "alert", "{error}" } }
767        }
768    }
769}
770
771// Isolate high-frequency viewport updates: pan only updates this transform,
772// without rerunning MapBody or cloning the document/tree component props.
773#[component]
774fn CanvasTransform(
775    viewport: Signal<Viewport>,
776    mut canvas: Signal<Option<Rc<MountedData>>>,
777    mut scale_probe: Signal<Option<Rc<MountedData>>>,
778    children: Element,
779) -> Element {
780    let context = use_context::<ViewContext>();
781    let view = viewport();
782    if let Err(error) = view.validate() {
783        return rsx! { p { class: "dme-error", role: "alert", "{error}" } };
784    }
785    let transform = format!(
786        "transform:translate({}px,{}px) scale({});",
787        view.translation.x, view.translation.y, view.scale
788    );
789    rsx! { div { class: "dme-canvas", style: transform, "data-scale": "{view.scale}",
790        onmounted: move |event| canvas.set(Some(event.data())),
791        onresize: move |_| context.resized(),
792        div { class: "dme-scale-probe", aria_hidden: "true", onmounted: move |event| scale_probe.set(Some(event.data())) }
793        {children}
794    } }
795}
796
797fn assigned_side(children: &[Node], id: &str) -> LayoutDirection {
798    let (mut left, mut right) = (0usize, 0usize);
799    for child in children {
800        let direction = match child.direction {
801            Some(NodeDirection::Left) => LayoutDirection::Left,
802            Some(NodeDirection::Right) => LayoutDirection::Right,
803            None if left <= right => LayoutDirection::Left,
804            None => LayoutDirection::Right,
805        };
806        if child.id == id {
807            return direction;
808        }
809        if direction == LayoutDirection::Left {
810            left += 1;
811        } else {
812            right += 1;
813        }
814    }
815    LayoutDirection::Right
816}
817fn side(direction: LayoutDirection, children: &[Node], id: &str) -> LayoutDirection {
818    if direction == LayoutDirection::Side {
819        assigned_side(children, id)
820    } else {
821        direction
822    }
823}
824
825#[component]
826fn NodeView(node: Node, direction: LayoutDirection, selection: Vec<String>) -> Element {
827    let context = use_context::<ViewContext>();
828    let class = match direction {
829        LayoutDirection::Left => "dme-branch dme-left",
830        LayoutDirection::Down => "dme-branch dme-down",
831        _ => "dme-branch dme-right",
832    };
833    rsx! {
834        div { class, onresize: move |_| context.resized(),
835            TopicView { key: "{node.id}", node: node.clone(), root: false, direction, selected: selection.contains(&node.id) }
836            if node.expanded && !node.children.is_empty() {
837                div { class: "dme-children", onresize: move |_| context.resized(),
838                    for child in node.children { NodeView { key: "{child.id}", node: child, direction, selection: selection.clone() } }
839                }
840            }
841        }
842    }
843}
844
845#[component]
846fn TopicView(
847    node: Node,
848    root: bool,
849    #[props(default)] direction: LayoutDirection,
850    selected: bool,
851) -> Element {
852    let mut context = use_context::<ViewContext>();
853    let mut mounts = context.mounts;
854    let mut own_element = use_signal(|| None::<Rc<MountedData>>);
855    let cleanup_id = node.id.clone();
856    use_drop(move || {
857        if let Ok(mut mounts) = mounts.try_write() {
858            mounts.remove(&cleanup_id);
859        }
860    });
861    let mut focused = use_signal(|| false);
862    let mount_id = node.id.clone();
863    let click_id = node.id.clone();
864    let drag_id = node.id.clone();
865    let context_id = node.id.clone();
866    let edit_node = node.clone();
867    let keyboard_node = node.clone();
868    let collapse_id = node.id.clone();
869    let expanded = node.expanded;
870    let editing = context
871        .editor
872        .read()
873        .as_ref()
874        .is_some_and(|session| session.id == node.id);
875    let style = node.style.clone().unwrap_or_default();
876    let image = node.image.clone().map(|image| {
877        let src = match context.image_url {
878            Some(resolve) => resolve.call(image.url.clone()),
879            None => image.url.clone(),
880        };
881        (src, image)
882    });
883    let mut broken = use_signal(|| None::<String>);
884    let has_note = node.note.is_some();
885    let has_image = image.is_some();
886    let image_width = image
887        .as_ref()
888        .map(|(_, image)| format!("--dme-image-width:{}px", image.width));
889    let flow = match direction {
890        LayoutDirection::Left => "left",
891        LayoutDirection::Down => "down",
892        _ => "right",
893    };
894    rsx! {
895        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,
896            tabindex: "0", role: "treeitem", aria_selected: selected,
897            aria_expanded: if node.children.is_empty() { None } else { Some(expanded.to_string()) },
898            font_size: style.font_size, font_family: style.font_family, color: style.color, background: style.background,
899            font_weight: style.font_weight, width: style.width, border: style.border, text_decoration: style.text_decoration,
900            "data-image": has_image, "data-note": has_note, style: image_width,
901            onmounted: move |event| {
902                own_element.set(Some(event.data()));
903                mounts.write().insert(mount_id.clone(), event.data());
904                if selected && !editing {
905                    spawn(async move {
906                        if let Err(error) = event.data().set_focus(true).await {
907                            if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
908                        }
909                    });
910                }
911            },
912            onfocus: move |_| focused.set(true),
913            onblur: move |_| focused.set(false),
914            onresize: move |_| context.resized(),
915            onpointerdown: move |event| {
916                event.stop_propagation();
917                context.suppress_click.set(false);
918                if event.trigger_button() == Some(MouseButton::Primary) && !event.modifiers().shift() && !event.modifiers().ctrl() && !event.modifiers().meta() {
919                    if let Some(handle) = context.handle {
920                        if handle.has_plugin(Capability::DragDrop) && matches!(handle.is_read_only(),Ok(false)) && !root && !editing {
921                            if !handle.selection().is_ok_and(|ids| ids.contains(&drag_id)) { context.select(&drag_id); }
922                            let at=event.client_coordinates(); let at=Point{x:at.x,y:at.y};
923                            let next=context.gesture_epoch.peek().wrapping_add(1);context.gesture_epoch.set(next);
924                            match handle.revision() {
925                                Ok(revision)=>context.gesture.set(Some(NodeDrag {start:at,current:at,source:Some(drag_id.clone()),active:false,revision})),
926                                Err(error)=>context.on_error.call(error),
927                            }
928                        }
929                    }
930                }
931            },
932            oncontextmenu: move |event| {
933                if context.handle.is_some_and(|h| h.has_plugin(Capability::ContextMenu)) {
934                    event.prevent_default(); event.stop_propagation(); context.select(&context_id);
935                    let at=event.client_coordinates(); context.menu.set(Some(Point{x:at.x,y:at.y}));
936                }
937            },
938            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()); },
939            ondoubleclick: move |event| { event.stop_propagation(); context.start_edit(&edit_node); },
940            onkeydown: move |event| {
941                if event.is_composing() { return; }
942                if event.key() == Key::F2 { event.prevent_default(); event.stop_propagation(); context.start_edit(&keyboard_node); } else { context.keyboard(event, &keyboard_node); }
943            },
944            if let Some((src, image)) = image {
945                if broken.read().as_deref() == Some(src.as_str()) {
946                    span { class: "dme-image-missing", role: "img", aria_label: image.alt.clone().unwrap_or_default(),
947                        style: format!("width:{}px;height:{}px", image.width, image.height),
948                        if context.locale == Locale::Zh { "图片无法显示" } else { "Image unavailable" }
949                    }
950                } else {
951                    img { class: "dme-image", src: "{src}", alt: image.alt.clone().unwrap_or_default(), draggable: "false",
952                        width: "{image.width}", height: "{image.height}",
953                        style: format!("width:{}px;height:{}px", image.width, image.height),
954                        onerror: move |_| broken.set(Some(src.clone())),
955                        onload: move |_| context.resized(),
956                    }
957                }
958            }
959            if editing {
960                if let Some(session) = context.editor.read().clone() {
961                    TopicEditor { key: "{session.id}-{session.revision}", session }
962                }
963            } else { span { class: "dme-topic-text", "{node.topic}" } }
964            if has_note && !editing {
965                span { class: "dme-note-marker", aria_label: if context.locale == Locale::Zh { "有备注" } else { "Has note" }, title: if context.locale == Locale::Zh { "有备注" } else { "Has note" } }
966            }
967            if selected && !editing {
968                span { class: "dme-focus-marker", aria_hidden: "true",
969                    onmounted: move |_| async move {
970                        if let Some(handle) = context.handle {
971                            if matches!(handle.owns_editor(context.view_id), Ok(true)) {
972                                let element = own_element.peek().clone();
973                                if let Some(element) = element {
974                                    if let Err(error) = element.set_focus(true).await {
975                                        if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
976                                    }
977                                }
978                            }
979                        }
980                    }
981                }
982            }
983            if !root && !editing && !node.children.is_empty() && context.handle.is_some() {
984                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"},
985                    onpointerdown: move |event| event.stop_propagation(),
986                    onclick: move |event| {
987                        event.stop_propagation();
988                        if let Some(handle) = context.handle { context.report(handle.apply(Operation::SetExpanded { id: collapse_id.clone(), expanded: !expanded }).map(|_| ())); }
989                    },
990                    ondoubleclick: move |event| event.stop_propagation(),
991                    onkeydown: move |event| event.stop_propagation(),
992                    span { class: "dme-collapse-icon", aria_hidden: "true" }
993                }
994            }
995        }
996    }
997}
998
999#[component]
1000fn TopicEditor(session: EditSession) -> Element {
1001    let context = use_context::<ViewContext>();
1002    let mut draft = use_signal(|| session.original.clone());
1003    let mut composing = use_signal(|| false);
1004    let mut composition_ended = use_signal(|| false);
1005    let mut finished = use_signal(|| false);
1006    let mut focused = use_signal(|| false);
1007    let mut editor = context.editor;
1008    let commit = move || -> Result<(), MindMapError> {
1009        let handle = context.handle.ok_or(MindMapError::StaleOperation)?;
1010        if handle.revision()? != session.revision {
1011            return Err(MindMapError::StaleOperation);
1012        }
1013        handle.update_draft(context.view_id, draft.peek().clone());
1014        handle.commit_pending().map(|_| ())
1015    };
1016    let discard = move || {
1017        if let Some(handle) = context.handle {
1018            context.report(handle.cancel_pending());
1019        }
1020    };
1021    rsx! {
1022        textarea { class: "dme-editor", autofocus: true, aria_label: "Edit node", rows: "3", value: session.original,
1023            onmounted: move |event| async move {
1024                if let Err(error) = event.data().set_focus(true).await {
1025                    // Dioxus Desktop 0.7.10 returns null for successful focus.
1026                    // Only a received native focus event confirms success.
1027                    if !*focused.peek() { context.on_error.call(MindMapError::Measurement(error.to_string())); }
1028                }
1029            },
1030            onfocus: move |_| focused.set(true),
1031            oninput: move |event| {
1032                let value = event.value();
1033                if let Some(handle) = context.handle { handle.update_draft(context.view_id, value.clone()); }
1034                draft.set(value); context.resized();
1035            },
1036            oncompositionstart: move |_| { composing.set(true); composition_ended.set(false); },
1037            oncompositionend: move |_| { composing.set(false); composition_ended.set(true); },
1038            onkeyup: move |_| composition_ended.set(false),
1039            onclick: move |event| event.stop_propagation(),
1040            onwheel: move |event| event.stop_propagation(),
1041            onkeydown: move |event| {
1042                event.stop_propagation();
1043                if composing() || composition_ended() || event.is_composing() { return; }
1044                let cancel = event.key() == Key::Escape;
1045                let submit = event.key() == Key::Tab || (event.key() == Key::Enter && !event.modifiers().shift());
1046                if cancel || submit {
1047                    event.prevent_default(); finished.set(true);
1048                    if submit { context.report(commit()); } else { discard(); }
1049                    editor.set(None);
1050
1051                }
1052            },
1053            onblur: move |_| {
1054                // Blur cancels by default; hosts that protect drafts opt into a
1055                // commit. Composition never blurs, so IME confirmation is safe.
1056                if !finished() {
1057                    finished.set(true);
1058                    if context.blur_commits { context.report(commit()); } else { discard(); }
1059                    editor.set(None);
1060                }
1061            },
1062        }
1063    }
1064}
1065
1066#[component]
1067fn GestureOverlay(
1068    gesture: Signal<Option<NodeDrag>>,
1069    locale: Locale,
1070    mounts: Signal<Mounts>,
1071    mut viewport: Signal<Viewport>,
1072    container: Signal<Option<Rc<MountedData>>>,
1073    handle: Option<MindMapHandle>,
1074) -> Element {
1075    let edge = use_resource(move || async move {
1076        let current = gesture.read().clone();
1077        let enabled = handle.is_some_and(|handle| handle.has_plugin(Capability::EdgePan));
1078        let Some(current) =
1079            current.filter(|current| current.active && current.source.is_some() && enabled)
1080        else {
1081            return Ok::<(), MindMapError>(());
1082        };
1083        let mounted = container.peek().clone();
1084        let Some(mounted) = mounted else {
1085            return Ok::<(), MindMapError>(());
1086        };
1087        loop {
1088            let rect = platform::measure(&mounted).await?;
1089            let delta = gesture::edge_pan(
1090                HitRect {
1091                    x: rect.x,
1092                    y: rect.y,
1093                    width: rect.width,
1094                    height: rect.height,
1095                },
1096                current.current,
1097            )?;
1098            if delta == Point::default() {
1099                return Ok::<(), MindMapError>(());
1100            }
1101            // The resource is cancelled on pointer movement, release, disable or unmount.
1102            if gesture.peek().as_ref() != Some(&current) {
1103                return Ok::<(), MindMapError>(());
1104            }
1105            let mut next = *viewport.peek();
1106            next.pan(delta)?;
1107            viewport.set(next);
1108            futures_timer::Delay::new(std::time::Duration::from_millis(16)).await;
1109        }
1110    });
1111    let target = use_resource(move || async move {
1112        let current = gesture.read().clone();
1113        let elements = mounts.peek().clone();
1114        let Some(current) = current.filter(|current| current.active && current.source.is_some())
1115        else {
1116            return Ok(None);
1117        };
1118        for (id, mounted) in elements {
1119            if current.source.as_ref() == Some(&id) {
1120                continue;
1121            }
1122            let r = platform::measure(&mounted).await?;
1123            let rect = HitRect {
1124                x: r.x,
1125                y: r.y,
1126                width: r.width,
1127                height: r.height,
1128            };
1129            if rect.contains(current.current) {
1130                return Ok::<_, MindMapError>(Some((id, rect, current.current)));
1131            }
1132        }
1133        Ok(None)
1134    });
1135    rsx! {
1136        if let Some(Err(error))=&*edge.read() {span {class:"dme-error",role:"alert","{error}"}}
1137        if let Some(current)=gesture() {
1138            if current.active {
1139                if current.source.is_some() {
1140                    div {class:"dme-drag-status",role:"status", if locale==Locale::Zh {"拖动节点 · Esc 取消"} else {"Moving branches · Esc to cancel"}}
1141                    if let Some(Ok(Some((id,rect,at))))=&*target.read() {
1142                        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),
1143                            span {class:"dme-drop-label", if locale==Locale::Zh {"释放以移动"} else {"Drop to move"}}
1144                        }
1145                        // The hit test used by commit decides before / after / child.
1146                        span {class:"dme-drag-point",style:format!("position:fixed;left:{}px;top:{}px",at.x,at.y)}
1147                    }
1148                    if let Some(Err(error))=&*target.read() {span {class:"dme-error",role:"alert","{error}"}}
1149                } else {
1150                    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())}
1151                }
1152            }
1153        }
1154    }
1155}