Files
mnote/rust/crates/mnote-web/src/tree_shell/page_runtime.rs
T

590 lines
20 KiB
Rust
Raw Normal View History

2026-04-28 16:30:51 +08:00
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<String>,
pub expandable_node_ids: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rows: Vec<PageTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub position: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeState {
pub focused_id: Option<String>,
pub expanded_ids: BTreeSet<String>,
pub drop_feedback: Option<PageTreeDropFeedback>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRuntimeTransition {
pub state: PageTreeRuntimeState,
pub outputs: BTreeSet<PageTreeRuntimeOutput>,
}
#[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<String>,
},
DispatchRename {
node_id: String,
title: String,
},
UpdateDropFeedback {
feedback: Option<PageTreeDropFeedback>,
},
UpdateDropFeedbackForTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
DispatchMove {
source_node_id: String,
target_parent_id: Option<String>,
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<String>,
},
RenameNode {
command_name: &'static str,
node_id: String,
title: String,
},
MoveSubtree {
command_name: &'static str,
source_node_id: String,
target_node_id: Option<String>,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
sort_order: Option<i64>,
},
}
#[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<String>,
pub target_parent_id: Option<String>,
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<String>) -> 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<String>,
sort_order: i64,
}
fn resolve_drop_feedback(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<PageTreeDropFeedback> {
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<ResolvedPageDropTarget> {
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::<Vec<_>>();
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<const N: usize>(
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, PageTreeRuntimeRow,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
fn ids(values: &[&str]) -> Vec<String> {
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(),
},
)));
}
}