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:
parent
009314662c
commit
50517a6431
14
ROADMAP.md
14
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/<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
24
docs/mcp.md
24
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/<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;
|
||||
|
|
|
|||
|
|
@ -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%)**
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue