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 #[props(default)]
58 pub fit_revision: u64,
59 #[props(default)]
61 pub image_url: Option<Callback<String, String>>,
62}
63
64#[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#[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 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 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((¢er_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 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 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#[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 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 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 if gesture.peek().as_ref() != Some(¤t) {
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 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}