1use crate::{
2 MindMapError, MindMapHandle, Node, Operation, Placement, STYLESHEET,
3 plugins::{self, Capability, Command, Labels},
4};
5use dioxus::prelude::*;
6use std::collections::BTreeSet;
7
8#[component]
11pub fn Outline(
12 handle: MindMapHandle,
13 #[props(default)] labels: Labels,
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 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: "{labels.outline}",
68 nav {class:"dme-breadcrumbs", aria_label: "{labels.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, labels: labels.clone(), 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 labels: Labels,
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 new_topic = labels.new_topic.clone();
94 let mut editing = use_signal(|| false);
95 let mut draft = use_signal(|| node.topic.clone());
96 let mut composing = use_signal(|| false);
97 let mut ended = use_signal(|| false);
98 let mut focused = use_signal(|| false);
99 let is_hidden = hidden.read().contains(&node.id);
100 let mut edit_topic = use_signal(|| None::<String>);
101 let cancel_id = node.id.clone();
102 let watch_id = node.id.clone();
103 let cancel = use_callback(move |_: ()| {
104 if handle.edits(owner, &cancel_id) {
105 if let Err(error) = handle.cancel_pending() {
106 on_error.call(error);
107 }
108 }
109 });
110 let watch_topic = node.topic.clone();
113 use_effect(use_reactive((&watch_topic,), move |(topic,)| {
114 let started = edit_topic.peek().clone();
115 if *editing.peek() && started.as_deref().is_some_and(|t| t != topic) {
116 editing.set(false);
117 }
118 }));
119 use_effect(move || {
120 if editing()
121 && (!matches!(handle.owns_editor(owner), Ok(true))
122 || !matches!(handle.is_read_only(), Ok(false))
123 || !handle.edits(owner, &watch_id))
124 {
125 editing.set(false);
126 }
127 });
128 let select_id = node.id.clone();
129 let edit_node = node.clone();
130 let fold_id = node.id.clone();
131 let toggle_id = node.id.clone();
132 let key_node = node.clone();
133 let drag_id = node.id.clone();
134 let drop_id = node.id.clone();
135
136 let report = move |result: Result<(), MindMapError>| {
137 if let Err(error) = result {
138 on_error.call(error);
139 }
140 };
141 let start = use_callback(move |_: ()| {
142 report((|| {
143 if handle.is_read_only()? {
144 return Err(MindMapError::ReadOnly);
145 }
146 handle.select(std::slice::from_ref(&edit_node.id))?;
147 handle.begin_edit(owner, &edit_node.id, &edit_node.topic)?;
148 edit_topic.set(Some(edit_node.topic.clone()));
149 draft.set(edit_node.topic.clone());
150 editing.set(true);
151 Ok(())
152 })());
153 });
154 rsx! {
155 div {class:"dme-outline-row", role:"treeitem", tabindex:"0", "data-outline-id":"{node.id}", "data-selected":selected,
156 style:format!("--dme-outline-depth:{depth}"), aria_level:depth+1, aria_selected:selected,
157 draggable: handle.has_plugin(Capability::DragDrop) && !matches!(handle.is_read_only(),Ok(true)),
158 ondragstart:move |_| { dragging.set(Some(drag_id.clone())); },
159 ondragover:move |event| event.prevent_default(),
160 ondrop:move |event| {
161 event.prevent_default(); let source=dragging.peek().clone(); dragging.set(None);
162 if let Some(source)=source { if source != drop_id {
163 report(handle.apply(Operation::Move {ids:vec![source],placement:Placement::In(drop_id.clone())}).map(|_| ()));
164 }}
165 },
166 ondragend:move |_| dragging.set(None),
167 onclick:move |event| {event.stop_propagation(); report(handle.select(std::slice::from_ref(&select_id)));},
168 ondoubleclick:move |_| start.call(()),
169 onkeydown:move |event| {
170 if event.is_composing() || editing() {return;}
171 if event.key()==Key::F2 {event.prevent_default(); start.call(()); return;}
172 let command=match event.key() {
173 Key::Tab if event.modifiers().shift()=>Some(Command::Outdent),Key::Tab=>Some(Command::Indent),
174 Key::Enter if event.modifiers().ctrl()||event.modifiers().meta()=>Some(Command::Parent),
175 Key::Enter if event.modifiers().shift()=>Some(Command::SiblingBefore),Key::Enter=>Some(Command::SiblingAfter),
176 Key::Delete|Key::Backspace=>Some(Command::Delete),_=>None,
177 };
178 if let Some(command)=command {event.prevent_default();report((||{handle.select(std::slice::from_ref(&key_node.id))?;plugins::execute(handle,command,&new_topic)})());}
179 },
180 button {class:"dme-outline-fold",r#type:"button",disabled:node.children.is_empty(), aria_expanded:!is_hidden,
181 aria_label:"{labels.toggle_children}",
182 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 {"▾"}
183 }
184 if editing() {
185 textarea {class:"dme-outline-editor", rows:"2", aria_label:"{labels.edit_topic}",value:node.topic.clone(),autofocus:true,
186 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()));}}},
187 onfocus:move |_| focused.set(true),
188 oninput:move |event| {let value=event.value(); handle.update_draft(owner, value.clone()); draft.set(value);},
189 oncompositionstart:move |_| {composing.set(true);ended.set(false);},oncompositionend:move |_| {composing.set(false);ended.set(true);},onkeyup:move |_| ended.set(false),
190 onkeydown:move |event| {
191 event.stop_propagation(); if composing() || ended() || event.is_composing() {return;}
192 if event.key()==Key::Escape {event.prevent_default();editing.set(false);cancel.call(());}
193 else if event.key()==Key::Enter && !event.modifiers().shift() {event.prevent_default();editing.set(false);
194 handle.update_draft(owner, draft.peek().clone());
195 report(handle.commit_pending().map(|_|()));
196 }
197 },onblur:move |_| {focused.set(false);
198 if editing() {editing.set(false); if blur_commits {handle.update_draft(owner, draft.peek().clone()); report(handle.commit_pending().map(|_|()));} else {cancel.call(());}}
199 }
200 }
201 } else {span {class:"dme-outline-label", "{node.topic}"}
202 if node.image.is_some() {span {class:"dme-outline-badge", "data-badge":"image", aria_label:"{labels.has_image}"}}
203 if node.note.is_some() {span {class:"dme-outline-badge", "data-badge":"note", aria_label:"{labels.has_note}"}}
204 }
205 button {class:"dme-outline-action",r#type:"button",title:"{labels.focus_branch}",onclick:move |event| {event.stop_propagation();focus.set(Some(fold_id.clone()));},"↗"}
206 }
207
208 }
209}
210
211#[component]
213pub fn Toolbar(
214 handle: MindMapHandle,
215 #[props(default)] labels: Labels,
216 #[props(default)] class: String,
217 #[props(default)] style: String,
218 #[props(default)] on_error: EventHandler<MindMapError>,
219) -> Element {
220 if !handle.has_plugin(Capability::Toolbar) {
221 return rsx! {};
222 }
223 let commands = match handle.plugin_commands() {
224 Ok(commands) => commands,
225 Err(error) => return rsx! {p{role:"alert","{error}"}},
226 };
227 rsx! { style {"{STYLESHEET}"} div {class:"dme-toolbar {class}",style,role:"toolbar", aria_label:"{labels.toolbar}",
228 for command in commands {button {r#type:"button", disabled:matches!(handle.is_read_only(),Ok(true)), onclick:{let topic=labels.new_topic.clone(); move |_| {if let Err(error)=plugins::execute(handle,command,&topic){on_error.call(error);}}},"{labels.command(command)}"}}
229 }}
230}