mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
M2: merge current main into PR #595
Preserve feature and upstream progress/catalog updates. Use native Command modifiers in macOS paste tests; Command+Control is an unbound chord.
This commit is contained in:
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let cmd = "instance.create";
|
||||
let c = req_id(cmd, p, "component")?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
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 (x, y) = match (num(p, "x"), num(p, "y")) {
|
||||
|
||||
@@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let doc = s.doc()?;
|
||||
let pn = doc.get(parent)?;
|
||||
if !pn.kind.is_container() {
|
||||
@@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
node.width = tw;
|
||||
node.height = th;
|
||||
}
|
||||
let index = num(p, "index").map(|i| i.max(0.0) as usize);
|
||||
let index = crate::params::opt_index(cmd, p, "index")?;
|
||||
doc.insert(node, parent, index)?;
|
||||
if let Some(Value::Object(props)) = get(p, "props") {
|
||||
super::props::apply(cmd, doc, nid, props)?;
|
||||
@@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
|
||||
@@ -25,6 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
|
||||
true,
|
||||
paste_design_layers
|
||||
),
|
||||
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
|
||||
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
|
||||
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
|
||||
@@ -496,9 +503,27 @@ fn bump_name(n: &str) -> String {
|
||||
}
|
||||
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
// Pages are not layers: they can't be pasted anywhere.
|
||||
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect();
|
||||
if layers.is_empty() && !ids.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&ds.doc, &layers);
|
||||
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.source_doc = uid;
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Copied = (Vec<(NodeId, Vec<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
|
||||
|
||||
fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
|
||||
let ids = z_sorted(doc, &roots(doc, ids));
|
||||
let mut nodes = Vec::new();
|
||||
let mut images = BTreeMap::new();
|
||||
@@ -521,28 +546,73 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
nodes.push((*i, sub));
|
||||
}
|
||||
let bounds = doc.bounds_of(&ids);
|
||||
let uid = ds.uid;
|
||||
let source = Some(ds.doc.clone());
|
||||
let source_path = ds.path.clone();
|
||||
s.clipboard.source = source;
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.source_doc = uid;
|
||||
Ok(())
|
||||
(nodes, images, bounds)
|
||||
}
|
||||
|
||||
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
|
||||
pub const CLIP_OPEN: &str = "(figma)";
|
||||
pub const CLIP_CLOSE: &str = "(/figma)";
|
||||
/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its
|
||||
/// base64 is checked first, so neither the text nor the decoded bytes can pass it.
|
||||
const MAX_CLIP_DATA: usize = 256 * 1024 * 1024;
|
||||
const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4;
|
||||
|
||||
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
|
||||
fn clip_base64(text: &str) -> Option<Vec<u8>> {
|
||||
use base64::Engine as _;
|
||||
if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 {
|
||||
return None;
|
||||
}
|
||||
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()
|
||||
}
|
||||
|
||||
/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in
|
||||
/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML
|
||||
/// holds no readable design data (it may just mention the marker).
|
||||
pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len();
|
||||
let len = html.get(start..)?.find(CLIP_CLOSE)?;
|
||||
let bytes = clip_base64(html.get(start..start + len)?)?;
|
||||
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteDesignLayers";
|
||||
let bytes = match (string(p, "html"), string(p, "data")) {
|
||||
(Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?,
|
||||
(None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?,
|
||||
(None, None) => return Err(bad(CMD, "pass `html` or `data`")),
|
||||
};
|
||||
let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??;
|
||||
let doc = std::sync::Arc::new(other.doc);
|
||||
let tops: Vec<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
|
||||
if tops.is_empty() {
|
||||
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.copy", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
copy_to_clipboard(s, "edit.copy", &ids)?;
|
||||
Ok(json!({"copied": s.clipboard.nodes.len()}))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.cut", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
delete(s, &json!({"ids": ids}))
|
||||
copy_to_clipboard(s, "edit.cut", &ids)?;
|
||||
let doc = s.doc()?;
|
||||
let layers: Vec<NodeId> = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect();
|
||||
delete(s, &json!({"ids": layers}))
|
||||
}
|
||||
|
||||
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
|
||||
@@ -1177,7 +1247,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
|
||||
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
|
||||
let idx = opt_index(cmd, p, "index")?;
|
||||
let new_paint = match get(p, "paint") {
|
||||
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
|
||||
None => None,
|
||||
|
||||
+141
-12
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -41,8 +41,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
|
||||
spec!("file.saveAs", "Save as", "{path}", false, save_as),
|
||||
spec!(
|
||||
"file.save",
|
||||
"Save",
|
||||
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save
|
||||
),
|
||||
spec!(
|
||||
"file.saveAs",
|
||||
"Save as",
|
||||
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save_as
|
||||
),
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
@@ -74,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
@@ -461,6 +473,11 @@ fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
fn import_summary(doc: &Document) -> Value {
|
||||
json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()})
|
||||
}
|
||||
|
||||
/// Load a document from any supported file (REST API file JSON images come from an `images`
|
||||
/// folder next to the file).
|
||||
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
|
||||
@@ -506,10 +523,8 @@ fn load_with(
|
||||
Ok((doc.into(), false))
|
||||
}
|
||||
_ => {
|
||||
if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
link_instances(&mut doc);
|
||||
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
|
||||
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -525,10 +540,20 @@ fn load_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
|
||||
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
let r = figcraft_format::fig::try_import(bytes, name)?;
|
||||
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
|
||||
link_instances(&mut doc);
|
||||
figcraft_format::Loaded { issues: warnings, ..doc.into() }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
|
||||
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
|
||||
@@ -677,9 +702,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -692,9 +718,10 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
|
||||
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
|
||||
let warnings = rest || ext(&name) == "svg";
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -795,8 +822,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"profile": profile.id(), "changed": changed}))
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches).
|
||||
/// Native saves act on the current document; a `.fig` copy exports the version shown, and a
|
||||
/// pending Save As choice must leave it shown until its destination is known.
|
||||
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
|
||||
let path =
|
||||
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
|
||||
path.is_some_and(|path| ext(&path) != "fig")
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
|
||||
/// the active document as an experimental `.fig` copy without changing its native save state.
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
if ext(&path) == "fig" {
|
||||
return export_fig(s, &json!({"path": path}));
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
@@ -815,6 +854,11 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return save_to(s, path);
|
||||
}
|
||||
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
|
||||
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
|
||||
Some(path) if ext(&path) == "fig" => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
|
||||
}
|
||||
Some(path) => save_to(s, path),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
@@ -1235,8 +1279,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
|
||||
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
|
||||
let summary = import_summary(&other.doc);
|
||||
let pages = append_pages(s.doc_mut()?, &other.doc)?;
|
||||
let mut out = json!({"pages": pages});
|
||||
let mut out = json!({"pages": pages, "summary": summary});
|
||||
if rest {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
@@ -1272,6 +1317,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
|
||||
// the layers of its store come along into the store of `doc`. Recorded copies are left to
|
||||
// `merge_store`, which shares them with copies `doc` already has.
|
||||
let cached: Vec<NodeId> = other
|
||||
.library_store()
|
||||
.and_then(|s| other.node(s))
|
||||
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
|
||||
.unwrap_or_default();
|
||||
for root in &cached {
|
||||
for id in other.subtree(*root) {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
let mut new_pages = Vec::new();
|
||||
for pg in &other.pages {
|
||||
let Some(pn) = other.node(*pg) else { continue };
|
||||
@@ -1289,6 +1347,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
new_pages.push(np);
|
||||
}
|
||||
if !cached.is_empty() {
|
||||
let store = doc.ensure_library_store();
|
||||
for root in &cached {
|
||||
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, *root, store, None)?;
|
||||
}
|
||||
}
|
||||
for new in map.values() {
|
||||
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
|
||||
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
|
||||
@@ -1354,6 +1419,70 @@ mod rest_import_tests {
|
||||
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
|
||||
}
|
||||
|
||||
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
|
||||
/// visible instance of it and a layer linked to the main's layer.
|
||||
fn cached_import() -> Document {
|
||||
let mut doc = Document::new("Cached");
|
||||
let page = doc.first_page().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let main = doc
|
||||
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
|
||||
.unwrap();
|
||||
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let inst =
|
||||
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let mut copy = doc.node(body).unwrap().clone();
|
||||
copy.id = doc.alloc_id();
|
||||
copy.parent = None;
|
||||
copy.source = Some(body);
|
||||
let copy_id = copy.id;
|
||||
doc.insert(copy, inst, None).unwrap();
|
||||
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Pasting layers that use a cached main into another document leaves the main behind, as it
|
||||
/// does for other mains that are not published: no link points at a layer of the target.
|
||||
#[test]
|
||||
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
|
||||
let src = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
|
||||
let ids = src.subtree(inst);
|
||||
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
|
||||
let nodes: Vec<Node> = ids.iter().filter_map(|i| src.node(*i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, inst, page, None).unwrap();
|
||||
crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap();
|
||||
let pasted = doc.node(map[&inst]).unwrap();
|
||||
let NodeKind::Instance { component, .. } = pasted.kind else { panic!() };
|
||||
assert!(!doc.contains(component), "the main stays behind: {component:?}");
|
||||
assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_pages_with_cached_library_components_brings_the_components() {
|
||||
let other = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
// Ids of the target that would collide with the other document's store.
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
append_pages(&mut doc, &other).unwrap();
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
|
||||
let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
|
||||
let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
|
||||
assert_eq!(doc.node(component).unwrap().name, "Cached main");
|
||||
assert!(doc.is_remote(component), "the main comes along into the store");
|
||||
let layer = doc.node(inst.children[0]).unwrap();
|
||||
assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_loads_with_linked_instances() {
|
||||
let bytes = serde_json::to_vec(&kit()).unwrap();
|
||||
|
||||
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"object.convertToFrame",
|
||||
"Convert to frame",
|
||||
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
|
||||
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
|
||||
true,
|
||||
|s, p| convert(s, p, "object.convertToFrame", true)
|
||||
),
|
||||
spec!(
|
||||
"object.convertToSection",
|
||||
"Convert to section",
|
||||
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
|
||||
true,
|
||||
convert_to_section
|
||||
),
|
||||
spec!(
|
||||
"object.wrapInSection",
|
||||
"Wrap in new section",
|
||||
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
let n = doc.get(i)?;
|
||||
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
|
||||
match (&n.kind, to_frame) {
|
||||
// A frame can't hold sections.
|
||||
(NodeKind::Section { .. }, true)
|
||||
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
|
||||
{
|
||||
return refuse("holds sections, which can't go inside a frame: convert those first");
|
||||
}
|
||||
(NodeKind::Section { .. }, true) => todo.push(i),
|
||||
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
|
||||
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
|
||||
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
|
||||
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
}
|
||||
for i in &todo {
|
||||
if to_frame {
|
||||
// The group's box as it is; nothing painted or clipped, so the look stays.
|
||||
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
|
||||
// section keeps its fill.
|
||||
let n = doc.get_mut(*i)?;
|
||||
if matches!(n.kind, NodeKind::Group) {
|
||||
n.fills.clear();
|
||||
n.strokes.clear();
|
||||
}
|
||||
n.kind = NodeKind::Frame;
|
||||
n.clip = false;
|
||||
n.fills.clear();
|
||||
n.strokes.clear();
|
||||
continue;
|
||||
}
|
||||
let n = doc.get_mut(*i)?;
|
||||
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
Ok(json!({"ids": todo}))
|
||||
}
|
||||
|
||||
/// Frames on the page or in a section become sections in place (sections only sit there).
|
||||
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "object.convertToSection";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut todo = Vec::new();
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
|
||||
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
|
||||
match &n.kind {
|
||||
NodeKind::Section { .. } => {}
|
||||
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
|
||||
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
|
||||
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
|
||||
NodeKind::Frame => todo.push(i),
|
||||
_ => return refuse("is not a frame"),
|
||||
}
|
||||
}
|
||||
for i in &todo {
|
||||
let n = doc.get_mut(*i)?;
|
||||
n.kind = NodeKind::Section { contents_hidden: false };
|
||||
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
doc.get_mut(k)?.child_layout.absolute = false;
|
||||
}
|
||||
figcraft_layout::coherent_sizing(doc, *i);
|
||||
}
|
||||
Ok(json!({"ids": todo}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.ungroup", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -309,33 +363,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let pw = doc.world_transform(parent);
|
||||
let mut path = BezPath::new();
|
||||
let mut even = false;
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in &ids {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
// Booleans flatten to their result; strokes don't get outlined.
|
||||
let Some((mut op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
|
||||
// booleans flatten to their result; strokes don't get outlined.
|
||||
let mut part = match &n.kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
_ => {
|
||||
let Some((op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||||
figcraft_render::subtree_outline(doc, n, 0)
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&op);
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
for r in &mut part.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
}
|
||||
};
|
||||
even |= rule == figcraft_render::FillRule::EvenOdd;
|
||||
// Into the common parent's space.
|
||||
let w = doc.world_transform(*i);
|
||||
op.apply_affine(pw.inverse() * w);
|
||||
path.extend(op.elements().iter().copied());
|
||||
let all: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
}
|
||||
if path.elements().is_empty() {
|
||||
let Some(bb) = net.bounds() else {
|
||||
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
|
||||
}
|
||||
};
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let bb = path.bounding_box();
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
for r in &mut net.regions {
|
||||
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
}
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let vid = doc.alloc_id();
|
||||
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
|
||||
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
@@ -556,6 +623,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in req_targets("vector.offset", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get(i)?.clone();
|
||||
// A vector can't hold children: leave containers (frames, groups, instances) alone.
|
||||
if !n.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
|
||||
@@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) {
|
||||
|
||||
/// [`apply_bindings`] for the layers `only` (every bound layer when `None`).
|
||||
pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>) {
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
let pending = doc.take_dirty();
|
||||
apply_bindings_matching(doc, None, |field| {
|
||||
super::lists::list_binding(field).is_some_and(|(list, _, prop)| {
|
||||
matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes)
|
||||
&& (prop == "color" || super::lists::stop_binding(prop).is_some())
|
||||
})
|
||||
});
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
return;
|
||||
@@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
|
||||
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
|
||||
for (field, var) in binds {
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
|
||||
@@ -332,7 +332,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.preview",
|
||||
"Preview text change",
|
||||
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
|
||||
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
|
||||
false,
|
||||
|s, p| {
|
||||
let c = req_string("text.preview", p, "command")?;
|
||||
|
||||
Reference in New Issue
Block a user