Skip to main content

dioxus_mind_elixir/plugins/
mod.rs

1//! Typed optional extensions. All document changes still use core transactions.
2pub mod export;
3pub mod gesture;
4use crate::{MindMapData, MindMapError, MindMapHandle, Node, Operation, Placement};
5use std::collections::BTreeMap;
6
7/// Optional interaction capability, scoped to a document handle.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Capability {
10    /// Structural shortcuts and navigation.
11    Keyboard,
12    /// Toggle selection and drag a selection rectangle.
13    MultiSelect,
14    /// Move selected branches with pointer gestures.
15    DragDrop,
16    /// Pan when a drag approaches the viewport edge.
17    EdgePan,
18    /// Context commands.
19    ContextMenu,
20    /// Toolbar commands.
21    Toolbar,
22    /// Shared outline.
23    Outline,
24    /// File export.
25    Export,
26}
27/// A checked command understood by built-in and host menus.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Command {
30    /// Insert a child of the selected node.
31    Child,
32    /// Insert a sibling after the selected node.
33    SiblingAfter,
34    /// Insert a sibling before the selected node.
35    SiblingBefore,
36    /// Insert a new parent.
37    Parent,
38    /// Delete selected branches.
39    Delete,
40    /// Copy selected branches after the selection.
41    Duplicate,
42    /// Toggle children visibility.
43    Fold,
44    /// Move a node under its previous sibling.
45    Indent,
46    /// Move a node after its parent.
47    Outdent,
48    /// Undo a transaction.
49    Undo,
50    /// Redo a transaction.
51    Redo,
52}
53/// Registration metadata. No dynamic code loader or global registry is used.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Plugin {
56    /// Stable unique registration key.
57    pub id: String,
58    /// Declared optional view capabilities.
59    pub capabilities: Vec<Capability>,
60    /// Commands offered to menus/toolbars.
61    pub commands: Vec<Command>,
62}
63impl Plugin {
64    /// Declare a host plugin.
65    pub fn new(id: impl Into<String>, capabilities: Vec<Capability>) -> Self {
66        Self {
67            id: id.into(),
68            capabilities,
69            commands: Vec::new(),
70        }
71    }
72    /// Standard interaction and presentation plugins, individually removable.
73    pub fn builtins() -> Vec<Self> {
74        [
75            Capability::Keyboard,
76            Capability::MultiSelect,
77            Capability::DragDrop,
78            Capability::EdgePan,
79            Capability::ContextMenu,
80            Capability::Toolbar,
81            Capability::Outline,
82            Capability::Export,
83        ]
84        .into_iter()
85        .map(|capability| {
86            let mut plugin = Self::new(format!("builtin.{capability:?}"), vec![capability]);
87            if matches!(capability, Capability::Toolbar | Capability::ContextMenu) {
88                plugin.commands = vec![
89                    Command::Child,
90                    Command::SiblingAfter,
91                    Command::Parent,
92                    Command::Duplicate,
93                    Command::Fold,
94                    Command::Delete,
95                    Command::Undo,
96                    Command::Redo,
97                ];
98            }
99            plugin
100        })
101        .collect()
102    }
103}
104/// Minimal registry, usable independently of Dioxus for validation.
105#[derive(Clone, Debug, Default)]
106pub struct PluginRegistry(BTreeMap<String, (Plugin, bool)>);
107impl PluginRegistry {
108    /// Register exactly once; duplicate IDs are errors.
109    pub fn register(&mut self, plugin: Plugin) -> Result<(), MindMapError> {
110        if plugin.id.trim().is_empty() || self.0.contains_key(&plugin.id) {
111            return Err(MindMapError::InvalidOperation(
112                "Empty or duplicate plugin ID".into(),
113            ));
114        }
115        self.0.insert(plugin.id.clone(), (plugin, true));
116        Ok(())
117    }
118    /// Enable/disable without changing document history.
119    pub fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<(), MindMapError> {
120        self.0
121            .get_mut(id)
122            .ok_or_else(|| MindMapError::InvalidOperation(format!("Unknown plugin: {id}")))?
123            .1 = enabled;
124        Ok(())
125    }
126    /// Remove registration and its capabilities.
127    pub fn unregister(&mut self, id: &str) -> Result<(), MindMapError> {
128        self.0
129            .remove(id)
130            .ok_or_else(|| MindMapError::InvalidOperation(format!("Unknown plugin: {id}")))?;
131        Ok(())
132    }
133    /// Whether a registration exists and is enabled.
134    pub fn is_enabled(&self, id: &str) -> bool {
135        self.0.get(id).is_some_and(|(_, enabled)| *enabled)
136    }
137    /// Whether an enabled plugin provides this capability.
138    pub fn provides(&self, capability: Capability) -> bool {
139        self.0
140            .values()
141            .any(|(plugin, enabled)| *enabled && plugin.capabilities.contains(&capability))
142    }
143    /// Deduplicated commands in registration order.
144    pub fn commands(&self) -> Vec<Command> {
145        let mut commands = Vec::new();
146        for (plugin, enabled) in self.0.values() {
147            if *enabled {
148                for command in &plugin.commands {
149                    if !commands.contains(command) {
150                        commands.push(*command);
151                    }
152                }
153            }
154        }
155        commands
156    }
157}
158/// Language of built-in controls.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub enum Locale {
161    /// English.
162    #[default]
163    En,
164    /// Simplified Chinese.
165    Zh,
166}
167impl Locale {
168    /// Translate a command label.
169    pub fn label(self, command: Command) -> &'static str {
170        match (self, command) {
171            (Self::En, Command::Child) => "Add child",
172            (Self::Zh, Command::Child) => "添加子节点",
173            (Self::En, Command::SiblingAfter) => "Add sibling",
174            (Self::Zh, Command::SiblingAfter) => "添加同级",
175            (Self::En, Command::SiblingBefore) => "Insert before",
176            (Self::Zh, Command::SiblingBefore) => "前插同级",
177            (Self::En, Command::Parent) => "Insert parent",
178            (Self::Zh, Command::Parent) => "插入父节点",
179            (Self::En, Command::Delete) => "Delete",
180            (Self::Zh, Command::Delete) => "删除",
181            (Self::En, Command::Duplicate) => "Duplicate",
182            (Self::Zh, Command::Duplicate) => "复制",
183            (Self::En, Command::Fold) => "Expand / collapse",
184            (Self::Zh, Command::Fold) => "展开 / 折叠",
185            (Self::En, Command::Indent) => "Indent",
186            (Self::Zh, Command::Indent) => "缩进",
187            (Self::En, Command::Outdent) => "Outdent",
188            (Self::Zh, Command::Outdent) => "提升层级",
189            (Self::En, Command::Undo) => "Undo",
190            (Self::Zh, Command::Undo) => "撤销",
191            (Self::En, Command::Redo) => "Redo",
192            (Self::Zh, Command::Redo) => "重做",
193        }
194    }
195    pub(crate) fn topic(self) -> &'static str {
196        if self == Self::Zh {
197            "新节点"
198        } else {
199            "New node"
200        }
201    }
202}
203/// Resolve an ID's parent using the full tree, including collapsed descendants.
204pub fn parent<'a>(root: &'a Node, id: &str) -> Option<&'a Node> {
205    if root.children.iter().any(|child| child.id == id) {
206        Some(root)
207    } else {
208        root.children.iter().find_map(|child| parent(child, id))
209    }
210}
211/// Resolve a command to a single core operation. IDs are deterministic and checked.
212pub fn command_operation(
213    data: &MindMapData,
214    ids: &[String],
215    command: Command,
216    locale: Locale,
217) -> Result<Operation, MindMapError> {
218    let id = ids
219        .first()
220        .ok_or_else(|| MindMapError::InvalidOperation("Select a node first".into()))?;
221    let node = data
222        .node_data
223        .find(id)
224        .ok_or_else(|| MindMapError::NodeNotFound(id.clone()))?;
225    let fresh = || -> Result<Node, MindMapError> {
226        let mut n = 1u64;
227        loop {
228            let candidate = format!("plugin-{n}");
229            if data.node_data.find(&candidate).is_none() {
230                return Ok(Node::new(candidate, locale.topic()));
231            }
232            n = n
233                .checked_add(1)
234                .ok_or_else(|| MindMapError::InvalidOperation("Node ID space exhausted".into()))?;
235        }
236    };
237    let operation = match command {
238        Command::Child => Operation::Insert {
239            node: fresh()?,
240            placement: Placement::In(id.clone()),
241        },
242        Command::SiblingAfter => Operation::Insert {
243            node: fresh()?,
244            placement: Placement::After(id.clone()),
245        },
246        Command::SiblingBefore => Operation::Insert {
247            node: fresh()?,
248            placement: Placement::Before(id.clone()),
249        },
250        Command::Parent => Operation::InsertParent {
251            target: id.clone(),
252            parent: fresh()?,
253        },
254        Command::Delete => Operation::Remove(ids.to_vec()),
255        Command::Duplicate => Operation::Copy {
256            ids: ids.to_vec(),
257            placement: Placement::After(id.clone()),
258        },
259        Command::Fold => Operation::SetExpanded {
260            id: id.clone(),
261            expanded: !node.expanded,
262        },
263        Command::Indent => {
264            let p = parent(&data.node_data, id).ok_or(MindMapError::RootOperation)?;
265            let index = p
266                .children
267                .iter()
268                .position(|n| n.id == *id)
269                .ok_or(MindMapError::StaleOperation)?;
270            let previous = index
271                .checked_sub(1)
272                .and_then(|i| p.children.get(i))
273                .ok_or_else(|| MindMapError::InvalidOperation("No previous sibling".into()))?;
274            Operation::Move {
275                ids: ids.to_vec(),
276                placement: Placement::In(previous.id.clone()),
277            }
278        }
279        Command::Outdent => {
280            let p = parent(&data.node_data, id).ok_or(MindMapError::RootOperation)?;
281            if p.id == data.node_data.id {
282                return Err(MindMapError::RootOperation);
283            }
284            Operation::Move {
285                ids: ids.to_vec(),
286                placement: Placement::After(p.id.clone()),
287            }
288        }
289        Command::Undo | Command::Redo => {
290            return Err(MindMapError::InvalidOperation(
291                "History command has no tree operation".into(),
292            ));
293        }
294    };
295    Ok(operation)
296}
297/// Execute a command through the shared checked handle.
298pub fn execute(
299    handle: MindMapHandle,
300    command: Command,
301    locale: Locale,
302) -> Result<(), MindMapError> {
303    match command {
304        Command::Undo => handle.undo(),
305        Command::Redo => handle.redo(),
306        _ => handle
307            .apply(command_operation(
308                &handle.document()?,
309                &handle.selection()?,
310                command,
311                locale,
312            )?)
313            .map(|_| ()),
314    }
315}