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/<id>; 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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 13:36:42 +07:00 committed by GitHub
parent 009314662c
commit 50517a6431
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2334 additions and 292 deletions

View File

@ -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/<id>`: 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

View File

@ -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<String>,
}
/// 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<Reaction>,
}
/// 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<String>, text: impl Into<String>, 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>) -> 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<NodeId>,
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<Reply>,
pub replies: Vec<Message>,
/// 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<Vec<String>>,
}
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<Item = &Message> {
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<Range<usize>> {
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<String> {
let mut out: Vec<String> = 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
}
}

View File

@ -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/<id>`.
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<u64> {
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> {
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> {
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<NodeId>,
/// Only comments on this top-level frame.
pub frame: Option<NodeId>,
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<NodeId> {
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<NodeId> {
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<u64> {
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<usize> {
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<String> {
@ -30,85 +256,212 @@ fn text(cmd: &str, p: &Value) -> crate::Result<String> {
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<NodeId> {
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<NodeId>, 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::<Vec<_>>(),
"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::<Vec<_>>(),
})
}
/// 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::<Vec<_>>(),
});
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<Value> {
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<NodeId>, 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<CommandSpec> {
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<CommandSpec> {
}
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<u64> =
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<u64> = get(p, "ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
let pts: Vec<geom::Point> =
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<Value> = doc
.comments
.iter()
.filter(|c| doc.pages.contains(&c.page))
.filter(|c| page.is_none_or(|pg| c.page == pg))
.filter(|c| include_resolved || !c.resolved)
.map(|c| comment_json(doc, c))
.collect();
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<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
Ok(json!({"comments": list}))
}
),

View File

@ -189,6 +189,12 @@ pub fn specs() -> Vec<CommandSpec> {
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")),

View File

@ -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<u64> {
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<_>>(), 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<u64> {
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<u64>, 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());
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<String> = ["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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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("<svg") || text.trim_start().starts_with("<?xml") {
// A comment's link opens its thread.
if text.trim().starts_with("figcraft://comment/") {
app.run("comment.open", json!({"link": text.trim()}));
} else if text.trim_start().starts_with("<svg") || text.trim_start().starts_with("<?xml") {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
app.run("file.import", json!({"data": b64, "name": "pasted.svg", "x": cx, "y": cy}));

View File

@ -177,11 +177,13 @@ pub struct PresentView {
pub scale: PresentScale,
/// Draw the device's body around its screen.
pub device_frame: bool,
/// Show the comments on the presented frame (Shift+C).
pub comments: bool,
}
impl Default for PresentView {
fn default() -> 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<Fling>,
/// 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 {

View File

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

View File

@ -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/<id>`: `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;

View File

@ -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%)**