mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
Merge remote-tracking branch 'origin/main' into feat/996-inspect-fields
# Conflicts: # docs/roadmap.md
This commit is contained in:
@@ -332,17 +332,79 @@ fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
|
||||
people.iter().map(String::as_str).chain([user]).collect()
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
for (r, who) in mentions(&m.text, people) {
|
||||
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
|
||||
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
|
||||
/// The names `text` @mentions by the rule the thread view highlights them with
|
||||
/// ([`figcraft_doc::mentions`]), and whether each is one of `people` (else it is the letters and
|
||||
/// digits after the `@`).
|
||||
fn mentioned<'a>(text: &'a str, people: &[&'a str]) -> impl Iterator<Item = (&'a str, bool)> {
|
||||
mentions(text, people).into_iter().map(move |(r, who)| (who.or_else(|| text.get(r.start + 1..r.end)).unwrap_or_default(), who.is_some()))
|
||||
}
|
||||
|
||||
/// Whether a message of `c` @mentions `name` (any case), as `comment.list` reports mentions;
|
||||
/// `name` resolves like one of `people`, so a name nobody in the file has can be several words.
|
||||
fn thread_mentions(c: &Comment, people: &[&str], name: &str) -> bool {
|
||||
let people: Vec<&str> = people.iter().copied().chain([name]).collect();
|
||||
c.messages().any(|m| mentioned(&m.text, &people).any(|(n, _)| n.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// A list's `mentions` filter: a name (a leading `@` is fine), or none.
|
||||
fn mentions_param(cmd: &str, p: &Value) -> crate::Result<Option<String>> {
|
||||
match get(p, "mentions") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) if !s.trim().trim_start_matches('@').trim().is_empty() => {
|
||||
Ok(Some(s.trim().trim_start_matches('@').trim().to_string()))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`mentions` must be a name")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads a list's params pick (`list_params`, `frame`, `mentions`), in list order, as
|
||||
/// `who` sees them; mentions resolve against `people` (`mentionable`).
|
||||
fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str]) -> crate::Result<Vec<&'s Comment>> {
|
||||
let (page, filter, sort, text) = list_params(cmd, s, p)?;
|
||||
let want = mentions_param(cmd, p)?;
|
||||
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
|
||||
Ok(query(s.doc()?, &q, who).into_iter().filter(|c| want.as_deref().is_none_or(|w| thread_mentions(c, people, w))).collect())
|
||||
}
|
||||
|
||||
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
|
||||
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
|
||||
for (name, known) in mentioned(text, people) {
|
||||
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
|
||||
if !named.contains(&v) {
|
||||
named.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most mentions `comment.prompt` lists for one thread (its list stays bounded).
|
||||
const PROMPT_MAX_MENTIONS: usize = 32;
|
||||
|
||||
/// `comment.people`: everyone who wrote or reacted in a comment, and every name a message
|
||||
/// @mentions (as `comment.list` reports it), once each in any case, sorted as
|
||||
/// `Document::comment_people` sorts.
|
||||
fn people_and_mentioned(doc: &Document, you: &str) -> Vec<String> {
|
||||
let people = doc.comment_people();
|
||||
let resolve = mentionable(&people, you);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let names = people
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.chain(doc.comments.iter().flat_map(Comment::messages).flat_map(|m| mentioned(&m.text, &resolve).map(|(n, _)| n)));
|
||||
for name in names {
|
||||
if seen.insert(name.to_lowercase()) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
out
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
push_mentions(&mut named, &m.text, people, user);
|
||||
json!({
|
||||
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
|
||||
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
|
||||
@@ -371,9 +433,23 @@ pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) ->
|
||||
|
||||
/// Bounded metadata for `comment.prompt`'s `comments` list. Full messages and replies stay in
|
||||
/// `comment.list`; a prompt over thousands of threads must not repeat all of them here.
|
||||
fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
fn comment_brief_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
|
||||
let (x, y) = c.position(doc);
|
||||
let frame = frame_of(doc, c);
|
||||
// The names the whole thread @mentions, as `comment.list` reports them per message.
|
||||
let mut named = Vec::new();
|
||||
for m in c.messages() {
|
||||
push_mentions(&mut named, &m.text, people, user);
|
||||
if named.len() >= PROMPT_MAX_MENTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
named.truncate(PROMPT_MAX_MENTIONS);
|
||||
for v in &mut named {
|
||||
if let Some(name) = v.get("name").and_then(Value::as_str).map(|n| clip_chars(n, 80)) {
|
||||
v["name"] = json!(name);
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"id": c.id, "page": c.page,
|
||||
"pageName": doc.node(c.page).map(|n| clip_chars(&n.name, 80)),
|
||||
@@ -381,7 +457,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
|
||||
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
|
||||
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
|
||||
"replies": c.replies.len(),
|
||||
"replies": c.replies.len(), "mentions": named,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -760,15 +836,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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?, frame?, resolved? (the list, as comment.list), author?}",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved?, mentions? (the list, as comment.list), author?} → the thread, as comment.list lists it",
|
||||
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 people = s.doc()?.comment_people();
|
||||
let ids: Vec<u64> = listed("comment.next", s, p, &who, &mentionable(&people, &who))?.iter().map(|c| c.id).collect();
|
||||
let at = id(p, "id").and_then(|i| ids.iter().position(|x| *x == i.0));
|
||||
let next = match at {
|
||||
Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)),
|
||||
@@ -801,22 +875,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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": acting_name(s)}))
|
||||
}),
|
||||
spec!(
|
||||
"comment.people",
|
||||
"People in comments",
|
||||
"{} → {people (everyone who wrote or reacted in a comment, and every name a message @mentions), you}",
|
||||
false,
|
||||
|s, _| {
|
||||
let you = acting_name(s);
|
||||
Ok(json!({"people": people_and_mentioned(s.doc()?, &you), "you": you}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"comment.list",
|
||||
"List comments",
|
||||
"{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)}",
|
||||
"{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), mentions? (a name, any case: only threads where a message @mentions it, as each message's `mentions` reports)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let doc = s.doc()?;
|
||||
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 people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
let list: Vec<Value> =
|
||||
listed("comment.list", s, p, &who, &people)?.into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"comments": list}))
|
||||
}
|
||||
),
|
||||
@@ -877,7 +957,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let count = selected.len();
|
||||
let (text, truncated) = prompt_text(doc, path.as_deref(), &selected);
|
||||
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who)).collect();
|
||||
let people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"count": count, "truncated": truncated, "text": text, "comments": comments}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -33,7 +33,7 @@ use load::font_report;
|
||||
#[cfg(test)]
|
||||
pub(crate) use load::{installed_family, sketch_line};
|
||||
pub use load::{load, load_bytes, load_path};
|
||||
pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
|
||||
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
@@ -181,11 +181,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
|
||||
"{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
|
||||
false,
|
||||
|s, p| {
|
||||
let path = req_string("file.recover", p, "path")?;
|
||||
s.docs.push(recovered(&path)?);
|
||||
let ds = match string(p, "data") {
|
||||
Some(d) => recovered_from(
|
||||
&base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?,
|
||||
)?,
|
||||
None => recovered(&req_string("file.recover", p, "path")?)?,
|
||||
};
|
||||
s.docs.push(ds);
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
|
||||
recovered_from(&read(path)?)
|
||||
}
|
||||
|
||||
/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its
|
||||
/// copies in the page's storage, not in files).
|
||||
pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
|
||||
let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
|
||||
}
|
||||
|
||||
@@ -693,6 +699,10 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?,
|
||||
// Capped like the sibling file; the browser has no file system, which `read` says.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Some(Value::String(path)) => parse(&read_capped(path, MAX_VARIABLES_FILE)?)?,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Some(Value::String(path)) => parse(&read(path)?)?,
|
||||
Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")),
|
||||
};
|
||||
|
||||
@@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" | "sketch" | "penpot" => {
|
||||
"figcraft" | "json" | "fig" | "jam" | "sketch" | "penpot" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
|
||||
@@ -475,13 +475,7 @@ fn collect_flatten_parts(
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
let Some(part) = outline_part(doc, node)? else { return Ok(()) };
|
||||
part
|
||||
}
|
||||
};
|
||||
@@ -490,6 +484,25 @@ fn collect_flatten_parts(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a layer other than a vector or a container flattens into: its outline (`None`: it has
|
||||
/// none). A boolean group has no outline only past the path operations' caps; flattening it would
|
||||
/// drop it, so that is refused.
|
||||
fn outline_part(doc: &Document, node: &Node) -> Result<Option<VectorNetwork>> {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else {
|
||||
if matches!(node.kind, NodeKind::BooleanOp { .. }) {
|
||||
return Err(bad("vector.flatten", figcraft_pathops::PathOpsError::TooComplex.to_string()));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
Ok(Some(part))
|
||||
}
|
||||
|
||||
fn append_flatten_part(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path to a regular file of at most 256 MiB; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -201,8 +201,9 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
}
|
||||
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only, and
|
||||
// commenting with a comment link.
|
||||
if let Some(why) = s.active_read_only().filter(|why| !crate::live::tool_left(why, t)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
}
|
||||
s.set_tool(t);
|
||||
@@ -369,6 +370,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!(
|
||||
"pref.scrollWheelZoom",
|
||||
"Scroll wheel zoom",
|
||||
"{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}",
|
||||
false,
|
||||
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
|
||||
),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
|
||||
Reference in New Issue
Block a user