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:
Brandon Thomas 2026-10-09 14:04:52 -04:00
parent 7aae256b4c
commit 3141226a2e
12 changed files with 780 additions and 9 deletions

1
Cargo.lock generated
View File

@ -1350,6 +1350,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"web-time",
]
[[package]]

View File

@ -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<NodeId>,
pub author: String,
pub text: String,
#[serde(default)]
pub created: u64,
#[serde(default)]
pub resolved: bool,
#[serde(default)]
pub replies: Vec<Reply>,
}
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
}
}

View File

@ -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<Color>,
/// Comment threads on any page.
#[serde(default)]
pub comments: Vec<Comment>,
}
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);

View File

@ -24,3 +24,4 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
web-time = "1.1.0"

View File

@ -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);
}
}

View File

@ -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>) {

View File

@ -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() {

View File

@ -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;
}

View File

@ -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<u64>,
/// 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;
}
}

View File

@ -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" => {

View File

@ -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]},

View File

@ -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),