use super::focus_state::TreeShellFocusState; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PageTreeRuntimeEnvironment { pub visible_node_ids: Vec, pub expandable_node_ids: BTreeSet, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rows: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PageTreeRuntimeRow { pub node_id: String, pub parent_node_id: Option, pub position: i64, } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PageTreeRuntimeState { pub focused_id: Option, pub expanded_ids: BTreeSet, pub drop_feedback: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageTreeRuntimeTransition { pub state: PageTreeRuntimeState, pub outputs: BTreeSet, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde( tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase" )] pub enum PageTreeRuntimeAction { Normalize, Focus { node_id: String, }, MoveNext, MovePrevious, MoveHome, MoveEnd, Expand { node_id: String, }, Collapse { node_id: String, }, Toggle { node_id: String, }, OpenFocused, ContextMenuFocused, DispatchCreate { parent_node_id: Option, }, DispatchRename { node_id: String, title: String, }, UpdateDropFeedback { feedback: Option, }, UpdateDropFeedbackForTarget { source_node_id: String, target_node_id: String, position: PageTreeDropPosition, }, DispatchMove { source_node_id: String, target_parent_id: Option, position: PageTreeDropPosition, }, DispatchMoveToTarget { source_node_id: String, target_node_id: String, position: PageTreeDropPosition, }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum PageTreeRuntimeOutput { DomPatch, Intent(PageTreeIntentEvent), CommandDispatch(PageTreeCommandDispatchEvent), } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum PageTreeIntentEvent { Open { node_id: String }, ContextMenu { node_id: String }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum PageTreeCommandDispatchEvent { CreateNode { command_name: &'static str, parent_node_id: Option, }, RenameNode { command_name: &'static str, node_id: String, title: String, }, MoveSubtree { command_name: &'static str, source_node_id: String, target_node_id: Option, target_parent_id: Option, position: PageTreeDropPosition, sort_order: Option, }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PageTreeDropFeedback { pub source_node_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_node_id: Option, pub target_parent_id: Option, pub position: PageTreeDropPosition, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PageTreeDropPosition { Before, Inside, After, } impl PageTreeRuntimeState { pub fn reduce( &self, env: &PageTreeRuntimeEnvironment, action: PageTreeRuntimeAction, ) -> PageTreeRuntimeTransition { match action { PageTreeRuntimeAction::Normalize => self.with_focus( TreeShellFocusState { focused_id: self.focused_id.clone(), } .normalize(&env.visible_node_ids) .focused_id, ), PageTreeRuntimeAction::Focus { node_id } => { let focused_id = env .visible_node_ids .iter() .any(|visible_id| visible_id == &node_id) .then_some(node_id); self.with_focus(focused_id) } PageTreeRuntimeAction::MoveNext => self.with_focus( TreeShellFocusState { focused_id: self.focused_id.clone(), } .move_next(&env.visible_node_ids) .focused_id, ), PageTreeRuntimeAction::MovePrevious => self.with_focus( TreeShellFocusState { focused_id: self.focused_id.clone(), } .move_previous(&env.visible_node_ids) .focused_id, ), PageTreeRuntimeAction::MoveHome => self.with_focus( TreeShellFocusState { focused_id: self.focused_id.clone(), } .move_home(&env.visible_node_ids) .focused_id, ), PageTreeRuntimeAction::MoveEnd => self.with_focus( TreeShellFocusState { focused_id: self.focused_id.clone(), } .move_end(&env.visible_node_ids) .focused_id, ), PageTreeRuntimeAction::Expand { node_id } => self.with_expansion(env, node_id, true), PageTreeRuntimeAction::Collapse { node_id } => self.with_expansion(env, node_id, false), PageTreeRuntimeAction::Toggle { node_id } => { let expanded = !self.expanded_ids.contains(&node_id); self.with_expansion(env, node_id, expanded) } PageTreeRuntimeAction::OpenFocused => { self.with_focused_intent(PageTreeIntentEvent::Open { node_id: self.focused_id.clone().unwrap_or_default(), }) } PageTreeRuntimeAction::ContextMenuFocused => { self.with_focused_intent(PageTreeIntentEvent::ContextMenu { node_id: self.focused_id.clone().unwrap_or_default(), }) } PageTreeRuntimeAction::DispatchCreate { parent_node_id } => transition( self.clone(), [PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::CreateNode { command_name: "tree.node.create", parent_node_id, }, )], ), PageTreeRuntimeAction::DispatchRename { node_id, title } => { let title = title.trim().to_string(); if node_id.trim().is_empty() || title.is_empty() { return transition(self.clone(), []); } transition( self.clone(), [PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::RenameNode { command_name: "tree.node.rename", node_id, title, }, )], ) } PageTreeRuntimeAction::UpdateDropFeedback { feedback } => { let mut state = self.clone(); state.drop_feedback = feedback; transition(state, [PageTreeRuntimeOutput::DomPatch]) } PageTreeRuntimeAction::UpdateDropFeedbackForTarget { source_node_id, target_node_id, position, } => { let mut state = self.clone(); state.drop_feedback = resolve_drop_feedback(env, &source_node_id, &target_node_id, position); transition(state, [PageTreeRuntimeOutput::DomPatch]) } PageTreeRuntimeAction::DispatchMove { source_node_id, target_parent_id, position, } => transition( self.clone(), [PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::MoveSubtree { command_name: "tree.subtree.move", source_node_id, target_node_id: None, target_parent_id, position, sort_order: None, }, )], ), PageTreeRuntimeAction::DispatchMoveToTarget { source_node_id, target_node_id, position, } => { let Some(resolved_drop) = resolve_drop_target(env, &source_node_id, &target_node_id, position) else { return transition(self.clone(), []); }; transition( self.clone(), [PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::MoveSubtree { command_name: "tree.subtree.move", source_node_id, target_node_id: Some(target_node_id), target_parent_id: resolved_drop.target_parent_id, position, sort_order: Some(resolved_drop.sort_order), }, )], ) } } } fn with_focus(&self, focused_id: Option) -> PageTreeRuntimeTransition { let mut state = self.clone(); state.focused_id = focused_id; transition(state, [PageTreeRuntimeOutput::DomPatch]) } fn with_expansion( &self, env: &PageTreeRuntimeEnvironment, node_id: String, expanded: bool, ) -> PageTreeRuntimeTransition { let mut state = self.clone(); if env.expandable_node_ids.contains(&node_id) { if expanded { state.expanded_ids.insert(node_id); } else { state.expanded_ids.remove(&node_id); } } transition(state, [PageTreeRuntimeOutput::DomPatch]) } fn with_focused_intent(&self, intent: PageTreeIntentEvent) -> PageTreeRuntimeTransition { let has_focus = !match &intent { PageTreeIntentEvent::Open { node_id } => node_id, PageTreeIntentEvent::ContextMenu { node_id } => node_id, } .is_empty(); if has_focus { transition(self.clone(), [PageTreeRuntimeOutput::Intent(intent)]) } else { transition(self.clone(), []) } } } #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedPageDropTarget { target_parent_id: Option, sort_order: i64, } fn resolve_drop_feedback( env: &PageTreeRuntimeEnvironment, source_node_id: &str, target_node_id: &str, position: PageTreeDropPosition, ) -> Option { let resolved = resolve_drop_target(env, source_node_id, target_node_id, position)?; Some(PageTreeDropFeedback { source_node_id: source_node_id.to_string(), target_node_id: Some(target_node_id.to_string()), target_parent_id: resolved.target_parent_id, position, }) } fn resolve_drop_target( env: &PageTreeRuntimeEnvironment, source_node_id: &str, target_node_id: &str, position: PageTreeDropPosition, ) -> Option { let source_node_id = source_node_id.trim(); let target_node_id = target_node_id.trim(); if source_node_id.is_empty() || target_node_id.is_empty() || source_node_id == target_node_id { return None; } let source_row = env.rows.iter().find(|row| row.node_id == source_node_id)?; let target_row = env.rows.iter().find(|row| row.node_id == target_node_id)?; if source_row.parent_node_id != target_row.parent_node_id { return None; } let mut siblings = env .rows .iter() .filter(|row| row.parent_node_id == target_row.parent_node_id) .collect::>(); siblings.sort_by(|left, right| { left.position .cmp(&right.position) .then_with(|| left.node_id.cmp(&right.node_id)) }); let target_index = siblings .iter() .position(|row| row.node_id == target_row.node_id)? as i64; let sort_order = match position { PageTreeDropPosition::Before | PageTreeDropPosition::Inside => target_index, PageTreeDropPosition::After => target_index + 1, }; Some(ResolvedPageDropTarget { target_parent_id: target_row.parent_node_id.clone(), sort_order, }) } fn transition( state: PageTreeRuntimeState, outputs: [PageTreeRuntimeOutput; N], ) -> PageTreeRuntimeTransition { PageTreeRuntimeTransition { state, outputs: outputs.into_iter().collect(), } } #[cfg(test)] mod tests { use super::{ PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeOutput, PageTreeRuntimeRow, PageTreeRuntimeState, }; fn ids(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_string()).collect() } #[test] fn page_tree_runtime_reducer_normalizes_focus_expansion_intents_and_drag_move() { let env = PageTreeRuntimeEnvironment { visible_node_ids: ids(&["doc:root", "doc:child", "doc:sibling"]), expandable_node_ids: ids(&["doc:root"]).into_iter().collect(), rows: vec![ PageTreeRuntimeRow { node_id: "doc:root".into(), parent_node_id: None, position: 0, }, PageTreeRuntimeRow { node_id: "doc:child".into(), parent_node_id: Some("doc:root".into()), position: 0, }, PageTreeRuntimeRow { node_id: "doc:sibling".into(), parent_node_id: Some("doc:root".into()), position: 1, }, ], }; let state = PageTreeRuntimeState { focused_id: Some("doc:missing".into()), ..PageTreeRuntimeState::default() }; let transition = state.reduce(&env, PageTreeRuntimeAction::Normalize); assert_eq!(transition.state.focused_id.as_deref(), Some("doc:root")); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::DomPatch)); let transition = transition .state .reduce(&env, PageTreeRuntimeAction::MoveNext); assert_eq!(transition.state.focused_id.as_deref(), Some("doc:child")); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::Expand { node_id: "doc:root".into(), }, ); assert!(transition.state.expanded_ids.contains("doc:root")); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::DomPatch)); let transition = transition .state .reduce(&env, PageTreeRuntimeAction::OpenFocused); assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent( PageTreeIntentEvent::Open { node_id: "doc:child".into(), }, ))); let transition = transition .state .reduce(&env, PageTreeRuntimeAction::ContextMenuFocused); assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent( PageTreeIntentEvent::ContextMenu { node_id: "doc:child".into(), }, ))); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::UpdateDropFeedback { feedback: Some(PageTreeDropFeedback { source_node_id: "doc:child".into(), target_node_id: Some("doc:sibling".into()), target_parent_id: Some("doc:root".into()), position: PageTreeDropPosition::Inside, }), }, ); assert_eq!( transition .state .drop_feedback .as_ref() .map(|feedback| feedback.position), Some(PageTreeDropPosition::Inside) ); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::UpdateDropFeedbackForTarget { source_node_id: "doc:child".into(), target_node_id: "doc:sibling".into(), position: PageTreeDropPosition::Before, }, ); assert_eq!( transition .state .drop_feedback .as_ref() .and_then(|feedback| feedback.target_node_id.as_deref()), Some("doc:sibling") ); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::DispatchMove { source_node_id: "doc:child".into(), target_parent_id: Some("doc:root".into()), position: PageTreeDropPosition::After, }, ); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::MoveSubtree { command_name: "tree.subtree.move", source_node_id: "doc:child".into(), target_node_id: None, target_parent_id: Some("doc:root".into()), position: PageTreeDropPosition::After, sort_order: None, }, ))); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::DispatchMoveToTarget { source_node_id: "doc:child".into(), target_node_id: "doc:sibling".into(), position: PageTreeDropPosition::Before, }, ); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::MoveSubtree { command_name: "tree.subtree.move", source_node_id: "doc:child".into(), target_node_id: Some("doc:sibling".into()), target_parent_id: Some("doc:root".into()), position: PageTreeDropPosition::Before, sort_order: Some(1), }, ))); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::DispatchCreate { parent_node_id: Some("doc:root".into()), }, ); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::CreateNode { command_name: "tree.node.create", parent_node_id: Some("doc:root".into()), }, ))); let transition = transition.state.reduce( &env, PageTreeRuntimeAction::DispatchRename { node_id: "doc:child".into(), title: "新标题".into(), }, ); assert!(transition .outputs .contains(&PageTreeRuntimeOutput::CommandDispatch( PageTreeCommandDispatchEvent::RenameNode { command_name: "tree.node.rename", node_id: "doc:child".into(), title: "新标题".into(), }, ))); } }