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:
parent
7aae256b4c
commit
3141226a2e
|
|
@ -1350,6 +1350,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ serde_json = { workspace = true }
|
|||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
web-time = "1.1.0"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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]},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue