From 50517a6431efb9326582f2c247a133d3801183d5 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 13:36:42 +0700 Subject: [PATCH] M7: comments polish (#74) Pins follow their layers (to other pages too) and stay where they were when the layer is deleted, re-pinned to the nearest surviving parent; your pins drag onto another frame; crowded pins cluster when zoomed out and a click zooms them apart; Shift+C shows or hides them. Threads: edit and delete your own messages (marked edited), emoji reactions, @mentions with a picker of the names used in the file (your name from Preferences), read/unread state per name, and Copy link (figcraft://comment/; pasting it or comment.open goes to the page, zooms to the frame and opens the thread). Comments panel: search (text, names, frame), filters (open, resolved, yours, unread), sort by date or page, each thread's frame and page, Up/Down to step through threads. The presenter shows the presented frame's comments (Comments button or Shift+C) to read and reply. New commands: comment.react, comment.markRead, comment.open, comment.next, comment.zoom, comment.link, comment.people, pref.userName; comment.list gains filter/sort/query/frame, comment.move re-pins. Old files load unchanged (the first message stays on the comment; missing read state counts as read). Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 14 +- crates/doc/src/comments.rs | 188 +++- crates/engine/src/cmd/comment.rs | 593 +++++++++-- crates/engine/src/cmd/view.rs | 6 + crates/engine/src/comments_polish_tests.rs | 298 ++++++ crates/engine/src/lib.rs | 7 + crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/comments.rs | 1114 ++++++++++++++++---- crates/ui-egui/src/comments_tests.rs | 227 ++++ crates/ui-egui/src/control.rs | 46 +- crates/ui-egui/src/dialogs.rs | 7 + crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/lib.rs | 23 +- crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/present.rs | 43 +- docs/control-protocol.md | 19 +- docs/mcp.md | 24 + docs/parity.md | 2 +- 18 files changed, 2334 insertions(+), 292 deletions(-) create mode 100644 crates/engine/src/comments_polish_tests.rs create mode 100644 crates/ui-egui/src/comments_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 355caea..dccfb34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,7 +149,16 @@ dimension measured side by side. options (bleed, CMYK). 10–20 h. 7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent (cursors, modifier keys, hover states, menus, popovers). 40–60 h. -8. **Collaboration (local):** comments landed (pins, threads, resolve, panel). Version history +8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them + (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto + another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show + or hide them; threads where you edit and delete your own messages, emoji reactions, @mentions + with a picker of the names used in the file (your name from Preferences), read and unread state, + resolve, and "Copy link" (`figcraft://comment/`: pasting or `comment.open` goes to the page, + zooms to the frame and opens the thread); the Comments panel with search (text, names, frame), + filters (open, resolved, yours, unread), sorting by date or page, each thread's frame and page, + and Up/Down to step through threads; comments on the presented frame (Comments button or + Shift+C: read and reply). All of it through `comment.*` commands. Version history landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; view a version on the canvas read-only before restoring it; autosave checkpoints every 30 minutes, folded in the panel). Multiplayer and plugins are not started. 20–50 h. @@ -173,7 +182,8 @@ dimension measured side by side. - **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); effects: inner shadows under strokes (render order), progressive blur, glass tuned side by side; the last text details (right-to-left list markers). -- **Then:** interop depth (6: .fig best effort), plugins and collaboration (8). +- **Then:** interop depth (6: .fig best effort), plugins and multiplayer (8; comments and version + history are in). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. ## Shipped so far diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs index db6bdf7..564c30d 100644 --- a/crates/doc/src/comments.rs +++ b/crates/doc/src/comments.rs @@ -1,17 +1,74 @@ //! Comments: pinned notes with reply threads, stored in the file. +use std::ops::Range; + use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; -/// One reply in a comment thread. +/// An emoji reaction on a message and who left it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Reply { +pub struct Reaction { + pub emoji: String, + pub users: Vec, +} + +/// One message in a comment thread (the first one, or a reply). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Message { pub author: String, pub text: String, /// Unix seconds. #[serde(default)] pub created: u64, + /// When the text was last edited (Unix seconds; 0: never). + #[serde(default, skip_serializing_if = "is_zero")] + pub edited: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reactions: Vec, +} + +/// A reply in a comment thread. +pub type Reply = Message; + +fn is_zero(v: &u64) -> bool { + *v == 0 +} + +impl Message { + pub fn new(author: impl Into, text: impl Into, created: u64) -> Self { + Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() } + } + + /// `user` left the `emoji` reaction. + pub fn reacted(&self, emoji: &str, user: &str) -> bool { + self.reactions.iter().any(|r| r.emoji == emoji && r.users.iter().any(|u| u == user)) + } + + /// Add `user`'s `emoji` reaction, or take it back (`on`: None toggles). True when it changed. + pub fn react(&mut self, emoji: &str, user: &str, on: Option) -> bool { + let want = on.unwrap_or(!self.reacted(emoji, user)); + if want == self.reacted(emoji, user) { + return false; + } + match self.reactions.iter().position(|r| r.emoji == emoji) { + Some(i) if want => { + if let Some(r) = self.reactions.get_mut(i) { + r.users.push(user.to_string()); + } + } + Some(i) => { + if let Some(r) = self.reactions.get_mut(i) { + r.users.retain(|u| u != user); + if r.users.is_empty() { + self.reactions.remove(i); + } + } + } + None => self.reactions.push(Reaction { emoji: emoji.to_string(), users: vec![user.to_string()] }), + } + true + } } /// A comment pinned to a point on a page, optionally anchored to a node so it follows it. @@ -24,14 +81,17 @@ pub struct Comment { pub y: f64, #[serde(default)] pub anchor: Option, - pub author: String, - pub text: String, - #[serde(default)] - pub created: u64, + /// The message that starts the thread (its fields sit on the comment itself in the file). + #[serde(flatten)] + pub first: Message, #[serde(default)] pub resolved: bool, #[serde(default)] - pub replies: Vec, + pub replies: Vec, + /// Who has read the thread as it is now. `None` (files from before read state was kept): + /// read by everyone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_by: Option>, } impl Comment { @@ -46,16 +106,107 @@ impl Comment { (self.x, self.y) } + /// The messages in order: the first one, then the replies. + pub fn messages(&self) -> impl Iterator { + std::iter::once(&self.first).chain(self.replies.iter()) + } + + /// A message by thread index (0: the first message, 1…: the replies). + pub fn message(&self, index: usize) -> Option<&Message> { + match index.checked_sub(1) { + None => Some(&self.first), + Some(i) => self.replies.get(i), + } + } + + /// A message by thread index, for editing. + pub fn message_mut(&mut self, index: usize) -> Option<&mut Message> { + match index.checked_sub(1) { + None => Some(&mut self.first), + Some(i) => self.replies.get_mut(i), + } + } + + /// When the thread last had a new message (Unix seconds). + pub fn last_activity(&self) -> u64 { + self.messages().map(|m| m.created).max().unwrap_or(0) + } + /// 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()); + let mut out: Vec<&str> = Vec::new(); + for m in self.messages() { + if !out.contains(&m.author.as_str()) { + out.push(m.author.as_str()); } } out } + + /// `user` wrote in the thread or is @mentioned in it. + pub fn involves(&self, user: &str) -> bool { + self.messages().any(|m| m.author == user || !mention_ranges(&m.text, &[user]).is_empty()) + } + + /// The thread has something `user` hasn't read. + pub fn is_unread(&self, user: &str) -> bool { + self.read_by.as_ref().is_some_and(|r| !r.iter().any(|u| u == user)) + } + + /// Mark the thread read or unread for `user`. True when it changed. + pub fn set_read(&mut self, user: &str, read: bool) -> bool { + if read != self.is_unread(user) { + return false; + } + if read { + if let Some(r) = self.read_by.as_mut() { + r.push(user.to_string()); + } + return true; + } + // Older files count as read by everyone: the thread's other participants stay so. + if self.read_by.is_none() { + self.read_by = Some(self.participants().into_iter().map(String::from).collect()); + } + if let Some(r) = self.read_by.as_mut() { + r.retain(|u| u != user); + } + true + } + + /// A new message from `user`: the thread is unread for everyone else. + pub fn touched_by(&mut self, user: &str) { + self.read_by = Some(vec![user.to_string()]); + } +} + +/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the +/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends +/// before one). +pub fn mention_ranges(text: &str, people: &[&str]) -> Vec> { + let mut out = Vec::new(); + let mut from = 0; + while let Some(off) = text.get(from..).and_then(|t| t.find('@')) { + let at = from + off; + from = at + 1; + let starts = text.get(..at).and_then(|t| t.chars().next_back()).is_none_or(|c| !c.is_alphanumeric()); + if !starts { + continue; + } + let rest = text.get(at + 1..).unwrap_or(""); + let best = people + .iter() + .filter(|n| !n.is_empty()) + .filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n))) + .filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric())) + .map(|n| n.len()) + .max(); + if let Some(len) = best { + out.push(at..at + 1 + len); + from = at + 1 + len; + } + } + out } impl Document { @@ -71,4 +222,19 @@ impl Document { pub fn next_comment_id(&self) -> u64 { self.comments.iter().map(|c| c.id).max().unwrap_or(0) + 1 } + + /// Everyone named in the file's comments (authors and those who reacted), sorted. + pub fn comment_people(&self) -> Vec { + let mut out: Vec = self + .comments + .iter() + .flat_map(|c| c.messages()) + .flat_map(|m| std::iter::once(&m.author).chain(m.reactions.iter().flat_map(|r| r.users.iter()))) + .filter(|n| !n.trim().is_empty()) + .cloned() + .collect(); + out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + out.dedup(); + out + } } diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index 6c3341f..845b42a 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -1,4 +1,8 @@ -//! Comments: add, reply, edit, resolve, delete and list comment threads. +//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads. +//! +//! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first +//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only +//! a message's author edits or deletes it, and only the thread's author deletes the thread. use figcraft_doc::*; use serde_json::{Value, json}; @@ -7,22 +11,244 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{Session, bad, spec}; -const DEFAULT_AUTHOR: &str = "You"; +/// The name comments are signed with until the user sets theirs. +pub const DEFAULT_AUTHOR: &str = "You"; + +/// Links to a thread: `figcraft://comment/`. +const LINK_PREFIX: &str = "figcraft://comment/"; + +/// Longest accepted reaction (bytes): one emoji with its modifiers. +const MAX_EMOJI: usize = 32; pub fn now_secs() -> u64 { web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } +/// A thread's link (`comment.open` takes it). +pub fn link(id: u64) -> String { + format!("{LINK_PREFIX}{id}") +} + +/// The thread id in a link (or a bare id). +pub fn parse_link(s: &str) -> Option { + let s = s.trim(); + s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/').parse().ok() +} + +/// Which threads a list shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Filter { + All, + /// Not resolved. + Open, + Resolved, + /// Threads you wrote in or are @mentioned in. + Mine, + Unread, +} + +impl Filter { + pub const ALL: [Filter; 5] = [Filter::Open, Filter::Resolved, Filter::Mine, Filter::Unread, Filter::All]; + + pub fn id(self) -> &'static str { + match self { + Filter::All => "all", + Filter::Open => "open", + Filter::Resolved => "resolved", + Filter::Mine => "mine", + Filter::Unread => "unread", + } + } + + pub fn label(self) -> &'static str { + match self { + Filter::All => "All comments", + Filter::Open => "Open", + Filter::Resolved => "Resolved", + Filter::Mine => "Only your threads", + Filter::Unread => "Unread", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim())) + } + + pub fn keeps(self, c: &Comment, user: &str) -> bool { + match self { + Filter::All => true, + Filter::Open => !c.resolved, + Filter::Resolved => c.resolved, + Filter::Mine => c.involves(user), + Filter::Unread => c.is_unread(user), + } + } +} + +/// How a list is ordered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Sort { + /// Latest activity first. + #[default] + Date, + /// By page (in the file's page order), then latest activity first. + Page, +} + +impl Sort { + pub const ALL: [Sort; 2] = [Sort::Date, Sort::Page]; + + pub fn id(self) -> &'static str { + match self { + Sort::Date => "date", + Sort::Page => "page", + } + } + + pub fn label(self) -> &'static str { + match self { + Sort::Date => "Sort by date", + Sort::Page => "Sort by page", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|f| f.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// What a comment list shows (the Comments panel, the canvas pins, `comment.list`). +#[derive(Clone, Debug)] +pub struct Query<'a> { + /// Only this page (None: every page). + pub page: Option, + /// Only comments on this top-level frame. + pub frame: Option, + pub filter: Filter, + pub sort: Sort, + /// Search text (messages, names, the frame's name), any case. + pub text: &'a str, +} + +impl Default for Query<'_> { + fn default() -> Self { + Self { page: None, frame: None, filter: Filter::All, sort: Sort::Date, text: "" } + } +} + +/// The comments `q` asks for, as `user` sees them, in order. +pub fn query<'a>(doc: &'a Document, q: &Query, user: &str) -> Vec<&'a Comment> { + let needle = q.text.trim().to_lowercase(); + let mut out: Vec<&Comment> = doc + .comments + .iter() + .filter(|c| doc.pages.contains(&c.page)) + .filter(|c| q.page.is_none_or(|p| c.page == p)) + .filter(|c| q.filter.keeps(c, user)) + .filter(|c| q.frame.is_none_or(|f| frame_of(doc, c) == Some(f))) + .filter(|c| needle.is_empty() || matches_text(doc, c, &needle)) + .collect(); + let page_index = |c: &Comment| doc.pages.iter().position(|p| *p == c.page).unwrap_or(usize::MAX); + match q.sort { + Sort::Date => out.sort_by_key(|c| (std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id))), + Sort::Page => out.sort_by_key(|c| (page_index(c), std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id))), + } + out +} + +fn matches_text(doc: &Document, c: &Comment, needle: &str) -> bool { + let has = |s: &str| s.to_lowercase().contains(needle); + c.messages().any(|m| has(&m.text) || has(&m.author)) || frame_of(doc, c).and_then(|f| doc.node(f)).is_some_and(|n| has(&n.name)) +} + +/// The top-level frame (or section) a comment is on: its anchor's, else the one under its pin. +pub fn frame_of(doc: &Document, c: &Comment) -> Option { + if let Some(a) = c.anchor.filter(|a| doc.contains(*a)) { + return top_level_of(doc, a).filter(|t| is_frame_or_section(doc, *t)); + } + let (x, y) = c.position(doc); + let page = doc.node(c.page)?; + page.children.iter().rev().copied().find(|f| { + is_frame_or_section(doc, *f) + && doc.node(*f).is_some_and(|n| n.visible) + && doc.world_bounds(*f).is_some_and(|b| b.contains(geom::Point::new(x, y))) + }) +} + +fn is_frame_or_section(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) +} + +/// The page child that holds `id` (or `id` itself at the top level). +fn top_level_of(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + let page = chain.iter().position(|c| doc.node(*c).is_some_and(Node::is_page))?; + chain.get(page.checked_sub(1)?).copied() +} + +/// After an edit: pins move to the page their layer moved to (with a parent, too), and a pin whose +/// layer was deleted stays where it was, pinned to the nearest surviving parent (or to the page). +pub fn follow_anchors(doc: &mut Document, before: &Document) { + let mut moves: Vec<(usize, Comment)> = Vec::new(); + for (i, c) in doc.comments.iter().enumerate() { + let Some(a) = c.anchor else { continue }; + let mut next = c.clone(); + if doc.contains(a) { + match doc.page_of(a) { + Some(pg) if pg != c.page => next.page = pg, + _ => continue, + } + } else { + if before.node(a).is_none() { + continue; + } + let pt = before.world_transform(a) * geom::Point::new(c.x, c.y); + let parent = before.ancestors(a).into_iter().find(|p| doc.node(*p).is_some_and(|n| !n.is_page()) && doc.page_of(*p) == Some(c.page)); + let (x, y) = local(doc, parent, pt.x, pt.y); + (next.x, next.y, next.anchor) = (x, y, parent); + } + moves.push((i, next)); + } + for (i, c) in moves { + if let Some(slot) = doc.comments.get_mut(i) { + *slot = c; + } + } +} + fn req_comment(cmd: &str, s: &Session, p: &Value) -> crate::Result { - let id = req_num(cmd, p, "id")? as u64; + let id = match get(p, "link").and_then(Value::as_str) { + Some(l) => parse_link(l).ok_or_else(|| bad(cmd, format!("not a comment link: {l}")))?, + None => req_index(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()) +/// The thread index of the message `reply` names (0: the first message). +fn message_index(cmd: &str, c: &Comment, p: &Value) -> crate::Result { + if get(p, "reply").is_none() { + return Ok(0); + } + let i = req_index(cmd, p, "reply")?; + if i >= c.replies.len() { + return Err(bad(cmd, format!("no reply {i}"))); + } + Ok(i + 1) +} + +/// The user's name as comments are signed (the default when it's blank). +pub fn user_name(s: &Session) -> String { + let n = s.prefs.user_name.trim(); + if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() } +} + +/// Who is acting: `author` from the params, else the user. +fn author(s: &Session, p: &Value) -> String { + string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s)) } fn text(cmd: &str, p: &Value) -> crate::Result { @@ -30,85 +256,212 @@ fn text(cmd: &str, p: &Value) -> crate::Result { if t.trim().is_empty() { return Err(bad(cmd, "comment text is empty")); } - Ok(t) + Ok(t.trim().to_string()) } -/// 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); +/// Refuse to change someone else's message. +fn own(cmd: &str, m: &Message, who: &str, what: &str) -> crate::Result<()> { + if m.author == who { + return Ok(()); + } + Err(bad(cmd, format!("only {} can {what} (acting as {who}; pass `author` to act as someone else)", m.author))) +} + +/// A node to pin to: a node id on `page`, `true` for the innermost frame under the point, else none. +fn anchor_at(doc: &Document, page: NodeId, x: f64, y: f64, v: Option<&Value>) -> Option { + match v { + Some(Value::Bool(true)) => frame_at(doc, page, x, y), + Some(v) => id_of(v).filter(|a| *a != page && doc.page_of(*a) == Some(page)), + None => None, + } +} + +/// A page point in `anchor`'s local space (as is without one). +fn local(doc: &Document, anchor: Option, x: f64, y: f64) -> (f64, f64) { + match anchor { + Some(a) => { + let l = doc.world_transform(a).inverse() * geom::Point::new(x, y); + (l.x, l.y) + } + None => (x, y), + } +} + +fn message_json(m: &Message) -> Value { 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::>(), + "author": m.author, "text": m.text, "created": m.created, "edited": m.edited, + "reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::>(), }) } +/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the +/// first message's fields, and the replies. +pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value { + let (x, y) = c.position(doc); + let frame = frame_of(doc, c); + let mut v = message_json(&c.first); + let extra = json!({ + "id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()), + "x": x, "y": y, "anchor": c.anchor, + "frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()), + "resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id), + "replies": c.replies.iter().map(message_json).collect::>(), + }); + if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) { + m.extend(e); + } + v +} + +/// Bring a thread into view (`comment.open`): its page, then `how` (fit, center or keep). +fn reveal(s: &mut Session, id: u64, how: &str) -> crate::Result<()> { + let doc = s.doc()?; + let c = doc.comment(id).ok_or_else(|| bad("comment.open", format!("no comment {id}")))?; + if !doc.pages.contains(&c.page) { + return Err(bad("comment.open", format!("comment {id} is on a deleted page"))); + } + let (x, y) = c.position(doc); + let frame = frame_of(doc, c).and_then(|f| doc.world_bounds(f)); + let page = c.page; + let ds = s.ds_mut()?; + let switched = ds.page != page; + if switched { + ds.page = page; + ds.selection.clear(); + ds.text_edit = None; + ds.zoom_to_fit(); + } + match (how, frame) { + ("fit", Some(b)) => ds.view.fit(b.union_pt(geom::Point::new(x, y)), 1.0), + ("fit", None) => { + ds.view.set_zoom_at(ds.view.zoom.max(1.0), (ds.view.cx, ds.view.cy)); + (ds.view.cx, ds.view.cy) = (x, y); + } + ("center", _) => (ds.view.cx, ds.view.cy) = (x, y), + _ if switched => (ds.view.cx, ds.view.cy) = (x, y), + _ => {} + } + Ok(()) +} + +/// Reveal a thread (`how`: fit, center or keep), mark it read for `who` and ask the UI to open it. +fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result { + reveal(s, id, how)?; + if s.doc()?.comment(id).is_some_and(|c| c.is_unread(who)) + && let Some(c) = s.doc_mut()?.comment_mut(id) + { + c.set_read(who, true); + } + s.request_ui("comment.open", json!({"id": id})); + let doc = s.doc()?; + let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?; + Ok(comment_json(doc, c, who)) +} + +/// A list's page (None: every page), filter, sort and search text from params. +fn list_params(cmd: &str, s: &Session, p: &Value) -> crate::Result<(Option, Filter, Sort, String)> { + let page = if string(p, "page").is_some_and(|v| v == "all") { None } else { Some(id(p, "page").unwrap_or(s.page()?)) }; + let filter = match string(p, "filter") { + Some(f) => Filter::parse(&f).ok_or_else(|| bad(cmd, "`filter` must be all, open, resolved, mine or unread"))?, + None if boolean(p, "resolved") == Some(false) => Filter::Open, + None => Filter::All, + }; + let sort = match string(p, "sort") { + Some(v) => Sort::parse(&v).ok_or_else(|| bad(cmd, "`sort` must be date or page"))?, + None => Sort::Date, + }; + Ok((page, filter, sort, string(p, "query").unwrap_or_default())) +} + 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)}", + "{x, y, text, page?, author?, anchor? (node id to pin to; true = innermost 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 who = author(s, 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 { + let anchor = anchor_at(doc, page, x, y, get(p, "anchor")); + let (lx, ly) = local(doc, anchor, x, y); + let mut c = Comment { id: doc.next_comment_id(), page, x: lx, y: ly, anchor, - author: author(p), - text, - created: now_secs(), + first: Message::new(who.clone(), text, now_secs()), resolved: false, replies: Vec::new(), + read_by: None, }; + c.touched_by(&who); let id = c.id; s.doc_mut()?.comments.push(c); - Ok(json!({"id": id})) + Ok(json!({"id": id, "link": link(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() + let who = author(s, p); + let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs()); + let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?; + c.replies.push(r); + c.resolved = false; + c.touched_by(&who); + Ok(json!({"reply": c.replies.len() - 1})) }), - spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message)}", true, |s, p| { + spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", 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, + let who = author(s, p); + let (i, same) = { + let c = s.doc()?.comment(id).ok_or_else(|| bad("comment.edit", "no comment"))?; + let i = message_index("comment.edit", c, p)?; + let m = c.message(i).ok_or_else(|| bad("comment.edit", "no message"))?; + own("comment.edit", m, &who, "edit this message")?; + (i, m.text == t) + }; + // The same text is no edit (and no undo step). + if !same && let Some(m) = s.doc_mut()?.comment_mut(id).and_then(|c| c.message_mut(i)) { + m.text = t; + m.edited = now_secs().max(1); } ok() }), + spec!( + "comment.react", + "React to comment", + "{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}", + true, + |s, p| { + let id = req_comment("comment.react", s, p)?; + let emoji = req_string("comment.react", p, "emoji")?.trim().to_string(); + if emoji.is_empty() || emoji.len() > MAX_EMOJI || emoji.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(bad("comment.react", "`emoji` must be one emoji")); + } + let who = author(s, p); + let c = s.doc()?.comment(id).ok_or_else(|| bad("comment.react", "no comment"))?; + let i = message_index("comment.react", c, p)?; + let has = c.message(i).is_some_and(|m| m.reacted(&emoji, &who)); + let on = boolean(p, "on").unwrap_or(!has); + // Already so: no edit (and no undo step). + if on != has + && let Some(m) = s.doc_mut()?.comment_mut(id).and_then(|c| c.message_mut(i)) + { + m.react(&emoji, &who, Some(on)); + } + Ok(json!({"on": on})) + } + ), 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); @@ -117,61 +470,133 @@ pub fn specs() -> Vec { } 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; + spec!( + "comment.move", + "Move comment", + "{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}", + 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()?; + let page = doc.comment(id).map(|c| c.page).ok_or_else(|| bad("comment.move", "no comment"))?; + let anchor = anchor_at(doc, page, x, y, Some(get(p, "anchor").unwrap_or(&Value::Bool(true)))); + let (lx, ly) = local(doc, anchor, x, y); + if let Some(c) = s.doc_mut()?.comment_mut(id) { + (c.x, c.y, c.anchor) = (lx, ly, anchor); } + ok() } - ok() - }), - spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply)}", true, |s, p| { + ), + spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| { let id = req_comment("comment.delete", s, p)?; - let reply = num(p, "reply").map(|r| r as usize); + let who = author(s, p); 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); + let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?; + match message_index("comment.delete", c, p)? { + 0 => { + own("comment.delete", &c.first, &who, "delete this thread")?; + doc.comments.retain(|c| c.id != id); + } + i => { + let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?; + own("comment.delete", m, &who, "delete this reply")?; + c.replies.remove(i - 1); } - None => doc.comments.retain(|c| c.id != id), } ok() }), + spec!( + "comment.markRead", + "Mark comment as read", + "{id? | link? (omit: every thread), read? (default true; false marks it unread), author?}", + false, + |s, p| { + let read = boolean(p, "read").unwrap_or(true); + let who = author(s, p); + let only = if get(p, "id").is_some() || get(p, "link").is_some() { Some(req_comment("comment.markRead", s, p)?) } else { None }; + let wants = |c: &Comment| only.is_none_or(|i| c.id == i) && c.is_unread(&who) == read; + // The document is left alone when nothing changes (no unsaved changes for nothing). + if !s.doc()?.comments.iter().any(wants) { + return Ok(json!({"changed": 0})); + } + let mut n = 0; + for c in s.doc_mut()?.comments.iter_mut().filter(|c| only.is_none_or(|i| c.id == i)) { + n += usize::from(c.set_read(&who, read)); + } + Ok(json!({"changed": n})) + } + ), + spec!( + "comment.open", + "Open comment", + "{id | link, view? (fit: zoom to the thread's frame, default; center: centre the pin; keep), author?}", + false, + |s, p| { + let id = req_comment("comment.open", s, p)?; + let how = string(p, "view").unwrap_or_else(|| "fit".into()); + if !matches!(how.as_str(), "fit" | "center" | "keep") { + return Err(bad("comment.open", "`view` must be fit, center or keep")); + } + open(s, id, &how, &author(s, p)) + } + ), + spec!( + "comment.next", + "Next comment", + "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}", + false, + |s, p| { + let dir = num(p, "dir").unwrap_or(1.0); + let doc = s.doc()?; + let who = author(s, p); + let (page, filter, sort, text) = list_params("comment.next", s, p)?; + let ids: Vec = + query(doc, &Query { page, frame: id(p, "frame"), filter, sort, text: &text }, &who).iter().map(|c| c.id).collect(); + let at = num(p, "id").and_then(|i| ids.iter().position(|x| *x as f64 == i)); + let next = match at { + Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)), + Some(i) => ids.get(i + 1).or(ids.get(i)), + None if dir < 0.0 => ids.last(), + None => ids.first(), + }; + let Some(&next) = next else { return Err(bad("comment.next", "no comments in the list")) }; + open(s, next, "center", &who) + } + ), + spec!("comment.zoom", "Zoom to comments", "{ids (comment ids on the current page)}", false, |s, p| { + let doc = s.doc()?; + let page = s.page()?; + let ids: Vec = get(p, "ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + let pts: Vec = + ids.iter().filter_map(|i| doc.comment(*i)).filter(|c| c.page == page).map(|c| geom::Point::from(c.position(doc))).collect(); + let Some(first) = pts.first() else { return Err(bad("comment.zoom", "`ids` names no comment on this page")) }; + let r = pts.iter().fold(geom::Rect::from_points(*first, *first), |r, pt| r.union_pt(*pt)); + let v = &mut s.ds_mut()?.view; + let zoom = v.zoom; + v.fit(r, crate::ViewState::MAX_ZOOM); + // Pins at one spot: at least double the zoom so they come apart. + if v.zoom < zoom * 2.0 { + v.set_zoom_at(zoom * 2.0, (v.cx, v.cy)); + } + Ok(json!({"zoom": v.zoom})) + }), + spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| { + let id = req_comment("comment.link", s, p)?; + Ok(json!({"link": link(id)})) + }), + spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }), spec!( "comment.list", "List comments", - "{page? (default: current; \"all\" for every page), resolved? (include resolved, default true)}", + "{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state)}", 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(); + let (page, filter, sort, text) = list_params("comment.list", s, p)?; + let who = author(s, p); + let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text }; + let list: Vec = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect(); Ok(json!({"comments": list})) } ), diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 93640a8..099d8d8 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -189,6 +189,12 @@ pub fn specs() -> Vec { s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big})) }), + spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| { + let name = req_string("pref.userName", p, "name")?; + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect(); + s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name }; + Ok(json!({"name": s.prefs.user_name})) + }), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/engine/src/comments_polish_tests.rs b/crates/engine/src/comments_polish_tests.rs new file mode 100644 index 0000000..42d319b --- /dev/null +++ b/crates/engine/src/comments_polish_tests.rs @@ -0,0 +1,298 @@ +//! Comment polish: pins that follow their layers (and stay put when the layers go), messages you +//! edit and delete, reactions, @mentions, read state, filters and sorting, links that reveal a +//! thread, and files from before all this. + +use figcraft_doc::{Comment, NodeId, mention_ranges}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn frame(s: &mut Session, x: f64, y: f64, name: &str) -> NodeId { + let f = s.execute("node.create", &json!({"type": "frame", "x": x, "y": y, "width": 200, "height": 200, "name": name})).unwrap(); + id_of(&f["id"]).unwrap() +} + +fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { + s.execute("comment.add", &json!({"x": x, "y": y, "text": text, "author": author, "anchor": true})).unwrap()["id"].as_u64().unwrap() +} + +fn list(s: &mut Session, p: Value) -> Vec { + s.execute("comment.list", &p).unwrap()["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect() +} + +fn comment(s: &Session, id: u64) -> Comment { + s.doc().unwrap().comment(id).unwrap().clone() +} + +#[test] +fn deleting_the_layer_keeps_the_pin_where_it_was() { + let mut s = Session::new(); + let outer = frame(&mut s, 100.0, 100.0, "Screen"); + let inner = + id_of(&s.execute("node.create", &json!({"type": "frame", "parent": outer, "x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"]) + .unwrap(); + let c = add(&mut s, 130.0, 140.0, "Nested", "You"); + assert_eq!(comment(&s, c).anchor, Some(inner)); + // Deleting the inner frame pins the comment to the outer one, at the same spot. + s.execute("edit.delete", &json!({"ids": [inner]})).unwrap(); + assert_eq!(comment(&s, c).anchor, Some(outer)); + assert_eq!(comment(&s, c).position(s.doc().unwrap()), (130.0, 140.0)); + // …which it then follows. + s.execute("node.set", &json!({"id": outer, "props": {"x": 200}})).unwrap(); + assert_eq!(comment(&s, c).position(s.doc().unwrap()), (230.0, 140.0)); + // Deleting the outer frame leaves it on the page, where it last was. + s.execute("edit.delete", &json!({"ids": [outer]})).unwrap(); + assert_eq!(comment(&s, c).anchor, None); + assert_eq!(comment(&s, c).position(s.doc().unwrap()), (230.0, 140.0)); + // Undo puts the frame and the pin back together. + assert!(s.undo().unwrap()); + assert_eq!(comment(&s, c).anchor, Some(outer)); +} + +#[test] +fn pins_follow_their_layer_to_another_page_and_can_be_moved() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let f = frame(&mut s, 0.0, 0.0, "Card"); + let g = frame(&mut s, 400.0, 0.0, "Other"); + let c = add(&mut s, 50.0, 60.0, "Here", "You"); + let p2 = id_of(&s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"]).unwrap(); + s.execute("page.select", &json!({"id": p1})).unwrap(); + s.execute("object.moveToPage", &json!({"ids": [f], "page": p2})).unwrap(); + assert_eq!(comment(&s, c).page, p2); + assert_eq!(comment(&s, c).anchor, Some(f)); + // Moving a pin onto another frame pins it there; `anchor: false` leaves it on the page. + s.execute("object.moveToPage", &json!({"ids": [f], "page": p1})).unwrap(); + s.execute("comment.move", &json!({"id": c, "x": 450, "y": 20})).unwrap(); + assert_eq!(comment(&s, c).anchor, Some(g)); + assert_eq!(comment(&s, c).position(s.doc().unwrap()), (450.0, 20.0)); + s.execute("comment.move", &json!({"id": c, "x": 1000, "y": 20, "anchor": false})).unwrap(); + assert_eq!(comment(&s, c).anchor, None); +} + +#[test] +fn only_authors_edit_and_delete_their_messages() { + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "First", "You"); + s.execute("comment.reply", &json!({"id": c, "text": "Second", "author": "Sam"})).unwrap(); + // Sam's reply isn't yours to edit or delete, nor is your thread Sam's to delete. + assert!(s.execute("comment.edit", &json!({"id": c, "reply": 0, "text": "Nope"})).is_err()); + assert!(s.execute("comment.delete", &json!({"id": c, "reply": 0})).is_err()); + assert!(s.execute("comment.delete", &json!({"id": c, "author": "Sam"})).is_err()); + assert!(s.execute("comment.edit", &json!({"id": c, "reply": -1, "text": "x"})).is_err()); + assert!(s.execute("comment.edit", &json!({"id": c, "reply": 5, "text": "x"})).is_err()); + s.execute("comment.edit", &json!({"id": c, "text": " First, edited "})).unwrap(); + let m = comment(&s, c).first; + assert_eq!(m.text, "First, edited"); + assert!(m.edited > 0); + s.execute("comment.edit", &json!({"id": c, "reply": 0, "text": "Second!", "author": "Sam"})).unwrap(); + assert_eq!(comment(&s, c).replies[0].text, "Second!"); + s.execute("comment.delete", &json!({"id": c, "reply": 0, "author": "Sam"})).unwrap(); + assert!(comment(&s, c).replies.is_empty()); + // Your name from Preferences signs and owns your comments. + s.execute("pref.userName", &json!({"name": " Ada "})).unwrap(); + let d = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Mine"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(comment(&s, d).first.author, "Ada"); + assert!(s.execute("comment.delete", &json!({"id": c})).is_err()); + s.execute("comment.delete", &json!({"id": d})).unwrap(); + s.execute("pref.userName", &json!({"name": " "})).unwrap(); + assert_eq!(s.prefs.user_name, "You"); +} + +#[test] +fn reactions_toggle_per_person() { + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "Ship it?", "You"); + s.execute("comment.reply", &json!({"id": c, "text": "Yes", "author": "Sam"})).unwrap(); + assert_eq!(s.execute("comment.react", &json!({"id": c, "emoji": "👍"})).unwrap()["on"], true); + s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam"})).unwrap(); + s.execute("comment.react", &json!({"id": c, "emoji": "🎉", "reply": 0})).unwrap(); + let m = comment(&s, c); + assert_eq!(m.first.reactions[0].users, vec!["You", "Sam"]); + assert_eq!(m.replies[0].reactions[0].emoji, "🎉"); + // Toggling off removes the person; the last one removes the reaction. `on` forces a state. + assert_eq!(s.execute("comment.react", &json!({"id": c, "emoji": "👍"})).unwrap()["on"], false); + s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam", "on": false})).unwrap(); + s.execute("comment.react", &json!({"id": c, "emoji": "👍", "author": "Sam", "on": false})).unwrap(); + assert!(comment(&s, c).first.reactions.is_empty()); + for bad in [json!(""), json!("two words"), json!("x".repeat(40)), json!(null)] { + assert!(s.execute("comment.react", &json!({"id": c, "emoji": bad})).is_err()); + } + // Reactions are one undo step each. + assert!(s.undo().unwrap()); + assert_eq!(comment(&s, c).first.reactions[0].users, vec!["Sam"]); +} + +#[test] +fn mentions_and_people() { + let people = ["Sam", "Sam Lee", "Ada"]; + let t = "Hi @sam lee and @Ada, not me@Ada or @Adam"; + let r = mention_ranges(t, &people); + assert_eq!(r.iter().map(|r| &t[r.clone()]).collect::>(), vec!["@sam lee", "@Ada"]); + assert!(mention_ranges("@", &people).is_empty()); + assert!(mention_ranges("é@Sam", &people).is_empty()); + assert_eq!(mention_ranges("(@Sam)", &people).len(), 1); + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "Ask @Ada", "Sam"); + s.execute("comment.react", &json!({"id": c, "emoji": "👀", "author": "Lin"})).unwrap(); + let p = s.execute("comment.people", &json!({})).unwrap(); + assert_eq!(p["people"], json!(["Lin", "Sam"])); + assert_eq!(p["you"], "You"); + // A thread you're mentioned in is yours. + assert!(comment(&s, c).involves("Ada")); + assert!(!comment(&s, c).involves("You")); +} + +#[test] +fn read_state_follows_new_messages() { + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "From Sam", "Sam"); + assert!(comment(&s, c).is_unread("You")); + assert!(!comment(&s, c).is_unread("Sam")); + assert_eq!(list(&mut s, json!({"filter": "unread"})), vec![c]); + let before = s.ds().unwrap().history.undo.len(); + assert_eq!(s.execute("comment.markRead", &json!({"id": c})).unwrap()["changed"], 1); + assert!(list(&mut s, json!({"filter": "unread"})).is_empty()); + // Read state isn't an undo step, and marking what's already read changes nothing. + assert_eq!(s.ds().unwrap().history.undo.len(), before); + let doc = s.ds().unwrap().doc.clone(); + s.execute("comment.markRead", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); + // A reply makes it unread for everyone else; mark unread puts it back. + s.execute("comment.reply", &json!({"id": c, "text": "More", "author": "Sam"})).unwrap(); + assert!(comment(&s, c).is_unread("You")); + s.execute("comment.markRead", &json!({})).unwrap(); + s.execute("comment.markRead", &json!({"link": format!("figcraft://comment/{c}"), "read": false})).unwrap(); + assert!(comment(&s, c).is_unread("You")); + assert!(!comment(&s, c).is_unread("Sam")); + assert!(s.execute("comment.markRead", &json!({"link": "nope"})).is_err()); +} + +#[test] +fn files_from_before_load_and_count_as_read() { + let old = json!({"id": 3, "page": 1, "x": 5.0, "y": 6.0, "anchor": null, "author": "Sam", "text": "Old", "created": 10, + "resolved": false, "replies": [{"author": "Lin", "text": "Reply", "created": 11}]}); + let mut c: Comment = serde_json::from_value(old).unwrap(); + assert_eq!(c.first.author, "Sam"); + assert_eq!(c.replies[0].text, "Reply"); + assert!(!c.is_unread("You")); + // The first message's fields stay on the comment itself; defaults are left out. + let v = serde_json::to_value(&c).unwrap(); + assert_eq!(v["text"], "Old"); + assert!(v.get("first").is_none() && v.get("readBy").is_none() && v.get("read_by").is_none() && v.get("edited").is_none()); + // Marking an old thread unread keeps everyone else's read. + assert!(c.set_read("Sam", false)); + assert!(c.is_unread("Sam")); + assert!(!c.is_unread("Lin")); + let back: Comment = serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap(); + assert_eq!(back, c); +} + +#[test] +fn filters_sort_search_and_frames() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let home = frame(&mut s, 0.0, 0.0, "Home"); + let a = add(&mut s, 10.0, 10.0, "Logo too big", "Sam"); + let b = add(&mut s, 500.0, 500.0, "Loose note @You", "Lin"); + let p2 = id_of(&s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"]).unwrap(); + let c = add(&mut s, 0.0, 0.0, "Second page", "You"); + s.execute("page.select", &json!({"id": p1})).unwrap(); + s.execute("comment.resolve", &json!({"id": a})).unwrap(); + // Make `a` the most recently active thread. + if let Some(m) = s.doc_mut().unwrap().comment_mut(a) { + m.first.created = 9_999_999_999; + } + assert_eq!(list(&mut s, json!({})), vec![a, b]); + // By date: latest activity first. By page: the first page's threads (latest first), then the next. + let mut by_date = vec![a]; + by_date.extend(newest_first(&s, &[b, c])); + assert_eq!(list(&mut s, json!({"page": "all"})), by_date); + assert_eq!(list(&mut s, json!({"page": "all", "sort": "page"})), vec![a, b, c]); + assert_eq!(list(&mut s, json!({"page": "all", "filter": "open"})).len(), 2); + assert_eq!(list(&mut s, json!({"page": "all", "filter": "resolved"})), vec![a]); + // Yours: threads you wrote in or are mentioned in. + let mine = list(&mut s, json!({"page": "all", "filter": "mine"})); + assert!(mine.contains(&b) && mine.contains(&c) && !mine.contains(&a)); + assert_eq!(list(&mut s, json!({"page": "all", "query": "LOGO"})), vec![a]); + // The frame's name matches too, and each thread names its frame and page. + assert_eq!(list(&mut s, json!({"page": "all", "query": "home"})), vec![a]); + assert_eq!(list(&mut s, json!({"frame": home})), vec![a]); + let all = s.execute("comment.list", &json!({"page": "all"})).unwrap(); + let ja = all["comments"].as_array().unwrap().iter().find(|j| j["id"] == a).unwrap().clone(); + assert_eq!(ja["frameName"], "Home"); + assert_eq!(ja["pageName"], "Page 1"); + assert_eq!(ja["link"], format!("figcraft://comment/{a}")); + assert_eq!(list(&mut s, json!({"page": p2})), vec![c]); + assert!(s.execute("comment.list", &json!({"filter": "weird"})).is_err()); + assert!(s.execute("comment.list", &json!({"sort": "weird"})).is_err()); +} + +/// `ids` newest first, as the date sort orders them. +fn newest_first(s: &Session, ids: &[u64]) -> Vec { + let doc = s.doc().unwrap(); + let mut v = ids.to_vec(); + v.sort_by_key(|i| { + let c = doc.comment(*i).unwrap(); + (std::cmp::Reverse(c.last_activity()), std::cmp::Reverse(c.id)) + }); + v +} + +#[test] +fn links_open_and_zoom_to_a_thread() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + frame(&mut s, 1000.0, 1000.0, "Far"); + let c = add(&mut s, 1050.0, 1060.0, "Look here", "Sam"); + let link = s.execute("comment.link", &json!({"id": c})).unwrap()["link"].as_str().unwrap().to_string(); + s.execute("page.add", &json!({"name": "Elsewhere"})).unwrap(); + assert_ne!(s.page().unwrap(), p1); + s.ui_requests.clear(); + let r = s.execute("comment.open", &json!({"link": link})).unwrap(); + assert_eq!(r["id"], c); + assert_eq!(r["unread"], false); + assert_eq!(s.page().unwrap(), p1); + let v = &s.ds().unwrap().view; + assert!(v.zoom <= 1.0 && (v.cx - 1100.0).abs() < 1.0 && (v.cy - 1100.0).abs() < 1.0); + assert!(s.ui_requests.iter().any(|u| u.kind == "comment.open" && u.data["id"] == c)); + // Centre keeps the zoom. + s.execute("view.set", &json!({"zoom": 3, "cx": 0, "cy": 0})).unwrap(); + s.execute("comment.open", &json!({"id": c, "view": "center"})).unwrap(); + let v = &s.ds().unwrap().view; + assert_eq!((v.zoom, v.cx, v.cy), (3.0, 1050.0, 1060.0)); + assert!(s.execute("comment.open", &json!({"id": c, "view": "nowhere"})).is_err()); + assert!(s.execute("comment.open", &json!({"link": "figcraft://comment/99"})).is_err()); + // Zooming to a cluster of pins at least doubles the zoom. + let d = add(&mut s, 1052.0, 1061.0, "Near", "Sam"); + s.execute("view.set", &json!({"zoom": 0.1})).unwrap(); + let z = s.execute("comment.zoom", &json!({"ids": [c, d]})).unwrap()["zoom"].as_f64().unwrap(); + assert!(z >= 0.2); + assert!(s.execute("comment.zoom", &json!({"ids": [12345]})).is_err()); +} + +#[test] +fn next_steps_through_the_list_across_pages() { + let mut s = Session::new(); + let a = add(&mut s, 0.0, 0.0, "One", "Sam"); + s.execute("page.add", &json!({"name": "Two"})).unwrap(); + let b = add(&mut s, 0.0, 0.0, "Two", "Sam"); + // Page order: `a` (first page), then `b`. + let step = |s: &mut Session, from: Option, dir: i32| { + s.execute("comment.next", &json!({"id": from, "dir": dir, "page": "all", "sort": "page"})).unwrap()["id"].as_u64().unwrap() + }; + assert_eq!(step(&mut s, None, 1), a); + let first_page = s.doc().unwrap().pages[0]; + assert_eq!(s.page().unwrap(), first_page); + assert_eq!(step(&mut s, Some(a), 1), b); + assert_ne!(s.page().unwrap(), first_page); + // The ends stay put. + assert_eq!(step(&mut s, Some(b), 1), b); + assert_eq!(step(&mut s, Some(b), -1), a); + assert_eq!(step(&mut s, Some(a), -1), a); + assert_eq!(step(&mut s, None, -1), b); + // Stepping reads the threads. + assert!(!comment(&s, a).is_unread("You") && !comment(&s, b).is_unread("You")); + assert!(s.execute("comment.next", &json!({"page": "all", "filter": "resolved"})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8ee5acb..c7a3d92 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -415,6 +415,8 @@ pub struct Prefs { pub number_keys_opacity: bool, pub show_dimensions: bool, pub theme: String, + /// The name comments are signed with (and whose comments count as yours). + pub user_name: String, } impl Default for Prefs { @@ -428,6 +430,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, theme: "light".into(), + user_name: cmd::comment::DEFAULT_AUTHOR.into(), } } } @@ -895,6 +898,8 @@ pub fn normalize(doc: &mut Document, before: &Document) { figcraft_layout::layout_frame(doc, f, &m); } doc.take_dirty(); + // 5. Comment pins follow their layers to other pages and stay put when the layers are deleted. + cmd::comment::follow_anchors(doc, before); } #[cfg(test)] @@ -906,6 +911,8 @@ mod autosave_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod comments_polish_tests; +#[cfg(test)] mod devinspect_tests; #[cfg(test)] mod effects_tests; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 29d996e..1b4bffa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -327,7 +327,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } return; } - if !app.canvas.dragging && resp.hovered() && crate::comments::input(app, &ctx, pos) { + if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) { return; } if !app.canvas.dragging && resp.hovered() && crate::guides::input(app, &ctx, pos) { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b8849dd..8a4c57d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1,10 +1,15 @@ -//! 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. +//! Comments: pins on the canvas (gathered into clusters when they crowd together; yours can be +//! dragged to another spot or frame), the thread popover (edit and delete your messages, emoji +//! reactions, @mentions, read state, copy link), the new-comment composer, the Comments panel shown +//! in place of the Design panel while the Comment tool is active (search, filter, sort, keyboard +//! navigation) and pins on a presented frame. Every change goes through a `comment.*` command. -use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::Comment; +use egui::text::{CCursor, CCursorRange, LayoutJob, TextFormat}; +use egui::{Align2, Color32, CornerRadius, Id, Key, Modifiers, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use figcraft_doc::{Comment, Message, NodeId, mention_ranges}; use figcraft_engine::ToolId; -use serde_json::json; +use figcraft_engine::cmd::comment::{self as cc, Filter, Query, Sort}; +use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; @@ -12,20 +17,201 @@ use crate::theme::{ACCENT, Tokens, regular, semibold}; const PIN: f32 = 28.0; const POPOVER_W: f32 = 300.0; +/// Pins whose centres come closer than this (screen points) gather into a cluster. +pub const CLUSTER_R: f32 = 24.0; +/// A press on your pin that moves further than this drags it. +const DRAG_SLOP: f32 = 3.0; +/// The reactions on offer (text emoji). +pub const REACTIONS: [&str; 8] = ["👍", "❤", "😂", "🎉", "👀", "🙏", "🔥", "✅"]; +/// Names the @mention picker lists at most. +const MENTION_ROWS: usize = 6; +/// A speech bubble: round on three corners, pointed at its spot. +const BUBBLE: CornerRadius = CornerRadius { nw: 14, ne: 14, se: 14, sw: 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, + /// What the panel (and the pins) show, and in what order. + pub filter: Filter, + pub sort: Sort, pub search: String, + /// A message being edited: (thread, message index, text). + pub editing: Option<(u64, usize, String)>, + pub mention: MentionPick, + drag: Option, + /// Scroll the panel to the open thread (after keyboard navigation). + scroll_to_open: bool, } -/// Pins are visible with the Comment tool, or when View › Comments is on. +impl Default for CommentUi { + fn default() -> Self { + Self { + open: None, + draft: None, + reply: String::new(), + filter: Filter::Open, + sort: Sort::Date, + search: String::new(), + editing: None, + mention: MentionPick::default(), + drag: None, + scroll_to_open: false, + } + } +} + +impl CommentUi { + /// A pin is being pressed or dragged. + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Show a thread (a pin click, a link, the panel, keyboard navigation). + pub fn show(&mut self, id: u64) { + if self.open != Some(id) { + self.reply.clear(); + } + self.open = Some(id); + self.draft = None; + self.editing = None; + self.mention = MentionPick::default(); + } + + /// Close the open thread and the draft. + pub fn close(&mut self) { + self.open = None; + self.draft = None; + self.editing = None; + self.mention = MentionPick::default(); + } +} + +/// The @mention picker: the names it offers for the `@query` being typed in one text field. +#[derive(Clone, Debug, Default)] +pub struct MentionPick { + field: Option, + /// The `@query`'s byte range in the field's text. + range: (usize, usize), + names: Vec, + pub sel: usize, + /// Dismissed with Escape: (field, where its `@` is). + dismissed: Option<(Id, usize)>, +} + +impl MentionPick { + /// It offers names (in some field). + pub fn active(&self) -> bool { + self.field.is_some() && !self.names.is_empty() + } + + pub fn names(&self) -> &[String] { + &self.names + } +} + +/// A pin being pressed: dragged when it's yours and the pointer moves. +#[derive(Clone, Copy, Debug)] +struct PinDrag { + id: u64, + from: Pos2, + /// The pointer's offset from the pin's spot. + grab: Vec2, + /// Where the pin's spot is now (screen). + at: Pos2, + mine: bool, + moved: bool, +} + +// --------------------------------------------------------------------------------------------- +// Pure helpers (tested in `comments_tests.rs`). + +/// The `@query` being typed just before the cursor (`cursor` in chars): its byte range, from the +/// `@` to the cursor. None when the cursor isn't in one (a mention starts the text or follows a +/// non-alphanumeric character, stays on one line and doesn't end in a space). +pub fn mention_query(text: &str, cursor: usize) -> Option<(usize, usize)> { + let end = text.char_indices().nth(cursor).map_or(text.len(), |(b, _)| b); + let before = text.get(..end)?; + let at = before.rfind('@')?; + let q = before.get(at + 1..)?; + if q.contains('\n') || q.chars().count() > 40 || q.ends_with(char::is_whitespace) { + return None; + } + let starts = before.get(..at)?.chars().next_back().is_none_or(|c| !c.is_alphanumeric()); + starts.then_some((at, end)) +} + +/// The people (not `me`) whose name, or a word in it, starts with `query` (any case). +pub fn mention_matches(people: &[String], query: &str, me: &str) -> Vec { + let q = query.to_lowercase(); + people + .iter() + .filter(|n| n.as_str() != me) + .filter(|n| { + let l = n.to_lowercase(); + l.starts_with(&q) || l.split_whitespace().any(|w| w.starts_with(&q)) + }) + .take(MENTION_ROWS) + .cloned() + .collect() +} + +/// Replace the `@query` at `range` with `@name ` and return the cursor after it (chars). +pub fn insert_mention(text: &mut String, range: (usize, usize), name: &str) -> usize { + let (a, b) = range; + if a > b || b > text.len() || !text.is_char_boundary(a) || !text.is_char_boundary(b) { + return text.chars().count(); + } + let ins = format!("@{name} "); + text.replace_range(a..b, &ins); + text.get(..a).map_or(0, |t| t.chars().count()) + ins.chars().count() +} + +/// Group points closer than `r` to a group's centre (in order: each joins the first group near +/// enough, else starts one). Returns the groups as indexes. +pub fn cluster(points: &[Pos2], r: f32) -> Vec> { + let mut groups: Vec<(Vec, Vec2)> = Vec::new(); + for (i, p) in points.iter().enumerate() { + let near = groups.iter_mut().find(|(g, sum)| (*sum / g.len() as f32).to_pos2().distance(*p) < r); + match near { + Some((g, sum)) => { + g.push(i); + *sum += p.to_vec2(); + } + None => groups.push((vec![i], p.to_vec2())), + } + } + groups.into_iter().map(|(g, _)| g).collect() +} + +/// A message's text with its @mentions of `people` picked out, wrapped at `width`. +pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() }; + let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() }; + let mut at = 0; + for r in mention_ranges(text, people) { + job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone()); + job.append(text.get(r.clone()).unwrap_or(""), 0.0, mention.clone()); + at = r.end; + } + job.append(text.get(at..).unwrap_or(""), 0.0, plain); + job.wrap.max_width = width; + job +} + +// --------------------------------------------------------------------------------------------- +// Pins. + +/// The user's name (comments are signed with it). +fn me(app: &FigApp) -> String { + cc::user_name(&app.session) +} + +/// Pins are visible with the Comment tool, or when View › Comments is on (Shift+C). fn visible(app: &FigApp) -> bool { app.session.tool.tool == ToolId::Comment || app.session.ds().is_ok_and(|ds| ds.view.comments) @@ -33,23 +219,87 @@ fn visible(app: &FigApp) -> bool { || 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)> { +/// A pin: its spot is the bubble's bottom-left corner (screen). +#[derive(Clone, Debug)] +struct Pin { + id: u64, + author: String, + resolved: bool, + unread: bool, + mine: bool, + at: Pos2, +} + +impl Pin { + fn of(c: &Comment, me: &str, at: Pos2) -> Self { + Pin { id: c.id, author: c.first.author.clone(), resolved: c.resolved, unread: c.is_unread(me), mine: c.first.author == me, at } + } +} + +/// The current page's pins (those the panel's filter keeps, and the open thread's) in drawing +/// order: oldest first, the open one on top; a dragged pin sits under the pointer. +fn pins(app: &FigApp) -> Vec { 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)) + let me = me(app); + let cu = &app.ui.comments; + let q = Query { page: Some(ds.page), filter: cu.filter, ..Query::default() }; + let mut list = cc::query(&ds.doc, &q, &me); + list.reverse(); + if let Some(c) = cu.open.and_then(|o| ds.doc.comment(o)).filter(|c| c.page == ds.page && !list.iter().any(|x| x.id == c.id)) { + list.push(c); + } + list.sort_by_key(|c| cu.open == Some(c.id)); + let drag = cu.drag.filter(|d| d.moved); + list.into_iter() .map(|c| { - let (x, y) = c.position(&ds.doc); - (c.clone(), app.to_screen(x, y)) + let at = match drag { + Some(d) if d.id == c.id => d.at, + _ => { + let (x, y) = c.position(&ds.doc); + app.to_screen(x, y) + } + }; + Pin::of(c, &me, at) }) .collect() } -fn pin_rect(anchor: Pos2) -> Rect { - Rect::from_min_max(pos2(anchor.x, anchor.y - PIN), pos2(anchor.x + PIN, anchor.y)) +/// What the canvas shows: single pins, and clusters of crowded ones (their threads, spot). +enum Spot { + One(Pin), + Many(Vec, Pos2), +} + +/// Pins, crowded ones gathered into clusters (the open and the dragged pin always stand alone, on +/// top). +fn spots(app: &FigApp) -> Vec { + let cu = &app.ui.comments; + let alone = |p: &Pin| cu.open == Some(p.id) || cu.drag.is_some_and(|d| d.id == p.id); + let (solo, rest): (Vec, Vec) = pins(app).into_iter().partition(alone); + let centres: Vec = rest.iter().map(|p| pin_rect(p.at).center()).collect(); + let mut out: Vec = Vec::new(); + for g in cluster(¢res, CLUSTER_R) { + let members: Vec<&Pin> = g.iter().filter_map(|i| rest.get(*i)).collect(); + match members.as_slice() { + [one] => out.push(Spot::One((*one).clone())), + many => { + let sum = many.iter().fold(Vec2::ZERO, |s, p| s + p.at.to_vec2()); + out.push(Spot::Many(many.iter().map(|p| p.id).collect(), (sum / many.len().max(1) as f32).to_pos2())); + } + } + } + out.extend(solo.into_iter().map(Spot::One)); + out +} + +fn pin_rect(at: Pos2) -> Rect { + Rect::from_min_max(pos2(at.x, at.y - PIN), pos2(at.x + PIN, at.y)) +} + +/// A cluster's bubble: wide enough for its count. +fn cluster_rect(at: Pos2, n: usize) -> Rect { + let digits = n.to_string().len() as f32; + Rect::from_min_max(pos2(at.x, at.y - PIN), pos2(at.x + PIN + 7.0 * (digits - 1.0), at.y)) } /// A stable colour per author. @@ -83,7 +333,7 @@ fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str) { /// Seconds since `t` (Unix seconds); `None` for unknown or future times. fn age(t: u64) -> Option { - let now = figcraft_engine::cmd::comment::now_secs(); + let now = cc::now_secs(); (t != 0 && now >= t).then(|| now - t) } @@ -115,56 +365,119 @@ pub fn ago_long(t: u64) -> String { } } -/// Draw the pins (and the draft pin). +/// One pin: the author's avatar in a bubble (accent when `hot`), a dot when unread, a check when +/// resolved. +fn paint_pin(painter: &egui::Painter, r: Rect, p: &Pin, hot: bool) { + painter.rect_filled(r.translate(vec2(0.0, 1.0)), BUBBLE, Color32::from_black_alpha(40)); + painter.rect_filled(r, BUBBLE, if hot { ACCENT } else { Color32::WHITE }); + painter.rect_stroke(r, BUBBLE, Stroke::new(1.0, Color32::from_black_alpha(30)), StrokeKind::Inside); + let fill = if p.resolved { Color32::from_gray(150) } else { author_color(&p.author) }; + painter.circle_filled(r.center(), 11.0, fill); + painter.text(r.center(), Align2::CENTER_CENTER, initials(&p.author), semibold(9.0), Color32::WHITE); + if p.unread { + let c = r.right_top() + vec2(-2.0, 2.0); + painter.circle_filled(c, 4.5, Color32::WHITE); + painter.circle_filled(c, 3.5, ACCENT); + } + if p.resolved { + icons::paint(painter, Rect::from_center_size(r.right_bottom() + vec2(-3.0, -3.0), vec2(10.0, 10.0)), Icon::Check, ACCENT); + } +} + +/// A cluster: a stack of bubbles with the number of threads in it. +fn paint_cluster(painter: &egui::Painter, at: Pos2, n: usize, hot: bool) { + let r = cluster_rect(at, n); + let back = r.translate(vec2(3.0, -3.0)); + let edge = Stroke::new(1.0, Color32::from_black_alpha(30)); + painter.rect_filled(back, BUBBLE, Color32::WHITE); + painter.rect_stroke(back, BUBBLE, edge, StrokeKind::Inside); + painter.rect_filled(r.translate(vec2(0.0, 1.0)), BUBBLE, Color32::from_black_alpha(40)); + painter.rect_filled(r, BUBBLE, if hot { ACCENT } else { Color32::WHITE }); + painter.rect_stroke(r, BUBBLE, edge, StrokeKind::Inside); + let inner = r.shrink(3.0); + painter.rect_filled(inner, CornerRadius::same(11), Color32::from_gray(44)); + painter.text(inner.center(), Align2::CENTER_CENTER, n.to_string(), semibold(11.0), Color32::WHITE); +} + +/// Draw the pins, clusters 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); + let hot = |r: Rect| hover.is_some_and(|h| r.contains(h)); + for spot in spots(app) { + match spot { + Spot::One(p) => { + let r = pin_rect(p.at); + paint_pin(painter, r, &p, app.ui.comments.open == Some(p.id) || hot(r)); + } + Spot::Many(ids, at) => paint_cluster(painter, at, ids.len(), hot(cluster_rect(at, ids.len()))), } } 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.rect_filled(r, BUBBLE, 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. +/// Open a thread, or close it when it's the open one. +fn toggle(app: &mut FigApp, id: u64) { + if app.ui.comments.open == Some(id) { + app.ui.comments.close(); + } else { + app.ui.comments.show(id); + } +} + +/// Pointer handling: a pin click opens its thread, dragging your pin moves it (onto the frame +/// under it), a cluster click zooms in until its pins come apart. Returns true when the comments +/// own the pointer. pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { if !visible(app) { + app.ui.comments.drag = None; 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(); + let (pressed, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + if let Some(mut d) = app.ui.comments.drag.take() { + if down { + d.moved |= d.mine && (pos - d.from).length() > DRAG_SLOP; + if d.moved { + d.at = pos - d.grab; + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); } - return true; + app.ui.comments.drag = Some(d); + } else if d.moved { + let p = app.to_page(d.at); + app.run("comment.move", json!({"id": d.id, "x": p.x, "y": p.y})); + } else { + toggle(app, d.id); + } + return true; + } + for spot in spots(app).iter().rev() { + match spot { + Spot::One(p) if pin_rect(p.at).contains(pos) => { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + if pressed { + app.ui.comments.drag = Some(PinDrag { id: p.id, from: pos, grab: pos - p.at, at: p.at, mine: p.mine, moved: false }); + } + return true; + } + Spot::Many(ids, at) if cluster_rect(*at, ids.len()).contains(pos) => { + ctx.set_cursor_icon(egui::CursorIcon::ZoomIn); + if pressed { + app.run("comment.zoom", json!({"ids": ids})); + } + 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; + app.ui.comments.close(); return app.session.tool.tool != ToolId::Comment; } false @@ -172,34 +485,77 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { /// 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.close(); app.ui.comments.draft = Some((x, y, String::new())); } -fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str) -> bool { +// --------------------------------------------------------------------------------------------- +// Composer and thread popover. + +/// The reply field's id for a thread. +fn reply_id(thread: u64) -> Id { + Id::new(("comment_reply", thread)) +} + +fn set_cursor(ctx: &egui::Context, id: Id, at: usize) { + let mut st = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at)))); + egui::TextEdit::store_state(ctx, id, st); +} + +/// Focus a thread's reply field with the cursor at `at` (chars). +pub fn focus_reply(ctx: &egui::Context, thread: u64, at: usize) { + set_cursor(ctx, reply_id(thread), at); + ctx.memory_mut(|m| m.request_focus(reply_id(thread))); +} + +/// A text field with a send button: Enter posts (Shift+Enter starts a new line); typing `@` offers +/// the names in `people` (arrows choose, Enter or Tab or a click inserts, Escape dismisses). +/// Returns true when the text is posted. +fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str, people: &[String], me: &str, pick: &mut MentionPick) -> bool { let t = Tokens::get(ui.ctx()); + let ctx = ui.ctx().clone(); let mut submit = false; + // The picker's keys and Enter, before the text field sees them. + if ui.memory(|m| m.has_focus(id)) { + if pick.field == Some(id) && pick.active() { + let n = pick.names.len(); + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) { + pick.sel = (pick.sel + 1) % n; + } + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) { + pick.sel = (pick.sel + n - 1) % n; + } + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + pick.dismissed = Some((id, pick.range.0)); + } else if ui.input_mut(|i| (!i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::Enter)) || i.consume_key(Modifiers::NONE, Key::Tab)) + && let Some(name) = pick.names.get(pick.sel).cloned() + { + let at = insert_mention(text, pick.range, &name); + set_cursor(&ctx, id, at); + } + } else if ui.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::Enter)) { + submit = true; + } + } let frame = egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(8, 6)); + let mut cursor = None; frame.show(ui, |ui| { ui.horizontal(|ui| { - let te = egui::TextEdit::multiline(text) + let out = 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); + .desired_width(ui.available_width() - 30.0) + .show(ui); + let resp = out.response.response; 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; + if resp.has_focus() { + cursor = out.cursor_range.map(|r| r.primary.index.0); } let (br, bresp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); let ready = !text.trim().is_empty(); @@ -214,123 +570,404 @@ fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str) -> bool { } }); }); + // The @mention picker for what's typed now (kept while nothing has the focus: a click on a + // name takes it from the field for a moment). + let found = cursor.and_then(|c| mention_query(text, c)).filter(|r| pick.dismissed != Some((id, r.0))); + let names = found.map(|r| mention_matches(people, text.get(r.0 + 1..r.1).unwrap_or(""), me)).unwrap_or_default(); + let elsewhere = cursor.is_none() && ui.memory(|m| m.focused().is_some()); + match found { + Some(r) if !names.is_empty() => { + if pick.field != Some(id) || pick.range.0 != r.0 { + pick.sel = 0; + } + pick.sel = pick.sel.min(names.len() - 1); + pick.field = Some(id); + pick.range = r; + pick.names = names; + } + _ if (cursor.is_some() || elsewhere) && pick.field == Some(id) => { + pick.field = None; + pick.names.clear(); + } + _ => {} + } + if pick.field == Some(id) && pick.active() { + ui.add_space(4.0); + let mut chosen = None; + for (k, name) in pick.names.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + if k == pick.sel || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), if k == pick.sel { t.selected_row } else { t.hover }); + } + avatar(ui.painter(), pos2(r.min.x + 14.0, r.center().y), 9.0, name); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text); + if resp.clicked() { + chosen = Some(name.clone()); + } + } + if let Some(name) = chosen { + let at = insert_mention(text, pick.range, &name); + set_cursor(&ctx, id, at); + ctx.memory_mut(|m| m.request_focus(id)); + *pick = MentionPick::default(); + } + } 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); +/// What the thread popover asked for. +enum Act { + Close, + Run(&'static str, Value), + CopyLink, + MarkUnread, + Edit(usize, String), } -/// 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; +/// `{id, reply?}` naming a message of a thread (index 0: the first message). +fn message_params(thread: u64, index: usize) -> Value { + match index.checked_sub(1) { + Some(r) => json!({"id": thread, "reply": r}), + None => json!({"id": thread}), } - 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; +} + +/// A small icon button drawn only while `show` (its message is hovered or its menu is open). +fn hover_button(ui: &mut Ui, id: Id, icon: Icon, show: bool, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()); + if !show { + return resp; + } + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), r.shrink(2.0), icon, t.icon_secondary); + resp.on_hover_text(tip) +} + +/// What every message of a thread needs to know. +struct ThreadCtx<'a> { + c: &'a Comment, + me: &'a str, + people: &'a [String], +} + +/// One message: author, time, its buttons on hover (react; edit and delete when it's yours), the +/// text (or its editor) and the reactions. +fn message( + ui: &mut Ui, + cx: &ThreadCtx, + i: usize, + m: &Message, + editing: &mut Option<(u64, usize, String)>, + pick: &mut MentionPick, + act: &mut Option, +) { + let t = Tokens::get(ui.ctx()); + let ctx = ui.ctx().clone(); + let id = cx.c.id; + let hover_key = Id::new(("comment_msg_hover", id, i)); + let hovered = ctx.data(|d| d.get_temp::(hover_key)).unwrap_or(false); + let indent = 36.0; + let block = ui.scope(|ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + avatar(ui.painter(), r.center(), 12.0, &m.author); + ui.add_space(4.0); + ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); + let when = if m.edited > 0 { format!("{} · edited", ago(m.created)) } else { ago(m.created) }; + ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if m.author == cx.me { + let menu = Id::new(("comment_msg_more_menu", id, i)); + let more = + hover_button(ui, Id::new(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), "More"); + egui::Popup::menu(&more).id(menu).show(|ui| { + if crate::right::menu_item(ui, "Edit", "") { + *act = Some(Act::Edit(i, m.text.clone())); + ui.close(); + } + if crate::right::menu_item(ui, if i == 0 { "Delete thread" } else { "Delete" }, "") { + *act = Some(Act::Run("comment.delete", message_params(id, i))); + ui.close(); + } + }); + } + let menu = Id::new(("comment_msg_react_menu", id, i)); + let react = hover_button( + ui, + Id::new(("comment_msg_react", id, i)), + Icon::Reaction, + hovered || egui::Popup::is_id_open(&ctx, menu), + "Add reaction", + ); + egui::Popup::menu(&react).id(menu).show(|ui| { + ui.horizontal(|ui| { + for e in REACTIONS { + let on = m.reacted(e, cx.me); + let (r, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + if on || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), if on { t.selected_row } else { t.hover }); + } + ui.painter().text(r.center(), Align2::CENTER_CENTER, e, regular(16.0), t.text); + if resp.clicked() { + let mut p = message_params(id, i); + p["emoji"] = json!(e); + *act = Some(Act::Run("comment.react", p)); + ui.close(); + } + } + }); + }); + }); + }); + match editing.as_mut().filter(|e| e.0 == id && e.1 == i) { + Some((_, _, text)) => { + ui.horizontal(|ui| { + ui.add_space(indent); + ui.vertical(|ui| { + if composer(ui, Id::new(("comment_edit", id, i)), text, "Edit comment", cx.people, cx.me, pick) { + let mut p = message_params(id, i); + p["text"] = json!(text.trim()); + *act = Some(Act::Run("comment.edit", p)); + } + ui.label(egui::RichText::new("Enter to save · Esc to cancel").font(regular(10.5)).color(t.text_tertiary)); + }); + }); + } + None => { + ui.horizontal(|ui| { + ui.add_space(indent); + let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect(); + names.push(cx.me); + ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap()); + }); + } } - 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 + if !m.reactions.is_empty() { + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.add_space(indent); + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for r in &m.reactions { + let on = r.users.iter().any(|u| u == cx.me); + // The emoji a size up from its count. + let mut job = LayoutJob::default(); + job.append( + &r.emoji, + 0.0, + TextFormat { font_id: regular(14.0), color: t.text, valign: egui::Align::Center, ..Default::default() }, + ); + job.append( + &r.users.len().to_string(), + 4.0, + TextFormat { font_id: regular(11.5), color: t.text, valign: egui::Align::Center, ..Default::default() }, + ); + let galley = ui.painter().layout_job(job); + let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(14.0, 6.0), Sense::click()); + let cr = CornerRadius::same((rect.height() / 2.0) as u8); + ui.painter().rect_filled( + rect, + cr, + if on { + t.selected_row + } else if resp.hovered() { + t.hover + } else { + t.field + }, + ); + if on { + ui.painter().rect_stroke(rect, cr, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + if resp.on_hover_text(r.users.join(", ")).clicked() { + let mut p = message_params(id, i); + p["emoji"] = json!(r.emoji); + *act = Some(Act::Run("comment.react", p)); + } + } + }); + } + ui.add_space(8.0); + }); + ctx.data_mut(|d| d.insert_temp(hover_key, block.response.contains_pointer())); +} + +/// The popover's place next to a pin's spot, inside `bounds`. +fn popover_pos(anchor: Pos2, bounds: Rect) -> Pos2 { + let mut p = anchor + vec2(PIN + 8.0, -PIN); + if p.x + POPOVER_W > bounds.max.x - 8.0 { + p.x = anchor.x - POPOVER_W - 8.0; + } + p.y = p.y.clamp(bounds.min.y + 8.0, (bounds.max.y - 200.0).max(bounds.min.y + 8.0)); + p +} + +fn popover_frame(t: &Tokens) -> egui::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)); + .inner_margin(egui::Margin::same(12)) +} + +/// Escape: ends an edit, else closes the open thread or the draft (an open @mention picker takes +/// it first). True when they closed. +fn escape(app: &mut FigApp, ctx: &egui::Context) -> bool { + let cu = &mut app.ui.comments; + if (cu.open.is_none() && cu.draft.is_none()) || cu.mention.active() || !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + return false; + } + if cu.editing.take().is_some() { + return false; + } + cu.close(); + true +} + +/// The thread popover and the draft composer, floating next to their pins on the canvas. +pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { + if escape(app, ctx) { + return; + } + let canvas = app.canvas.rect; if let Some((x, y, mut text)) = app.ui.comments.draft.clone() { - let pos = place(app.to_screen(x, y)); + let t = Tokens::get(ctx); + let people = app.session.doc().map(|d| d.comment_people()).unwrap_or_default(); + let me = me(app); + let mut pick = std::mem::take(&mut app.ui.comments.mention); 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"); - }); - }); + egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( + ctx, + |ui| { + popover_frame(&t).show(ui, |ui| { + ui.set_width(POPOVER_W - 24.0); + post = composer(ui, Id::new("comment_draft_text"), &mut text, "Add a comment", &people, &me, &mut pick); + }); + }, + ); + app.ui.comments.mention = pick; 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())); + if let Some(id) = r.and_then(|v| v.get("id").and_then(Value::as_u64)) { + app.ui.comments.show(id); + } } 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; + let Some(pin) = pins(app).into_iter().find(|p| p.id == open) else { + app.ui.comments.close(); return; }; - let pos = place(anchor); + thread(app, ctx, open, pin.at, canvas); +} + +/// A thread's popover next to the spot `anchor` (screen), kept inside `bounds`: its messages and a +/// reply field. Showing it reads the thread. +pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, bounds: Rect) { + let t = Tokens::get(ctx); + let Ok(ds) = app.session.ds() else { return }; + let previewing = ds.preview.is_some(); + let Some(c) = ds.doc.comment(id).cloned() else { + app.ui.comments.close(); + return; + }; + let people = ds.doc.comment_people(); + let me = me(app); + if c.is_unread(&me) && !previewing { + app.run("comment.markRead", json!({"id": id})); + } 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| { + let mut editing = app.ui.comments.editing.take().filter(|e| e.0 == id && c.message(e.1).is_some()); + let mut pick = std::mem::take(&mut app.ui.comments.mention); + let mut act: Option = None; + let cx = ThreadCtx { c: &c, me: &me, people: &people }; + egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { + popover_frame(&t).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!({}))); + act = Some(Act::Close); } 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}))); + if crate::right::menu_item(ui, "Mark as unread", "") { + act = Some(Act::MarkUnread); + ui.close(); + } + if crate::right::menu_item(ui, "Copy link", "") { + act = Some(Act::CopyLink); 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}))); + act = Some(Act::Run("comment.resolve", json!({"id": id, "resolved": !c.resolved}))); + ui.close(); + } + if c.first.author == me && crate::right::menu_item(ui, "Delete thread", "") { + act = Some(Act::Run("comment.delete", json!({"id": id}))); 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}))); + act = Some(Act::Run("comment.resolve", json!({"id": 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); + for (i, m) in c.messages().enumerate() { + message(ui, &cx, i, m, &mut editing, &mut pick, &mut act); } }); - if composer(ui, Id::new(("comment_reply", c.id)), &mut reply, "Reply") { - action = Some(("comment.reply", json!({"id": c.id, "text": reply.trim()}))); + if composer(ui, reply_id(id), &mut reply, "Reply", &people, &me, &mut pick) { + act = Some(Act::Run("comment.reply", json!({"id": id, "text": reply.trim()}))); } }); }); app.ui.comments.reply = reply; - match action { - Some(("close", _)) => app.ui.comments.open = None, - Some((cmd, p)) => { + app.ui.comments.editing = editing; + app.ui.comments.mention = pick; + match act { + Some(Act::Close) => app.ui.comments.close(), + Some(Act::Edit(i, text)) => { + app.ui.comments.editing = Some((id, i, text)); + app.ui.comments.mention = MentionPick::default(); + } + Some(Act::CopyLink) => { + if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) { + app.services.clipboard_set_text(&link); + app.toast("Link copied"); + } + } + // It closes: an open thread reads itself again. + Some(Act::MarkUnread) => { + if app.run("comment.markRead", json!({"id": id, "read": false})).is_some() { + app.ui.comments.close(); + } + } + Some(Act::Run(cmd, p)) => { + let thread_deleted = cmd == "comment.delete" && p.get("reply").is_none(); 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; + match cmd { + "comment.reply" => app.ui.comments.reply.clear(), + "comment.edit" => { + app.ui.comments.editing = None; + app.ui.comments.mention = MentionPick::default(); + } + _ if thread_deleted => app.ui.comments.close(), + _ => {} } } } @@ -338,16 +975,85 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { } } +// --------------------------------------------------------------------------------------------- +// The Comments panel and keyboard navigation. + +/// `comment.next` params for the panel's list (every page, its filter, sort and search). +fn panel_params(cu: &CommentUi, dir: i32) -> Value { + json!({"id": cu.open, "dir": dir, "page": "all", "filter": cu.filter.id(), "sort": cu.sort.id(), "query": cu.search}) +} + +/// Open the next (`dir` 1) or previous (-1) thread in the panel's list, bring it into view and +/// put the keyboard in its reply field (where the arrows go on stepping while it's empty). +pub fn step(app: &mut FigApp, ctx: &egui::Context, dir: i32) -> Option { + let p = panel_params(&app.ui.comments, dir); + let r = app.session.execute("comment.next", &p).ok(); + app.handle_ui_requests(); + if r.is_some() + && let Some(open) = app.ui.comments.open + { + app.ui.comments.scroll_to_open = true; + ctx.memory_mut(|m| m.request_focus(reply_id(open))); + } + r +} + +/// Up and Down step through the panel's threads while the Comment tool is active and nothing is +/// being typed (an empty reply field doesn't count). +pub fn keys(app: &mut FigApp, ctx: &egui::Context) { + let cu = &app.ui.comments; + if app.session.tool.tool != ToolId::Comment || cu.draft.is_some() || cu.editing.is_some() || cu.mention.active() { + return; + } + let free = match ctx.memory(|m| m.focused()) { + None => true, + Some(f) => cu.open.is_some_and(|o| f == reply_id(o)) && cu.reply.is_empty(), + }; + if !free { + return; + } + for (key, dir) in [(Key::ArrowDown, 1), (Key::ArrowUp, -1)] { + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, key)) { + step(app, ctx, dir); + } + } +} + /// 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(); + let me = me(app); 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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + let menu = icons::button(ui, Icon::Adjust, 24.0, app.ui.comments.filter != Filter::Open, "Filter and sort"); + egui::Popup::menu(&menu).width(200.0).show(|ui| { + for f in Filter::ALL { + if crate::right::menu_check(ui, app.ui.comments.filter == f, f.label(), "") { + app.ui.comments.filter = f; + ui.close(); + } + } + ui.separator(); + for s in Sort::ALL { + if crate::right::menu_check(ui, app.ui.comments.sort == s, s.label(), "") { + app.ui.comments.sort = s; + ui.close(); + } + } + ui.separator(); + if crate::right::menu_item(ui, "Mark all as read", "") { + app.run("comment.markRead", json!({})); + ui.close(); + } + }); + }); }); ui.add_space(8.0); ui.horizontal(|ui| { @@ -357,88 +1063,142 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { 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; - } - }); + // Another filter than the usual one says so, with a way back. + if app.ui.comments.filter != Filter::Open { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new(app.ui.comments.filter.label()).font(regular(11.0)).color(t.text_secondary)); + ui.add_space(4.0); + if ui.add(egui::Label::new(egui::RichText::new("Show open").font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() { + app.ui.comments.filter = Filter::Open; + } + }); + } 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))); + let search = app.ui.comments.search.clone(); + let q = Query { page: None, frame: None, filter: app.ui.comments.filter, sort: app.ui.comments.sort, text: &search }; + let list = cc::query(&doc, &q, &me); if list.is_empty() { + let (title, sub) = if doc.comments.iter().any(|c| doc.pages.contains(&c.page)) { + ("No matching comments", "Try another search or filter.") + } else { + ("No comments yet", "Click anywhere on the canvas to leave one.") + }; 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.label(egui::RichText::new(title).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)); + ui.label(egui::RichText::new(sub).font(regular(11.0)).color(t.text_tertiary)); }); return; } - let mut go: Option<(u64, figcraft_doc::NodeId)> = None; + let by_page = app.ui.comments.sort == Sort::Page; + let scroll = std::mem::take(&mut app.ui.comments.scroll_to_open); + let mut go: Option = None; + let mut last_page: Option = None; for c in list { + if by_page && last_page != Some(c.page) { + last_page = Some(c.page); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + let name = doc.node(c.page).map(|n| n.name.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(name).font(semibold(11.0)).color(t.text_secondary)); + }); + ui.add_space(2.0); + } 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 { + let body = ui.painter().layout(c.first.text.clone(), regular(12.0), t.text, w - 64.0); + let lines = body.rows.len().clamp(1, 3) as f32; + let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0 + 16.0 * lines), Sense::click()); + if app.ui.comments.open == Some(c.id) { ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row); + if scroll { + resp.scroll_to_me(Some(egui::Align::Center)); + } } 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 unread = c.is_unread(&me); + if unread { + ui.painter().circle_filled(pos2(r.min.x + 8.0, r.min.y + 22.0), 3.5, ACCENT); + } + avatar(ui.painter(), pos2(r.min.x + 28.0, r.min.y + 22.0), 12.0, &c.first.author); + let name_font = if unread { semibold(12.0) } else { regular(12.0) }; + ui.painter().text(pos2(r.min.x + 48.0, r.min.y + 22.0), Align2::LEFT_CENTER, &c.first.author, name_font, t.text); + ui.painter().text(pos2(r.max.x - 16.0, r.min.y + 22.0), Align2::RIGHT_CENTER, ago(c.last_activity()), regular(11.0), t.text_tertiary); 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); + // The thread's frame and page, replies, state. let mut meta = Vec::new(); + if let Some(f) = cc::frame_of(&doc, c).and_then(|f| doc.node(f)) { + meta.push(f.name.clone()); + } + if !by_page && c.page != page { + meta.push(doc.node(c.page).map(|n| n.name.clone()).unwrap_or_default()); + } 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); + let galley = ui.painter().layout_no_wrap(meta.join(" · "), regular(11.0), t.text_secondary); + let at = pos2(r.min.x + 48.0, r.max.y - 12.0 - galley.size().y / 2.0); + ui.painter().with_clip_rect(Rect::from_min_max(pos2(at.x, r.min.y), pos2(r.max.x - 16.0, r.max.y))).galley(at, galley, 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)); + go = Some(c.id); } } - 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; + if let Some(id) = go { + app.run("comment.open", json!({"id": id, "view": "center"})); + } +} + +// --------------------------------------------------------------------------------------------- +// The presenter. + +/// Pins on the presented `frame` (placed by the player's `place`, inside `clip`) while the +/// presenter shows comments, and the open thread's popover (read and reply). +pub fn presenter(app: &mut FigApp, ctx: &egui::Context, frame: NodeId, place: crate::present::Place, clip: Rect) { + if escape(app, ctx) { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let Some(fb) = doc.world_bounds(frame) else { return }; + let me = me(app); + let open = app.ui.comments.open; + // The frame as designed maps onto the frame as presented. + let to_screen = |(x, y): (f64, f64)| place.pt(place.page_min.0 + x - fb.x0, place.page_min.1 + y - fb.y0); + let q = Query { frame: Some(frame), filter: app.ui.comments.filter, ..Query::default() }; + let mut list = cc::query(&doc, &q, &me); + list.reverse(); + if let Some(c) = open.and_then(|o| doc.comment(o)).filter(|c| cc::frame_of(&doc, c) == Some(frame) && !list.iter().any(|x| x.id == c.id)) { + list.push(c); + } + let pins: Vec = list.iter().map(|c| Pin::of(c, &me, to_screen(c.position(&doc)))).filter(|p| clip.contains(p.at)).collect(); + let mut clicked = None; + for p in &pins { + let r = pin_rect(p.at); + egui::Area::new(Id::new(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + let (rr, resp) = ui.allocate_exact_size(r.size(), Sense::click()); + paint_pin(ui.painter(), rr, p, open == Some(p.id) || resp.hovered()); + if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + clicked = Some(p.id); + } + }); + } + if let Some(id) = clicked { + toggle(app, id); + } + match app.ui.comments.open.and_then(|o| pins.iter().find(|p| p.id == o)) { + Some(p) => thread(app, ctx, p.id, p.at, ctx.content_rect()), + // A thread on another frame (or none) closes. + None => app.ui.comments.close(), } } diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs new file mode 100644 index 0000000..bf30d5e --- /dev/null +++ b/crates/ui-egui/src/comments_tests.rs @@ -0,0 +1,227 @@ +//! Comments in the UI, driven headless through egui (the harness is `present_tests`'): crowded +//! pins cluster and zoom apart, your pins drag onto another frame, Shift+C hides the pins, the +//! arrows step through threads, `@` offers names, Escape closes, the presenter shows the presented +//! frame's comments; and the helpers on their own. + +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::comments::{cluster, insert_mention, mention_matches, mention_query}; +use crate::present_tests::{Ui, id}; + +fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { + s.execute("comment.add", &json!({"x": x, "y": y, "text": text, "author": author, "anchor": true})).unwrap()["id"].as_u64().unwrap() +} + +/// The middle of the pin whose spot is page point (`x`, `y`). +fn pin_at(u: &Ui, x: f64, y: f64) -> Pos2 { + u.app.to_screen(x, y) + vec2(14.0, -14.0) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// Click the pin whose spot is page point (`x`, `y`). +fn click_pin(u: &mut Ui, x: f64, y: f64) { + let p = pin_at(u, x, y); + click(u, p); +} + +fn key(u: &mut Ui, key: Key, modifiers: Modifiers) { + let k = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + u.frame(vec![k(true), k(false)]); + u.frame(vec![]); +} + +fn comments(u: &Ui) -> Value { + u.app.inspect()["comments"].clone() +} + +#[test] +fn mention_helpers() { + // The `@query` before the cursor. + assert_eq!(mention_query("Hi @Sa", 6), Some((3, 6))); + assert_eq!(mention_query("@", 1), Some((0, 1))); + assert_eq!(mention_query("Hi @Sam Lee", 11), Some((3, 11))); + assert_eq!(mention_query("Hi @Sam ", 8), None, "a space ends it"); + assert_eq!(mention_query("mail@x", 6), None, "inside a word"); + assert_eq!(mention_query("@Sa\nx", 5), None, "on another line"); + assert_eq!(mention_query("é @Sa", 4), Some((3, 5)), "byte range, char cursor"); + assert_eq!(mention_query("Hi @Sa", 99), Some((3, 6)), "a cursor past the end"); + let people: Vec = ["Ada Lovelace", "Sam", "Sam Lee", "You"].iter().map(|s| s.to_string()).collect(); + assert_eq!(mention_matches(&people, "sa", "You"), vec!["Sam", "Sam Lee"]); + assert_eq!(mention_matches(&people, "love", "You"), vec!["Ada Lovelace"], "a later word"); + assert_eq!(mention_matches(&people, "", "You").len(), 3, "everyone but you"); + // Inserting replaces the query and leaves the cursor after a space. + let mut t = String::from("Hi @Sa, ok"); + let at = insert_mention(&mut t, (3, 6), "Sam Lee"); + assert_eq!(t, "Hi @Sam Lee , ok"); + assert_eq!(at, 12); + let mut t = String::from("é"); + assert_eq!(insert_mention(&mut t, (1, 2), "X"), 1, "not on a char boundary: unchanged"); + assert_eq!(insert_mention(&mut t, (5, 9), "X"), 1, "out of range: unchanged"); + assert_eq!(t, "é"); +} + +#[test] +fn clusters_group_nearby_points() { + let pts = [pos2(0.0, 0.0), pos2(10.0, 0.0), pos2(100.0, 0.0), pos2(5.0, 12.0), pos2(300.0, 300.0)]; + assert_eq!(cluster(&pts, 24.0), vec![vec![0, 1, 3], vec![2], vec![4]]); + assert!(cluster(&[], 24.0).is_empty()); + assert_eq!(cluster(&pts, 0.0).len(), 5); +} + +#[test] +fn crowded_pins_cluster_and_a_click_zooms_them_apart() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})).unwrap(); + for (i, x) in [100.0, 104.0, 108.0].into_iter().enumerate() { + add(&mut s, x, 100.0, &format!("Note {i}"), "Sam"); + } + let mut u = Ui::new(s); + u.run("view.set", json!({"zoom": 1, "cx": 100, "cy": 100})); + // Three pins a few points apart: one cluster. + let zoom = u.app.session.ds().unwrap().view.zoom; + let spot = pin_at(&u, 104.0, 100.0); + click(&mut u, spot); + let after = u.app.session.ds().unwrap().view.zoom; + assert!(after >= zoom * 2.0, "{zoom} -> {after}"); + assert_eq!(comments(&u)["open"], Value::Null, "a cluster click opens no thread"); + // Zoomed in, they stand apart: a click opens one. + u.run("view.set", json!({"zoom": 8, "cx": 104, "cy": 100})); + click_pin(&mut u, 108.0, 100.0); + assert_eq!(comments(&u)["open"], 3); +} + +#[test] +fn your_pins_drag_onto_another_frame() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200})).unwrap(); + let b = id(&s.execute("node.create", &json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200})).unwrap()); + let mine = add(&mut s, 50.0, 50.0, "Mine", "You"); + let theirs = add(&mut s, 50.0, 150.0, "Theirs", "Sam"); + let mut u = Ui::new(s); + u.run("view.set", json!({"zoom": 1, "cx": 250, "cy": 100})); + let drag = |u: &mut Ui, from: Pos2, to: Pos2| { + u.button(from, true); + for k in 1..=4 { + let p = from + (to - from) * (k as f32 / 4.0); + u.frame(vec![Event::PointerMoved(p)]); + } + u.button(to, false); + u.frame(vec![]); + }; + let (from, to) = (pin_at(&u, 50.0, 50.0), pin_at(&u, 350.0, 60.0)); + drag(&mut u, from, to); + let c = u.app.session.doc().unwrap().comment(mine).unwrap().clone(); + assert_eq!(c.anchor, Some(b)); + let (x, y) = c.position(u.app.session.doc().unwrap()); + assert!((x - 350.0).abs() < 0.01 && (y - 60.0).abs() < 0.01, "{x}, {y}"); + // Someone else's pin doesn't move: the press opens it instead. + let (from, to) = (pin_at(&u, 50.0, 150.0), pin_at(&u, 350.0, 160.0)); + drag(&mut u, from, to); + assert_eq!(u.app.session.doc().unwrap().comment(theirs).unwrap().position(u.app.session.doc().unwrap()), (50.0, 150.0)); + assert_eq!(comments(&u)["open"], theirs); +} + +#[test] +fn shift_c_hides_the_pins_and_escape_closes_a_thread() { + let mut s = Session::new(); + let c = add(&mut s, 100.0, 100.0, "Hello", "Sam"); + let mut u = Ui::new(s); + u.run("view.set", json!({"zoom": 1, "cx": 100, "cy": 100})); + assert!(u.app.session.ds().unwrap().view.comments); + key(&mut u, Key::C, Modifiers::SHIFT); + assert!(!u.app.session.ds().unwrap().view.comments); + // Hidden pins don't take clicks. + click_pin(&mut u, 100.0, 100.0); + assert_eq!(comments(&u)["open"], Value::Null); + key(&mut u, Key::C, Modifiers::SHIFT); + click_pin(&mut u, 100.0, 100.0); + assert_eq!(comments(&u)["open"], c); + // Opening it read it. + assert!(!u.app.session.doc().unwrap().comment(c).unwrap().is_unread("You")); + key(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!(comments(&u)["open"], Value::Null); +} + +#[test] +fn arrows_step_through_the_threads_with_the_comment_tool() { + let mut s = Session::new(); + let a = add(&mut s, 0.0, 0.0, "First", "Sam"); + let b = add(&mut s, 900.0, 900.0, "Second", "Sam"); + if let Some(c) = s.doc_mut().unwrap().comment_mut(a) { + c.first.created += 10; + } + let mut u = Ui::new(s); + u.run("tool.comment", json!({})); + // Newest first: `a`, then `b`; the view follows. + key(&mut u, Key::ArrowDown, Modifiers::NONE); + assert_eq!(comments(&u)["open"], a); + key(&mut u, Key::ArrowDown, Modifiers::NONE); + assert_eq!(comments(&u)["open"], b); + let v = &u.app.session.ds().unwrap().view; + assert_eq!((v.cx, v.cy), (900.0, 900.0)); + key(&mut u, Key::ArrowUp, Modifiers::NONE); + assert_eq!(comments(&u)["open"], a); + // Typing a reply keeps the arrows in the field. + u.frame(vec![Event::Text("x".into())]); + key(&mut u, Key::ArrowDown, Modifiers::NONE); + assert_eq!(comments(&u)["open"], a); +} + +#[test] +fn typing_at_offers_names_and_enter_inserts_one() { + let mut s = Session::new(); + let c = add(&mut s, 100.0, 100.0, "Who can check?", "Sam Lee"); + s.execute("comment.reply", &json!({"id": c, "text": "Me", "author": "Ada"})).unwrap(); + let mut u = Ui::new(s); + u.run("view.set", json!({"zoom": 1, "cx": 100, "cy": 100})); + u.run("comment.open", json!({"id": c, "view": "keep"})); + u.frames(2, Vec::new); + u.frame(vec![Event::Text("Thanks @sa".into())]); + u.frame(vec![]); + assert_eq!(comments(&u)["mentions"], json!(["Sam Lee"])); + key(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(u.app.ui.comments.reply, "Thanks @Sam Lee "); + assert_eq!(comments(&u)["mentions"], json!([])); + // Enter now posts the reply, the mention included. + u.frame(vec![Event::Text("!".into())]); + key(&mut u, Key::Enter, Modifiers::NONE); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.comment(c).unwrap().replies.last().unwrap().text, "Thanks @Sam Lee !"); + assert!(doc.comment(c).unwrap().involves("Sam Lee")); + assert!(u.app.ui.comments.reply.is_empty()); +} + +#[test] +fn the_presenter_shows_the_frames_comments() { + let mut s = Session::new(); + let f = id(&s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + let other = id(&s.execute("node.create", &json!({"type": "frame", "x": 600, "y": 0, "width": 400, "height": 300})).unwrap()); + let c = add(&mut s, 100.0, 100.0, "On the frame", "Sam"); + add(&mut s, 700.0, 100.0, "Elsewhere", "Sam"); + let mut u = Ui::new(s); + u.run("proto.present", json!({"start": f})); + assert!(u.app.ui.present.is_some()); + // Off by default; Shift+C shows them. + assert_eq!(u.app.inspect()["presenter"]["comments"], false); + key(&mut u, Key::C, Modifiers::SHIFT); + assert_eq!(u.app.inspect()["presenter"]["comments"], true); + // The frame is presented at 100% (Fit never scales up), centred under the bar. + let screen = egui::Rect::from_min_size(pos2(0.0, 40.0), vec2(1440.0, 860.0)); + let frame_min = screen.center() - vec2(200.0, 150.0); + let pin = frame_min + vec2(100.0, 100.0) + vec2(14.0, -14.0); + click(&mut u, pin); + assert_eq!(comments(&u)["open"], c); + assert!(u.app.ui.present.is_some(), "the click didn't reach the prototype or close it"); + // Escape closes the thread first, then the presenter. + key(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!(comments(&u)["open"], Value::Null); + assert!(u.app.ui.present.is_some()); + let _ = other; +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c536862..3ed8e94 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,7 @@ //! - `app.quit {}` use egui::Context; +use figcraft_engine::cmd::comment::{Filter, Sort}; use figcraft_engine::{PointerEvent, PointerKind}; use serde_json::{Value, json}; @@ -142,6 +143,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if app.session.tool_key(key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())? { return Ok(json!({"ok": true})); } + // The Comment tool's arrows step through the comment threads. + if app.session.tool.tool == figcraft_engine::ToolId::Comment && matches!(spec.as_str(), "ArrowUp" | "ArrowDown") { + let r = crate::comments::step(app, ctx, if spec == "ArrowUp" { -1 } else { 1 }); + return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())})); + } match crate::shortcuts::parse(&spec) { Some(want) => { let id = figcraft_engine::catalog::MENU @@ -265,8 +271,10 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul } // 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; + match v.as_u64() { + Some(id) => app.ui.comments.show(id), + None => app.ui.comments.close(), + } } if let Some(v) = p.get("commentDraft") { match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) { @@ -275,7 +283,39 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul } } if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) { - app.ui.comments.show_resolved = v; + app.ui.comments.filter = if v { Filter::All } else { Filter::Open }; + } + if let Some(v) = p.get("commentFilter").and_then(Value::as_str) { + app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?; + } + if let Some(v) = p.get("commentSort").and_then(Value::as_str) { + app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?; + } + if let Some(v) = p.get("commentSearch").and_then(Value::as_str) { + app.ui.comments.search = v.to_string(); + } + // The open thread's reply field, with the cursor at its end (an `@` there offers names). + if let Some(v) = p.get("commentReply").and_then(Value::as_str) { + app.ui.comments.reply = v.to_string(); + if let Some(id) = app.ui.comments.open { + crate::comments::focus_reply(ctx, id, v.chars().count()); + } + } + // Edit a message of the open thread: its index (0: the first message); null stops. + if let Some(v) = p.get("commentEdit") { + app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) { + (Some(id), Some(i)) => { + let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone()); + Some((id, i as usize, text.ok_or("commentEdit: no such message")?)) + } + _ => None, + }; + } + if let Some(v) = p.get("presentComments").and_then(Value::as_bool) { + app.ui.present_view.comments = v; + if let Some(pres) = app.ui.present.as_mut() { + pres.view.comments = v; + } } // Colour picker on the selection's paint `{fills?, index?}`; null closes it. if let Some(v) = p.get("colorPicker") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b579249..95fce35 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -128,6 +128,13 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.run(c, json!({})); } ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Your name in comments"); + if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, "You") { + app.run("pref.userName", json!({"name": name})); + } + }); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Nudge amount"); let mut small = prefs.nudge_small; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f34a093..123c06a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -162,6 +162,8 @@ icons! { ViewList, /// Show as a grid of swatches. ViewGrid, + /// Add an emoji reaction: a smiling face. + Reaction, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -947,6 +949,12 @@ fn draw(p: &P, icon: Icon) { p.line(7.0, y + 1.5, 14.0, y + 1.5); } } + Reaction => { + p.circle(8.0, 8.0, 5.5); + p.dot(6.0, 6.5, 0.9); + p.dot(10.0, 6.5, 0.9); + p.arc(8.0, 8.5, 3.0, 0.6, PI - 0.6); + } ViewGrid => { for (x, y) in [(2.5, 2.5), (9.5, 2.5), (2.5, 9.5), (9.5, 9.5)] { p.rect(x, y, x + 4.0, y + 4.0, 1.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 009decc..7b3f48d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,8 @@ pub mod vector_panel; pub mod versions; pub mod widgets; +#[cfg(test)] +mod comments_tests; #[cfg(test)] mod layers_drag_tests; #[cfg(test)] @@ -412,6 +414,11 @@ impl FigApp { "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), + "comment.open" => { + if let Some(id) = r.data.get("id").and_then(Value::as_u64) { + self.ui.comments.show(id); + } + } "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); @@ -573,8 +580,10 @@ impl FigApp { toolbar::show(self, ui); } }); - // Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor. + // Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor, and + // the arrows step through comment threads with the Comment tool. present::show_pane(self, &ctx); + comments::keys(self, &ctx); shortcuts::handle(self, &ctx); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); @@ -623,7 +632,7 @@ impl FigApp { "dark": self.ui.dark, "presenting": self.ui.present.is_some(), "presenter": self.ui.present.as_ref().map(present::Presenter::inspect), - "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame}, + "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), @@ -633,7 +642,15 @@ impl FigApp { "stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()), "stylePickerView": if self.ui.style_grid { "grid" } else { "list" }, "textPreview": figcraft_engine::textpreview::active(&self.session), - "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}, + "comments": { + "open": self.ui.comments.open, + "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), + "filter": self.ui.comments.filter.id(), + "sort": self.ui.comments.sort.id(), + "search": self.ui.comments.search, + "editing": self.ui.comments.editing.as_ref().map(|e| [e.0, e.1 as u64]), + "mentions": self.ui.comments.mention.names(), + }, "frameMs": self.ui.frame_ms, "renderMs": self.ui.render_ms, "canvas": { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8fc6b62..33333e7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -45,7 +45,10 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { } // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { - if text.trim_start().starts_with(" Self { - Self { scale: PresentScale::Fit, device_frame: true } + Self { scale: PresentScale::Fit, device_frame: true, comments: false } } } @@ -264,6 +266,8 @@ pub struct Presenter { fling: Option, /// The wheel's last target and its speed (units/s), while it turns. wheel: Option<(Scroller, (f64, f64))>, + /// The frame shown last, where it was placed and what it could draw into (comment pins go there). + shown: Option<(NodeId, Place, Rect)>, } impl Presenter { @@ -314,6 +318,7 @@ impl Presenter { zoom: 1.0, fling: None, wheel: None, + shown: None, } } @@ -516,6 +521,7 @@ impl Presenter { "viewport": doc.and_then(|d| viewport(d, self.current)).map(|v| [v.0, v.1]), "scale": self.view.scale.id(), "deviceFrame": self.view.device_frame, + "comments": self.view.comments, "deviceColor": doc.and_then(|d| page_settings(d, self.current).device_color()).map(|c| c.name), "responsive": self.fit.map(|f| [f.0, f.1]), "frameSize": doc.and_then(|d| d.node(self.current)).map(|n| [n.width, n.height]), @@ -1451,6 +1457,8 @@ enum BarAction { Options, /// Turn responsive presentation on or off (saved in the file: `proto.settings`). Responsive(bool), + /// Show or hide the comments on the presented frame. + Comments, } /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. @@ -1462,9 +1470,24 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { pres.sync(&ds.doc, now); } let mut action = None; + // Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments. + let typing = ctx.egui_wants_keyboard_input(); + if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) { + action = Some(BarAction::Comments); + } egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { - action = player(&mut pres, ui, ui.max_rect(), now, FULL); + action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action); }); + if action == Some(BarAction::Comments) { + pres.view.comments = !pres.view.comments; + } + if pres.view.comments + && let Some((frame, place, clip)) = pres.shown + { + crate::comments::presenter(app, &ctx, frame, place, clip); + } else if app.ui.comments.open.is_some() { + app.ui.comments.close(); + } for u in pres.urls.drain(..) { app.services.open_url(&u); } @@ -1724,6 +1747,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now); } let Some(l) = layout(p, area, chrome) else { return action }; + p.shown = Some((p.current, l.place, l.clip.intersect(area))); let painter = ui.painter_at(area); let body = l.device.zip(l.color); if l.device.is_some() { @@ -1782,7 +1806,12 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let buttons: &[(BarAction, &str, Icon)] = if chrome.pane { &[(BarAction::Close, "Close preview", Icon::Close), (BarAction::Present, "Present", Icon::Play), (BarAction::Restart, "Restart", Icon::Reset)] } else { - &[(BarAction::Close, "Close", Icon::Close), (BarAction::Restart, "Restart", Icon::Reset), (BarAction::Options, "Options", Icon::Dots)] + &[ + (BarAction::Close, "Close", Icon::Close), + (BarAction::Restart, "Restart", Icon::Reset), + (BarAction::Options, "Options", Icon::Dots), + (BarAction::Comments, "Comments", Icon::Comment), + ] }; let mut x = bar.max.x - if chrome.pane { 6.0 } else { 8.0 }; for (act, label, icon) in buttons { @@ -1793,9 +1822,15 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) if chrome.pane { resp = resp.on_hover_text(*label); } - if resp.hovered() { + let on = *act == BarAction::Comments && p.view.comments; + if on { + ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT); + } else if resp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), hover); } + if *act == BarAction::Comments { + resp = resp.on_hover_text("Show comments (Shift+C)"); + } if chrome.pane { icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon); } else { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 696144d..7a284ca 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -89,8 +89,10 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d ### `ui.key {key, shift?, alt?, cmd?, ctrl?}` A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` `Shift+E` `Shift+W` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key, -`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). While text is being -edited, keys go to the text editor. Returns `{"command": id}` for shortcuts. +`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). With the Comment +tool, `ArrowUp`/`ArrowDown` open the previous/next thread of the Comments panel's list instead +(returns `{"comment": id}`). While text is being edited, keys go to the text editor. Returns +`{"command": id}` for shortcuts. ### `ui.type {text}` @@ -117,7 +119,7 @@ and its release (or a double-click's two clicks) as separate calls. Returns `{qu Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `iconGallery` (bool: @@ -134,8 +136,15 @@ profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid|text` picker for the selection, `text` being the text style browser of the Typography section; `null` closes it) with `stylePickerEdit` (a style id or name: open its editor), `stylePickerView` (`list|grid`), `protoEditor` (`[layer id, interaction index]`: open that interaction's editor in the -Prototype panel; `null` closes it). Returns `ui.inspect` (which reports `stylePicker` and -`stylePickerView`). +Prototype panel; `null` closes it). Comments: `commentOpen` (a thread id opens its popover, `null` +closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter` +(`open|resolved|mine|unread|all`: what the Comments panel and the pins show), `commentSort` +(`date|page`), `commentSearch` (the panel's search text), `commentReply` (the open thread's reply +text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a +message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool: +the presenter shows the presented frame's comments, also Shift+C there). Returns `ui.inspect` +(which reports `stylePicker`, `stylePickerView`, and `comments`: `open`, `draft`, `filter`, `sort`, +`search`, `editing` and the `mentions` the picker offers). ### `ui.screenshot {path}` diff --git a/docs/mcp.md b/docs/mcp.md index 2f5933d..9615df7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -324,6 +324,30 @@ visibility, text, font properties, single solid fills and strokes) and `explicit link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a frame back to Auto, inheriting its parent's mode). +### Comments + +Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or +`true` for the innermost frame under the point) so they move with it; when the layer is deleted the +pin stays where it was, on the nearest surviving parent. Driven with `run_command`: +`comment.add {x, y, text, page?, anchor?}` (returns `{id, link}`), `comment.reply {id, text}`, +`comment.edit {id, text, reply?}` and `comment.delete {id, reply?}` (a message is the thread's +first one, or `reply`: an index into its replies; deleting the first message deletes the thread), +`comment.react {id, emoji, reply?, on?}` (toggles your reaction), `comment.resolve {id, resolved?}`, +`comment.move {id, x, y, anchor?}` (pins it to the frame under the new point unless `anchor: false`). +Everyone acts as the name from Preferences (`pref.userName {name}`, default "You") unless `author` +says otherwise; only a message's author can edit or delete it. `@Name` in a message mentions +someone (`comment.people` lists the names used in the file). + +Read state is kept per name: a new message is unread for everyone but its author; +`comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or +unread (not an undo step). `comment.list {page? | "all", filter? (all|open|resolved|mine|unread), +sort? (date|page), query?, frame?}` returns the threads with their position, `frame`/`frameName`, +`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Links are +`figcraft://comment/`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes +to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move) +and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous +thread of a list the same way. `comment.zoom {ids}` zooms in on a group of pins. + ### Version history Named snapshots of the document, kept in the `.figcraft` file (they persist once the file is saved; diff --git a/docs/parity.md b/docs/parity.md index 5394465..4ed0a4c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (398 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (407 commands in the engine). **197 / 202 menu items (97.5%)**