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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum Alignment {
20 #[default]
22 Root,
23 Nodes,
25}
26
27#[derive(Props, Clone, PartialEq)]
29pub struct MindMapProps {
30 pub data: ReadSignal<MindMapData>,
32 #[props(default)]
34 pub class: String,
35 #[props(default)]
37 pub style: String,
38 #[props(default)]
40 pub measurement_revision: u64,
41 #[props(default)]
43 pub alignment: Alignment,
44 #[props(default)]
46 pub theme: Option<Theme>,
47 #[props(default)]
49 pub viewport: Option<Signal<Viewport>>,
50 #[props(default)]
52 pub center_revision: u64,
53 #[props(default)]
55 pub on_error: EventHandler<MindMapError>,
56}
57
58#[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#[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 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 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((¢er_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 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 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#[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 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 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 if gesture.peek().as_ref() != Some(¤t) {
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 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}