mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
1056 lines
39 KiB
Rust
1056 lines
39 KiB
Rust
//! The FigCraft document model.
|
|
//!
|
|
//! A [`Document`] holds pages; each page is the root of a tree of [`Node`]s (frames, groups, shapes,
|
|
//! text, components, instances…). Nodes are stored by id in a map of `Arc`s, so cloning a document
|
|
//! (an undo snapshot) only copies pointers, and editing one node copies only that node.
|
|
//!
|
|
//! Everything here is pure data plus queries and structural edits; commands, history and rendering
|
|
//! live in higher crates.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod colors;
|
|
pub mod comments;
|
|
pub mod geometry;
|
|
pub mod handoff;
|
|
pub mod ids;
|
|
pub mod library;
|
|
pub mod node;
|
|
pub mod props;
|
|
pub mod proto;
|
|
pub mod styles;
|
|
pub mod text;
|
|
pub mod vector;
|
|
pub mod vector_faces;
|
|
|
|
pub use colors::*;
|
|
pub use comments::*;
|
|
pub use figcraft_color as color;
|
|
pub use figcraft_geom as geom;
|
|
pub use handoff::*;
|
|
pub use library::*;
|
|
pub use node::*;
|
|
pub use props::*;
|
|
pub use styles::*;
|
|
pub use text::*;
|
|
pub use vector::*;
|
|
pub use vector_faces::Face;
|
|
|
|
use figcraft_color::{Color, ColorProfile};
|
|
use figcraft_geom::{Affine, Point, Rect};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
/// Stable node identifier, unique within a document.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct NodeId(pub u64);
|
|
|
|
impl std::fmt::Display for NodeId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for NodeId {
|
|
type Err = std::num::ParseIntError;
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
// Accept "12" and Figma-style "0:12".
|
|
let t = s.trim();
|
|
let t = t.rsplit(':').next().unwrap_or(t);
|
|
t.parse::<u64>().map(NodeId)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, PartialEq)]
|
|
pub enum DocError {
|
|
#[error("no such node {0}")]
|
|
NoNode(NodeId),
|
|
#[error("{0} can't contain children")]
|
|
NotContainer(NodeId),
|
|
#[error("moving {0} into {1} would create a cycle")]
|
|
Cycle(NodeId, NodeId),
|
|
#[error("{0}")]
|
|
Invalid(String),
|
|
}
|
|
|
|
pub type DocResult<T> = Result<T, DocError>;
|
|
|
|
/// The set of touched node ids. Never part of equality or serialisation.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Dirty(pub std::collections::BTreeSet<NodeId>);
|
|
|
|
impl PartialEq for Dirty {
|
|
fn eq(&self, _: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Hands out node ids. A single-user document counts up through the whole id range; in a live
|
|
/// collaboration session each participant allocates in its own space, `client << 32 | counter`, so
|
|
/// ids made at the same time on different machines never collide. Saved as the file's `next_id`;
|
|
/// the client id is never saved. Never part of equality: two documents with the same content are
|
|
/// equal however far their counters went.
|
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
struct IdAlloc {
|
|
/// The next id to hand out.
|
|
next: u64,
|
|
/// The client whose space ids come from (0: the whole range, as in a single-user file).
|
|
#[serde(skip)]
|
|
client: u32,
|
|
}
|
|
|
|
impl PartialEq for IdAlloc {
|
|
fn eq(&self, _: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
impl IdAlloc {
|
|
/// Whether `id` lies in the space this allocator hands out (any id without a client).
|
|
fn owns(&self, id: NodeId) -> bool {
|
|
self.client == 0 || id.0 >> 32 == u64::from(self.client)
|
|
}
|
|
|
|
/// Never hand out `id` (or anything below it in this space) again.
|
|
fn reserve(&mut self, id: NodeId) {
|
|
if self.owns(id) && id.0 >= self.next {
|
|
self.next = id.0.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
fn alloc(&mut self) -> NodeId {
|
|
let id = NodeId(self.next);
|
|
let next = self.next.saturating_add(1);
|
|
// A client space holds 2^32 ids; a full one keeps returning its last id (an insert then
|
|
// fails as a duplicate) rather than spilling into another client's space.
|
|
if self.owns(NodeId(next)) {
|
|
self.next = next;
|
|
}
|
|
id
|
|
}
|
|
}
|
|
|
|
/// Raw image bytes (encoded PNG/JPEG/…) kept by content hash.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ImageData {
|
|
pub bytes: Arc<Vec<u8>>,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
/// A whole design file.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Document {
|
|
pub name: String,
|
|
/// Page ids in order.
|
|
pub pages: Vec<NodeId>,
|
|
nodes: imbl::OrdMap<NodeId, Arc<Node>>,
|
|
/// Nodes touched since the last [`take_dirty`](Self::take_dirty) (edited, added or removed).
|
|
#[serde(skip)]
|
|
dirty: Dirty,
|
|
next_id: IdAlloc,
|
|
#[serde(default)]
|
|
pub styles: Vec<Style>,
|
|
#[serde(default)]
|
|
pub variables: Variables,
|
|
/// Images by content hash; serialized separately by the format crate.
|
|
#[serde(skip)]
|
|
pub images: BTreeMap<String, ImageData>,
|
|
/// The page shown when the file opens.
|
|
#[serde(default)]
|
|
pub current_page: Option<NodeId>,
|
|
/// Colours used recently / saved in "Document colors".
|
|
#[serde(default)]
|
|
pub document_colors: Vec<Color>,
|
|
/// Comment threads on any page.
|
|
#[serde(default)]
|
|
pub comments: Vec<Comment>,
|
|
/// Enabled libraries and the components and styles copied from them.
|
|
#[serde(default, skip_serializing_if = "Libraries::is_unused")]
|
|
pub libraries: Arc<Libraries>,
|
|
/// This file's published library; serialized separately by the format crate.
|
|
#[serde(skip)]
|
|
pub published: Option<Arc<PublishedLibrary>>,
|
|
/// The colour space colour values are in (sRGB unless the file says otherwise).
|
|
#[serde(default, skip_serializing_if = "ColorProfile::is_srgb")]
|
|
pub color_profile: ColorProfile,
|
|
/// Image adjustment presets saved in this file.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub image_presets: Vec<ImagePreset>,
|
|
/// Annotation categories (`None`: [`AnnotationCategory::defaults`]).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub annotation_categories: Option<Vec<AnnotationCategory>>,
|
|
/// What ready-for-dev frames and sections looked like before they changed (by layer; see
|
|
/// [`handoff`]).
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
|
|
}
|
|
|
|
impl Default for Document {
|
|
fn default() -> Self {
|
|
Self::new("Untitled")
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// A new file with one empty page ("Page 1").
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
let mut doc = Self::without_pages(name);
|
|
let p = doc.add_page("Page 1");
|
|
doc.current_page = Some(p);
|
|
doc
|
|
}
|
|
|
|
/// A document with no pages at all: not a valid file on its own (see [`repair`](Self::repair)),
|
|
/// only a base to build one onto, e.g. by applying a collaboration snapshot.
|
|
pub fn without_pages(name: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
pages: Vec::new(),
|
|
nodes: imbl::OrdMap::new(),
|
|
dirty: Dirty::default(),
|
|
next_id: IdAlloc { next: 1, client: 0 },
|
|
styles: Vec::new(),
|
|
variables: Variables::default(),
|
|
images: BTreeMap::new(),
|
|
current_page: None,
|
|
document_colors: Vec::new(),
|
|
comments: Vec::new(),
|
|
libraries: Arc::default(),
|
|
published: None,
|
|
color_profile: ColorProfile::Srgb,
|
|
image_presets: Vec::new(),
|
|
annotation_categories: None,
|
|
dev_snapshots: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Node ids touched since the last call (edited, added or removed), clearing the record.
|
|
pub fn take_dirty(&mut self) -> std::collections::BTreeSet<NodeId> {
|
|
std::mem::take(&mut self.dirty.0)
|
|
}
|
|
|
|
/// Record `ids` as touched, for passes that follow the dirty set (e.g. after changes made
|
|
/// elsewhere were applied).
|
|
pub fn mark_dirty(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
|
self.dirty.0.extend(ids);
|
|
}
|
|
|
|
/// Node ids touched so far (without clearing).
|
|
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
|
&self.dirty.0
|
|
}
|
|
|
|
/// A fresh unused id (in this document's client space during a collaboration session; see
|
|
/// [`set_client_id`](Self::set_client_id)).
|
|
pub fn alloc_id(&mut self) -> NodeId {
|
|
self.next_id.alloc()
|
|
}
|
|
|
|
/// The id [`alloc_id`](Self::alloc_id) returns next.
|
|
pub fn next_id(&self) -> NodeId {
|
|
NodeId(self.next_id.next)
|
|
}
|
|
|
|
/// The collaboration client ids are allocated for (0: none, a single-user document).
|
|
pub fn client_id(&self) -> u32 {
|
|
self.next_id.client
|
|
}
|
|
|
|
/// Allocate new ids as `client << 32 | counter`, continuing after the highest id of that space
|
|
/// already in the document, so participants of a live session never make the same id. 0 goes
|
|
/// back to single-user allocation (after the highest id of any space).
|
|
pub fn set_client_id(&mut self, client: u32) {
|
|
let (lo, hi) = if client == 0 { (0, u64::MAX) } else { (u64::from(client) << 32, (u64::from(client) << 32) | u64::from(u32::MAX)) };
|
|
let highest = self.nodes.range(NodeId(lo)..=NodeId(hi)).next_back().map(|(k, _)| *k);
|
|
// Re-setting the same client keeps its counter (ids handed out stay handed out).
|
|
let keep = if self.next_id.client == client { self.next_id.next } else { 0 };
|
|
let start = if client == 0 { keep.max(1) } else { keep.max(lo | 1) };
|
|
self.next_id = IdAlloc { next: start, client };
|
|
if let Some(h) = highest {
|
|
self.next_id.reserve(h);
|
|
}
|
|
}
|
|
|
|
pub fn node(&self, id: NodeId) -> Option<&Node> {
|
|
self.nodes.get(&id).map(Arc::as_ref)
|
|
}
|
|
|
|
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
|
|
self.nodes.get(&id)
|
|
}
|
|
|
|
/// Mutable access (copy-on-write when a snapshot shares the node).
|
|
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
|
|
let n = self.nodes.get_mut(&id).map(Arc::make_mut);
|
|
if n.is_some() {
|
|
self.dirty.0.insert(id);
|
|
}
|
|
n
|
|
}
|
|
|
|
/// Undo a write that changed nothing: when node `old.id` equals `old` by value, `old` goes back
|
|
/// in its place (shared with the snapshots holding it again) and, unless `was_dirty`, the node
|
|
/// leaves the dirty set. Returns whether it did.
|
|
pub fn keep_if_unchanged(&mut self, old: Arc<Node>, was_dirty: bool) -> bool {
|
|
let id = old.id;
|
|
let same = self.nodes.get(&id).is_some_and(|n| Arc::ptr_eq(n, &old) || **n == *old);
|
|
if same {
|
|
self.nodes.insert(id, old);
|
|
if !was_dirty {
|
|
self.dirty.0.remove(&id);
|
|
}
|
|
}
|
|
same
|
|
}
|
|
|
|
pub fn get(&self, id: NodeId) -> DocResult<&Node> {
|
|
self.node(id).ok_or(DocError::NoNode(id))
|
|
}
|
|
|
|
pub fn get_mut(&mut self, id: NodeId) -> DocResult<&mut Node> {
|
|
self.node_mut(id).ok_or(DocError::NoNode(id))
|
|
}
|
|
|
|
pub fn contains(&self, id: NodeId) -> bool {
|
|
self.nodes.contains_key(&id)
|
|
}
|
|
|
|
pub fn node_count(&self) -> usize {
|
|
self.nodes.len()
|
|
}
|
|
|
|
/// Ids of the nodes added, removed or changed (by value) since `before`, ascending, or `None`
|
|
/// when more than `limit` differ. Structure the two versions share is skipped, so comparing an
|
|
/// edit with the version it came from costs about the size of the edit, not of the document.
|
|
pub fn changed_ids(&self, before: &Document, limit: usize) -> Option<Vec<NodeId>> {
|
|
let mut out = Vec::new();
|
|
for d in before.nodes.diff(&self.nodes) {
|
|
if out.len() >= limit {
|
|
return None;
|
|
}
|
|
out.push(match d {
|
|
imbl::ordmap::DiffItem::Add(k, _) | imbl::ordmap::DiffItem::Remove(k, _) => *k,
|
|
imbl::ordmap::DiffItem::Update { new: (k, _), .. } => *k,
|
|
});
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Every node id (pages included), ascending.
|
|
pub fn ids(&self) -> impl Iterator<Item = NodeId> + '_ {
|
|
self.nodes.keys().copied()
|
|
}
|
|
|
|
pub fn nodes(&self) -> impl Iterator<Item = &Node> + '_ {
|
|
self.nodes.values().map(Arc::as_ref)
|
|
}
|
|
|
|
/// Add a page at the end.
|
|
pub fn add_page(&mut self, name: &str) -> NodeId {
|
|
let id = self.alloc_id();
|
|
let node = Node::new(
|
|
id,
|
|
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
|
name,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.0.insert(id);
|
|
self.pages.push(id);
|
|
id
|
|
}
|
|
|
|
/// The page a node is on (a page is on itself).
|
|
pub fn page_of(&self, id: NodeId) -> Option<NodeId> {
|
|
let mut cur = id;
|
|
for _ in 0..10_000 {
|
|
let n = self.node(cur)?;
|
|
if n.is_page() {
|
|
return Some(cur);
|
|
}
|
|
cur = n.parent?;
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn first_page(&self) -> Option<NodeId> {
|
|
self.pages.first().copied()
|
|
}
|
|
|
|
/// Insert `node` (its id must come from [`alloc_id`](Self::alloc_id)) as a child of `parent` at
|
|
/// `index` (None = on top).
|
|
pub fn insert(&mut self, mut node: Node, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
|
let p = self.get(parent)?;
|
|
if !p.kind.is_container() {
|
|
return Err(DocError::NotContainer(parent));
|
|
}
|
|
let id = node.id;
|
|
if self.nodes.contains_key(&id) {
|
|
return Err(DocError::Invalid(format!("node {id} already exists")));
|
|
}
|
|
self.reserve_ids(id);
|
|
node.parent = Some(parent);
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.0.insert(id);
|
|
let p = self.get_mut(parent)?;
|
|
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
|
p.children.insert(at, id);
|
|
Ok(id)
|
|
}
|
|
|
|
/// Create a node of `kind` under `parent`, positioned at `(x, y)` in the parent's space.
|
|
pub fn create(&mut self, parent: NodeId, kind: NodeKind, name: &str, x: f64, y: f64, w: f64, h: f64) -> DocResult<NodeId> {
|
|
let id = self.alloc_id();
|
|
let mut node = Node::new(id, kind, name, w, h);
|
|
node.transform = Affine::translate((x, y));
|
|
self.insert(node, parent, None)
|
|
}
|
|
|
|
/// Whether `ancestor` is `id` or one of its ancestors.
|
|
pub fn is_ancestor(&self, ancestor: NodeId, id: NodeId) -> bool {
|
|
let mut cur = Some(id);
|
|
let mut guard = 0;
|
|
while let Some(c) = cur {
|
|
if c == ancestor {
|
|
return true;
|
|
}
|
|
guard += 1;
|
|
if guard > 10_000 {
|
|
return false;
|
|
}
|
|
cur = self.node(c).and_then(|n| n.parent);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Ancestors from the parent up to (and including) the page.
|
|
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
|
let mut out = Vec::new();
|
|
let mut cur = self.node(id).and_then(|n| n.parent);
|
|
while let Some(c) = cur {
|
|
if out.len() > 10_000 {
|
|
break;
|
|
}
|
|
out.push(c);
|
|
cur = self.node(c).and_then(|n| n.parent);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `id` and all its descendants, pre-order.
|
|
pub fn subtree(&self, id: NodeId) -> Vec<NodeId> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![id];
|
|
while let Some(c) = stack.pop() {
|
|
if out.len() > 5_000_000 {
|
|
break;
|
|
}
|
|
let Some(n) = self.node(c) else { continue };
|
|
out.push(c);
|
|
stack.extend(n.children.iter().rev());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Remove a node and its subtree. Pages can only be removed while another page remains.
|
|
pub fn remove(&mut self, id: NodeId) -> DocResult<()> {
|
|
let node = self.get(id)?;
|
|
let parent = node.parent;
|
|
if node.is_page() {
|
|
if self.pages.len() <= 1 {
|
|
return Err(DocError::Invalid("a file needs at least one page".into()));
|
|
}
|
|
self.pages.retain(|p| *p != id);
|
|
if self.current_page == Some(id) {
|
|
self.current_page = self.pages.first().copied();
|
|
}
|
|
}
|
|
if let Some(parent) = parent
|
|
&& let Some(p) = self.node_mut(parent)
|
|
{
|
|
p.children.retain(|c| *c != id);
|
|
}
|
|
for d in self.subtree(id) {
|
|
self.nodes.remove(&d);
|
|
self.dirty.0.insert(d);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Index of `id` among its siblings.
|
|
pub fn index_in_parent(&self, id: NodeId) -> Option<usize> {
|
|
let parent = self.node(id)?.parent?;
|
|
self.node(parent)?.children.iter().position(|c| *c == id)
|
|
}
|
|
|
|
/// Move `id` under `new_parent` at `index` (None = top), keeping its world position.
|
|
pub fn reparent(&mut self, id: NodeId, new_parent: NodeId, index: Option<usize>) -> DocResult<()> {
|
|
if self.is_ancestor(id, new_parent) {
|
|
return Err(DocError::Cycle(id, new_parent));
|
|
}
|
|
let np = self.get(new_parent)?;
|
|
if !np.kind.is_container() {
|
|
return Err(DocError::NotContainer(new_parent));
|
|
}
|
|
let node = self.get(id)?;
|
|
if node.is_page() {
|
|
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
|
|
}
|
|
let world = self.world_transform(id);
|
|
let old_parent = node.parent;
|
|
let old_index = self.index_in_parent(id);
|
|
if let Some(op) = old_parent
|
|
&& let Some(p) = self.node_mut(op)
|
|
{
|
|
p.children.retain(|c| *c != id);
|
|
}
|
|
let parent_world = self.world_transform(new_parent);
|
|
let np = self.get_mut(new_parent)?;
|
|
let mut at = index.unwrap_or(np.children.len());
|
|
// Moving within the same parent: the removal shifted later indices.
|
|
if old_parent == Some(new_parent)
|
|
&& let (Some(oi), Some(_)) = (old_index, index)
|
|
&& oi < at
|
|
{
|
|
at = at.saturating_sub(1);
|
|
}
|
|
let at = at.min(np.children.len());
|
|
np.children.insert(at, id);
|
|
let n = self.get_mut(id)?;
|
|
n.parent = Some(new_parent);
|
|
n.transform = parent_world.inverse() * world;
|
|
Ok(())
|
|
}
|
|
|
|
/// Add page node `node` (its id unused; no parent, no children) at the end of the page list.
|
|
pub fn insert_page(&mut self, mut node: Node) -> DocResult<NodeId> {
|
|
let id = node.id;
|
|
if !node.is_page() {
|
|
return Err(DocError::Invalid(format!("{id} is not a page")));
|
|
}
|
|
if self.nodes.contains_key(&id) {
|
|
return Err(DocError::Invalid(format!("node {id} already exists")));
|
|
}
|
|
self.reserve_ids(id);
|
|
node.parent = None;
|
|
node.children.clear();
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.0.insert(id);
|
|
self.pages.push(id);
|
|
Ok(id)
|
|
}
|
|
|
|
/// Move `id` under `parent` at `index` (clamped) keeping its local transform, unlike
|
|
/// [`reparent`](Self::reparent), which keeps its place on the page.
|
|
pub fn place(&mut self, id: NodeId, parent: NodeId, index: usize) -> DocResult<()> {
|
|
if self.get(id)?.is_page() {
|
|
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
|
|
}
|
|
if !self.get(parent)?.kind.is_container() {
|
|
return Err(DocError::NotContainer(parent));
|
|
}
|
|
if self.is_ancestor(id, parent) {
|
|
return Err(DocError::Cycle(id, parent));
|
|
}
|
|
if let Some(old) = self.get(id)?.parent
|
|
&& let Some(p) = self.node_mut(old)
|
|
{
|
|
p.children.retain(|c| *c != id);
|
|
}
|
|
let p = self.get_mut(parent)?;
|
|
let at = index.min(p.children.len());
|
|
p.children.insert(at, id);
|
|
self.get_mut(id)?.parent = Some(parent);
|
|
Ok(())
|
|
}
|
|
|
|
/// Reorder `id` within its parent to `index`.
|
|
pub fn reorder(&mut self, id: NodeId, index: usize) -> DocResult<()> {
|
|
let parent = self.get(id)?.parent.ok_or(DocError::Invalid("page".into()))?;
|
|
let p = self.get_mut(parent)?;
|
|
p.children.retain(|c| *c != id);
|
|
let at = index.min(p.children.len());
|
|
p.children.insert(at, id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Transform from `id`'s local space to its page.
|
|
pub fn world_transform(&self, id: NodeId) -> Affine {
|
|
let mut xf = Affine::IDENTITY;
|
|
let mut cur = Some(id);
|
|
let mut guard = 0;
|
|
while let Some(c) = cur {
|
|
let Some(n) = self.node(c) else { break };
|
|
if n.is_page() {
|
|
break;
|
|
}
|
|
xf = n.transform * xf;
|
|
cur = n.parent;
|
|
guard += 1;
|
|
if guard > 10_000 {
|
|
break;
|
|
}
|
|
}
|
|
xf
|
|
}
|
|
|
|
/// Set `id`'s transform so that its world transform becomes `world`.
|
|
pub fn set_world_transform(&mut self, id: NodeId, world: Affine) -> DocResult<()> {
|
|
let parent = self.get(id)?.parent;
|
|
let pw = parent.map(|p| self.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
|
self.get_mut(id)?.transform = pw.inverse() * world;
|
|
Ok(())
|
|
}
|
|
|
|
/// Axis-aligned bounds of the node's box in page coordinates.
|
|
pub fn world_bounds(&self, id: NodeId) -> Option<Rect> {
|
|
let n = self.node(id)?;
|
|
if n.is_page() {
|
|
return self.page_content_bounds(id);
|
|
}
|
|
Some(figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect()))
|
|
}
|
|
|
|
/// Bounds of everything painted by a node, effects and strokes included (page coordinates).
|
|
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
|
let n = self.node(id)?;
|
|
let reach = n.paint_reach();
|
|
let mut r = figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect().inflate(reach, reach));
|
|
if !(n.clip && n.kind.is_frame_like()) || n.kind.bounds_follow_children() {
|
|
for c in &n.children {
|
|
if let Some(cr) = self.world_render_bounds(*c) {
|
|
r = r.union(cr);
|
|
}
|
|
}
|
|
}
|
|
Some(r)
|
|
}
|
|
|
|
/// Union of the top-level nodes' bounds on a page.
|
|
pub fn page_content_bounds(&self, page: NodeId) -> Option<Rect> {
|
|
let p = self.node(page)?;
|
|
p.children.iter().filter_map(|c| self.world_bounds(*c)).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// Union of several nodes' world bounds.
|
|
pub fn bounds_of(&self, ids: &[NodeId]) -> Option<Rect> {
|
|
ids.iter().filter_map(|i| self.world_bounds(*i)).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// Resize a group/boolean so its box tightly wraps its children (children keep world position).
|
|
pub fn fit_to_children(&mut self, id: NodeId) -> DocResult<()> {
|
|
let n = self.get(id)?;
|
|
if n.children.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let world = self.world_transform(id);
|
|
let inv = world.inverse();
|
|
// Children bounds in the group's current local space.
|
|
let mut local: Option<Rect> = None;
|
|
for c in &n.children {
|
|
let Some(cn) = self.node(*c) else { continue };
|
|
let r = figcraft_geom::transform_rect_bbox(inv * self.world_transform(*c), cn.local_rect());
|
|
local = Some(local.map_or(r, |l| l.union(r)));
|
|
}
|
|
let Some(local) = local else { return Ok(()) };
|
|
let children = n.children.clone();
|
|
let child_worlds: Vec<(NodeId, Affine)> = children.iter().map(|c| (*c, self.world_transform(*c))).collect();
|
|
let shift = Affine::translate((local.x0, local.y0));
|
|
let nw = world * shift;
|
|
{
|
|
let g = self.get_mut(id)?;
|
|
g.transform *= shift;
|
|
g.width = local.width();
|
|
g.height = local.height();
|
|
}
|
|
let ninv = nw.inverse();
|
|
for (c, w) in child_worlds {
|
|
if let Some(cn) = self.node_mut(c) {
|
|
cn.transform = ninv * w;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Fit every group/boolean ancestor of `id` (innermost first).
|
|
pub fn fit_ancestors(&mut self, id: NodeId) {
|
|
for a in self.ancestors(id) {
|
|
let follows = self.node(a).is_some_and(|n| n.kind.bounds_follow_children());
|
|
if follows {
|
|
let _ = self.fit_to_children(a);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deep-copy `id`'s subtree under `parent` (at `index`), returning the new root id. The copy
|
|
/// keeps the original's local transform; ids are fresh.
|
|
pub fn duplicate_into(&mut self, id: NodeId, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
|
let mut map = BTreeMap::new();
|
|
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
|
for n in &src_nodes {
|
|
let nid = self.alloc_id();
|
|
map.insert(n.id, nid);
|
|
}
|
|
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
|
}
|
|
|
|
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
|
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
|
|
/// subtree are remapped too.
|
|
pub fn paste_nodes(
|
|
&mut self,
|
|
nodes: &[Node],
|
|
map: &BTreeMap<NodeId, NodeId>,
|
|
root: NodeId,
|
|
parent: NodeId,
|
|
index: Option<usize>,
|
|
) -> DocResult<NodeId> {
|
|
let p = self.get(parent)?;
|
|
if !p.kind.is_container() {
|
|
return Err(DocError::NotContainer(parent));
|
|
}
|
|
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
|
|
for n in nodes {
|
|
let Some(new_id) = map.get(&n.id) else { continue };
|
|
let mut c = n.clone();
|
|
c.id = *new_id;
|
|
c.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
|
|
c.parent = if n.id == root { Some(parent) } else { n.parent.and_then(|p| map.get(&p).copied()) };
|
|
for it in &mut c.interactions {
|
|
proto::walk_mut(&mut it.actions, &mut |a| remap_action(a, map));
|
|
}
|
|
if let NodeKind::Instance { component, .. } = &mut c.kind
|
|
&& let Some(m) = map.get(component)
|
|
{
|
|
*component = *m;
|
|
}
|
|
// A pattern tiling a layer of the copy tiles the copy's layer.
|
|
for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) {
|
|
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
|
|
&& let Some(m) = map.get(&NodeId(pt.source))
|
|
{
|
|
pt.source = m.0;
|
|
}
|
|
}
|
|
self.reserve_ids(*new_id);
|
|
self.nodes.insert(*new_id, Arc::new(c));
|
|
self.dirty.0.insert(*new_id);
|
|
}
|
|
let p = self.get_mut(parent)?;
|
|
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
|
p.children.insert(at, new_root);
|
|
Ok(new_root)
|
|
}
|
|
|
|
/// Find nodes on `page` (or all pages) whose name contains `query` (case-insensitive).
|
|
pub fn find_by_name(&self, query: &str, page: Option<NodeId>) -> Vec<NodeId> {
|
|
let q = query.to_lowercase();
|
|
let roots: Vec<NodeId> = page.map(|p| vec![p]).unwrap_or_else(|| self.pages.clone());
|
|
roots
|
|
.into_iter()
|
|
.flat_map(|r| self.subtree(r))
|
|
.filter(|i| self.node(*i).is_some_and(|n| !n.is_page() && n.name.to_lowercase().contains(&q)))
|
|
.collect()
|
|
}
|
|
|
|
/// The nearest frame-like ancestor (excluding `id` itself).
|
|
pub fn containing_frame(&self, id: NodeId) -> Option<NodeId> {
|
|
self.ancestors(id).into_iter().find(|a| self.node(*a).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })))
|
|
}
|
|
|
|
/// The top-level frame containing `id` (or `id` if it is one): the outermost frame, on the
|
|
/// page or in a section (sections nest and hold top-level frames).
|
|
pub fn top_level_frame(&self, id: NodeId) -> Option<NodeId> {
|
|
let mut chain = vec![id];
|
|
chain.extend(self.ancestors(id));
|
|
// chain ends with the page; below it, past any sections, is the top-level node.
|
|
let page_pos = chain.iter().position(|c| self.node(*c).is_some_and(Node::is_page))?;
|
|
let top = chain.iter().take(page_pos).rev().copied().find(|c| !self.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })))?;
|
|
self.node(top).filter(|n| n.kind.is_frame_like()).map(|_| top)
|
|
}
|
|
|
|
/// Top-level frames of a page (also those in sections) in reading order (rows top to bottom,
|
|
/// then left to right).
|
|
pub fn top_frames(&self, page: NodeId) -> Vec<NodeId> {
|
|
self.frames_in(page)
|
|
}
|
|
|
|
/// The top-level frames inside page or section `container` (and its sections, at any depth), in
|
|
/// reading order.
|
|
pub fn frames_in(&self, container: NodeId) -> Vec<NodeId> {
|
|
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<(NodeId, Rect)>) {
|
|
let Some(n) = doc.node(id) else { return };
|
|
for c in &n.children {
|
|
let Some(cn) = doc.node(*c) else { continue };
|
|
if cn.kind.is_frame_like() {
|
|
if let Some(b) = doc.world_bounds(*c) {
|
|
out.push((*c, b));
|
|
}
|
|
} else if matches!(cn.kind, NodeKind::Section { .. }) && depth < 64 {
|
|
walk(doc, *c, depth + 1, out);
|
|
}
|
|
}
|
|
}
|
|
let mut v = Vec::new();
|
|
walk(self, container, 0, &mut v);
|
|
v.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0).then(a.1.x0.total_cmp(&b.1.x0)));
|
|
v.into_iter().map(|(i, _)| i).collect()
|
|
}
|
|
|
|
/// The sections on page `page` (nested ones after their section), in layer order.
|
|
pub fn sections_in(&self, page: NodeId) -> Vec<NodeId> {
|
|
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<NodeId>) {
|
|
let Some(n) = doc.node(id) else { return };
|
|
for c in &n.children {
|
|
if doc.node(*c).is_some_and(|cn| matches!(cn.kind, NodeKind::Section { .. })) && depth < 64 {
|
|
out.push(*c);
|
|
walk(doc, *c, depth + 1, out);
|
|
}
|
|
}
|
|
}
|
|
let mut v = Vec::new();
|
|
walk(self, page, 0, &mut v);
|
|
v
|
|
}
|
|
|
|
/// A unique name like "Frame 3" for a new node of a kind.
|
|
pub fn next_name(&self, base: &str) -> String {
|
|
let mut max = 0u64;
|
|
for n in self.nodes() {
|
|
if let Some(rest) = n.name.strip_prefix(base)
|
|
&& let Some(num) = rest.strip_prefix(' ')
|
|
&& let Ok(k) = num.parse::<u64>()
|
|
{
|
|
max = max.max(k);
|
|
}
|
|
}
|
|
format!("{base} {}", max + 1)
|
|
}
|
|
|
|
pub fn style(&self, id: &str) -> Option<&Style> {
|
|
self.styles.iter().find(|s| s.id == id || s.name == id)
|
|
}
|
|
|
|
/// The effective variable modes for a node (walking up its ancestors).
|
|
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
|
let mut out = BTreeMap::new();
|
|
let mut chain = vec![id];
|
|
chain.extend(self.ancestors(id));
|
|
for c in chain.into_iter().rev() {
|
|
if let Some(n) = self.node(c) {
|
|
for (k, v) in &n.var_modes {
|
|
out.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Check structural invariants (used by tests and after loading untrusted files).
|
|
pub fn validate(&self) -> Vec<String> {
|
|
let mut issues = Vec::new();
|
|
for (id, n) in self.nodes.iter() {
|
|
if n.id != *id {
|
|
issues.push(format!("node key {id} has id {}", n.id));
|
|
}
|
|
for c in &n.children {
|
|
match self.node(*c) {
|
|
Some(cn) if cn.parent == Some(*id) => {}
|
|
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
|
None => issues.push(format!("child {c} of {id} missing")),
|
|
}
|
|
}
|
|
if let Some(p) = n.parent
|
|
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
|
|
{
|
|
issues.push(format!("{id} not listed in parent {p}"));
|
|
}
|
|
// The library store's root is a page that is never listed.
|
|
let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
|
|
if n.is_page() != listed {
|
|
issues.push(format!("page list mismatch for {id}"));
|
|
}
|
|
}
|
|
issues
|
|
}
|
|
|
|
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
|
pub fn repair(&mut self) {
|
|
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
|
for id in &ids {
|
|
if let Some(n) = self.nodes.get_mut(id) {
|
|
let n = Arc::make_mut(n);
|
|
n.id = *id;
|
|
}
|
|
}
|
|
// Drop dangling children and children claimed by another parent.
|
|
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
|
for id in &ids {
|
|
let Some(n) = self.nodes.get(id) else { continue };
|
|
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
|
if keep != n.children
|
|
&& let Some(n) = self.nodes.get_mut(id)
|
|
{
|
|
Arc::make_mut(n).children = keep;
|
|
}
|
|
}
|
|
// Orphans (parent missing or not listing them) are removed.
|
|
loop {
|
|
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
|
if n.is_page() {
|
|
return None;
|
|
}
|
|
match n.parent {
|
|
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
|
_ => Some(*k),
|
|
}
|
|
});
|
|
let Some(o) = orphan else { break };
|
|
for d in self.subtree(o) {
|
|
self.nodes.remove(&d);
|
|
}
|
|
}
|
|
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
|
// don't start with their layer, are dropped.
|
|
self.dev_snapshots.retain(|k, snap| {
|
|
self.nodes.get(k).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed)
|
|
&& snap.nodes.first().is_some_and(|n| n.id == *k)
|
|
});
|
|
let store = self.libraries.store.filter(|s| self.nodes.get(s).is_some_and(|n| n.is_page()));
|
|
if store != self.libraries.store {
|
|
Arc::make_mut(&mut self.libraries).store = store;
|
|
}
|
|
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
|
|
for (k, n) in self.nodes.iter() {
|
|
if n.is_page() && !self.pages.contains(k) && Some(*k) != store {
|
|
self.pages.push(*k);
|
|
}
|
|
}
|
|
if self.pages.is_empty() {
|
|
let p = self.add_page("Page 1");
|
|
self.current_page = Some(p);
|
|
}
|
|
let client = self.client_id();
|
|
self.set_client_id(client);
|
|
if self.current_page.is_none_or(|p| !self.pages.contains(&p)) {
|
|
self.current_page = self.pages.first().copied();
|
|
}
|
|
self.link_layer_text_styles();
|
|
}
|
|
|
|
/// Text layers linked to a text style whose chars carry no link (files from before per-char
|
|
/// links, importers that link layers): the chars showing the style link to it, and the layer
|
|
/// stays linked only if they all do.
|
|
pub fn link_layer_text_styles(&mut self) {
|
|
let defs: BTreeMap<String, TextStyle> = self
|
|
.styles
|
|
.iter()
|
|
.filter_map(|s| match &s.kind {
|
|
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let legacy = |n: &Node| match &n.kind {
|
|
NodeKind::Text(t) => n.styles.text.is_some() && t.spans().iter().all(|(_, _, s)| s.style_id.is_none()),
|
|
_ => false,
|
|
};
|
|
let ids: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| legacy(n)).map(|(k, _)| *k).collect();
|
|
for id in ids {
|
|
let Some(n) = self.nodes.get_mut(&id) else { continue };
|
|
let n = Arc::make_mut(n);
|
|
let sid = n.styles.text.clone().unwrap_or_default();
|
|
if let NodeKind::Text(t) = &mut n.kind {
|
|
if let Some(def) = defs.get(&sid) {
|
|
t.each_style(|s| {
|
|
if s.shows_text_style(def) {
|
|
s.style_id = Some(sid.clone());
|
|
}
|
|
});
|
|
}
|
|
n.styles.text = t.text_style_link().flatten().map(str::to_string);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click.
|
|
/// `deep` selects the innermost leaf (Cmd-click); otherwise top-level children of frames
|
|
/// resolve like Figma's click-through rules (see [`geometry::select_target`]). Boolean groups
|
|
/// are hit where their `result` is (see [`geometry::BoolResult`]).
|
|
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64, result: geometry::BoolResult) -> Option<NodeId> {
|
|
geometry::hit_leaf(self, page, pt, tolerance, result)
|
|
}
|
|
|
|
/// `n`'s children as paint runs (index ranges into `n.children`), bottom to top: a visible
|
|
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
|
|
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
|
|
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
|
|
let kids = &n.children;
|
|
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
|
|
let mut runs = Vec::new();
|
|
let mut i = 0;
|
|
while i < kids.len() {
|
|
let mut j = i + 1;
|
|
if node(i).is_some_and(|k| k.is_mask && k.visible) {
|
|
while j < kids.len() && !node(j).is_some_and(|k| k.is_mask) {
|
|
j += 1;
|
|
}
|
|
}
|
|
runs.push(i..j);
|
|
i = j;
|
|
}
|
|
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
|
|
runs.reverse();
|
|
}
|
|
runs
|
|
}
|
|
}
|
|
|
|
fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
|
use proto::Action::*;
|
|
match a {
|
|
Navigate { dest, .. } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {
|
|
if let Some(m) = map.get(dest) {
|
|
*dest = *m;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod blur_tests;
|
|
#[cfg(test)]
|
|
mod boolean_hit_tests;
|
|
#[cfg(test)]
|
|
mod client_id_tests;
|
|
#[cfg(test)]
|
|
mod colors_tests;
|
|
#[cfg(test)]
|
|
mod diff_tests;
|
|
#[cfg(test)]
|
|
mod effects_tests;
|
|
#[cfg(test)]
|
|
mod handoff_tests;
|
|
#[cfg(test)]
|
|
mod ids_tests;
|
|
#[cfg(test)]
|
|
mod print_tests;
|
|
#[cfg(test)]
|
|
mod proto_expr_mode_tests;
|
|
#[cfg(test)]
|
|
mod proto_expr_parse_tests;
|
|
#[cfg(test)]
|
|
mod proto_expr_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
mod width_sides_tests;
|
|
#[cfg(test)]
|
|
mod width_tests;
|