mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. **Fixes (the suite failed on 7 tests before them)** - **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did. - **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`). - **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be. - **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step. - **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor. - **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back). - **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too. Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports. No new commands or UI strings (no i18n changes); each edit stays one undo step.
324 lines
12 KiB
Rust
324 lines
12 KiB
Rust
//! Libraries: a file's published components, styles and variables, and the libraries other files
|
|
//! use.
|
|
//!
|
|
//! **Publishing** records a [`PublishedLibrary`] on the document: a [`LibraryManifest`] (id,
|
|
//! version, the components, component sets, styles, variable collections and variables it offers,
|
|
//! each stamped with the library version it last changed in) and a snapshot document holding just
|
|
//! those assets and what they depend on. The format crate stores both next to `document.json`, so files using the library
|
|
//! only ever see what was published, never later unpublished edits.
|
|
//!
|
|
//! **Using** a library: a document lists the library files it has enabled ([`LibraryLink`]).
|
|
//! Placing a library component copies it (with the components and styles it uses) into the
|
|
//! document's *library store*: a hidden page-like root that is not in [`Document::pages`]. The
|
|
//! copies are read-only "remote" main components, so their instances behave like any other
|
|
//! instance. Library styles, variables and collections are copied into the document's styles and
|
|
//! variables as linked, read-only copies. [`ImportedComponent`] / [`ImportedRecord`] records map
|
|
//! each copy to its library asset and version; updating re-imports a copy in place, keeping its
|
|
//! ids (and with them the overrides of instances and the bindings of layers).
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::Arc;
|
|
|
|
use figcraft_color::{Paint, PaintKind};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{CollectionId, Document, Node, NodeId, NodeKind, StyleKind, VariableId};
|
|
|
|
/// Name of the library store's root (never shown; older readers list it as a page).
|
|
pub const STORE_NAME: &str = "Library components";
|
|
|
|
/// What a published library offers.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LibraryManifest {
|
|
/// Stable id of the library (kept across publishes).
|
|
pub id: String,
|
|
/// The library file's name when it was published.
|
|
pub name: String,
|
|
/// Increases by one with every publish.
|
|
pub version: u64,
|
|
pub description: String,
|
|
/// When it was published (Unix seconds).
|
|
pub time: u64,
|
|
/// Component and component set roots in the snapshot: the published ones and those they use.
|
|
pub components: Vec<LibraryAsset>,
|
|
/// Styles in the snapshot: the published ones and those the components use.
|
|
pub styles: Vec<LibraryStyle>,
|
|
/// Variable collections in the snapshot: the published ones and those variables in it use.
|
|
pub collections: Vec<LibraryCollection>,
|
|
/// Variables in the snapshot: the published ones and those they alias or components bind.
|
|
pub variables: Vec<LibraryVariable>,
|
|
}
|
|
|
|
impl LibraryManifest {
|
|
pub fn component(&self, id: NodeId) -> Option<&LibraryAsset> {
|
|
self.components.iter().find(|a| a.id == id)
|
|
}
|
|
|
|
pub fn style(&self, id: &str) -> Option<&LibraryStyle> {
|
|
self.styles.iter().find(|s| s.id == id)
|
|
}
|
|
|
|
pub fn collection(&self, id: &str) -> Option<&LibraryCollection> {
|
|
self.collections.iter().find(|c| c.id == id)
|
|
}
|
|
|
|
pub fn variable(&self, id: &str) -> Option<&LibraryVariable> {
|
|
self.variables.iter().find(|v| v.id == id)
|
|
}
|
|
}
|
|
|
|
/// A component (or component set) root in a library snapshot.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct LibraryAsset {
|
|
/// Its node id in the snapshot (the library file's own id).
|
|
pub id: NodeId,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
/// A component set (its variants come with it).
|
|
#[serde(default)]
|
|
pub set: bool,
|
|
/// The library version this asset (or anything it is made of) last changed in.
|
|
#[serde(default)]
|
|
pub version: u64,
|
|
/// Offered in the Assets panel; `false` for components only other assets use.
|
|
#[serde(default)]
|
|
pub published: bool,
|
|
}
|
|
|
|
/// A style in a library snapshot.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LibraryStyle {
|
|
pub id: String,
|
|
pub name: String,
|
|
/// paint | text | effect | grid
|
|
#[serde(rename = "type")]
|
|
pub kind: String,
|
|
pub version: u64,
|
|
pub published: bool,
|
|
}
|
|
|
|
/// A variable collection in a library snapshot.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LibraryCollection {
|
|
pub id: CollectionId,
|
|
pub name: String,
|
|
/// The library version its name or modes last changed in.
|
|
pub version: u64,
|
|
/// Offered to files using the library; `false` for collections only other assets use.
|
|
pub published: bool,
|
|
}
|
|
|
|
/// A variable in a library snapshot.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LibraryVariable {
|
|
pub id: VariableId,
|
|
pub name: String,
|
|
/// Its collection's id in the snapshot.
|
|
pub collection: CollectionId,
|
|
/// color | number | string | boolean
|
|
#[serde(rename = "type")]
|
|
pub kind: String,
|
|
pub version: u64,
|
|
/// Offered to files using the library; `false` for variables hidden from publishing that
|
|
/// published variables alias or published components bind.
|
|
pub published: bool,
|
|
}
|
|
|
|
/// A document's published library: the manifest and the snapshot it describes (with its images).
|
|
#[derive(Clone, Debug)]
|
|
pub struct PublishedLibrary {
|
|
pub manifest: LibraryManifest,
|
|
pub doc: Arc<Document>,
|
|
}
|
|
|
|
/// The snapshot follows from the manifest (every publish gets a new version), so comparing
|
|
/// manifests is enough and never walks the snapshot.
|
|
impl PartialEq for PublishedLibrary {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
Arc::ptr_eq(&self.doc, &other.doc) || self.manifest == other.manifest
|
|
}
|
|
}
|
|
|
|
/// A library file this document uses.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LibraryLink {
|
|
/// The library's id (its manifest's).
|
|
pub id: String,
|
|
pub name: String,
|
|
/// Where the library file is.
|
|
pub path: String,
|
|
/// The library version this document last saw (when it was added or its updates accepted).
|
|
pub version: u64,
|
|
}
|
|
|
|
/// A library component (or set) copied into the library store.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ImportedComponent {
|
|
/// The library's id.
|
|
pub library: String,
|
|
/// The asset's id in the library snapshot.
|
|
pub key: NodeId,
|
|
/// The local copy's root.
|
|
pub root: NodeId,
|
|
/// The asset version copied.
|
|
#[serde(default)]
|
|
pub version: u64,
|
|
/// Library node id → local node id for every layer of the copy (kept across updates).
|
|
#[serde(default)]
|
|
pub ids: BTreeMap<NodeId, NodeId>,
|
|
}
|
|
|
|
/// A library style, variable or variable collection copied into the document (a linked,
|
|
/// read-only remote copy).
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ImportedRecord {
|
|
pub library: String,
|
|
/// Its id in the library.
|
|
pub key: String,
|
|
/// The local copy's id.
|
|
pub local: String,
|
|
pub version: u64,
|
|
}
|
|
|
|
/// A library style copied into the document's styles.
|
|
pub type ImportedStyle = ImportedRecord;
|
|
|
|
/// A document's library data: enabled libraries and the assets copied from them.
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Libraries {
|
|
pub enabled: Vec<LibraryLink>,
|
|
/// Root of the library store (a page-kind node not listed in `pages`).
|
|
pub store: Option<NodeId>,
|
|
pub components: Vec<ImportedComponent>,
|
|
pub styles: Vec<ImportedStyle>,
|
|
/// Library variable collections copied into the document's variables.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub collections: Vec<ImportedRecord>,
|
|
/// Library variables copied into the document's variables.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub variables: Vec<ImportedRecord>,
|
|
}
|
|
|
|
impl Libraries {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.enabled.is_empty()
|
|
&& self.store.is_none()
|
|
&& self.components.is_empty()
|
|
&& self.styles.is_empty()
|
|
&& self.collections.is_empty()
|
|
&& self.variables.is_empty()
|
|
}
|
|
|
|
/// Serde helper: leave the field out of files that use no libraries.
|
|
pub fn is_unused(l: &Arc<Libraries>) -> bool {
|
|
l.is_empty()
|
|
}
|
|
|
|
pub fn link(&self, id: &str) -> Option<&LibraryLink> {
|
|
self.enabled.iter().find(|l| l.id == id)
|
|
}
|
|
|
|
/// The import record of the copy rooted at local node `root`.
|
|
pub fn import_of(&self, root: NodeId) -> Option<&ImportedComponent> {
|
|
self.components.iter().find(|c| c.root == root)
|
|
}
|
|
|
|
/// The import record of the local (remote) style `local`.
|
|
pub fn style_import(&self, local: &str) -> Option<&ImportedStyle> {
|
|
self.styles.iter().find(|s| s.local == local)
|
|
}
|
|
|
|
/// The import record of the local (remote) variable `local`.
|
|
pub fn variable_import(&self, local: &str) -> Option<&ImportedRecord> {
|
|
self.variables.iter().find(|s| s.local == local)
|
|
}
|
|
|
|
/// The import record of the local (remote) variable collection `local`.
|
|
pub fn collection_import(&self, local: &str) -> Option<&ImportedRecord> {
|
|
self.collections.iter().find(|s| s.local == local)
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// The library store's root, if the document has one.
|
|
pub fn library_store(&self) -> Option<NodeId> {
|
|
self.libraries.store.filter(|s| self.node(*s).is_some_and(|n| n.is_page() && n.parent.is_none()) && !self.pages.contains(s))
|
|
}
|
|
|
|
/// The library store's root, created when missing.
|
|
pub fn ensure_library_store(&mut self) -> NodeId {
|
|
if let Some(s) = self.library_store() {
|
|
return s;
|
|
}
|
|
let id = self.alloc_id();
|
|
let node = Node::new(
|
|
id,
|
|
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
|
STORE_NAME,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.touch(id);
|
|
Arc::make_mut(&mut self.libraries).store = Some(id);
|
|
id
|
|
}
|
|
|
|
/// Whether `id` is a remote (library) layer kept in the library store.
|
|
pub fn is_remote(&self, id: NodeId) -> bool {
|
|
self.library_store().is_some_and(|s| self.page_of(id) == Some(s))
|
|
}
|
|
|
|
/// Mutable library data (copy-on-write).
|
|
pub fn libraries_mut(&mut self) -> &mut Libraries {
|
|
Arc::make_mut(&mut self.libraries)
|
|
}
|
|
|
|
/// Keep [`alloc_id`](Self::alloc_id) above `id` (for a document that reuses another's ids).
|
|
/// During a collaboration session only ids of this document's own client space count.
|
|
pub fn reserve_ids(&mut self, id: NodeId) {
|
|
self.next_id.reserve(id);
|
|
}
|
|
}
|
|
|
|
/// Image hashes painted by `paints`.
|
|
pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut BTreeSet<String>) {
|
|
for p in paints {
|
|
if let PaintKind::Image(img) = &p.kind {
|
|
out.insert(img.image.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Image hashes a node paints (fills, strokes, text and text run fills).
|
|
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
|
|
paint_images(n.fills.iter().chain(&n.strokes), out);
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
paint_images(t.style.fills.iter().flatten(), out);
|
|
for r in &t.runs {
|
|
paint_images(r.style.fills.iter().flatten(), out);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Image hashes a document uses (its nodes and its paint styles).
|
|
pub fn used_images(doc: &Document) -> BTreeSet<String> {
|
|
let mut out = BTreeSet::new();
|
|
for n in doc.nodes() {
|
|
node_images(n, &mut out);
|
|
}
|
|
for s in &doc.styles {
|
|
if let StyleKind::Paint { paints } = &s.kind {
|
|
paint_images(paints, &mut out);
|
|
}
|
|
}
|
|
out
|
|
}
|