Merge main into #1546

This commit is contained in:
bflatastic
2026-10-29 23:25:32 +07:00
29 changed files with 1088 additions and 142 deletions
+28 -8
View File
@@ -7,7 +7,7 @@
use std::fmt::Write as _;
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
@@ -326,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
}
}
fn message_json(m: &Message) -> Value {
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
/// as the thread view highlights them.
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))});
if !named.contains(&v) {
named.push(v);
}
}
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<_>>(),
"mentions": named,
})
}
/// 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 {
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
let (x, y) = c.position(doc);
let frame = frame_of(doc, c);
let mut v = message_json(&c.first);
let mut v = message_json(&c.first, people, user);
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<_>>(),
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
});
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
m.extend(e);
@@ -410,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
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))
let people = doc.comment_people();
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
}
/// A list's page (None: every page), filter, sort and search text from params.
@@ -796,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
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();
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();
Ok(json!({"comments": list}))
}
),
+2 -2
View File
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let mut slot = crate::drop::slot(doc, page, pt);
// Never inside its own main component (or a copy of it).
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
// Never inside its own main component, or one it contains (directly or through others).
if crate::components::would_contain_itself(doc, c, slot.parent) {
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
}
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
+2 -2
View File
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
/// (the layers' overrides follow by name when the instance is rebuilt).
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
if doc.is_ancestor(c, i) {
return Err("an instance can't show the component it is inside".into());
if crate::components::would_contain_itself(doc, c, i) {
return Err(crate::components::contains_itself_error(doc, c));
}
let defaults = doc.node(c).map(default_props).unwrap_or_default();
match doc.get_mut(i).map(|n| &mut n.kind) {
+13
View File
@@ -70,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
/// Components containing each other's instances are broken apart, with a note.
pub(super) fn load_with(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
let notes = crate::components::break_component_cycles(&mut loaded.doc);
loaded.issues.extend(notes);
Ok((loaded, rest))
}
fn load_any(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
match ext(name_hint).as_str() {
"svg" => {