diff --git a/src-tauri/bindings/types.ts b/src-tauri/bindings/types.ts index 186d78f..d20caa7 100644 --- a/src-tauri/bindings/types.ts +++ b/src-tauri/bindings/types.ts @@ -12,7 +12,7 @@ export type ChatMessage = { role: string, content: string, }; export type Deck = { v: number, theme: string, slides: Array, chat_id: string | null, }; -export type ElementStyle = { font_size: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, label_color: string | null, label_size: number | null, fit: string | null, shape_type: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chart_type: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, }; +export type ElementStyle = { fontSize: number | null, color: string | null, align: string | null, bold: boolean | null, italic: boolean | null, anim: string | null, label: string | null, labelColor: string | null, labelSize: number | null, fit: string | null, shapeType: ShapeType | null, fill: string | null, gradient: boolean | null, opacity: number | null, radius: number | null, max: number | null, chartType: ChartType | null, legend: boolean | null, stack: boolean | null, grid: boolean | null, header: boolean | null, lang: string | null, inline: boolean | null, icon: string | null, accent: string | null, }; export type ElementType = "title" | "text" | "list" | "stat" | "quote" | "image" | "shape" | "chart" | "card" | "table" | "code" | "formula"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d01495..4d4794d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod color; mod model; +mod op; /// IPC 验证命令 #[tauri::command] diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index dbbf639..0c262ab 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -157,6 +157,7 @@ pub struct RichLine { #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export, export_to = "../bindings/types.ts")] +#[serde(rename_all = "camelCase")] pub struct ElementStyle { pub font_size: Option, pub color: Option, diff --git a/src-tauri/src/op.rs b/src-tauri/src/op.rs new file mode 100644 index 0000000..8a3bd78 --- /dev/null +++ b/src-tauri/src/op.rs @@ -0,0 +1,558 @@ +/* ===================================================================== + * 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"); + } +}