From 3141226a2ea53758015ffc8017d165c25d48eec7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 14:04:52 -0400 Subject: [PATCH] Comments: pins, threads with replies, resolve, comments panel, comment.* commands; real timestamps on web Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/doc/src/comments.rs | 74 ++++++ crates/doc/src/lib.rs | 6 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/comment.rs | 236 +++++++++++++++++ crates/engine/src/cmd/file.rs | 9 +- crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/canvas.rs | 5 + crates/ui-egui/src/comments.rs | 426 +++++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 14 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/right.rs | 5 + 12 files changed, 780 insertions(+), 9 deletions(-) create mode 100644 crates/doc/src/comments.rs create mode 100644 crates/engine/src/cmd/comment.rs create mode 100644 crates/ui-egui/src/comments.rs diff --git a/Cargo.lock b/Cargo.lock index 01ee217..4e6e381 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1350,6 +1350,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "web-time", ] [[package]] diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs new file mode 100644 index 0000000..db6bdf7 --- /dev/null +++ b/crates/doc/src/comments.rs @@ -0,0 +1,74 @@ +//! Comments: pinned notes with reply threads, stored in the file. + +use serde::{Deserialize, Serialize}; + +use crate::{Document, NodeId}; + +/// One reply in a comment thread. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Reply { + pub author: String, + pub text: String, + /// Unix seconds. + #[serde(default)] + pub created: u64, +} + +/// A comment pinned to a point on a page, optionally anchored to a node so it follows it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Comment { + pub id: u64, + pub page: NodeId, + /// Page coordinates, or the offset in the anchor's local space when `anchor` is set. + pub x: f64, + pub y: f64, + #[serde(default)] + pub anchor: Option, + pub author: String, + pub text: String, + #[serde(default)] + pub created: u64, + #[serde(default)] + pub resolved: bool, + #[serde(default)] + pub replies: Vec, +} + +impl Comment { + /// Where the pin sits on its page (anchored comments follow their node). + pub fn position(&self, doc: &Document) -> (f64, f64) { + if let Some(a) = self.anchor + && doc.node(a).is_some() + { + let p = doc.world_transform(a) * crate::geom::Point::new(self.x, self.y); + return (p.x, p.y); + } + (self.x, self.y) + } + + /// Everyone in the thread, in order of first appearance. + pub fn participants(&self) -> Vec<&str> { + let mut out: Vec<&str> = vec![self.author.as_str()]; + for r in &self.replies { + if !out.contains(&r.author.as_str()) { + out.push(r.author.as_str()); + } + } + out + } +} + +impl Document { + pub fn comment(&self, id: u64) -> Option<&Comment> { + self.comments.iter().find(|c| c.id == id) + } + + pub fn comment_mut(&mut self, id: u64) -> Option<&mut Comment> { + self.comments.iter_mut().find(|c| c.id == id) + } + + /// The next free comment id. + pub fn next_comment_id(&self) -> u64 { + self.comments.iter().map(|c| c.id).max().unwrap_or(0) + 1 + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8588ba2..7306318 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ //! live in higher crates. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod comments; pub mod geometry; pub mod node; pub mod props; @@ -16,6 +17,7 @@ pub mod styles; pub mod text; pub mod vector; +pub use comments::*; pub use figcraft_color as color; pub use figcraft_geom as geom; pub use node::*; @@ -107,6 +109,9 @@ pub struct Document { /// Colours used recently / saved in "Document colors". #[serde(default)] pub document_colors: Vec, + /// Comment threads on any page. + #[serde(default)] + pub comments: Vec, } impl Default for Document { @@ -129,6 +134,7 @@ impl Document { images: BTreeMap::new(), current_page: None, document_colors: Vec::new(), + comments: Vec::new(), }; let p = doc.add_page("Page 1"); doc.current_page = Some(p); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 8ce9605..8efbe68 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,3 +24,4 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } base64 = { workspace = true } +web-time = "1.1.0" diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs new file mode 100644 index 0000000..6c3341f --- /dev/null +++ b/crates/engine/src/cmd/comment.rs @@ -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 { + 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); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0487f1d..f720673 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -133,14 +133,7 @@ fn version_list() -> &'static std::sync::Mutex 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) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e0814f6..4f93a73 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ace06e..bf5a220 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -124,6 +124,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } overlays(app, &painter, &t); crate::handles::draw(app, &painter); + crate::comments::draw(app, &painter); + crate::comments::popovers(app, &ctx); // Cursor. if resp.hovered() { let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() { @@ -251,6 +253,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } return; } + if !app.canvas.dragging && resp.hovered() && crate::comments::input(app, &ctx, pos) { + return; + } if !app.canvas.dragging && resp.hovered() && crate::handles::input(app, &ctx, pos) { return; } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs new file mode 100644 index 0000000..c183dbd --- /dev/null +++ b/crates/ui-egui/src/comments.rs @@ -0,0 +1,426 @@ +//! Comments: pins on the canvas, the thread popover, the new-comment composer and the Comments +//! panel shown in place of the Design panel while the Comment tool is active. + +use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::Comment; +use figcraft_engine::ToolId; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; + +const PIN: f32 = 28.0; +const POPOVER_W: f32 = 300.0; + +/// Comment UI state. +#[derive(Default)] +pub struct CommentUi { + /// The open thread. + pub open: Option, + /// A new comment being written at a page point. + pub draft: Option<(f64, f64, String)>, + pub reply: String, + pub show_resolved: bool, + pub search: String, +} + +/// Pins are visible with the Comment tool, or when View › Comments is on. +fn visible(app: &FigApp) -> bool { + app.session.tool.tool == ToolId::Comment + || app.session.ds().is_ok_and(|ds| ds.view.comments) + || app.ui.comments.open.is_some() + || app.ui.comments.draft.is_some() +} + +/// Comments on the current page that should show a pin, with their screen anchor (bottom-left). +fn pins(app: &FigApp) -> Vec<(Comment, Pos2)> { + let Ok(ds) = app.session.ds() else { return Vec::new() }; + ds.doc + .comments + .iter() + .filter(|c| c.page == ds.page) + .filter(|c| !c.resolved || app.ui.comments.show_resolved || app.ui.comments.open == Some(c.id)) + .map(|c| { + let (x, y) = c.position(&ds.doc); + (c.clone(), app.to_screen(x, y)) + }) + .collect() +} + +fn pin_rect(anchor: Pos2) -> Rect { + Rect::from_min_max(pos2(anchor.x, anchor.y - PIN), pos2(anchor.x + PIN, anchor.y)) +} + +/// A stable colour per author. +pub fn author_color(name: &str) -> Color32 { + const PALETTE: [Color32; 8] = [ + Color32::from_rgb(0x0D, 0x99, 0xFF), + Color32::from_rgb(0x97, 0x47, 0xFF), + Color32::from_rgb(0x14, 0xAE, 0x5C), + Color32::from_rgb(0xF2, 0x48, 0x22), + Color32::from_rgb(0xFF, 0xA6, 0x29), + Color32::from_rgb(0xE8, 0x3E, 0x8C), + Color32::from_rgb(0x00, 0xA3, 0xA3), + Color32::from_rgb(0x66, 0x5C, 0xE0), + ]; + let h = name.bytes().fold(5381u32, |h, b| h.wrapping_mul(33) ^ u32::from(b)); + PALETTE.get(h as usize % PALETTE.len()).copied().unwrap_or(ACCENT) +} + +fn initials(name: &str) -> String { + let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect(); + if out.is_empty() { + out.push('?'); + } + out.to_uppercase() +} + +fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str) { + painter.circle_filled(center, r, author_color(name)); + painter.text(center, Align2::CENTER_CENTER, initials(name), semibold(r * 0.8), Color32::WHITE); +} + +/// "now", "5m", "3h", "2d", or a week count. +pub fn ago(created: u64) -> String { + let now = figcraft_engine::cmd::comment::now_secs(); + if created == 0 || now < created { + return String::new(); + } + let d = now - created; + match d { + 0..60 => "now".into(), + 60..3600 => format!("{}m", d / 60), + 3600..86_400 => format!("{}h", d / 3600), + 86_400..604_800 => format!("{}d", d / 86_400), + _ => format!("{}w", d / 604_800), + } +} + +/// Draw the pins (and the draft pin). +pub fn draw(app: &FigApp, painter: &egui::Painter) { + if !visible(app) { + return; + } + let hover = painter.ctx().input(|i| i.pointer.hover_pos()); + for (c, a) in pins(app) { + let r = pin_rect(a); + let open = app.ui.comments.open == Some(c.id); + let hot = open || hover.is_some_and(|h| r.contains(h)); + // A speech bubble: round on three corners, pointed at the anchor. + let cr = CornerRadius { nw: 14, ne: 14, se: 14, sw: 0 }; + painter.rect_filled(r.translate(vec2(0.0, 1.0)), cr, Color32::from_black_alpha(40)); + painter.rect_filled(r, cr, if hot { ACCENT } else { Color32::WHITE }); + painter.rect_stroke(r, cr, Stroke::new(1.0, Color32::from_black_alpha(30)), StrokeKind::Inside); + let fill = if c.resolved { Color32::from_gray(150) } else { author_color(&c.author) }; + painter.circle_filled(r.center(), 11.0, fill); + painter.text(r.center(), Align2::CENTER_CENTER, initials(&c.author), semibold(9.0), Color32::WHITE); + if c.resolved { + icons::paint(painter, Rect::from_center_size(r.right_top() + vec2(-3.0, 3.0), vec2(10.0, 10.0)), Icon::Check, ACCENT); + } + } + if let Some((x, y, _)) = &app.ui.comments.draft { + let r = pin_rect(app.to_screen(*x, *y)); + let cr = CornerRadius { nw: 14, ne: 14, se: 14, sw: 0 }; + painter.rect_filled(r, cr, ACCENT); + painter.circle_filled(r.center(), 3.0, Color32::WHITE); + } +} + +/// Pointer handling: clicking a pin opens its thread. Returns true when a pin owns the pointer. +pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { + if !visible(app) { + return false; + } + let pressed = ctx.input(|i| i.pointer.primary_clicked()); + for (c, a) in pins(app).iter().rev() { + if pin_rect(*a).contains(pos) { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + if pressed { + app.ui.comments.open = if app.ui.comments.open == Some(c.id) { None } else { Some(c.id) }; + app.ui.comments.draft = None; + app.ui.comments.reply.clear(); + } + return true; + } + } + // A click elsewhere on the canvas closes an open thread (the Comment tool then starts a draft). + if pressed && app.ui.comments.open.is_some() { + app.ui.comments.open = None; + return app.session.tool.tool != ToolId::Comment; + } + false +} + +/// Start a new comment at a page point (the Comment tool's click). +pub fn start_draft(app: &mut FigApp, x: f64, y: f64) { + app.ui.comments.open = None; + app.ui.comments.draft = Some((x, y, String::new())); +} + +fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let mut submit = false; + let frame = egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(8, 6)); + frame.show(ui, |ui| { + ui.horizontal(|ui| { + let te = egui::TextEdit::multiline(text) + .id(id) + .hint_text(hint) + .font(regular(12.0)) + .frame(egui::Frame::NONE) + .desired_rows(1) + .desired_width(ui.available_width() - 30.0); + let resp = ui.add(te); + if !resp.has_focus() && ui.memory(|m| m.focused().is_none()) { + resp.request_focus(); + } + let enter = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift); + if enter { + // Drop the newline the TextEdit just inserted. + while text.ends_with('\n') { + text.pop(); + } + submit = true; + } + let (br, bresp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + let ready = !text.trim().is_empty(); + ui.painter().circle_filled(br.center(), 12.0, if ready { ACCENT } else { t.hover }); + let c = br.center(); + let col = if ready { Color32::WHITE } else { t.icon_secondary }; + ui.painter().line_segment([c + vec2(0.0, 5.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); + ui.painter().line_segment([c + vec2(-4.0, -1.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); + ui.painter().line_segment([c + vec2(4.0, -1.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); + if bresp.on_hover_text("Post").clicked() { + submit = true; + } + }); + }); + submit && !text.trim().is_empty() +} + +fn message(ui: &mut Ui, author: &str, text: &str, created: u64) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + avatar(ui.painter(), r.center(), 12.0, author); + ui.add_space(4.0); + ui.label(egui::RichText::new(author).font(semibold(12.0)).color(t.text)); + ui.label(egui::RichText::new(ago(created)).font(regular(11.0)).color(t.text_tertiary)); + }); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.add(egui::Label::new(egui::RichText::new(text).font(regular(12.0)).color(t.text)).wrap()); + }); + ui.add_space(8.0); +} + +/// The thread popover and the draft composer, floating next to their pins. +pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { + let t = Tokens::get(ctx); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && (app.ui.comments.open.is_some() || app.ui.comments.draft.is_some()) { + app.ui.comments.open = None; + app.ui.comments.draft = None; + return; + } + let canvas = app.canvas.rect; + let place = |anchor: Pos2| { + let mut p = anchor + vec2(PIN + 8.0, -PIN); + if p.x + POPOVER_W > canvas.max.x - 8.0 { + p.x = anchor.x - POPOVER_W - 8.0; + } + p.y = p.y.clamp(canvas.min.y + 8.0, (canvas.max.y - 200.0).max(canvas.min.y + 8.0)); + p + }; + let frame = egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(12)) + .shadow(egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(40) }) + .inner_margin(egui::Margin::same(12)); + if let Some((x, y, mut text)) = app.ui.comments.draft.clone() { + let pos = place(app.to_screen(x, y)); + let mut post = false; + egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + frame.show(ui, |ui| { + ui.set_width(POPOVER_W - 24.0); + post = composer(ui, Id::new("comment_draft_text"), &mut text, "Add a comment"); + }); + }); + if post { + let r = app.run("comment.add", json!({"x": x, "y": y, "text": text.trim(), "anchor": true})); + app.ui.comments.draft = None; + app.ui.comments.open = r.and_then(|v| v.get("id").and_then(|i| i.as_u64())); + } else if let Some(d) = &mut app.ui.comments.draft { + d.2 = text; + } + return; + } + let Some(open) = app.ui.comments.open else { return }; + let Some((c, anchor)) = pins(app).into_iter().find(|(c, _)| c.id == open) else { + app.ui.comments.open = None; + return; + }; + let pos = place(anchor); + let mut reply = std::mem::take(&mut app.ui.comments.reply); + let mut action: Option<(&str, serde_json::Value)> = None; + egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + frame.show(ui, |ui| { + ui.set_width(POPOVER_W - 24.0); + // Header: resolve, more, close. + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Comment").font(semibold(12.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, Icon::Close, 24.0, false, "Close").clicked() { + action = Some(("close", json!({}))); + } + let more = icons::button(ui, Icon::Dots, 24.0, false, "More"); + egui::Popup::menu(&more).show(|ui| { + if crate::right::menu_item(ui, "Delete thread", "") { + action = Some(("comment.delete", json!({"id": c.id}))); + ui.close(); + } + if crate::right::menu_item(ui, if c.resolved { "Mark as unresolved" } else { "Mark as resolved" }, "") { + action = Some(("comment.resolve", json!({"id": c.id, "resolved": !c.resolved}))); + ui.close(); + } + }); + let tip = if c.resolved { "Unresolve" } else { "Resolve" }; + if icons::button(ui, Icon::Check, 24.0, c.resolved, tip).clicked() { + action = Some(("comment.resolve", json!({"id": c.id, "resolved": !c.resolved}))); + } + }); + }); + ui.add_space(8.0); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + message(ui, &c.author, &c.text, c.created); + for r in &c.replies { + message(ui, &r.author, &r.text, r.created); + } + }); + if composer(ui, Id::new(("comment_reply", c.id)), &mut reply, "Reply") { + action = Some(("comment.reply", json!({"id": c.id, "text": reply.trim()}))); + } + }); + }); + app.ui.comments.reply = reply; + match action { + Some(("close", _)) => app.ui.comments.open = None, + Some((cmd, p)) => { + if app.run(cmd, p).is_some() { + if cmd == "comment.reply" { + app.ui.comments.reply.clear(); + } + if cmd == "comment.delete" { + app.ui.comments.open = None; + } + } + } + None => {} + } +} + +/// The Comments panel (right side, Comment tool). +pub fn panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(ds) = app.session.ds() else { return }; + let page = ds.page; + let doc = ds.doc.clone(); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Comments").font(semibold(12.0)).color(t.text)); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + let w = ui.available_width() - 16.0; + if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, "Search comments") { + app.ui.comments.search = s; + } + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + if crate::widgets::checkbox(ui, app.ui.comments.show_resolved, "Show resolved comments") { + app.ui.comments.show_resolved = !app.ui.comments.show_resolved; + } + }); + ui.add_space(8.0); + let q = app.ui.comments.search.to_lowercase(); + let mut list: Vec<&Comment> = doc + .comments + .iter() + .filter(|c| doc.pages.contains(&c.page)) + .filter(|c| !c.resolved || app.ui.comments.show_resolved) + .filter(|c| { + q.is_empty() + || c.text.to_lowercase().contains(&q) + || c.author.to_lowercase().contains(&q) + || c.replies.iter().any(|r| r.text.to_lowercase().contains(&q)) + }) + .collect(); + // Current page first, newest first. + list.sort_by_key(|c| (c.page != page, std::cmp::Reverse(c.created), std::cmp::Reverse(c.id))); + if list.is_empty() { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("No comments yet").font(semibold(12.0)).color(t.text_secondary)); + ui.add_space(4.0); + ui.label(egui::RichText::new("Click anywhere on the canvas to leave one.").font(regular(11.0)).color(t.text_tertiary)); + }); + return; + } + let mut go: Option<(u64, figcraft_doc::NodeId)> = None; + for c in list { + let w = ui.available_width(); + let lines = c.text.lines().count().clamp(1, 3) as f32; + let h = 52.0 + 16.0 * lines; + let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); + let open = app.ui.comments.open == Some(c.id); + if open { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + avatar(ui.painter(), pos2(r.min.x + 28.0, r.min.y + 22.0), 12.0, &c.author); + ui.painter().text(pos2(r.min.x + 48.0, r.min.y + 22.0), Align2::LEFT_CENTER, &c.author, semibold(12.0), t.text); + ui.painter().text(pos2(r.max.x - 16.0, r.min.y + 22.0), Align2::RIGHT_CENTER, ago(c.created), regular(11.0), t.text_tertiary); + let body = ui.painter().layout(c.text.clone(), regular(12.0), t.text, w - 64.0); + let clip = Rect::from_min_size(pos2(r.min.x + 48.0, r.min.y + 38.0), vec2(w - 64.0, 16.0 * lines)); + ui.painter().with_clip_rect(clip).galley(clip.min, body, t.text); + let mut meta = Vec::new(); + if !c.replies.is_empty() { + meta.push(format!("{} {}", c.replies.len(), if c.replies.len() == 1 { "reply" } else { "replies" })); + } + if c.page != page { + meta.push(doc.node(c.page).map(|n| n.name.clone()).unwrap_or_default()); + } + if c.resolved { + meta.push("Resolved".into()); + } + if !meta.is_empty() { + ui.painter().text(pos2(r.min.x + 48.0, r.max.y - 12.0), Align2::LEFT_CENTER, meta.join(" · "), regular(11.0), t.text_secondary); + } + ui.painter().line_segment([pos2(r.min.x + 16.0, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); + if resp.clicked() { + go = Some((c.id, c.page)); + } + } + if let Some((id, pg)) = go { + if pg != page { + app.run("page.select", json!({"id": pg})); + } + // Centre the pin in view. + if let Ok(ds) = app.session.ds_mut() + && let Some(c) = ds.doc.comment(id).cloned() + { + let (x, y) = c.position(&ds.doc); + ds.view.cx = x; + ds.view.cy = y; + ds.revision += 1; + } + app.ui.comments.open = Some(id); + app.ui.comments.draft = None; + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dff817b..30c2f87 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -207,6 +207,20 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) { app.ui.main_menu = h; } + // Comment thread popover: an id opens it, null closes it. + if let Some(v) = p.get("commentOpen") { + app.ui.comments.open = v.as_u64(); + app.ui.comments.draft = None; + } + if let Some(v) = p.get("commentDraft") { + match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => crate::comments::start_draft(app, x, y), + _ => app.ui.comments.draft = None, + } + } + if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) { + app.ui.comments.show_resolved = v; + } Ok(app.inspect()) } "ui.render" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 35e06d6..eec32a3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ pub mod canvas; pub mod colorpicker; +pub mod comments; pub mod component_panel; pub mod control; pub mod credits; @@ -107,6 +108,7 @@ pub struct UiState { pub zoom_menu: bool, pub find: Option<(String, String)>, pub left_collapsed: bool, + pub comments: comments::CommentUi, } impl Default for UiState { @@ -143,6 +145,7 @@ impl Default for UiState { zoom_menu: false, find: None, left_collapsed: false, + comments: comments::CommentUi::default(), } } } @@ -216,7 +219,11 @@ impl FigApp { } "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), - "comment.new" => self.toast("Comments are local notes (coming soon)"), + "comment.new" => { + let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + comments::start_draft(self, x, y); + } "dialog.unsaved" => self.toast("This file has unsaved changes"), _ => {} } @@ -388,6 +395,7 @@ impl FigApp { "presenting": self.ui.present.is_some(), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), + "comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved}, "frameMs": self.ui.frame_ms, "renderMs": self.ui.render_ms, "canvas": {"rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y]}, diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 138fd76..6f7e227 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -129,6 +129,11 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { ui.painter().line_segment([tabs.left_bottom(), tabs.right_bottom()], Stroke::new(1.0, t.border)); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 0.0); + if app.session.tool.tool == figcraft_engine::ToolId::Comment { + crate::comments::panel(app, ui); + ui.add_space(80.0); + return; + } match app.ui.right_tab { RightTab::Design => design(app, ui), RightTab::Prototype => crate::proto_panel::prototype(app, ui),