mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -06:00
Comments: pins, threads with replies, resolve, comments panel, comment.* commands; real timestamps on web
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7aae256b4c
commit
3141226a2e
@@ -0,0 +1,236 @@
|
||||
//! 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<u64> {
|
||||
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<String> {
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> = 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<NodeId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -133,14 +133,7 @@ fn version_list() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Ve
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
super::comment::now_secs()
|
||||
}
|
||||
|
||||
fn push_version(ds: &DocState, name: String, desc: String, doc: Arc<Document>) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The command registry. Each area module exposes `specs()`; [`all_commands`] collects them once.
|
||||
|
||||
pub mod arrange;
|
||||
pub mod comment;
|
||||
pub mod component;
|
||||
pub mod create;
|
||||
pub mod edit;
|
||||
@@ -70,6 +71,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(styles::specs());
|
||||
list.extend(component::specs());
|
||||
list.extend(proto::specs());
|
||||
list.extend(comment::specs());
|
||||
list.extend(crate::vectoredit::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
|
||||
Reference in New Issue
Block a user