Skip to main content

dioxus_mind_elixir/
outline.rs

1use crate::{
2    MindMapError, MindMapHandle, Node, Operation, Placement, STYLESHEET,
3    plugins::{self, Capability, Command, Locale},
4};
5use dioxus::prelude::*;
6use std::collections::BTreeSet;
7
8/// Shared document outline. Edits and moves use the same history as the map.
9/// Browsing a collapsed branch here does not expand the map automatically.
10#[component]
11pub fn Outline(
12    handle: MindMapHandle,
13    #[props(default)] locale: Locale,
14    #[props(default)] class: String,
15    #[props(default)] style: String,
16    #[props(default)] on_error: EventHandler<MindMapError>,
17    #[props(default)] blur_commits: bool,
18) -> Element {
19    let mut focus = use_signal(|| None::<String>);
20    let dragging = use_signal(|| None::<String>);
21    let hidden = use_signal(BTreeSet::<String>::new);
22    let data = match handle.document() {
23        Ok(data) => data,
24        Err(error) => return rsx! { p {role:"alert", "{error}"} },
25    };
26    let selection = match handle.selection() {
27        Ok(ids) => ids,
28        Err(error) => return rsx! {p {role:"alert", "{error}"}},
29    };
30    if !handle.has_plugin(Capability::Outline) {
31        return rsx! {};
32    }
33    let root = focus()
34        .as_ref()
35        .and_then(|id| data.node_data.find(id))
36        .unwrap_or(&data.node_data)
37        .clone();
38    let mut crumbs = vec![data.node_data.clone()];
39    if root.id != data.node_data.id {
40        let mut path = Vec::new();
41        let mut node = &root;
42        while let Some(parent) = plugins::parent(&data.node_data, &node.id) {
43            path.push(node.clone());
44            node = parent;
45        }
46        path.reverse();
47        crumbs.extend(path);
48    }
49    let mut rows = Vec::new();
50    // Outline's expansion belongs to its own view; map collapse is not a DOM lookup gate.
51    fn flatten(
52        node: &Node,
53        depth: usize,
54        rows: &mut Vec<(Node, usize)>,
55        hidden: &BTreeSet<String>,
56    ) {
57        rows.push((node.clone(), depth));
58        if !hidden.contains(&node.id) {
59            for child in &node.children {
60                flatten(child, depth + 1, rows, hidden);
61            }
62        }
63    }
64    flatten(&root, 0, &mut rows, &hidden.read());
65    rsx! {
66        style {"{STYLESHEET}"}
67        section {class:"dme-outline {class}", style, aria_label: if locale == Locale::Zh {"大纲"} else {"Outline"},
68            nav {class:"dme-breadcrumbs", aria_label: if locale == Locale::Zh {"路径"} else {"Breadcrumbs"},
69                for crumb in crumbs { button {r#type:"button", onclick:move |_| focus.set(Some(crumb.id.clone())), "{crumb.topic}"} }
70            }
71            div {role:"tree",
72                for (node,depth) in rows {
73                    OutlineRow {key:"{node.id}", selected:selection.contains(&node.id), node, depth, handle, locale, focus, dragging, hidden, on_error, blur_commits}
74                }
75            }
76        }
77    }
78}
79#[component]
80fn OutlineRow(
81    node: Node,
82    depth: usize,
83    selected: bool,
84    handle: MindMapHandle,
85    locale: Locale,
86    mut focus: Signal<Option<String>>,
87    mut dragging: Signal<Option<String>>,
88    mut hidden: Signal<BTreeSet<String>>,
89    on_error: EventHandler<MindMapError>,
90    blur_commits: bool,
91) -> Element {
92    let owner = dioxus::core::current_scope_id().0;
93    let mut editing = use_signal(|| false);
94    let mut draft = use_signal(|| node.topic.clone());
95    let mut composing = use_signal(|| false);
96    let mut ended = use_signal(|| false);
97    let mut focused = use_signal(|| false);
98    let is_hidden = hidden.read().contains(&node.id);
99    let mut edit_revision = use_signal(|| None::<u64>);
100    let cancel_id = node.id.clone();
101    let watch_id = node.id.clone();
102    let cancel = use_callback(move |_: ()| {
103        if handle.edits(owner, &cancel_id) {
104            if let Err(error) = handle.cancel_pending() {
105                on_error.call(error);
106            }
107        }
108    });
109    use_effect(move || {
110        if editing()
111            && !matches!((handle.revision(), *edit_revision.read()), (Ok(current),Some(start)) if current==start)
112        {
113            editing.set(false);
114        }
115    });
116    use_effect(move || {
117        if editing()
118            && (!matches!(handle.owns_editor(owner), Ok(true))
119                || !matches!(handle.is_read_only(), Ok(false))
120                || !handle.edits(owner, &watch_id))
121        {
122            editing.set(false);
123        }
124    });
125    let select_id = node.id.clone();
126    let edit_node = node.clone();
127    let fold_id = node.id.clone();
128    let toggle_id = node.id.clone();
129    let key_node = node.clone();
130    let drag_id = node.id.clone();
131    let drop_id = node.id.clone();
132
133    let report = move |result: Result<(), MindMapError>| {
134        if let Err(error) = result {
135            on_error.call(error);
136        }
137    };
138    let start = use_callback(move |_: ()| {
139        report((|| {
140            if handle.is_read_only()? {
141                return Err(MindMapError::ReadOnly);
142            }
143            handle.select(std::slice::from_ref(&edit_node.id))?;
144            handle.begin_edit(owner, &edit_node.id, &edit_node.topic)?;
145            edit_revision.set(Some(handle.revision()?));
146            draft.set(edit_node.topic.clone());
147            editing.set(true);
148            Ok(())
149        })());
150    });
151    rsx! {
152        div {class:"dme-outline-row", role:"treeitem", tabindex:"0", "data-outline-id":"{node.id}", "data-selected":selected,
153            style:format!("--dme-outline-depth:{depth}"), aria_level:depth+1, aria_selected:selected,
154            draggable: handle.has_plugin(Capability::DragDrop) && !matches!(handle.is_read_only(),Ok(true)),
155            ondragstart:move |_| { dragging.set(Some(drag_id.clone())); },
156            ondragover:move |event| event.prevent_default(),
157            ondrop:move |event| {
158                event.prevent_default(); let source=dragging.peek().clone(); dragging.set(None);
159                if let Some(source)=source { if source != drop_id {
160                    report(handle.apply(Operation::Move {ids:vec![source],placement:Placement::In(drop_id.clone())}).map(|_| ()));
161                }}
162            },
163            ondragend:move |_| dragging.set(None),
164            onclick:move |event| {event.stop_propagation(); report(handle.select(std::slice::from_ref(&select_id)));},
165            ondoubleclick:move |_| start.call(()),
166            onkeydown:move |event| {
167                if event.is_composing() || editing() {return;}
168                if event.key()==Key::F2 {event.prevent_default(); start.call(()); return;}
169                let command=match event.key() {
170                    Key::Tab if event.modifiers().shift()=>Some(Command::Outdent),Key::Tab=>Some(Command::Indent),
171                    Key::Enter if event.modifiers().ctrl()||event.modifiers().meta()=>Some(Command::Parent),
172                    Key::Enter if event.modifiers().shift()=>Some(Command::SiblingBefore),Key::Enter=>Some(Command::SiblingAfter),
173                    Key::Delete|Key::Backspace=>Some(Command::Delete),_=>None,
174                };
175                if let Some(command)=command {event.prevent_default();report((||{handle.select(std::slice::from_ref(&key_node.id))?;plugins::execute(handle,command,locale)})());}
176            },
177            button {class:"dme-outline-fold",r#type:"button",disabled:node.children.is_empty(), aria_expanded:!is_hidden,
178                aria_label:if locale==Locale::Zh {"显示或隐藏大纲子节点"} else {"Toggle outline children"},
179                onclick:move |event| {event.stop_propagation();if is_hidden {hidden.write().remove(&toggle_id);} else {hidden.write().insert(toggle_id.clone());}}, if is_hidden {"▸"} else {"▾"}
180            }
181            if editing() {
182                textarea {class:"dme-outline-editor", rows:"2", aria_label:if locale==Locale::Zh {"编辑大纲节点"} else {"Edit outline node"},value:node.topic.clone(),autofocus:true,
183                    onmounted:move |event| async move {if let Err(error)=event.data().set_focus(true).await {if !*focused.peek() {on_error.call(MindMapError::Measurement(error.to_string()));}}},
184                    onfocus:move |_| focused.set(true),
185                    oninput:move |event| {let value=event.value(); handle.update_draft(owner, value.clone()); draft.set(value);},
186                    oncompositionstart:move |_| {composing.set(true);ended.set(false);},oncompositionend:move |_| {composing.set(false);ended.set(true);},onkeyup:move |_| ended.set(false),
187                    onkeydown:move |event| {
188                        event.stop_propagation(); if composing() || ended() || event.is_composing() {return;}
189                        if event.key()==Key::Escape {event.prevent_default();editing.set(false);cancel.call(());}
190                        else if event.key()==Key::Enter && !event.modifiers().shift() {event.prevent_default();editing.set(false);
191                            handle.update_draft(owner, draft.peek().clone());
192                            report(handle.commit_pending().map(|_|()));
193                        }
194                    },onblur:move |_| {focused.set(false);
195                        if editing() {editing.set(false); if blur_commits {handle.update_draft(owner, draft.peek().clone()); report(handle.commit_pending().map(|_|()));} else {cancel.call(());}}
196                    }
197                }
198            } else {span {class:"dme-outline-label", "{node.topic}"}
199                if node.image.is_some() {span {class:"dme-outline-badge", "data-badge":"image", aria_label:if locale==Locale::Zh {"有配图"} else {"Has image"}}}
200                if node.note.is_some() {span {class:"dme-outline-badge", "data-badge":"note", aria_label:if locale==Locale::Zh {"有备注"} else {"Has note"}}}
201            }
202            button {class:"dme-outline-action",r#type:"button",title:if locale==Locale::Zh {"聚焦此分支"} else {"Focus branch"},onclick:move |event| {event.stop_propagation();focus.set(Some(fold_id.clone()));},"↗"}
203        }
204
205    }
206}
207
208/// Optional toolbar. Host applications can also render their own command menus.
209#[component]
210pub fn Toolbar(
211    handle: MindMapHandle,
212    #[props(default)] locale: Locale,
213    #[props(default)] class: String,
214    #[props(default)] style: String,
215    #[props(default)] on_error: EventHandler<MindMapError>,
216) -> Element {
217    if !handle.has_plugin(Capability::Toolbar) {
218        return rsx! {};
219    }
220    let commands = match handle.plugin_commands() {
221        Ok(commands) => commands,
222        Err(error) => return rsx! {p{role:"alert","{error}"}},
223    };
224    rsx! { style {"{STYLESHEET}"} div {class:"dme-toolbar {class}",style,role:"toolbar", aria_label:if locale==Locale::Zh {"脑图工具栏"} else {"Mind map toolbar"},
225        for command in commands {button {r#type:"button", disabled:matches!(handle.is_read_only(),Ok(true)), onclick:move |_| {if let Err(error)=plugins::execute(handle,command,locale){on_error.call(error);}},"{locale.label(command)}"}}
226    }}
227}