/* ===================================================================== * op.rs — Op Log reducer(对应 src/core/op.ts) * * 纯函数:apply_op(deck, op) → new deck,不 mutate 输入 * invert_op(deck, op) → 能撤销 op 的逆 op * * 所有 Op 通过 serde_json::Value 传递 payload,按 op_type 分发。 * ===================================================================== */ use crate::model::{Deck, Slide, SlideElement}; /* ---------- 工具 ---------- */ fn clone_deck(deck: &Deck) -> Deck { serde_json::from_str(&serde_json::to_string(deck).unwrap()).unwrap() } fn find_element(deck: &Deck, slide_idx: usize, el_id: &str) -> Option { let slide = deck.slides.get(slide_idx)?; slide.elements.iter().position(|e| e.id == el_id) } /* ---------- reducer ---------- */ pub fn apply_op(input_deck: &Deck, op: &crate::model::Op) -> Deck { let mut deck = clone_deck(input_deck); match op.op_type.as_str() { "add_slide" => { if let Some(slide) = op.payload.get("slide").and_then(|v| v.as_object()) { if let Ok(s) = serde_json::from_value(serde_json::Value::Object(slide.clone())) as Result { let at_index = op.payload.get("atIndex") .or_else(|| op.payload.get("at_index")) .and_then(|v| v.as_f64()) .map(|f| f as usize) .unwrap_or(deck.slides.len()); let idx = at_index.min(deck.slides.len()); deck.slides.insert(idx, s); } } } "del_slide" => { if deck.slides.len() <= 1 { return deck; } if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) { if index < deck.slides.len() { deck.slides.remove(index); } } } "move_slide" => { let from = op.payload.get("from").and_then(|v| v.as_f64()).map(|f| f as usize); let to = op.payload.get("to").and_then(|v| v.as_f64()).map(|f| f as usize); if let (Some(from), Some(to)) = (from, to) { if from != to && from < deck.slides.len() && to < deck.slides.len() { let item = deck.slides.remove(from); deck.slides.insert(to, item); } } } "replace_slide" => { if let Some(slide_val) = op.payload.get("slide") { if let Ok(mut s) = serde_json::from_value(slide_val.clone()) as Result { if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) { if let Some(existing) = deck.slides.get(index) { s.id = existing.id.clone(); // 保持 id 不变 } if index < deck.slides.len() { deck.slides[index] = s; } } } } } "set_slide_bg" => { if let Some(index) = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize) { if let Some(bg) = op.payload.get("bg").and_then(|v| v.as_str()) { if let Some(slide) = deck.slides.get_mut(index) { slide.background = bg.to_string(); } } } } "add_element" => { if let Some(el_val) = op.payload.get("element") { if let Ok(el) = serde_json::from_value(el_val.clone()) as Result { if let Some(slide_idx) = op.payload.get("slideIdx") .or_else(|| op.payload.get("slide_idx")) .and_then(|v| v.as_f64()) .map(|f| f as usize) { if let Some(slide) = deck.slides.get_mut(slide_idx) { slide.elements.push(el); } } } } } "update_element" => { let slide_idx = op.payload.get("slideIdx") .or_else(|| op.payload.get("slide_idx")) .and_then(|v| v.as_f64()) .map(|f| f as usize); let element_id = op.payload.get("elementId") .or_else(|| op.payload.get("element_id")) .and_then(|v| v.as_str()); if let (Some(sidx), Some(eid)) = (slide_idx, element_id) { if let Some(eidx) = find_element(&deck, sidx, eid) { let slide = &mut deck.slides[sidx]; let el = &mut slide.elements[eidx]; // 应用 patch if let Some(patch) = op.payload.get("patch").and_then(|v| v.as_object()) { if let Some(style_patch) = patch.get("style").and_then(|v| v.as_object()) { // 字段级 merge style let current_style_val = serde_json::to_value(&el.style).unwrap_or_default(); if let Ok(mut current_style_map) = current_style_val.as_object().cloned().ok_or(()) { for (k, v) in style_patch { current_style_map.insert(k.clone(), v.clone()); } if let Ok(new_style) = serde_json::from_value(serde_json::Value::Object(current_style_map)) { el.style = new_style; } } } // 非 style 字段 let el_val = serde_json::to_value(&el.clone()).unwrap_or_default(); if let Ok(mut el_map) = el_val.as_object().cloned().ok_or(()) { for (k, v) in patch { if k != "style" { el_map.insert(k.clone(), v.clone()); } } if let Ok(new_el) = serde_json::from_value(serde_json::Value::Object(el_map)) { *el = new_el; } } } } } } "del_element" => { let slide_idx = op.payload.get("slideIdx") .or_else(|| op.payload.get("slide_idx")) .and_then(|v| v.as_f64()) .map(|f| f as usize); let element_id = op.payload.get("elementId") .or_else(|| op.payload.get("element_id")) .and_then(|v| v.as_str()); if let (Some(sidx), Some(eid)) = (slide_idx, element_id) { if let Some(slide) = deck.slides.get_mut(sidx) { slide.elements.retain(|e| e.id != eid); } } } "move_element_z" => { let slide_idx = op.payload.get("slideIdx") .or_else(|| op.payload.get("slide_idx")) .and_then(|v| v.as_f64()) .map(|f| f as usize); let element_id = op.payload.get("elementId") .or_else(|| op.payload.get("element_id")) .and_then(|v| v.as_str()); let dir = op.payload.get("dir").and_then(|v| v.as_f64()).map(|f| f as i64); if let (Some(sidx), Some(eid), Some(dir)) = (slide_idx, element_id, dir) { if let Some(eidx) = find_element(&deck, sidx, eid) { let j = if dir > 0 { eidx + 1 } else { eidx.saturating_sub(1) }; if let Some(slide) = deck.slides.get_mut(sidx) { if j < slide.elements.len() { slide.elements.swap(eidx, j); } } } } } "set_theme" => { if let Some(theme) = op.payload.get("theme").and_then(|v| v.as_str()) { deck.theme = theme.to_string(); } } "replace_deck" => { if let Some(new_deck) = op.payload.get("deck") { if let Ok(mut d) = serde_json::from_value(new_deck.clone()) as Result { d.v = deck.v; // 保持版本号 return d; } } } _ => {} // 未知 Op,不修改 } deck } /* ---------- 逆操作计算 ---------- */ pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op { use crate::model::Op; use serde_json::json; match op.op_type.as_str() { "add_slide" => { // 逆:删除加的那页 let slide_id = op.payload.get("slide").and_then(|v| v.get("id")).and_then(|v| v.as_str()); let idx = if let Some(sid) = slide_id { input_deck.slides.iter().position(|s| s.id == sid) } else { op.payload.get("atIndex").and_then(|v| v.as_f64()).map(|f| f as usize) }; Op { op_type: "del_slide".into(), client_id: None, timestamp: None, payload: json!({ "index": idx.unwrap_or(input_deck.slides.len().saturating_sub(1)) }), } } "del_slide" => { // 逆:把删掉的页插回原位 let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize); if let Some(deleted) = op.payload.get("deletedSlide").or_else(|| op.payload.get("deleted_slide")) { Op { op_type: "add_slide".into(), client_id: None, timestamp: None, payload: json!({ "atIndex": index, "slide": deleted }), } } else { Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) } } } "move_slide" => { let from = op.payload.get("from").cloned().unwrap_or_default(); let to = op.payload.get("to").cloned().unwrap_or_default(); Op { op_type: "move_slide".into(), client_id: None, timestamp: None, payload: json!({ "from": to, "to": from }), } } "replace_slide" => { let index = op.payload.get("index").cloned().unwrap_or_default(); let old = op.payload.get("oldSlide").or_else(|| op.payload.get("old_slide")).cloned() .unwrap_or_else(|| { let idx = index.as_f64().map(|f| f as usize).unwrap_or(0); serde_json::to_value(input_deck.slides.get(idx).cloned().unwrap_or_else(|| Slide { id: String::new(), background: "bg".into(), elements: vec![] })).unwrap_or_default() }); Op { op_type: "replace_slide".into(), client_id: None, timestamp: None, payload: json!({ "index": index, "slide": old }), } } "set_slide_bg" => { let index = op.payload.get("index").and_then(|v| v.as_f64()).map(|f| f as usize); let old_bg = index.and_then(|i| input_deck.slides.get(i)).map(|s| s.background.clone()).unwrap_or_else(|| "bg".into()); Op { op_type: "set_slide_bg".into(), client_id: None, timestamp: None, payload: json!({ "index": index, "bg": old_bg }), } } "add_element" => { let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default(); let element_id = op.payload.get("element").and_then(|v| v.get("id")).cloned().unwrap_or_default(); Op { op_type: "del_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": slide_idx, "elementId": element_id }), } } "update_element" => { // 构造反向 patch:取当前值 let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize); let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string(); let patch = op.payload.get("patch").cloned().unwrap_or_default(); if let Some(sidx) = slide_idx { if let Some(eidx) = find_element(input_deck, sidx, &element_id) { let el = &input_deck.slides[sidx].elements[eidx]; let el_val = serde_json::to_value(el).unwrap_or_default(); let mut backward_patch = serde_json::Map::new(); if let Some(patch_obj) = patch.as_object() { if let Some(style_patch) = patch_obj.get("style").and_then(|v| v.as_object()) { let current_style = &el.style; let style_val = serde_json::to_value(current_style).unwrap_or_default(); let mut old_style = serde_json::Map::new(); if let Some(style_map) = style_val.as_object() { for (k, _) in style_patch { if let Some(v) = style_map.get(k) { old_style.insert(k.clone(), v.clone()); } } } backward_patch.insert("style".into(), serde_json::Value::Object(old_style)); } for (k, _) in patch_obj { if k != "style" { if let Some(v) = el_val.get(k) { backward_patch.insert(k.clone(), v.clone()); } } } } return Op { op_type: "update_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": sidx, "elementId": element_id, "patch": serde_json::Value::Object(backward_patch) }), }; } } Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) } } "del_element" => { let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize); let _element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string(); if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) { Op { op_type: "add_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": slide_idx, "element": deleted }), } } else { Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }) } } } "move_element_z" => { let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).cloned().unwrap_or_default(); let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).cloned().unwrap_or_default(); let dir = op.payload.get("dir").and_then(|v| v.as_f64()).unwrap_or(0.0); Op { op_type: "move_element_z".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": slide_idx, "elementId": element_id, "dir": -dir }), } } "set_theme" => { Op { op_type: "set_theme".into(), client_id: None, timestamp: None, payload: json!({ "theme": input_deck.theme }), } } "replace_deck" => { Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }), } } _ => Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": clone_deck(input_deck) }), }, } } #[cfg(test)] mod tests { use super::*; use crate::model::*; use serde_json::json; fn make_test_deck() -> Deck { Deck { v: 3, theme: "indigo".into(), slides: vec![ Slide { id: "s1".into(), background: "bg".into(), elements: vec![ SlideElement { id: "e1".into(), element_type: "title".into(), x: 10.0, y: 10.0, w: 80.0, h: 15.0, content: "Hello".into(), style: ElementStyle { font_size: Some(48.0), ..Default::default() }, segments: None, }, ], }, Slide { id: "s2".into(), background: "panel".into(), elements: vec![], }, ], chat_id: None, } } #[test] fn test_add_slide() { let deck = make_test_deck(); let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] }; let op = Op { op_type: "add_slide".into(), client_id: None, timestamp: None, payload: json!({ "atIndex": 1, "slide": new_slide }), }; let result = apply_op(&deck, &op); assert_eq!(result.slides.len(), 3); assert_eq!(result.slides[1].id, "s3"); } #[test] fn test_del_slide() { let deck = make_test_deck(); let op = Op { op_type: "del_slide".into(), client_id: None, timestamp: None, payload: json!({ "index": 1 }), }; let result = apply_op(&deck, &op); assert_eq!(result.slides.len(), 1); assert_eq!(result.slides[0].id, "s1"); } #[test] fn test_set_theme() { let deck = make_test_deck(); let op = Op { op_type: "set_theme".into(), client_id: None, timestamp: None, payload: json!({ "theme": "rose" }), }; let result = apply_op(&deck, &op); assert_eq!(result.theme, "rose"); } #[test] fn test_update_element() { let deck = make_test_deck(); let op = Op { op_type: "update_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }), }; let result = apply_op(&deck, &op); assert_eq!(result.slides[0].elements[0].style.font_size, Some(56.0)); } #[test] fn test_del_element() { let deck = make_test_deck(); let op = Op { op_type: "del_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": 0, "elementId": "e1" }), }; let result = apply_op(&deck, &op); assert_eq!(result.slides[0].elements.len(), 0); } #[test] fn test_move_slide() { let deck = make_test_deck(); let op = Op { op_type: "move_slide".into(), client_id: None, timestamp: None, payload: json!({ "from": 0, "to": 1 }), }; let result = apply_op(&deck, &op); assert_eq!(result.slides[0].id, "s2"); assert_eq!(result.slides[1].id, "s1"); } #[test] fn test_undo_update() { let deck = make_test_deck(); let op = Op { op_type: "update_element".into(), client_id: None, timestamp: None, payload: json!({ "slideIdx": 0, "elementId": "e1", "patch": { "style": { "fontSize": 56 } } }), }; let backward = invert_op(&deck, &op); let applied = apply_op(&deck, &op); let undone = apply_op(&applied, &backward); assert_eq!(undone.slides[0].elements[0].style.font_size, Some(48.0)); } #[test] fn test_undo_add_slide() { let deck = make_test_deck(); let new_slide = Slide { id: "s3".into(), background: "bg".into(), elements: vec![] }; let op = Op { op_type: "add_slide".into(), client_id: None, timestamp: None, payload: json!({ "atIndex": 1, "slide": new_slide }), }; let backward = invert_op(&deck, &op); let applied = apply_op(&deck, &op); assert_eq!(applied.slides.len(), 3); let undone = apply_op(&applied, &backward); assert_eq!(undone.slides.len(), 2); } #[test] fn test_replace_deck_undo() { let deck = make_test_deck(); let new_deck = Deck { v: 3, theme: "rose".into(), slides: vec![Slide { id: "s9".into(), background: "bg".into(), elements: vec![] }], chat_id: None }; let op = Op { op_type: "replace_deck".into(), client_id: None, timestamp: None, payload: json!({ "deck": new_deck }), }; let backward = invert_op(&deck, &op); let applied = apply_op(&deck, &op); assert_eq!(applied.slides.len(), 1); let undone = apply_op(&applied, &backward); assert_eq!(undone.slides.len(), 2); assert_eq!(undone.slides[0].id, "s1"); } }