//! Comments: add, reply, edit, resolve, delete and list comment threads. use figcraft_doc::*; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; use crate::{Session, bad, spec}; const DEFAULT_AUTHOR: &str = "You"; pub fn now_secs() -> u64 { web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn req_comment(cmd: &str, s: &Session, p: &Value) -> crate::Result { let id = req_num(cmd, p, "id")? as u64; if s.doc()?.comment(id).is_none() { return Err(bad(cmd, format!("no comment {id}"))); } Ok(id) } fn author(p: &Value) -> String { string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| DEFAULT_AUTHOR.into()) } fn text(cmd: &str, p: &Value) -> crate::Result { let t = req_string(cmd, p, "text")?; if t.trim().is_empty() { return Err(bad(cmd, "comment text is empty")); } Ok(t) } /// JSON view of a comment (with its resolved page position). pub fn comment_json(doc: &Document, c: &Comment) -> Value { let (x, y) = c.position(doc); json!({ "id": c.id, "page": c.page, "x": x, "y": y, "anchor": c.anchor, "author": c.author, "text": c.text, "created": c.created, "resolved": c.resolved, "replies": c.replies.iter().map(|r| json!({"author": r.author, "text": r.text, "created": r.created})).collect::>(), }) } pub fn specs() -> Vec { vec![ spec!( "comment.add", "Add comment", "{x, y, text, page?, author?, anchor? (node id to pin to; true = topmost frame under the point)}", true, |s, p| { let x = req_num("comment.add", p, "x")?; let y = req_num("comment.add", p, "y")?; let text = text("comment.add", p)?; let page = id(p, "page").unwrap_or(s.page()?); let doc = s.doc()?; if !doc.pages.contains(&page) { return Err(bad("comment.add", format!("{page} is not a page"))); } // Anchor: an explicit node, or (anchor: true) the innermost frame under the point. let anchor = match get(p, "anchor") { Some(Value::Bool(true)) => frame_at(doc, page, x, y), Some(v) => id_of(v).filter(|a| doc.node(*a).is_some()), None => None, }; let (lx, ly) = match anchor { Some(a) => { let l = doc.world_transform(a).inverse() * geom::Point::new(x, y); (l.x, l.y) } None => (x, y), }; let c = Comment { id: doc.next_comment_id(), page, x: lx, y: ly, anchor, author: author(p), text, created: now_secs(), resolved: false, replies: Vec::new(), }; let id = c.id; s.doc_mut()?.comments.push(c); Ok(json!({"id": id})) } ), spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| { let id = req_comment("comment.reply", s, p)?; let r = Reply { author: author(p), text: text("comment.reply", p)?, created: now_secs() }; if let Some(c) = s.doc_mut()?.comment_mut(id) { c.replies.push(r); c.resolved = false; } ok() }), spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message)}", true, |s, p| { let id = req_comment("comment.edit", s, p)?; let t = text("comment.edit", p)?; let reply = num(p, "reply").map(|r| r as usize); let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.edit", "no comment"))?; match reply { Some(i) => c.replies.get_mut(i).ok_or_else(|| bad("comment.edit", format!("no reply {i}")))?.text = t, None => c.text = t, } ok() }), spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| { let id = req_comment("comment.resolve", s, p)?; let v = boolean(p, "resolved").unwrap_or(true); if let Some(c) = s.doc_mut()?.comment_mut(id) { c.resolved = v; } ok() }), spec!("comment.move", "Move comment", "{id, x, y}", true, |s, p| { let id = req_comment("comment.move", s, p)?; let (x, y) = (req_num("comment.move", p, "x")?, req_num("comment.move", p, "y")?); let doc = s.doc_mut()?; let anchor = doc.comment(id).and_then(|c| c.anchor).filter(|a| doc.node(*a).is_some()); let (lx, ly) = match anchor { Some(a) => { let l = doc.world_transform(a).inverse() * geom::Point::new(x, y); (l.x, l.y) } None => (x, y), }; if let Some(c) = doc.comment_mut(id) { c.x = lx; c.y = ly; if anchor.is_none() { c.anchor = None; } } ok() }), spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply)}", true, |s, p| { let id = req_comment("comment.delete", s, p)?; let reply = num(p, "reply").map(|r| r as usize); let doc = s.doc_mut()?; match reply { Some(i) => { let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?; if i >= c.replies.len() { return Err(bad("comment.delete", format!("no reply {i}"))); } c.replies.remove(i); } None => doc.comments.retain(|c| c.id != id), } ok() }), spec!( "comment.list", "List comments", "{page? (default: current; \"all\" for every page), resolved? (include resolved, default true)}", false, |s, p| { let doc = s.doc()?; let all = string(p, "page").is_some_and(|v| v == "all"); let page = if all { None } else { Some(id(p, "page").unwrap_or(s.page()?)) }; let include_resolved = boolean(p, "resolved").unwrap_or(true); let list: Vec = doc .comments .iter() .filter(|c| doc.pages.contains(&c.page)) .filter(|c| page.is_none_or(|pg| c.page == pg)) .filter(|c| include_resolved || !c.resolved) .map(|c| comment_json(doc, c)) .collect(); Ok(json!({"comments": list})) } ), ] } /// Innermost frame (or component/instance/section) under a page point, for anchoring. fn frame_at(doc: &Document, page: NodeId, x: f64, y: f64) -> Option { let pt = geom::Point::new(x, y); let mut found = None; let mut stack = vec![page]; while let Some(parent) = stack.pop() { let Some(n) = doc.node(parent) else { continue }; for c in n.children.iter().rev() { let Some(cn) = doc.node(*c) else { continue }; if !cn.visible || !cn.kind.is_container() { continue; } let l = doc.world_transform(*c).inverse() * pt; if l.x >= 0.0 && l.y >= 0.0 && l.x <= cn.width && l.y <= cn.height { if cn.kind.is_frame_like() || matches!(cn.kind, NodeKind::Section { .. }) { found = Some(*c); } stack.push(*c); break; } } } found } #[cfg(test)] mod tests { use serde_json::json; use crate::Session; #[test] fn comment_thread_lifecycle_and_anchoring() { let mut s = Session::new(); let f = s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); let fid = crate::params::id_of(&f["id"]).unwrap(); let c = s.execute("comment.add", &json!({"x": 150, "y": 160, "text": "Tighten this", "anchor": true})).unwrap(); let cid = c["id"].as_u64().unwrap(); assert_eq!(s.doc().unwrap().comment(cid).unwrap().anchor, Some(fid)); // The pin follows its frame. s.execute("node.set", &json!({"id": fid, "props": {"x": 300}})).unwrap(); let l = s.execute("comment.list", &json!({})).unwrap(); assert_eq!(l["comments"][0]["x"].as_f64().unwrap(), 350.0); s.execute("comment.reply", &json!({"id": cid, "text": "Done", "author": "Sam"})).unwrap(); s.execute("comment.resolve", &json!({"id": cid})).unwrap(); let open = s.execute("comment.list", &json!({"resolved": false})).unwrap(); assert!(open["comments"].as_array().unwrap().is_empty()); assert_eq!(s.doc().unwrap().comment(cid).unwrap().participants(), vec!["You", "Sam"]); assert!(s.execute("comment.add", &json!({"x": 0, "y": 0, "text": " "})).is_err()); assert!(s.execute("comment.reply", &json!({"id": 999, "text": "x"})).is_err()); s.execute("comment.delete", &json!({"id": cid})).unwrap(); assert!(s.doc().unwrap().comments.is_empty()); assert!(s.undo().unwrap()); assert_eq!(s.doc().unwrap().comments.len(), 1); } }