Files
u-ppt/src-tauri/src/op.rs
T

559 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 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<usize> {
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<Slide, _> {
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<Slide, _> {
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<SlideElement, _> {
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<Deck, _> {
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");
}
}