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#[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) -> Element {
18 let mut focus = use_signal(|| None::<String>);
19 let dragging = use_signal(|| None::<String>);
20 let hidden = use_signal(BTreeSet::<String>::new);
21 let data = match handle.document() {
22 Ok(data) => data,
23 Err(error) => return rsx! { p {role:"alert", "{error}"} },
24 };
25 let selection = match handle.selection() {
26 Ok(ids) => ids,
27 Err(error) => return rsx! {p {role:"alert", "{error}"}},
28 };
29 if !handle.has_plugin(Capability::Outline) {
30 return rsx! {};
31 }
32 let root = focus()
33 .as_ref()
34 .and_then(|id| data.node_data.find(id))
35 .unwrap_or(&data.node_data)
36 .clone();
37 let mut crumbs = vec![data.node_data.clone()];
38 if root.id != data.node_data.id {
39 let mut path = Vec::new();
40 let mut node = &root;
41 while let Some(parent) = plugins::parent(&data.node_data, &node.id) {
42 path.push(node.clone());
43 node = parent;
44 }
45 path.reverse();
46 crumbs.extend(path);
47 }
48 let mut rows = Vec::new();
49 fn flatten(
51 node: &Node,
52 depth: usize,
53 rows: &mut Vec<(Node, usize)>,
54 hidden: &BTreeSet<String>,
55 ) {
56 rows.push((node.clone(), depth));
57 if !hidden.contains(&node.id) {
58 for child in &node.children {
59 flatten(child, depth + 1, rows, hidden);
60 }
61 }
62 }
63 flatten(&root, 0, &mut rows, &hidden.read());
64 rsx! {
65 style {"{STYLESHEET}"}
66 section {class:"dme-outline {class}", style, aria_label: if locale == Locale::Zh {"大纲"} else {"Outline"},
67 nav {class:"dme-breadcrumbs", aria_label: if locale == Locale::Zh {"路径"} else {"Breadcrumbs"},
68 for crumb in crumbs { button {r#type:"button", onclick:move |_| focus.set(Some(crumb.id.clone())), "{crumb.topic}"} }
69 }
70 div {role:"tree",
71 for (node,depth) in rows {
72 OutlineRow {key:"{node.id}", selected:selection.contains(&node.id), node, depth, handle, locale, focus, dragging, hidden, on_error}
73 }
74 }
75 }
76 }
77}
78#[component]
79fn OutlineRow(
80 node: Node,
81 depth: usize,
82 selected: bool,
83 handle: MindMapHandle,
84 locale: Locale,
85 mut focus: Signal<Option<String>>,
86 mut dragging: Signal<Option<String>>,
87 mut hidden: Signal<BTreeSet<String>>,
88 on_error: EventHandler<MindMapError>,
89) -> Element {
90 let owner = dioxus::core::current_scope_id().0;
91 let mut editing = use_signal(|| false);
92 let mut draft = use_signal(|| node.topic.clone());
93 let mut composing = use_signal(|| false);
94 let mut ended = use_signal(|| false);
95 let mut focused = use_signal(|| false);
96 let is_hidden = hidden.read().contains(&node.id);
97 let mut edit_revision = use_signal(|| None::<u64>);
98 use_effect(move || {
99 if editing()
100 && !matches!((handle.revision(), *edit_revision.read()), (Ok(current),Some(start)) if current==start)
101 {
102 editing.set(false);
103 }
104 });
105 use_effect(move || {
106 if !matches!(handle.owns_editor(owner), Ok(true))
107 || !matches!(handle.is_read_only(), Ok(false))
108 {
109 editing.set(false);
110 }
111 });
112 let select_id = node.id.clone();
113 let edit_node = node.clone();
114 let fold_id = node.id.clone();
115 let toggle_id = node.id.clone();
116 let key_node = node.clone();
117 let drag_id = node.id.clone();
118 let drop_id = node.id.clone();
119 let input_id = node.id.clone();
120 let report = move |result: Result<(), MindMapError>| {
121 if let Err(error) = result {
122 on_error.call(error);
123 }
124 };
125 let start = use_callback(move |_: ()| {
126 report((|| {
127 if handle.is_read_only()? {
128 return Err(MindMapError::ReadOnly);
129 }
130 handle.select(std::slice::from_ref(&edit_node.id))?;
131 handle.claim_editor(owner)?;
132 edit_revision.set(Some(handle.revision()?));
133 draft.set(edit_node.topic.clone());
134 editing.set(true);
135 Ok(())
136 })());
137 });
138 rsx! {
139 div {class:"dme-outline-row", role:"treeitem", tabindex:"0", "data-outline-id":"{node.id}", "data-selected":selected,
140 style:format!("--dme-outline-depth:{depth}"), aria_level:depth+1, aria_selected:selected,
141 draggable: handle.has_plugin(Capability::DragDrop) && !matches!(handle.is_read_only(),Ok(true)),
142 ondragstart:move |_| { dragging.set(Some(drag_id.clone())); },
143 ondragover:move |event| event.prevent_default(),
144 ondrop:move |event| {
145 event.prevent_default(); let source=dragging.peek().clone(); dragging.set(None);
146 if let Some(source)=source { if source != drop_id {
147 report(handle.apply(Operation::Move {ids:vec![source],placement:Placement::In(drop_id.clone())}).map(|_| ()));
148 }}
149 },
150 ondragend:move |_| dragging.set(None),
151 onclick:move |event| {event.stop_propagation(); report(handle.select(std::slice::from_ref(&select_id)));},
152 ondoubleclick:move |_| start.call(()),
153 onkeydown:move |event| {
154 if event.is_composing() || editing() {return;}
155 if event.key()==Key::F2 {event.prevent_default(); start.call(()); return;}
156 let command=match event.key() {
157 Key::Tab if event.modifiers().shift()=>Some(Command::Outdent),Key::Tab=>Some(Command::Indent),
158 Key::Enter if event.modifiers().ctrl()||event.modifiers().meta()=>Some(Command::Parent),
159 Key::Enter if event.modifiers().shift()=>Some(Command::SiblingBefore),Key::Enter=>Some(Command::SiblingAfter),
160 Key::Delete|Key::Backspace=>Some(Command::Delete),_=>None,
161 };
162 if let Some(command)=command {event.prevent_default();report((||{handle.select(std::slice::from_ref(&key_node.id))?;plugins::execute(handle,command,locale)})());}
163 },
164 button {class:"dme-outline-fold",r#type:"button",disabled:node.children.is_empty(), aria_expanded:!is_hidden,
165 aria_label:if locale==Locale::Zh {"显示或隐藏大纲子节点"} else {"Toggle outline children"},
166 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 {"▾"}
167 }
168 if editing() {
169 textarea {class:"dme-outline-editor", rows:"2", aria_label:if locale==Locale::Zh {"编辑大纲节点"} else {"Edit outline node"},value:node.topic.clone(),autofocus:true,
170 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()));}}},
171 onfocus:move |_| focused.set(true),
172 oninput:move |event| draft.set(event.value()),
173 oncompositionstart:move |_| {composing.set(true);ended.set(false);},oncompositionend:move |_| {composing.set(false);ended.set(true);},onkeyup:move |_| ended.set(false),
174 onkeydown:move |event| {
175 event.stop_propagation(); if composing() || ended() || event.is_composing() {return;}
176 if event.key()==Key::Escape {event.prevent_default();editing.set(false);}
177 else if event.key()==Key::Enter && !event.modifiers().shift() {event.prevent_default();editing.set(false);
178 if !draft.peek().trim().is_empty() && *draft.peek()!=node.topic {
179 report(handle.apply(Operation::SetTopic{id:input_id.clone(),topic:draft.peek().clone()}).map(|_|()));
180 }
181 }
182 },onblur:move |_| {focused.set(false);editing.set(false);}
183 }
184 } else {span {class:"dme-outline-label", "{node.topic}"}}
185 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()));},"↗"}
186 }
187
188 }
189}
190
191#[component]
193pub fn Toolbar(
194 handle: MindMapHandle,
195 #[props(default)] locale: Locale,
196 #[props(default)] class: String,
197 #[props(default)] style: String,
198 #[props(default)] on_error: EventHandler<MindMapError>,
199) -> Element {
200 if !handle.has_plugin(Capability::Toolbar) {
201 return rsx! {};
202 }
203 let commands = match handle.plugin_commands() {
204 Ok(commands) => commands,
205 Err(error) => return rsx! {p{role:"alert","{error}"}},
206 };
207 rsx! { style {"{STYLESHEET}"} div {class:"dme-toolbar {class}",style,role:"toolbar", aria_label:if locale==Locale::Zh {"脑图工具栏"} else {"Mind map toolbar"},
208 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)}"}}
209 }}
210}