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")?;
|
||||
|
||||
+203
-23
@@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
/// Bounded source and parent lookups while a rebase reads an unchanged document.
|
||||
struct RebaseSources<'a> {
|
||||
doc: &'a Document,
|
||||
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
|
||||
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
|
||||
main_changes: BTreeMap<NodeId, bool>,
|
||||
}
|
||||
|
||||
impl<'a> RebaseSources<'a> {
|
||||
const PARENTS: usize = 512;
|
||||
const CACHE: usize = 8192;
|
||||
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
|
||||
}
|
||||
|
||||
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
|
||||
if let Some(chain) = self.chains.get(&source) {
|
||||
return chain.clone();
|
||||
}
|
||||
let read = || {
|
||||
let mut chain = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = Some(source);
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(id) = current else { return Some(chain) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
chain.push(id);
|
||||
current = n.source;
|
||||
}
|
||||
current.is_none().then_some(chain)
|
||||
};
|
||||
let chain = read();
|
||||
if self.chains.len() < Self::CACHE {
|
||||
self.chains.insert(source, chain.clone());
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Validate the complete parent chain, even if `main` was found before a cycle.
|
||||
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
|
||||
if let Some(found) = self.parents.get(&(id, main)) {
|
||||
return *found;
|
||||
}
|
||||
let read = || {
|
||||
let mut current = Some(id);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut found = false;
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(found) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
found |= id == main;
|
||||
current = self.doc.node(id)?.parent;
|
||||
}
|
||||
current.is_none().then_some(found)
|
||||
};
|
||||
let found = read();
|
||||
if self.parents.len() < Self::CACHE {
|
||||
self.parents.insert((id, main), found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
|
||||
let chain = self.chain(source)?;
|
||||
if chain.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let mut member = None;
|
||||
for id in chain {
|
||||
if self.in_main(id, main)? && id != main && member.is_none() {
|
||||
member = Some(id);
|
||||
}
|
||||
}
|
||||
member
|
||||
}
|
||||
|
||||
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
|
||||
fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
|
||||
let mut owners = Vec::new();
|
||||
let mut current = self.doc.node(id)?.parent;
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(owners) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
if holds_content(n) {
|
||||
return Some(owners);
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &n.kind {
|
||||
owners.push((id, *component));
|
||||
}
|
||||
current = n.parent;
|
||||
}
|
||||
current.is_none().then_some(owners)
|
||||
}
|
||||
|
||||
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
|
||||
}
|
||||
|
||||
/// A contextual child belongs to the same placed slot as its enclosing instance.
|
||||
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
let Some((owner, main)) = owners.first() else { return false };
|
||||
if self.member(source, *main, target).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.in_main(source, *main) == Some(true) {
|
||||
return true;
|
||||
}
|
||||
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
|
||||
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
|
||||
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
|
||||
}
|
||||
|
||||
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
|
||||
let source = self.doc.node(id)?.source?;
|
||||
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|
||||
|| !self.valid(source, id, owners)
|
||||
|| !self.contextual(source, owners)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(source)
|
||||
}
|
||||
|
||||
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
|
||||
for (index, (id, main)) in owners.iter().enumerate() {
|
||||
if *id == stop {
|
||||
break;
|
||||
}
|
||||
let changed = if let Some(changed) = self.main_changes.get(id) {
|
||||
*changed
|
||||
} else {
|
||||
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => component != main,
|
||||
Some(NodeKind::Frame) => true,
|
||||
_ => false,
|
||||
});
|
||||
if self.main_changes.len() < Self::CACHE {
|
||||
self.main_changes.insert(*id, changed);
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
|
||||
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
|
||||
/// the look is kept exactly. Values a component property sets aren't overrides.
|
||||
@@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
|
||||
};
|
||||
let mut updates = Vec::new();
|
||||
let mut sources = RebaseSources::new(doc);
|
||||
let root_owners = sources.owners(inst).unwrap_or_default();
|
||||
let placed_source = sources.placed(inst, &root_owners);
|
||||
let mut contexts = vec![(inst, component)];
|
||||
contexts.extend_from_slice(&root_owners);
|
||||
for (id, path) in &mine {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
// Follow the source chain into the main first (exact), then fall back to the name path.
|
||||
let mut main = None;
|
||||
let mut cur = n.source;
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(c) = cur else { break };
|
||||
if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
|
||||
main = Some(c);
|
||||
break;
|
||||
}
|
||||
cur = doc.node(c).and_then(|x| x.source);
|
||||
let owners = sources.owners(*id).unwrap_or_default();
|
||||
let nearest_main = owners.first().map(|(_, main)| *main);
|
||||
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
|
||||
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
|
||||
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
|
||||
// rebase. An outer name path must not rebind those children to the old expanded copies.
|
||||
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
|
||||
updates.push((*id, valid_source, n.overrides.clone()));
|
||||
continue;
|
||||
}
|
||||
let main = main.or_else(|| theirs.get(path).copied());
|
||||
main = main
|
||||
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
|
||||
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
|
||||
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
|
||||
if let Some(m) = main.and_then(|m| doc.node(m)) {
|
||||
if let Some(m) = source.and_then(|source| doc.node(source)) {
|
||||
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
|
||||
bound.extend(variable_groups(n, m));
|
||||
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
|
||||
@@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
overrides.insert("component".into());
|
||||
}
|
||||
}
|
||||
updates.push((*id, main, overrides));
|
||||
updates.push((*id, source, overrides));
|
||||
}
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
let root_groups = match (doc.node(component), doc.node(inst)) {
|
||||
// A placed nested root compares to its actual slot. A detached root compares to its own main.
|
||||
let root_base = placed_source.or_else(|| {
|
||||
let chain = sources.chain(component)?;
|
||||
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
|
||||
});
|
||||
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
|
||||
(Some(m), Some(n)) => {
|
||||
let from_variables = variable_groups(n, m);
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.collect();
|
||||
// A layer name of its own (a nested instance renamed in its old main) stays.
|
||||
if n.name != default_instance_name(doc, component) {
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
groups.push("name".into());
|
||||
}
|
||||
if placed_source.is_some()
|
||||
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
|
||||
{
|
||||
groups.push("component".into());
|
||||
}
|
||||
groups
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
drop(sources);
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = Some(component);
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "components_rebase_source_tests.rs"]
|
||||
mod rebase_source_tests;
|
||||
|
||||
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
|
||||
/// the left-most).
|
||||
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
|
||||
doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap()
|
||||
}
|
||||
|
||||
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
|
||||
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
|
||||
}
|
||||
|
||||
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
|
||||
let mut n = doc.node(source).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.parent = None;
|
||||
n.source = Some(source);
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId {
|
||||
let mut n = doc.node(main).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() };
|
||||
n.name = name.into();
|
||||
n.parent = None;
|
||||
n.source = None;
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
doc: Document,
|
||||
inner: NodeId,
|
||||
base: NodeId,
|
||||
slot: NodeId,
|
||||
context: NodeId,
|
||||
root: NodeId,
|
||||
nested: NodeId,
|
||||
child: NodeId,
|
||||
}
|
||||
|
||||
fn scene() -> Scene {
|
||||
let mut doc = Document::new("Source context");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = instance(&mut doc, inner, outer, "Nested");
|
||||
doc.node_mut(slot).unwrap().source = Some(inner);
|
||||
let context = doc.node(slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
doc.node_mut(context).unwrap().opacity = 0.3;
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
doc.node_mut(child).unwrap().opacity = 0.2;
|
||||
Scene { doc, inner, base, slot, context, root, nested, child }
|
||||
}
|
||||
|
||||
fn both(s: &mut Scene) {
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_default_only_sources_promote_to_the_actual_outer_context() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().source = None;
|
||||
s.doc.node_mut(s.child).unwrap().source = None;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_a_context_source_from_another_nested_slot() {
|
||||
let mut s = scene();
|
||||
let outer = s.doc.node(s.slot).unwrap().parent.unwrap();
|
||||
let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot");
|
||||
s.doc.node_mut(sibling).unwrap().source = Some(s.inner);
|
||||
let sibling_child = s.doc.node(sibling).unwrap().children[0];
|
||||
mirror(&mut s.doc, sibling, s.root);
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(sibling_child);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0);
|
||||
assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_mark_inherited_context_values_as_local_overrides() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.child).unwrap().opacity = 0.3;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_multilevel_context_without_shortening_source_chains() {
|
||||
let mut doc = Document::new("Nested source contexts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let wrapper = component(&mut doc, page, "Wrapper");
|
||||
let default_slot = instance(&mut doc, inner, wrapper, "Nested");
|
||||
doc.node_mut(default_slot).unwrap().source = Some(inner);
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot");
|
||||
doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper);
|
||||
let source_slot = doc.node(wrapper_slot).unwrap().children[0];
|
||||
let context = doc.node(source_slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let wrapper_copy = doc.node(root).unwrap().children[0];
|
||||
let nested = doc.node(wrapper_copy).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().name = "Renamed nested".into();
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
for id in [nested, wrapper_copy, root] {
|
||||
rebase_instance(&mut doc, id);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(source_slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(context));
|
||||
}
|
||||
assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0]));
|
||||
assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_placed_frame_to_instance_and_current_main_children() {
|
||||
let mut doc = Document::new("Typed nested source");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let body = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot");
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() };
|
||||
let child = mirror(&mut doc, body, nested);
|
||||
rebase_instance(&mut doc, nested);
|
||||
rebase_instance(&mut doc, root);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(body));
|
||||
assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.remove(s.child).unwrap();
|
||||
s.child = mirror(&mut s.doc, body, s.nested);
|
||||
s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into();
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(body));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let detached = mirror(&mut s.doc, s.nested, page);
|
||||
let child = s.doc.node(detached).unwrap().children[0];
|
||||
// The old source references still exist, but their Outer context is absent here.
|
||||
s.doc.node_mut(detached).unwrap().source = Some(s.slot);
|
||||
s.doc.node_mut(child).unwrap().source = Some(s.context);
|
||||
s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
rebase_instance(&mut s.doc, detached);
|
||||
assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner));
|
||||
assert_eq!(s.doc.node(child).unwrap().source, Some(s.base));
|
||||
assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let foreign = component(&mut s.doc, page, "Foreign");
|
||||
let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(foreign_body);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap");
|
||||
assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.base).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, None);
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.context).unwrap().parent = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().parent = Some(s.child);
|
||||
s.doc.node_mut(s.child).unwrap().parent = Some(s.nested);
|
||||
let sources = RebaseSources::new(&s.doc);
|
||||
assert!(sources.owners(s.child).is_none());
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() };
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Frames and groups converted into each other in place: the layers stay where they are on the
|
||||
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
|
||||
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
|
||||
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
|
||||
#[test]
|
||||
fn junk_parameters_are_refused_not_fatal() {
|
||||
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
|
||||
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
|
||||
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
|
||||
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_on_the_page_becomes_a_section_and_back() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
|
||||
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
|
||||
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
|
||||
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
|
||||
let n = s.doc().unwrap().node(f).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
|
||||
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
|
||||
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
|
||||
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
|
||||
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
|
||||
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
|
||||
let n = s.doc().unwrap().node(f).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
|
||||
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
|
||||
// Each conversion is one undo step.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(kind(&s, f), NodeKind::Frame));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
let mut s = Session::new();
|
||||
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
|
||||
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
|
||||
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
|
||||
assert!(err.contains("inside a frame"), "{err}");
|
||||
let r = rect(&mut s, outer, 150.0, 150.0);
|
||||
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
|
||||
// A frame inside a section converts; a section holding a section can't become a frame.
|
||||
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
|
||||
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
|
||||
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
|
||||
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
|
||||
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in
|
||||
//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
params::id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn copied_file() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap());
|
||||
s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap();
|
||||
s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap();
|
||||
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
B64.decode(r["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn clipboard_html(buffer: &[u8]) -> String {
|
||||
let mut wrapped = B64.encode(buffer);
|
||||
wrapped.insert(8, '\n');
|
||||
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
|
||||
}
|
||||
|
||||
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_is_found_in_the_html() {
|
||||
let file = copied_file();
|
||||
assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file));
|
||||
assert_eq!(design_clipboard_buffer("<p>plain</p>"), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None);
|
||||
// Web text that only mentions the markers holds no design data.
|
||||
assert_eq!(design_clipboard_buffer(&format!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_design_data_is_refused_before_decoding() {
|
||||
// Just past the cap (256 MB of data, as base64), made of valid base64 characters.
|
||||
let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4);
|
||||
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None);
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err();
|
||||
assert!(e.to_string().contains("256 MB"), "{e}");
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_paste_into_the_page_and_undo() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
assert_eq!(s.selection(), ids);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page)));
|
||||
let card = doc.node(ids[0]).unwrap();
|
||||
assert_eq!(names(&s, &card.children), ["Badge"]);
|
||||
assert_eq!((card.width, card.height), (200.0, 100.0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_paste_repeats_the_copied_layers() {
|
||||
let mut s = Session::new();
|
||||
// `data` may be wrapped and unpadded, like the HTML's base64.
|
||||
let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string();
|
||||
data.insert(8, '\n');
|
||||
s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
|
||||
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pastes_into_the_selected_frame() {
|
||||
let mut s = Session::new();
|
||||
let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [host]})).unwrap();
|
||||
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
|
||||
let first = params::id_of(&r["ids"][0]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_content_is_refused_without_changes() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().nodes().count();
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "<b>hi</b>"})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
|
||||
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
|
||||
assert_eq!(s.doc().unwrap().nodes().count(), before);
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
@@ -50,3 +50,43 @@ fn local_samples_match_their_exports() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers of an instance copy keep their link to the layer they stand for when the file is
|
||||
/// opened, also after they were renamed in the copy (a name is no identity), and the override
|
||||
/// that a rename is stays an override.
|
||||
#[test]
|
||||
fn opened_fig_links_instance_layers_by_identity_not_by_name() {
|
||||
use base64::Engine as _;
|
||||
let mut s = Session::new();
|
||||
let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap();
|
||||
let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"}));
|
||||
let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}}));
|
||||
let inner = make(&mut s, "component.create", json!({"ids": [body]}));
|
||||
let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"}));
|
||||
let outer = make(&mut s, "component.create", json!({"ids": [frame]}));
|
||||
let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer}));
|
||||
s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap();
|
||||
let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200}));
|
||||
let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0];
|
||||
let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0];
|
||||
s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap();
|
||||
let _ = label;
|
||||
|
||||
let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id()));
|
||||
std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let mut opened = Session::new();
|
||||
let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
result.unwrap();
|
||||
let doc = opened.doc().unwrap();
|
||||
let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
|
||||
let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
let slot = doc.node(copy.children[0]).unwrap();
|
||||
let label = doc.node(slot.children[0]).unwrap();
|
||||
assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label"));
|
||||
assert_eq!(slot.source, Some(outer.children[0]));
|
||||
assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0]));
|
||||
assert!(slot.overrides.contains("name") && label.overrides.contains("name"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be
|
||||
//! reopened (JSON writes NaN and infinity as null).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn rect(s: &mut Session) -> crate::NodeId {
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
crate::params::id_of(&r["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn assert_roundtrips(s: &Session) {
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_moves_are_refused_and_the_file_still_reopens() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok();
|
||||
let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308}));
|
||||
assert!(second.is_err(), "a move past the f64 range is refused");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform);
|
||||
assert_roundtrips(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_scale_is_refused_and_the_file_still_reopens() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok();
|
||||
let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300}));
|
||||
assert!(second.is_err(), "a size past the f64 range is refused");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height);
|
||||
assert_roundtrips(&s);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_stroke_weight_with_round_caps_still_saves_and_exports() {
|
||||
for weight in [1e6, 1e7, 1e10, 1e30] {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap());
|
||||
s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}}))
|
||||
.unwrap();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id()));
|
||||
let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err());
|
||||
let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"}));
|
||||
assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than
|
||||
//! quietly meaning "none".
|
||||
//!
|
||||
//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id —
|
||||
//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the
|
||||
//! layer landed on the page while the call reported success. The same shape sat on `image.place`
|
||||
//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets
|
||||
//! layers (an entry that wasn't an id was dropped and the rest ran).
|
||||
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The page's children, by name (bottom first).
|
||||
fn top(s: &Session) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
let page = s.page().unwrap();
|
||||
doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// The message of a failure, or `None` when the call went through.
|
||||
fn failed(r: std::result::Result<Value, EngineError>) -> Option<String> {
|
||||
match r {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(parent: Value) -> Value {
|
||||
json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})
|
||||
}
|
||||
|
||||
fn card(s: &mut Session) -> NodeId {
|
||||
let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] {
|
||||
let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused"));
|
||||
assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}");
|
||||
}
|
||||
assert_eq!(top(&s), ["Card"], "nothing landed on the page");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_the_parent_out_or_null_still_means_the_page() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for parent in [None, Some(json!(null))] {
|
||||
let mut p = rect(json!(0));
|
||||
match parent {
|
||||
None => _ = p.as_object_mut().unwrap().remove("parent"),
|
||||
Some(v) => p["parent"] = v,
|
||||
}
|
||||
s.execute("node.create", &p).unwrap();
|
||||
}
|
||||
assert_eq!(top(&s), ["Card", "child", "child"]);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_id_that_names_nothing_is_still_reported() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail"));
|
||||
assert!(msg.contains("no such node 99999"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_that_is_not_a_whole_non_negative_number_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
// (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every
|
||||
// other number, so it is deliberately not in this list.)
|
||||
for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] {
|
||||
let mut p = rect(json!(card));
|
||||
p["index"] = index.clone();
|
||||
let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused"));
|
||||
assert!(msg.contains("`index`"), "{index}: {msg}");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_index_still_inserts_where_it_says() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for name in ["a", "b"] {
|
||||
s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10}))
|
||||
.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect();
|
||||
assert_eq!(names, ["a", "mid", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
// The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame.
|
||||
for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] {
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused"));
|
||||
assert!(msg.contains("`ids`"), "{ids}: {msg}");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail"));
|
||||
assert!(msg.contains("`ids` must be an array"), "{msg}");
|
||||
// An explicit null says the same as leaving it out: the selection.
|
||||
s.execute("select.set", &json!({"ids": [card]})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_id_that_is_not_an_id_is_refused() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail"));
|
||||
assert!(msg.contains("`id` must be a node id"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_and_an_instance_parent_are_checked_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}})));
|
||||
assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place");
|
||||
let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}})));
|
||||
assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_bad_value_is_cut_short_in_the_error() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let junk = "é".repeat(10_000);
|
||||
let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail"));
|
||||
assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}");
|
||||
assert!(msg.chars().count() < 200, "{} chars", msg.chars().count());
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Imported file bytes already live in the document: the command log keeps no second copy.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#3366cc"/></svg>"##;
|
||||
|
||||
fn payload() -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(SVG)
|
||||
}
|
||||
|
||||
fn holds_payload(journal: &[(String, Value)]) -> bool {
|
||||
let payload = payload();
|
||||
journal.iter().any(|(_, params)| params.to_string().contains(&payload))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_imports_keep_no_payload_in_the_journal() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node_count();
|
||||
s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap();
|
||||
assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file");
|
||||
// Not journaling is no change to the edit itself: still one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node_count(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_wrappers_do_not_retain_import_payloads() {
|
||||
let mut s = Session::new();
|
||||
let nested =
|
||||
json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}});
|
||||
s.execute("command.batch", &json!({"commands": [nested]})).unwrap();
|
||||
assert!(!holds_payload(&s.journal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_imports_and_ordinary_edits_are_still_journaled() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("kit.svg");
|
||||
std::fs::write(&path, SVG).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["file.import", "shape.rectangle"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type and nesting level.
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
@@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
|
||||
+85
-10
@@ -358,8 +358,8 @@ pub struct DocState {
|
||||
versions_changed: bool,
|
||||
/// A version shown in place of the document (read-only).
|
||||
pub preview: Option<VersionPreview>,
|
||||
/// The document under a live text preview (a hovered text style or font instance, a dragged
|
||||
/// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back.
|
||||
/// The document under a live text preview (a hovered font family or style, text style or
|
||||
/// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back.
|
||||
pub text_preview: Option<Arc<Document>>,
|
||||
/// The document as of the last version saved or restored (none yet): autosaves, restores and
|
||||
/// merges only add a checkpoint for changes since.
|
||||
@@ -391,7 +391,8 @@ pub struct VersionPreview {
|
||||
/// Why edits are refused while a version is shown.
|
||||
pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit";
|
||||
|
||||
/// Commands that first end a version preview: they act on the document itself.
|
||||
/// Commands that first end a version preview: they act on the document itself. Saving does so
|
||||
/// only with a native destination; export copies and pending format choices keep the preview.
|
||||
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -402,6 +403,8 @@ impl DocState {
|
||||
doc.repair();
|
||||
}
|
||||
doc.link_layer_text_styles();
|
||||
cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
// Initial import settlement belongs to the saved baseline, not the user's first edit.
|
||||
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
|
||||
let doc = Arc::new(doc);
|
||||
let mut st = Self {
|
||||
@@ -653,8 +656,16 @@ pub struct Prefs {
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
/// Recently run actions (the Actions palette's Recents), newest first, at most
|
||||
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
|
||||
/// `widget:<plugin>`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub recent_actions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Recently run actions kept for the Actions palette (newest first).
|
||||
pub const MAX_RECENT_ACTIONS: usize = 5;
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -682,6 +693,7 @@ impl Default for Prefs {
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
recent_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,7 +734,8 @@ pub struct Session {
|
||||
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
|
||||
/// theme, so what scripts make is the same everywhere.
|
||||
pub ui_dark: Option<bool>,
|
||||
/// Command log (id + params) for actions/automation.
|
||||
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
|
||||
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Recently used colours.
|
||||
pub recent_colors: Vec<figcraft_color::Color>,
|
||||
@@ -747,6 +760,19 @@ impl Default for Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
|
||||
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"file.import" => params::get(params, "data").is_some(),
|
||||
"command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| {
|
||||
commands.iter().any(|c| {
|
||||
c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params))
|
||||
})
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A session with one new document.
|
||||
pub fn new() -> Self {
|
||||
@@ -871,6 +897,7 @@ impl Session {
|
||||
// Every other command acts on the document itself, not on a live text preview.
|
||||
textpreview::end(self);
|
||||
if ENDS_PREVIEW.contains(&id)
|
||||
&& (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params))
|
||||
&& let Ok(ds) = self.ds_mut()
|
||||
{
|
||||
ds.end_preview();
|
||||
@@ -879,16 +906,21 @@ impl Session {
|
||||
let result = guard::run(id, || (spec.run)(self, params))
|
||||
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_preview(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
|
||||
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 5000 {
|
||||
self.journal.drain(..1000);
|
||||
// Imported bytes already live in the document: a Base64 copy in the log would stay
|
||||
// for the whole session.
|
||||
if !command_carries_import_bytes(id, params) {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 5000 {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -917,6 +949,22 @@ impl Session {
|
||||
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
|
||||
}
|
||||
|
||||
/// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
|
||||
/// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
|
||||
fn check_finite_geometry(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
if Arc::ptr_eq(&ds.doc, bdoc) {
|
||||
return Ok(());
|
||||
}
|
||||
let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite());
|
||||
let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b));
|
||||
match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) {
|
||||
Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A live session's document you may only view (or that is on its way to a server) is
|
||||
/// read-only: refuse an edit (it is rolled back).
|
||||
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
@@ -1136,13 +1184,22 @@ impl Session {
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
|
||||
for (step, (id, p)) in cmds.iter().enumerate() {
|
||||
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
|
||||
results.push(s.execute(id, &p)?);
|
||||
let v = s.batch_step(&results, step, id, p)?;
|
||||
results.push(v);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
|
||||
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
|
||||
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
|
||||
/// resolve `$step` the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
|
||||
self.execute(cmd, &p)
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
@@ -1501,6 +1558,8 @@ mod cursor_tests;
|
||||
#[cfg(test)]
|
||||
mod dark_page_text_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_resources_tests;
|
||||
#[cfg(test)]
|
||||
mod devcompare_tests;
|
||||
@@ -1529,6 +1588,8 @@ mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod finite_geometry_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_label_tests;
|
||||
@@ -1549,8 +1610,14 @@ mod history_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod html_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod id_params_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
@@ -1591,10 +1658,14 @@ mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod page_clipboard_tests;
|
||||
#[cfg(test)]
|
||||
mod page_copy_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod page_target_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_index_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
@@ -1643,6 +1714,8 @@ mod rotated_select_tests;
|
||||
#[cfg(test)]
|
||||
mod rotation_origin_tests;
|
||||
#[cfg(test)]
|
||||
mod save_format_tests;
|
||||
#[cfg(test)]
|
||||
mod scale_tool_tests;
|
||||
#[cfg(test)]
|
||||
mod section_contents_tests;
|
||||
@@ -1711,6 +1784,8 @@ mod vector_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_multi_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_offset_tests;
|
||||
#[cfg(test)]
|
||||
mod version_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
@@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
|
||||
if !variables_unchanged(before, after) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) };
|
||||
let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store);
|
||||
if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) {
|
||||
let changed_remote = |id| {
|
||||
if !before.is_remote(id) && !after.is_remote(id) {
|
||||
return false;
|
||||
}
|
||||
match (before.node_arc(id), after.node_arc(id)) {
|
||||
(Some(old), Some(new)) => !Arc::ptr_eq(old, new),
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if after.dirty().iter().any(|id| changed_remote(*id)) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn dirty_library_document() -> (Document, NodeId) {
|
||||
let mut doc = Document::new("Dirty library cache");
|
||||
let store = doc.ensure_library_store();
|
||||
let remote = doc.alloc_id();
|
||||
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
||||
(doc, remote)
|
||||
}
|
||||
|
||||
/// An imported cache is dirty until the first edit; that alone is no edit of the library copies.
|
||||
#[test]
|
||||
fn read_only_allows_unchanged_predirty_remote_nodes() {
|
||||
let (before, remote) = dirty_library_document();
|
||||
assert!(before.dirty().contains(&remote));
|
||||
|
||||
let mut after = before.clone();
|
||||
after.add_page("Unrelated page");
|
||||
assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set");
|
||||
check_read_only("page.add", &before, &after).unwrap();
|
||||
|
||||
let mut edited = before.clone();
|
||||
edited.get_mut(remote).unwrap().opacity = 0.5;
|
||||
assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into()));
|
||||
|
||||
let mut moved = before.clone();
|
||||
moved.reparent(remote, before.first_page().unwrap(), None).unwrap();
|
||||
assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into()));
|
||||
|
||||
let mut deleted = before.clone();
|
||||
deleted.remove(remote).unwrap();
|
||||
assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into()));
|
||||
}
|
||||
|
||||
/// The library file's layers.
|
||||
struct Kit {
|
||||
button: NodeId,
|
||||
@@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
@@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() {
|
||||
let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap();
|
||||
let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") };
|
||||
assert!(doc.is_remote(component));
|
||||
let store_roots = |s: &Session| {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len())
|
||||
};
|
||||
assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice");
|
||||
// A second import shares the copies it already has.
|
||||
ex(&mut t, "file.import", json!({"path": path}));
|
||||
healthy(&t);
|
||||
assert_eq!(t.doc().unwrap().libraries.components.len(), 2);
|
||||
assert_eq!(store_roots(&t), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a
|
||||
//! selection of only pages under their own command id.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
#[test]
|
||||
fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() {
|
||||
for cmd in ["edit.copy", "edit.cut"] {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
match s.execute(cmd, &json!({"ids": [page.0]})) {
|
||||
Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd),
|
||||
r => panic!("{cmd} on a page: {r:?}"),
|
||||
}
|
||||
let _ = s.execute("edit.paste", &json!({}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate());
|
||||
assert!(doc.node(page).is_some(), "{cmd} removed the page");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_of_a_page_and_a_layer_moves_only_the_layer() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let rect = crate::params::id_of(&r["id"]).unwrap();
|
||||
s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(page).is_some());
|
||||
assert!(s.doc().unwrap().node(rect).is_none());
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of
|
||||
//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn paints(s: &Session, id: NodeId) -> String {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
format!("{:?} {:?}", n.fills, n.strokes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||||
for add in ["fill.add", "stroke.add"] {
|
||||
for c in ["#0000FF", "#FFFF00"] {
|
||||
ex(&mut s, add, json!({"ids": [r], "color": c}));
|
||||
}
|
||||
}
|
||||
let before = paints(&s, r);
|
||||
for cmd in ["fill.set", "stroke.set"] {
|
||||
for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] {
|
||||
let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"}));
|
||||
assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}");
|
||||
assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document");
|
||||
}
|
||||
ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"}));
|
||||
assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(paints(&s, r), before);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
|
||||
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
|
||||
}
|
||||
|
||||
/// The offending value for an error message, cut to a few dozen characters so a huge param
|
||||
/// doesn't end up in the error whole.
|
||||
fn shown(v: &Value) -> String {
|
||||
const MAX: usize = 40;
|
||||
let text = v.to_string();
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((cut, _)) => format!("{}…", &text[..cut]),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a
|
||||
/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" —
|
||||
/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't
|
||||
/// resolved yet silently becomes "no parent" and the layer lands on the page.
|
||||
pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result<Option<NodeId>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none".
|
||||
pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result<Option<usize>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => req_index(cmd, p, key).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` (array), `id` (single), or the current selection.
|
||||
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
|
||||
if let Some(Value::Array(a)) = get(p, "ids") {
|
||||
@@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
|
||||
s.selection()
|
||||
}
|
||||
|
||||
/// Like [`targets`] but an empty result is an error.
|
||||
/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an
|
||||
/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit
|
||||
/// `null` says the same as leaving the key out.
|
||||
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match get(p, "ids") {
|
||||
Some(Value::Array(a)) => {
|
||||
if let Some(v) = a.iter().find(|v| id_of(v).is_none()) {
|
||||
return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v))));
|
||||
}
|
||||
}
|
||||
Some(Value::Null) | None => {
|
||||
if let Some(v) = get(p, "id").filter(|v| !v.is_null())
|
||||
&& id_of(v).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v))));
|
||||
}
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))),
|
||||
}
|
||||
let t = targets(s, p);
|
||||
if t.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
|
||||
|
||||
@@ -111,6 +111,10 @@ const DENIED: &[&str] = &[
|
||||
"edit.redo",
|
||||
"edit.repeat",
|
||||
"command.batch",
|
||||
// Network sessions, online files and their recovery copies on disk.
|
||||
"collab.",
|
||||
// Opens files in new windows.
|
||||
"window.",
|
||||
"view.theme",
|
||||
];
|
||||
/// Read-only exceptions to [`DENIED`].
|
||||
@@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if command == "image.place" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the image as `data`".into());
|
||||
}
|
||||
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
|
||||
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
|
||||
return Some("plugins can't write files: leave out `path` to get the result back".into());
|
||||
}
|
||||
if command == "codeConnect.import" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the connections as `json`".into());
|
||||
}
|
||||
// Variables JSON may be given as a path: only objects and JSON text for plugins.
|
||||
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
|
||||
return Some("plugins can't read files: pass the variables JSON itself".into());
|
||||
}
|
||||
if !m.permissions.allows(command) {
|
||||
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
|
||||
}
|
||||
|
||||
@@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
("plugin.remove", json!({"id": "org.test.deny"})),
|
||||
("edit.undo", json!({})),
|
||||
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
|
||||
("variable.export", json!({"format": "css", "path": target})),
|
||||
("codeConnect.export", json!({"path": target})),
|
||||
("codeConnect.import", json!({"path": "/etc/passwd"})),
|
||||
("component.playground", json!({"id": a, "path": target})),
|
||||
("variable.import", json!({"variables": "/etc/passwd"})),
|
||||
("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})),
|
||||
("window.new", json!({"path": "C:/Windows/win.ini"})),
|
||||
("doc.switch", json!({"index": 0})),
|
||||
("pref.keepTool", json!({})),
|
||||
// Without a name it would open inline renaming in the layers panel: a UI request.
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Save As can write an experimental `.fig` copy without replacing native save state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_format::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{DocState, EngineError, Session};
|
||||
|
||||
struct SaveFiles {
|
||||
dir: PathBuf,
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl SaveFiles {
|
||||
fn new() -> Self {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self { dir, files: Vec::new() }
|
||||
}
|
||||
|
||||
fn path(&mut self, name: &str) -> PathBuf {
|
||||
let path = self.dir.join(name);
|
||||
self.files.push(path.clone());
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SaveFiles {
|
||||
fn drop(&mut self) {
|
||||
for path in &self.files {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(path);
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, name: &str) {
|
||||
s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
||||
}
|
||||
|
||||
fn assert_versions_unchanged(after: &[Version], before: &[Version]) {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (after, before) in after.iter().zip(before) {
|
||||
assert_eq!(
|
||||
(&after.id, &after.name, &after.description, after.time, after.auto),
|
||||
(&before.id, &before.name, &before.description, before.time, before.auto)
|
||||
);
|
||||
assert_eq!(*after.document().unwrap(), *before.document().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) {
|
||||
let after = s.ds().unwrap();
|
||||
assert_eq!(after.path, before.path);
|
||||
assert_eq!(after.is_dirty(), before.is_dirty());
|
||||
assert_eq!(after.versions_changed, before.versions_changed);
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc));
|
||||
assert_eq!(
|
||||
after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)),
|
||||
before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page))
|
||||
);
|
||||
if let (Some(after), Some(before)) = (&after.preview, &before.preview) {
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
}
|
||||
assert_eq!((&after.selection, after.page), (&before.selection, before.page));
|
||||
assert_versions_unchanged(&after.versions, &before.versions);
|
||||
assert_eq!(after.branches.len(), before.branches.len());
|
||||
for (after, before) in after.branches.iter().zip(&before.branches) {
|
||||
assert_eq!(
|
||||
(&after.id, &after.name, after.time, after.client, after.state),
|
||||
(&before.id, &before.name, before.time, before.client, before.state)
|
||||
);
|
||||
assert_eq!(*after.base, *before.base);
|
||||
assert_eq!(*after.head, *before.head);
|
||||
assert_versions_unchanged(&after.versions, &before.versions);
|
||||
}
|
||||
for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (after, before) in after.iter().zip(before) {
|
||||
assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page));
|
||||
assert!(Arc::ptr_eq(&after.doc, &before.doc));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.prefs.recent_files, recent);
|
||||
}
|
||||
|
||||
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert_eq!(result["format"], "fig");
|
||||
assert_eq!(result["bytes"], bytes.len());
|
||||
assert!(result["warnings"].is_array());
|
||||
assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data");
|
||||
figcraft_format::fig::import(&bytes, "Copy").unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_save_as_keeps_native_path_dirty_state_and_history() {
|
||||
let mut files = SaveFiles::new();
|
||||
let native = files.path("design.figcraft");
|
||||
let copy = files.path("copy.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Saved rectangle");
|
||||
s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
rect(&mut s, "New rectangle");
|
||||
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap();
|
||||
s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap();
|
||||
rect(&mut s, "Undone rectangle");
|
||||
s.undo().unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(before.is_dirty());
|
||||
assert!(!before.history.redo.is_empty());
|
||||
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert!(imported.nodes().any(|n| n.name == "New rectangle"));
|
||||
assert!(!imported.nodes().any(|n| n.name == "Undone rectangle"));
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out"));
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle"));
|
||||
assert_eq!(persisted.versions[0].name, "Before copy");
|
||||
assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_fig_copy_remains_unsaved() {
|
||||
let mut files = SaveFiles::new();
|
||||
let copy = files.path("unsaved.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Unsaved rectangle");
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle"));
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_fig_copy_keeps_clean_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
let copy = files.path("empty.fig");
|
||||
let mut s = Session::new();
|
||||
let before = s.ds().unwrap().clone();
|
||||
assert!(!before.is_dirty());
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert_eq!(imported.pages.len(), 1);
|
||||
assert_eq!(imported.node_count(), 1);
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_save_commands_accept_uppercase_extension() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let copy = files.path(&format!("copy-{index}.FIG"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, command);
|
||||
let before = s.ds().unwrap().clone();
|
||||
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == command));
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_from_branch_exports_active_document_without_saving_main() {
|
||||
let mut files = SaveFiles::new();
|
||||
let native = files.path("main.figcraft");
|
||||
let copy = files.path("branch.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Main rectangle");
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
rect(&mut s, "Branch rectangle");
|
||||
s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap();
|
||||
// Branch edits normally sync to main before commands; capture that state before exporting.
|
||||
s.execute("branch.list", &json!({})).unwrap();
|
||||
let branch = s.ds().unwrap().clone();
|
||||
let main = s.docs[0].clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
|
||||
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert_save_state_unchanged(&s, &branch, &recent);
|
||||
assert_eq!(s.docs[0].path, main.path);
|
||||
assert_eq!(s.docs[0].is_dirty(), main.is_dirty());
|
||||
assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc));
|
||||
assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc));
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
|
||||
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert!(!s.docs[0].is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_write_failure_keeps_save_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
let blocked = files.path("blocked.fig");
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
let missing = files.path("missing/copy.fig");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Kept rectangle");
|
||||
s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap();
|
||||
rect(&mut s, "Later rectangle");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
for command in ["file.save", "file.saveAs"] {
|
||||
for path in [&blocked, &missing] {
|
||||
assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err());
|
||||
assert_save_state_unchanged(&s, &before, &[]);
|
||||
assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_without_extension_stays_native() {
|
||||
let mut files = SaveFiles::new();
|
||||
let requested = files.dir.join("native");
|
||||
let native = files.path("native.figcraft");
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "Native rectangle");
|
||||
let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap();
|
||||
assert_eq!(result["path"], native.to_string_lossy().as_ref());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert!(!requested.exists());
|
||||
let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap();
|
||||
assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let native = files.path(&format!("current-{index}.figcraft"));
|
||||
let copy = files.path(&format!("version-{index}.FIG"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&native).unwrap();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(before.preview.is_some());
|
||||
assert!(before.is_dirty());
|
||||
|
||||
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
let imported = read_fig(©, &result);
|
||||
assert!(imported.nodes().any(|n| n.name == "A"));
|
||||
assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&native).unwrap(), saved);
|
||||
s.execute("version.exitPreview", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "B"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_save_choice_keeps_version_preview() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
if native_path {
|
||||
let native = files.path(&format!("saved-{index}.figcraft"));
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
}
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_from_version_preview_writes_current_document() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() {
|
||||
let native = files.path(&format!("native-version-{index}.figcraft"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_none());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
||||
assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_save_protects_legacy_fig_destination() {
|
||||
let mut files = SaveFiles::new();
|
||||
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
|
||||
let legacy = files.path(&format!("legacy-{index}.FIG"));
|
||||
let native = files.path(&format!("corrected-{index}.figcraft"));
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, "A");
|
||||
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
|
||||
// Earlier builds could write native data under .fig; keep those bytes safe on plain Save.
|
||||
let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
|
||||
std::fs::write(&legacy, &sentinel).unwrap();
|
||||
s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned());
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
rect(&mut s, "B");
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
let before = s.ds().unwrap().clone();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
|
||||
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
|
||||
assert_save_state_unchanged(&s, &before, &recent);
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
||||
|
||||
s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_none());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
|
||||
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
|
||||
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
|
||||
assert_eq!(loaded.versions[0].name, "A only");
|
||||
rect(&mut s, "C");
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert!(loaded.0.nodes().any(|n| n.name == "C"));
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
|
||||
}
|
||||
}
|
||||
@@ -275,3 +275,92 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() {
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true}));
|
||||
assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_moves_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
|
||||
let home = (Point::new(500.0, 0.0), Point::new(600.0, 0.0));
|
||||
// (selection before, Shift-drag to (from r at 520,20), selection after, where r and empty end up, derivation)
|
||||
let rows: Vec<(Vec<NodeId>, (f64, f64), Vec<NodeId>, (Point, Point), &str)> = vec![
|
||||
(
|
||||
vec![k.r],
|
||||
(560.0, 22.0),
|
||||
vec![k.r],
|
||||
(Point::new(540.0, 0.0), Point::new(600.0, 0.0)),
|
||||
"Shift-drag on the selected layer moves it along one axis and keeps it selected",
|
||||
),
|
||||
(
|
||||
vec![k.r, k.empty],
|
||||
(560.0, 22.0),
|
||||
vec![k.r, k.empty],
|
||||
(Point::new(540.0, 0.0), Point::new(640.0, 0.0)),
|
||||
"Shift-drag on one of several selected layers moves them all",
|
||||
),
|
||||
(
|
||||
vec![k.empty],
|
||||
(560.0, 22.0),
|
||||
vec![k.r, k.empty],
|
||||
(Point::new(540.0, 0.0), Point::new(640.0, 0.0)),
|
||||
"Shift-drag on an unselected layer adds it and moves the selection",
|
||||
),
|
||||
(vec![k.r, k.empty], (521.0, 21.0), vec![k.empty], home, "a Shift-press that wobbles under the drag threshold is a click: the layer leaves"),
|
||||
];
|
||||
for (before, to, after, end, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
drag(&mut s, (520.0, 20.0), to, SHIFT);
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
assert_eq!((at(&s, k.r), at(&s, k.empty)), end, "{why}");
|
||||
if end != home {
|
||||
assert!(s.undo().unwrap(), "{why}: one undo step");
|
||||
assert_eq!((at(&s, k.r), at(&s, k.empty)), home, "{why}: undone");
|
||||
}
|
||||
}
|
||||
// Shift let go before the button: still a Shift-click.
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
|
||||
s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 520.0, 20.0, NONE)).unwrap();
|
||||
assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
|
||||
let n = layers(&s);
|
||||
let alt_shift = |kind, x, y| {
|
||||
let mut e = ev(kind, x, y, SHIFT);
|
||||
e.alt = true;
|
||||
e
|
||||
};
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r]}));
|
||||
s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap();
|
||||
s.pointer(&alt_shift(PointerKind::Up, 520.0, 20.0)).unwrap();
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new(), "an Alt+Shift-click is a Shift-click: the layer leaves the selection");
|
||||
assert_eq!(layers(&s), n, "and nothing is copied");
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r]}));
|
||||
s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap();
|
||||
s.pointer(&alt_shift(PointerKind::Move, 560.0, 22.0)).unwrap();
|
||||
s.pointer(&alt_shift(PointerKind::Up, 560.0, 22.0)).unwrap();
|
||||
let sel = s.selection();
|
||||
assert_eq!(layers(&s), n + 1, "an Alt+Shift-drag copies the layer");
|
||||
assert!(sel.len() == 1 && sel[0] != k.r, "the copy is selected");
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.world_bounds(sel[0]).unwrap().origin(), Point::new(540.0, 0.0), "the copy moves along one axis");
|
||||
assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(500.0, 0.0), "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_takes_a_layer_out_where_nothing_can_move() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// A version being viewed is read-only: the press can't start a move.
|
||||
let v = ex(&mut s, "file.saveVersion", json!({"name": "v"}));
|
||||
ex(&mut s, "version.preview", json!({"id": v["id"]}));
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]}));
|
||||
assert!(s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).is_err(), "a version being viewed can't be edited");
|
||||
s.pointer(&ev(PointerKind::Up, 520.0, 20.0, SHIFT)).unwrap();
|
||||
assert_eq!(s.selection(), vec![k.empty], "Shift-click still takes the layer out");
|
||||
}
|
||||
|
||||
@@ -396,6 +396,30 @@ fn boolean_results_and_the_look_they_take() {
|
||||
assert!(path.area().abs() < 1e-6, "no overlap, nothing to show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_keeps_open_paths() {
|
||||
// #377: open paths have no fill outline, so two pen paths flattened to "nothing to flatten"
|
||||
// and next to a closed shape they disappeared.
|
||||
let mut s = Session::new();
|
||||
let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 50], [200, 0]]}));
|
||||
let b = create(&mut s, "vector.create", json!({"points": [[0, 100], [100, 150], [200, 100]]}));
|
||||
let tri = create(&mut s, "vector.create", json!({"points": [[300, 0], [400, 0], [400, 100]], "closed": true}));
|
||||
let v = create(&mut s, "vector.flatten", json!({"ids": [a, b, tri]}));
|
||||
let NodeKind::Vector { network } = node(&s, v).kind else { panic!("flatten makes one vector") };
|
||||
assert_eq!((network.vertices.len(), network.segments.len()), (9, 7), "both open paths and the triangle");
|
||||
assert_eq!(network.regions.len(), 1, "only the triangle is filled");
|
||||
let bb = s.doc().unwrap().world_bounds(v).unwrap();
|
||||
assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 400.0, 150.0), "nothing moves");
|
||||
|
||||
// Joining two open paths goes the same way.
|
||||
let mut s = Session::new();
|
||||
let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]}));
|
||||
let b = create(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]]}));
|
||||
let v = create(&mut s, "vector.join", json!({"ids": [a, b]}));
|
||||
let NodeKind::Vector { network } = node(&s, v).kind else { panic!("join makes one vector") };
|
||||
assert_eq!(network.segments.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_and_outline_stroke() {
|
||||
// Flatten: one vector layer from the selection, in one step; Alt+Shift+F does it.
|
||||
|
||||
@@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() {
|
||||
assert!(links(&s, t).iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_show_a_hovered_font_family() {
|
||||
let mut s = new_session();
|
||||
let t = text(&mut s, "Preview me");
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
// Hovering a font family shows it on the canvas: no undo step, not dirty.
|
||||
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap();
|
||||
assert_eq!(data(&s, t).style_at(0).font_family, "Arial");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
// Moving to another family replaces the preview, from the document itself.
|
||||
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap();
|
||||
assert_eq!(data(&s, t).style_at(0).font_family, "Georgia");
|
||||
// Closing without picking puts the document back as it was.
|
||||
assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true));
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
// Clicking commits it as one undo step.
|
||||
s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap();
|
||||
assert_eq!(data(&s, t).style_at(0).font_family, "Georgia");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
|
||||
assert!(s.ds().unwrap().text_preview.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_refuse_what_they_cant_show() {
|
||||
let mut s = new_session();
|
||||
|
||||
@@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() {
|
||||
assert_eq!(data(&s, t).list, P, "toggled off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_with_a_start_number_toggles_off() {
|
||||
// An imported list that starts at 2 has the start on its first item only; it is still one
|
||||
// numbered list, so the button turns it back to plain (#686).
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "two\nthree\nfour");
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
|
||||
td.paragraphs = vec![n(1), n(0), n(0)];
|
||||
}
|
||||
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start");
|
||||
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(lists(&s, t), [(P, 0); 3]);
|
||||
assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start");
|
||||
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_list_starts() {
|
||||
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
|
||||
@@ -117,7 +135,7 @@ fn list_keys_at_list_items() {
|
||||
for (chars, before, caret, k, shift, after, want, why) in cases {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, chars);
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
@@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() {
|
||||
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
|
||||
// the one they split from; joined paragraphs keep the first one's.
|
||||
let mut t = TextData::new("ab\ncd\nef");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
|
||||
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
|
||||
t.replace_range(1, 1, "x\ny");
|
||||
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
|
||||
@@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() {
|
||||
(seed >> 33) as usize % n.max(1)
|
||||
};
|
||||
let mut t = TextData::new("a\nb\nc\nd");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
|
||||
for _ in 0..500 {
|
||||
let n = t.char_count();
|
||||
let (a, b) = (rnd(n + 2), rnd(n + 2));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Live text previews: what a text command would do, shown on the canvas without an undo step —
|
||||
//! while the pointer is over a text style in the style browser or a font's named instance, or
|
||||
//! while a variable font axis slider is dragged. The document under the preview is kept and comes
|
||||
//! while the pointer is over a font family or style, a text style in the style browser or a
|
||||
//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes
|
||||
//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so
|
||||
//! committing (running the command for real) applies to the document itself as one undo step, and
|
||||
//! Escape just ends the preview.
|
||||
|
||||
+26
-14
@@ -344,6 +344,9 @@ pub(crate) enum Gesture {
|
||||
targets: Targets,
|
||||
duplicate: bool,
|
||||
moved: bool,
|
||||
/// A Shift-press on this selected layer: it leaves the selection on release, unless the
|
||||
/// press became a drag.
|
||||
deselect: Option<NodeId>,
|
||||
},
|
||||
Resize {
|
||||
start: Point,
|
||||
@@ -1194,15 +1197,15 @@ impl Session {
|
||||
Some(target) => {
|
||||
// Clicking inside the current selection's box keeps it (drag them all).
|
||||
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
|
||||
// Shift on a selected layer takes it out on release if the press doesn't become a
|
||||
// drag; a drag moves the selection with it. Where no drag can start, it goes now.
|
||||
let deselect = (ev.shift && sel.contains(&target)).then_some(target);
|
||||
let without_target = || sel.iter().copied().filter(|x| *x != target).collect::<Vec<_>>();
|
||||
let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {
|
||||
sel.clone()
|
||||
} else if ev.shift {
|
||||
} else if ev.shift && deselect.is_none() {
|
||||
let mut s2 = sel.clone();
|
||||
if let Some(pos) = s2.iter().position(|x| *x == target) {
|
||||
s2.remove(pos);
|
||||
} else {
|
||||
s2.push(target);
|
||||
}
|
||||
s2.push(target);
|
||||
s2
|
||||
} else if sel.contains(&target) {
|
||||
sel.clone()
|
||||
@@ -1210,12 +1213,12 @@ impl Session {
|
||||
vec![target]
|
||||
};
|
||||
self.set_selection(new_sel.clone());
|
||||
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) {
|
||||
return Ok(());
|
||||
}
|
||||
// Locked layers selected in the layers panel stay put.
|
||||
let ids: Vec<NodeId> = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect();
|
||||
if ids.is_empty() {
|
||||
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
|
||||
if deselect.is_some() {
|
||||
self.set_selection(without_target());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut targets = Targets::gather(&doc, page, &ids);
|
||||
@@ -1225,8 +1228,13 @@ impl Session {
|
||||
{
|
||||
targets.add_box(b);
|
||||
}
|
||||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||||
if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) {
|
||||
if deselect.is_some() {
|
||||
self.set_selection(without_target());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect };
|
||||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||||
}
|
||||
None => {
|
||||
@@ -1735,7 +1743,7 @@ impl Session {
|
||||
self.tool.overlay.drop_cell = None;
|
||||
let drop_target = self.tool.overlay.drop_target.take();
|
||||
match g {
|
||||
Gesture::Move { ids, moved, duplicate, start, .. } => {
|
||||
Gesture::Move { ids, moved, duplicate, start, deselect, .. } => {
|
||||
if moved {
|
||||
// A free move lands in the frame under the pointer (or the page) where it was
|
||||
// dropped; layers in an auto layout frame already sit in their slot.
|
||||
@@ -1777,7 +1785,11 @@ impl Session {
|
||||
}
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
if !(ev.shift || ev.cmd || duplicate) {
|
||||
if let Some(d) = deselect {
|
||||
let sel = self.selection().into_iter().filter(|x| *x != d).collect();
|
||||
self.set_selection(sel);
|
||||
}
|
||||
if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) {
|
||||
self.widget_click(start, ev.tol() * 2.0)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,79 @@ use serde_json::json;
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap();
|
||||
let page = s.doc().unwrap().first_page().unwrap();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap();
|
||||
s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let node = doc.get_mut(id).unwrap();
|
||||
node.name = "load-binding-sentinel".into();
|
||||
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
|
||||
node.width = 5.0;
|
||||
node.source = Some(page);
|
||||
node.overrides.insert("sentinel".into());
|
||||
doc.take_dirty();
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId {
|
||||
let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap();
|
||||
assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into()));
|
||||
assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0));
|
||||
assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill"));
|
||||
node.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_color_bindings_settle_in_constructor_and_loaders() {
|
||||
let (stale, _id) = stale_bound_color_document();
|
||||
let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform;
|
||||
|
||||
let mut state = Session::with_document(stale.clone());
|
||||
let id = assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page());
|
||||
assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(!state.ds().unwrap().is_dirty());
|
||||
assert!(state.doc().unwrap().dirty().is_empty());
|
||||
assert_eq!(state.undo_depth(), (0, 0));
|
||||
state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
|
||||
let native = figcraft_format::save_bytes(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
|
||||
// A native file settles when it opens as a document, not in the loader.
|
||||
let opened = Session::with_document(loaded);
|
||||
let loaded = opened.doc().unwrap();
|
||||
let loaded_id = assert_dark_bound_color(loaded, name_transform);
|
||||
assert_eq!(loaded_id, id);
|
||||
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
|
||||
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(loaded.dirty().is_empty());
|
||||
|
||||
let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap();
|
||||
assert_dark_bound_color(&loaded, name_transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_paint_settlement_preserves_existing_dirty_nodes() {
|
||||
let (mut doc, _) = stale_bound_color_document();
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.mark_dirty([page]);
|
||||
|
||||
crate::cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
|
||||
assert_eq!(doc.dirty().iter().copied().collect::<Vec<_>>(), [page]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_layers_follow_variable_values_and_unbind() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() {
|
||||
assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_strokes_are_easy_to_enter_and_bend() {
|
||||
use crate::tools::ToolId;
|
||||
// A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it.
|
||||
let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0);
|
||||
// (layer, screen px off the stroke, in edit mode after a double-click, derivation)
|
||||
let rows: Vec<(&str, f64, bool, &str)> = vec![
|
||||
("line", 0.0, true, "double-clicking a selected line edits it as a vector"),
|
||||
("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"),
|
||||
("line", 8.0, false, "a double-click well away from it does not"),
|
||||
("path", 4.0, true, "a stroke-only path reaches as far as a line does"),
|
||||
("path", 8.0, false, "and no further"),
|
||||
];
|
||||
for (layer, off, after, why) in rows {
|
||||
let mut s = Session::new();
|
||||
let id = if layer == "line" {
|
||||
s.set_tool(ToolId::Line);
|
||||
drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE);
|
||||
*s.selection().first().unwrap()
|
||||
} else {
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap();
|
||||
// Styled like a pen stroke: 1 px, no fill.
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
s.set_selection(vec![id]);
|
||||
id
|
||||
};
|
||||
assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke");
|
||||
let p = at(off);
|
||||
click(&mut s, p, NONE);
|
||||
s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap();
|
||||
assert_eq!(editing(&s).is_some(), after, "{why}");
|
||||
if after {
|
||||
// Cmd/Ctrl then bends it through the pointer.
|
||||
drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE });
|
||||
let w = s.doc().unwrap().world_transform(id);
|
||||
let n = net(&s, id);
|
||||
assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it");
|
||||
assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_follows_the_documented_rules() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `vector.offset` on layers with children: containers are left alone.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// No node that holds children may be a non-container.
|
||||
fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) {
|
||||
let doc = s.doc().unwrap();
|
||||
for &i in ids {
|
||||
let n = doc.node(i).unwrap();
|
||||
assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_leaves_containers_with_children_alone() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
|
||||
for target in [f, g] {
|
||||
s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap();
|
||||
assert_children_only_in_containers(&s, &[f, g, inner, a, b]);
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(f).unwrap().kind.is_container());
|
||||
assert_eq!(doc.node(f).unwrap().children, vec![inner]);
|
||||
assert!(doc.node(g).unwrap().kind.is_container());
|
||||
assert_eq!(doc.node(g).unwrap().children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_still_converts_a_plain_rectangle() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap();
|
||||
let n = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Vector { .. }));
|
||||
assert!(n.width > 10.0);
|
||||
}
|
||||
@@ -996,8 +996,13 @@ impl Session {
|
||||
self.tool.overlay.guides.clear();
|
||||
let Gesture::Vector(g) = g else { return Ok(()) };
|
||||
match g {
|
||||
VGesture::Points { moved, split, click_clears, .. } => {
|
||||
VGesture::Points { verts, others, moved, split, click_clears, .. } => {
|
||||
if moved || split.is_some() {
|
||||
if let ([v], None) = (verts.as_slice(), split)
|
||||
&& others.iter().all(|(_, o)| o.is_empty())
|
||||
{
|
||||
self.merge_dropped_point(ve.id, *v, 6.0 / ev.zoom.max(1e-6))?;
|
||||
}
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
@@ -1057,4 +1062,40 @@ impl Session {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its
|
||||
/// points, merges into that point (in the same undo step as the move), which stays selected.
|
||||
fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> {
|
||||
let moved = self.ds()?.doc.get(id)?.clone();
|
||||
let w = self.ds()?.doc.world_transform(id);
|
||||
let NodeKind::Vector { network: net } = &moved.kind else { return Ok(()) };
|
||||
let Some(p) = net.vertices.get(v).map(|x| w * x.point()) else { return Ok(()) };
|
||||
let Some(keep) = net
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != v)
|
||||
.map(|(i, x)| (i, (w * x.point() - p).hypot()))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut merged = net.clone();
|
||||
ops::merge_vertices(&mut merged, keep, v);
|
||||
// A merge that would leave no segment (a 2-point line's ends dropped together) only moves.
|
||||
if merged.segments.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The preview starts again from the document before the drag: bring the moved layer.
|
||||
self.preview(|doc, _| {
|
||||
*doc.get_mut(id)? = moved;
|
||||
edit(doc, id, |net| *net = merged)
|
||||
})?;
|
||||
set_state(self, |e| {
|
||||
e.vertices = vec![if keep > v { keep - 1 } else { keep }];
|
||||
e.segments.clear();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,36 @@ fn mirroring_position_and_join_commands() {
|
||||
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_dropped_on_another_merges_into_it() {
|
||||
// #401: an open path's end dragged onto its start closes the path at that point.
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]]}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
drag(&mut s, (0.0, 100.0), (2.0, 3.0));
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.vertices.len(), 3, "the dropped point merged");
|
||||
assert!((0..3).all(|v| n.incident(v).len() == 2), "a closed path: {n:?}");
|
||||
assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 0.0, 0.0)], "the point it merged into stays selected");
|
||||
s.undo().unwrap();
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.vertices.len(), 4, "one undo step takes back the move and the merge");
|
||||
vertex_at(&n, 0.0, 100.0);
|
||||
// A drop away from every point only moves it.
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": []}));
|
||||
drag(&mut s, (0.0, 100.0), (0.0, 50.0));
|
||||
assert_eq!(net(&s, id).vertices.len(), 4);
|
||||
// A 2-point line's end dropped on its other end only moves: no segment would be left.
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
drag(&mut s, (100.0, 0.0), (2.0, 0.0));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_past_the_conversion_leaves_edit_mode() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -248,6 +248,19 @@ fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() {
|
||||
assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_click_on_a_selected_widget_only_deselects_it() {
|
||||
let mut s = session();
|
||||
let (w, _) = insert(&mut s);
|
||||
let target = s.doc().unwrap().world_bounds(node(&s, w).children[1]).unwrap().center();
|
||||
let mut down = PointerEvent::at(PointerKind::Down, target.x, target.y, 1.0);
|
||||
down.shift = true;
|
||||
// Shift let go before the button: still a Shift-click, not a click on the widget.
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, target.x, target.y, 1.0)).unwrap();
|
||||
assert_eq!((s.selection(), state(&mut s, w)), (vec![], json!({"n": 1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_widgets_and_clicks_are_refused_and_roll_back() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user