M3: component slots and nested override edge cases (#56)
Slots: a frame of a main component becomes a slot property (component.makeSlot / removeSlot, preferred components, type "slot" in component.properties). Instances show the main's default content until the slot is edited; from then on its layers are the instance's own (override group "slot"): free to add, remove and reorder, kept across main updates, variant switches, copy/paste and detach, and instances placed there stand alone. instance.slotInsert fills a slot, instance.resetSlot brings the default content back. Structure protection allows edits inside slots only; drops from the Assets panel, canvas drags, drawing and paste land in slots. UI: slot icon in the layers panel, dashed outlines and labels on the canvas, slot property rows with a fill menu in the properties panel, Make/Remove slot on frames inside a main. Nested overrides: overrides inside a nested instance survive the main moving it into another frame (layers matched by source alone when unique); a nested instance copied out of an instance is re-linked to its own main with its look kept (values set by component properties aren't overrides); plain layers copied out of instances stop mirroring them; canvas drags can no longer pull layers out of an instance or reorder its auto layout children. Tests for swaps, variant switches, main restructuring, detach and copy/paste. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec5da02a35
commit
0638c98564
18
ROADMAP.md
18
ROADMAP.md
|
|
@ -70,8 +70,15 @@ dimension measured side by side.
|
|||
show on the main's instances); component descriptions (edited on the main, shown on
|
||||
instances); local libraries (publish a file's components, sets and styles with per-asset
|
||||
versions, enable library files, place library components and apply library styles as
|
||||
read-only linked copies, "Library updates available" with overrides kept on update).
|
||||
*Left:* nested override edge cases, slots, library variables. 7–11 h.
|
||||
read-only linked copies, "Library updates available" with overrides kept on update); slots
|
||||
(a frame of a main made a slot property with preferred components; instances fill it with any
|
||||
content of their own, kept across main updates, variant switches, copy/paste and detach, with
|
||||
the main's default content until edited; reset slot; slot badges in the layers panel and on the
|
||||
canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in
|
||||
slots); nested overrides kept when the main moves or swaps a nested instance, and on nested
|
||||
instances copied out of an instance (re-linked to their own main); canvas drags can no longer
|
||||
pull layers out of an instance.
|
||||
*Left:* library variables. 2–4 h.
|
||||
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
|
||||
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
|
||||
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
|
||||
|
|
@ -113,8 +120,7 @@ dimension measured side by side.
|
|||
### Where we're going
|
||||
|
||||
- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers,
|
||||
a real-file corpus); variable width strokes; component slots and nested override edge cases
|
||||
(in progress).
|
||||
a real-file corpus); variable width strokes (in progress); library variables.
|
||||
- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8).
|
||||
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
|
||||
|
||||
|
|
@ -141,7 +147,7 @@ dimension measured side by side.
|
|||
eyedropper), text editing (caret, selection, words, lines), auto layout reorder by dragging,
|
||||
drop into frames, align/distribute/tidy, z-order, flip/rotate, scale tool, booleans, flatten,
|
||||
outline stroke, components (create, instances, sync with overrides, reset, detach, push changes,
|
||||
variants, properties), styles and variables, prototype commands, find/replace, batch rename,
|
||||
variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename,
|
||||
copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG
|
||||
in/out, PNG/JPG/WebP), version snapshots.
|
||||
- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip, icon rail, file header, pages and
|
||||
|
|
@ -170,7 +176,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
|||
| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 |
|
||||
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |
|
||||
| Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 |
|
||||
| Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 |
|
||||
| Components & variants | 9 | 71% | library variables | 2–4 |
|
||||
| Styles & variables | 5 | 82% | bindings that follow effects when they are reordered, grid and component property bindings | 1–2 |
|
||||
| Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 |
|
||||
| Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 |
|
||||
|
|
|
|||
|
|
@ -37,10 +37,25 @@ impl BoolOp {
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PropDef {
|
||||
Boolean { default: bool },
|
||||
Text { default: String },
|
||||
InstanceSwap { default: NodeId },
|
||||
Variant { options: Vec<String>, default: String },
|
||||
Boolean {
|
||||
default: bool,
|
||||
},
|
||||
Text {
|
||||
default: String,
|
||||
},
|
||||
InstanceSwap {
|
||||
default: NodeId,
|
||||
},
|
||||
Variant {
|
||||
options: Vec<String>,
|
||||
default: String,
|
||||
},
|
||||
/// A frame (bound through `prop_refs["slot"]`) whose instances take any content of their own;
|
||||
/// `preferred` components are offered first when filling it.
|
||||
Slot {
|
||||
#[serde(default)]
|
||||
preferred: Vec<NodeId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a node is, with per-type data.
|
||||
|
|
@ -380,11 +395,13 @@ pub struct Node {
|
|||
/// Handoff status of a frame or section (shown as a badge next to its name).
|
||||
#[serde(default, skip_serializing_if = "DevStatus::is_none")]
|
||||
pub dev_status: DevStatus,
|
||||
/// Inside an instance: property groups overridden locally ("fills", "characters", …).
|
||||
/// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot":
|
||||
/// this slot holds the instance's own content).
|
||||
#[serde(default)]
|
||||
pub overrides: BTreeSet<String>,
|
||||
/// Component property references: which property controls this node's
|
||||
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap).
|
||||
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot
|
||||
/// property this frame is ("slot").
|
||||
#[serde(default)]
|
||||
pub prop_refs: BTreeMap<String, String>,
|
||||
/// Free-form plugin / agent data.
|
||||
|
|
|
|||
|
|
@ -93,12 +93,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"instances": out}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"component.makeSlot",
|
||||
"Make slot",
|
||||
"{id? (a frame inside a main component), name? (the slot property's name; the frame's by default), preferred? [component or set ids]} (its instances take any content there)",
|
||||
true,
|
||||
make_slot
|
||||
),
|
||||
spec!(
|
||||
"component.removeSlot",
|
||||
"Remove slot",
|
||||
"{id? (a slot frame of a main component)} (instances lose their own content in it)",
|
||||
true,
|
||||
remove_slot
|
||||
),
|
||||
spec!(
|
||||
"instance.slotInsert",
|
||||
"Insert into slot",
|
||||
"{id? (a slot inside an instance, or the instance with `slot`), slot? (slot property name), component, index? (child index; on top when omitted), replace? (clear the slot first)}",
|
||||
true,
|
||||
slot_insert
|
||||
),
|
||||
spec!(
|
||||
"instance.resetSlot",
|
||||
"Reset slot",
|
||||
"{id? (a slot inside an instance, or the instance with `slot`), slot?} (back to the main's default content)",
|
||||
true,
|
||||
reset_slot
|
||||
),
|
||||
spec!("component.detach", "Detach instance", "{ids?}", true, detach),
|
||||
spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance),
|
||||
spec!(
|
||||
"component.resetOverride",
|
||||
"Reset override",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props; all when omitted)}",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props|slot; all when omitted)}",
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
|
|
@ -143,14 +171,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"component.addProperty",
|
||||
"Add component property",
|
||||
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant, default?, layer? (node bound to it)}",
|
||||
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (slots: component ids)}",
|
||||
true,
|
||||
add_prop
|
||||
),
|
||||
spec!(
|
||||
"component.editProperty",
|
||||
"Edit component property",
|
||||
"{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value)}",
|
||||
"{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (slots: component ids)}",
|
||||
true,
|
||||
edit_prop
|
||||
),
|
||||
|
|
@ -158,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"component.bindProperty",
|
||||
"Bind layer to property",
|
||||
"{layer?, property (null/\"\" unbinds), field?: visible|characters|mainComponent}",
|
||||
"{layer?, property (null/\"\" unbinds), field?: visible|characters|mainComponent|slot}",
|
||||
true,
|
||||
bind_prop
|
||||
),
|
||||
|
|
@ -397,30 +425,15 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if inside_instance(doc, i) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into()));
|
||||
}
|
||||
// Own layers lose their link; nested instances stay instances of their own mains.
|
||||
let mut nested = Vec::new();
|
||||
for d in doc.subtree(i).into_iter().filter(|d| *d != i) {
|
||||
let parent_inst = doc.node(d).and_then(|n| n.parent).and_then(|p| owning_instance(doc, p));
|
||||
if parent_inst != Some(i) {
|
||||
continue;
|
||||
}
|
||||
if matches!(doc.node(d).map(|n| &n.kind), Some(NodeKind::Instance { .. })) {
|
||||
nested.push(d);
|
||||
} else if let Some(n) = doc.node_mut(d) {
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.clear();
|
||||
}
|
||||
}
|
||||
// Own layers lose their link (slot content stays as it is); nested instances stay
|
||||
// instances of their own mains.
|
||||
let nested = unlink_layers(doc, i, Some(i));
|
||||
let n = doc.get_mut(i)?;
|
||||
n.kind = NodeKind::Frame;
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.remove("mainComponent");
|
||||
for nn in nested {
|
||||
if let Some(m) = doc.node_mut(nn) {
|
||||
m.prop_refs.clear();
|
||||
}
|
||||
rebase_instance(doc, nn);
|
||||
}
|
||||
out.push(i);
|
||||
|
|
@ -503,6 +516,108 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
|
||||
/// A property name for `owner` based on `base` (a layer name): valid and not taken yet.
|
||||
fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String {
|
||||
let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::<String>().trim().to_string();
|
||||
let base = if base.is_empty() { "Slot".to_string() } else { base };
|
||||
let taken = |n: &str| {
|
||||
owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n))
|
||||
|| matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n))
|
||||
};
|
||||
(1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// The `preferred` components of a slot property: component or component set ids.
|
||||
fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?;
|
||||
let mut out = Vec::new();
|
||||
for x in list.iter().take(500) {
|
||||
let c = id_of(x)
|
||||
.filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })))
|
||||
.ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?;
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.makeSlot";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?;
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none();
|
||||
if !matches!(n.kind, NodeKind::Frame) || !inside_main {
|
||||
return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot"));
|
||||
}
|
||||
if is_slot(doc, id) {
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name)));
|
||||
}
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.removeSlot";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?;
|
||||
let doc = s.doc()?;
|
||||
if owning_instance(doc, id).is_some() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into()));
|
||||
}
|
||||
let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?;
|
||||
delete_prop(s, &json!({"id": id, "name": name}))?;
|
||||
Ok(json!({"id": id, "name": name}))
|
||||
}
|
||||
|
||||
/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`.
|
||||
fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result<NodeId> {
|
||||
let slot = match name {
|
||||
Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?,
|
||||
None => id,
|
||||
};
|
||||
if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() {
|
||||
return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)"));
|
||||
}
|
||||
Ok(slot)
|
||||
}
|
||||
|
||||
fn slot_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "instance.slotInsert";
|
||||
let c = req_id(cmd, p, "component")?;
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?;
|
||||
let index = match get(p, "index").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?;
|
||||
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
|
||||
if boolean(p, "replace").unwrap_or(false) {
|
||||
for k in doc.get(slot)?.children.clone() {
|
||||
doc.remove(k)?;
|
||||
}
|
||||
}
|
||||
let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?;
|
||||
s.set_selection(vec![i]);
|
||||
Ok(json!({"id": i, "slot": slot}))
|
||||
}
|
||||
|
||||
fn reset_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "instance.resetSlot";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?;
|
||||
let changed = slot_edited(doc, slot);
|
||||
if changed {
|
||||
doc.get_mut(slot)?.overrides.remove(SLOT);
|
||||
}
|
||||
Ok(json!({"id": slot, "changed": changed}))
|
||||
}
|
||||
|
||||
fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.pushChanges";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
|
|
@ -703,6 +818,7 @@ fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Re
|
|||
let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
|
||||
if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) }
|
||||
}
|
||||
PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -754,7 +870,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
for (k, v) in values {
|
||||
ip.insert(k, v);
|
||||
}
|
||||
for (k, d) in &defs {
|
||||
for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) {
|
||||
ip.entry(k.clone()).or_insert_with(|| prop_default_value(d));
|
||||
}
|
||||
}
|
||||
|
|
@ -877,13 +993,22 @@ fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.set_selection(vec![set]);
|
||||
return Ok(json!({"id": set}));
|
||||
}
|
||||
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")),
|
||||
"slot" => {
|
||||
if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) {
|
||||
return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)"));
|
||||
}
|
||||
PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? }
|
||||
}
|
||||
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")),
|
||||
};
|
||||
for c in &comps {
|
||||
set_prop_defs(doc, *c, |m| {
|
||||
m.insert(name.clone(), def.clone());
|
||||
})?;
|
||||
// Existing instances get the default.
|
||||
if !takes_value(&def) {
|
||||
continue;
|
||||
}
|
||||
for inst in instances_of(doc, *c) {
|
||||
if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind {
|
||||
props.entry(name.clone()).or_insert_with(|| prop_default_value(&def));
|
||||
|
|
@ -920,6 +1045,7 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
|
|||
PropDef::Text { .. } => "characters",
|
||||
PropDef::InstanceSwap { .. } => "mainComponent",
|
||||
PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")),
|
||||
PropDef::Slot { .. } => SLOT,
|
||||
};
|
||||
let field = field.unwrap_or(expected);
|
||||
if field != expected {
|
||||
|
|
@ -929,6 +1055,14 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
|
|||
"characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")),
|
||||
"mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")),
|
||||
"visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")),
|
||||
SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")),
|
||||
SLOT if doc
|
||||
.subtree(comp)
|
||||
.into_iter()
|
||||
.any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) =>
|
||||
{
|
||||
return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string());
|
||||
|
|
@ -941,9 +1075,9 @@ fn bind_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let property = string(p, "property");
|
||||
let field = string(p, "field");
|
||||
if let Some(f) = &field
|
||||
&& !matches!(f.as_str(), "visible" | "characters" | "mainComponent")
|
||||
&& !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT)
|
||||
{
|
||||
return Err(bad(cmd, "field: visible|characters|mainComponent"));
|
||||
return Err(bad(cmd, "field: visible|characters|mainComponent|slot"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
bind(cmd, doc, layer, property.as_deref(), field.as_deref())?;
|
||||
|
|
@ -1034,16 +1168,20 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return ok();
|
||||
}
|
||||
let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?;
|
||||
let new_def = match get(p, "default") {
|
||||
Some(v) => match coerce(cmd, doc, &name, &def, v)? {
|
||||
Value::Bool(b) => PropDef::Boolean { default: b },
|
||||
Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t },
|
||||
other => match (&def, id_of(&other)) {
|
||||
(PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c },
|
||||
_ => def.clone(),
|
||||
let new_def = match (&def, get(p, "preferred")) {
|
||||
(PropDef::Slot { .. }, Some(_)) => PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? },
|
||||
(_, Some(_)) => return Err(bad(cmd, "`preferred` is for slot properties")),
|
||||
(_, None) => match get(p, "default") {
|
||||
Some(v) => match coerce(cmd, doc, &name, &def, v)? {
|
||||
Value::Bool(b) => PropDef::Boolean { default: b },
|
||||
Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t },
|
||||
other => match (&def, id_of(&other)) {
|
||||
(PropDef::InstanceSwap { .. }, Some(c)) => PropDef::InstanceSwap { default: c },
|
||||
_ => def.clone(),
|
||||
},
|
||||
},
|
||||
None => def.clone(),
|
||||
},
|
||||
None => def.clone(),
|
||||
};
|
||||
for c in &comps {
|
||||
let had = prop_defs(doc, *c).contains_key(&name);
|
||||
|
|
@ -1105,6 +1243,7 @@ fn def_json(name: &str, d: &PropDef) -> Value {
|
|||
PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}),
|
||||
PropDef::InstanceSwap { default } => json!({"name": name, "type": "instanceSwap", "default": default}),
|
||||
PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}),
|
||||
PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1127,9 +1266,21 @@ fn describe_props(doc: &Document, id: NodeId) -> Value {
|
|||
list.push(v);
|
||||
}
|
||||
}
|
||||
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
|
||||
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
|
||||
for (k, d) in prop_defs(doc, comp) {
|
||||
let mut v = def_json(&k, &d);
|
||||
if let Some(vals) = &values {
|
||||
if !takes_value(&d) {
|
||||
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
|
||||
if values.is_some() {
|
||||
v["value"] = layers.first().map_or(Value::Null, |l| {
|
||||
let count = doc.node(*l).map_or(0, |n| n.children.len());
|
||||
json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count})
|
||||
});
|
||||
} else {
|
||||
v["layers"] = json!(layers);
|
||||
}
|
||||
} else if let Some(vals) = &values {
|
||||
v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d));
|
||||
}
|
||||
list.push(v);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
/// The top-most frame-like (or section) node on the page containing page point `pt`,
|
||||
/// excluding `exclude` and their descendants.
|
||||
pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> Option<NodeId> {
|
||||
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize) -> Option<NodeId> {
|
||||
/// `locked`: inside an instance, where only slots (and what's in them) take layers.
|
||||
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize, locked: bool) -> Option<NodeId> {
|
||||
if depth > 128 || exclude.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -117,17 +118,16 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
|
|||
if is_container && !inside {
|
||||
return None;
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
for c in n.children.iter().rev() {
|
||||
if let Some(f) = rec(doc, *c, pt, exclude, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
let locked = crate::components::locks_children(doc, id, locked);
|
||||
for c in n.children.iter().rev() {
|
||||
if let Some(f) = rec(doc, *c, pt, exclude, depth + 1, locked) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
(is_container && inside && !matches!(n.kind, NodeKind::Instance { .. })).then_some(id)
|
||||
(is_container && inside && !locked).then_some(id)
|
||||
}
|
||||
let p = doc.node(page)?;
|
||||
p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0))
|
||||
p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0, false))
|
||||
}
|
||||
|
||||
pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@
|
|||
//! `prop_refs` on the layers they control.
|
||||
//!
|
||||
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
|
||||
//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance) and
|
||||
//! `props` (property values of a nested instance).
|
||||
//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance),
|
||||
//! `props` (property values of a nested instance) and `slot` (a slot holding the instance's own
|
||||
//! content).
|
||||
//!
|
||||
//! Instances never gain or lose layers of their own: commands that would add, remove or reorder
|
||||
//! layers inside an instance are refused ([`check_structure_edit`]).
|
||||
//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A
|
||||
//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An
|
||||
//! instance's copy of it shows the main's default content until its layers are edited; from then
|
||||
//! on (override group `slot`) its children are the instance's own content: free to edit, kept
|
||||
//! across syncs and variant switches, and outside the instance's structure (instances placed in it
|
||||
//! stand alone).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
|
|
@ -23,14 +29,48 @@ use serde_json::Value;
|
|||
|
||||
/// Nesting limit for instances inside instances.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// The override group (and `prop_refs` field) of slots.
|
||||
pub const SLOT: &str = "slot";
|
||||
/// Every override group a layer can carry.
|
||||
pub const OVERRIDE_GROUPS: &[&str] =
|
||||
&["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position", "component", "props"];
|
||||
pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"fills",
|
||||
"strokes",
|
||||
"effects",
|
||||
"opacity",
|
||||
"visible",
|
||||
"name",
|
||||
"corners",
|
||||
"characters",
|
||||
"textStyle",
|
||||
"size",
|
||||
"position",
|
||||
"component",
|
||||
"props",
|
||||
SLOT,
|
||||
];
|
||||
|
||||
fn is_instance(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))
|
||||
}
|
||||
|
||||
/// A slot (inside an instance) whose children are the instance's own content.
|
||||
fn holds_content(n: &Node) -> bool {
|
||||
n.overrides.contains(SLOT)
|
||||
}
|
||||
|
||||
/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content:
|
||||
/// content belongs to no instance (it edits freely, and instances placed in it stand alone).
|
||||
fn scope(doc: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let mut out = vec![id];
|
||||
for a in doc.ancestors(id) {
|
||||
if doc.node(a).is_some_and(holds_content) {
|
||||
break;
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The component a node belongs to (itself, or the nearest component ancestor).
|
||||
pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
|
|
@ -40,21 +80,117 @@ pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
|
|||
|
||||
/// The innermost instance containing `id` (`id` itself when it is an instance).
|
||||
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
chain.into_iter().find(|c| is_instance(doc, *c))
|
||||
scope(doc, id).into_iter().find(|c| is_instance(doc, *c))
|
||||
}
|
||||
|
||||
/// The outermost instance containing `id` (`id` itself when it is a top-level instance).
|
||||
pub fn root_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
chain.into_iter().rfind(|c| is_instance(doc, *c))
|
||||
scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c))
|
||||
}
|
||||
|
||||
/// The innermost instance `id` is a layer of (never `id` itself).
|
||||
fn enclosing_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c))
|
||||
}
|
||||
|
||||
/// Whether `id` is a layer inside an instance (not the outermost instance itself).
|
||||
pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).and_then(|n| n.parent).is_some_and(|p| owning_instance(doc, p).is_some())
|
||||
enclosing_instance(doc, id).is_some()
|
||||
}
|
||||
|
||||
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
|
||||
/// a slot property of its component, or an instance's copy of one (followed through its sources).
|
||||
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
|
||||
let mut cur = id;
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let n = doc.node(cur)?;
|
||||
if !matches!(n.kind, NodeKind::Frame) {
|
||||
return None;
|
||||
}
|
||||
if let Some(name) = n.prop_refs.get(SLOT) {
|
||||
let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None };
|
||||
return match props.get(name) {
|
||||
Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
cur = n.source?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The name of the slot property frame `id` stands for ([`slot_def`]).
|
||||
pub fn slot_name(doc: &Document, id: NodeId) -> Option<String> {
|
||||
slot_def(doc, id).map(|(name, _)| name.to_string())
|
||||
}
|
||||
|
||||
/// Whether `id` is a slot (in a main component, or an instance's copy of one).
|
||||
pub fn is_slot(doc: &Document, id: NodeId) -> bool {
|
||||
slot_def(doc, id).is_some()
|
||||
}
|
||||
|
||||
/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's
|
||||
/// default content.
|
||||
pub fn slot_edited(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).is_some_and(holds_content)
|
||||
}
|
||||
|
||||
/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and
|
||||
/// reordered freely.
|
||||
pub fn slot_scope(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
for c in chain {
|
||||
if is_instance(doc, c) {
|
||||
return None;
|
||||
}
|
||||
if is_slot(doc, c) {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an
|
||||
/// instance, outside its slots.
|
||||
pub fn structure_locked(doc: &Document, id: NodeId) -> bool {
|
||||
owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none()
|
||||
}
|
||||
|
||||
/// For tree walks that look for where layers can go: whether the children of `id` are locked (in
|
||||
/// an instance, outside its slots), given whether `id` itself is (`locked`).
|
||||
pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool {
|
||||
is_instance(doc, id) || (locked && !is_slot(doc, id))
|
||||
}
|
||||
|
||||
/// Whether layer `id`'s place (parent and position among its siblings) changed between two
|
||||
/// versions only where instance structure allows it.
|
||||
pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool {
|
||||
let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id)));
|
||||
let (was, is) = (place(before), place(after));
|
||||
was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p)))
|
||||
}
|
||||
|
||||
/// The slots of a component or instance (its own: not those of instances nested in it, nor of the
|
||||
/// content put into its slots), with their property names, top-most first.
|
||||
pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
|
||||
let mut seen = 0usize;
|
||||
while let Some(c) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > 1_000_000 {
|
||||
break;
|
||||
}
|
||||
let Some(n) = doc.node(c) else { continue };
|
||||
if let Some(name) = slot_name(doc, c) {
|
||||
out.push((c, name));
|
||||
}
|
||||
if !is_instance(doc, c) && !holds_content(n) {
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All instances of `component` in the document.
|
||||
|
|
@ -73,17 +209,24 @@ pub fn default_instance_name(doc: &Document, component: NodeId) -> String {
|
|||
set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into())
|
||||
}
|
||||
|
||||
/// Default property values of a component.
|
||||
/// Default property values of a component (slots hold content, not values: they have none).
|
||||
pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
if let NodeKind::Component { props, .. } = &comp.kind {
|
||||
for (k, d) in props {
|
||||
out.insert(k.clone(), prop_default_value(d));
|
||||
if takes_value(d) {
|
||||
out.insert(k.clone(), prop_default_value(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether instances store a value for the property (every kind but slots).
|
||||
pub fn takes_value(d: &PropDef) -> bool {
|
||||
!matches!(d, PropDef::Slot { .. })
|
||||
}
|
||||
|
||||
/// A property definition's default as a JSON value.
|
||||
pub fn prop_default_value(d: &PropDef) -> Value {
|
||||
match d {
|
||||
|
|
@ -91,6 +234,7 @@ pub fn prop_default_value(d: &PropDef) -> Value {
|
|||
PropDef::Text { default } => Value::String(default.clone()),
|
||||
PropDef::InstanceSwap { default } => Value::from(default.0),
|
||||
PropDef::Variant { default, .. } => Value::String(default.clone()),
|
||||
PropDef::Slot { .. } => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +353,8 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
|
|||
}
|
||||
|
||||
/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With
|
||||
/// `src_names`, layers are named after the main layer they mirror (renames don't break matching).
|
||||
/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames
|
||||
/// don't break matching), and the content of slots holding the instance's own is left out.
|
||||
fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap<NodeId, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut stack = vec![(root, String::new())];
|
||||
|
|
@ -228,7 +373,9 @@ fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap<NodeId, S
|
|||
if out.len() > 1_000_000 {
|
||||
return out;
|
||||
}
|
||||
stack.push((*c, path));
|
||||
if !(src_names && holds_content(cn)) {
|
||||
stack.push((*c, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -271,10 +418,56 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rebuild instance `inst` from its main component, keeping ids and overrides. An instance whose
|
||||
/// main component was deleted keeps its layers as they are.
|
||||
/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its
|
||||
/// slots. An instance whose main component was deleted keeps its layers as they are.
|
||||
pub fn sync_instance(doc: &mut Document, inst: NodeId) {
|
||||
let kept = slot_content(doc, inst);
|
||||
sync_instance_depth(doc, inst, 0);
|
||||
restore_slot_content(doc, kept);
|
||||
}
|
||||
|
||||
/// The slots in instance `inst` (nested instances included) holding the instance's own content:
|
||||
/// each with its content (the subtrees of its children, pre-order).
|
||||
fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec<Node>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
|
||||
while let Some(c) = stack.pop() {
|
||||
let Some(n) = doc.node(c) else { continue };
|
||||
if holds_content(n) {
|
||||
let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect();
|
||||
out.push((c, nodes));
|
||||
} else {
|
||||
stack.extend(n.children.iter().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put slot content back after a rebuild, into the slots that still hold the instance's own
|
||||
/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints
|
||||
/// and geometry overrides ran before, so the content keeps its place.
|
||||
fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec<Node>)>) {
|
||||
for (slot, nodes) in kept {
|
||||
let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id));
|
||||
if !keep {
|
||||
continue;
|
||||
}
|
||||
let ids: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
|
||||
for n in nodes {
|
||||
// Pre-order: every parent is back before its children.
|
||||
let _ = insert_linked(doc, n);
|
||||
}
|
||||
// Never leave links to a layer that couldn't come back.
|
||||
for id in ids.into_iter().chain([slot]) {
|
||||
let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c)));
|
||||
if dangling {
|
||||
let live: Vec<NodeId> = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default();
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.children = live;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
||||
|
|
@ -295,31 +488,48 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
|||
eff_props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// Old layers, by (parent, source) and by name path.
|
||||
// Old layers, by (parent, source), by source alone (when only one layer mirrors it) and by
|
||||
// name path.
|
||||
let old_paths = path_map(doc, inst, true);
|
||||
let mut by_source: BTreeMap<(NodeId, NodeId), Node> = BTreeMap::new();
|
||||
let mut by_src: BTreeMap<NodeId, Option<Node>> = BTreeMap::new();
|
||||
let mut by_path: BTreeMap<String, Node> = BTreeMap::new();
|
||||
for (i, path) in &old_paths {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
if let (Some(s), Some(p)) = (n.source, n.parent) {
|
||||
by_source.entry((p, s)).or_insert_with(|| n.clone());
|
||||
if let Some(s) = n.source {
|
||||
if let Some(p) = n.parent {
|
||||
by_source.entry((p, s)).or_insert_with(|| n.clone());
|
||||
}
|
||||
by_src.entry(s).and_modify(|e| *e = None).or_insert_with(|| Some(n.clone()));
|
||||
}
|
||||
by_path.insert(path.clone(), n.clone());
|
||||
}
|
||||
for c in root.children.clone() {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
// Ids for the copies: reuse the matching old layer's id.
|
||||
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
|
||||
// parent keeps its id and overrides).
|
||||
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let main_paths = path_map(doc, component, false);
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
map.insert(component, inst);
|
||||
let mut olds: BTreeMap<NodeId, Node> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
// Slots holding the instance's own content (new ids), and main layers not copied: the
|
||||
// default content of those slots.
|
||||
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for s in &src {
|
||||
let Some(sn) = doc.node(*s) else { continue };
|
||||
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
|
||||
skipped.insert(*s);
|
||||
continue;
|
||||
}
|
||||
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
|
||||
let old = parent_new.and_then(|pn| by_source.get(&(pn, *s))).or_else(|| main_paths.get(s).and_then(|p| by_path.get(p)));
|
||||
let old = parent_new
|
||||
.and_then(|pn| by_source.get(&(pn, *s)))
|
||||
.or_else(|| by_src.get(s).and_then(Option::as_ref))
|
||||
.or_else(|| main_paths.get(s).and_then(|p| by_path.get(p)));
|
||||
let nid = match old {
|
||||
Some(o) if !doc.contains(o.id) && used.insert(o.id) => {
|
||||
olds.insert(o.id, o.clone());
|
||||
|
|
@ -327,6 +537,9 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
|||
}
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
if olds.get(&nid).is_some_and(holds_content) && is_slot(doc, *s) {
|
||||
edited.insert(nid);
|
||||
}
|
||||
map.insert(*s, nid);
|
||||
}
|
||||
// Build the copies (pre-order: parents first).
|
||||
|
|
@ -350,6 +563,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
|||
if let Some(old) = olds.get(&nid) {
|
||||
carry_overrides(old, &mut n, Carry::Values);
|
||||
}
|
||||
if !edited.contains(&nid) {
|
||||
// No longer a slot in the main: it mirrors the main again.
|
||||
n.overrides.remove(SLOT);
|
||||
}
|
||||
// Component properties: the main layer's bindings use this instance's values; layers of
|
||||
// nested instances use the nested instance's values (one level per source hop).
|
||||
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
|
||||
|
|
@ -551,6 +768,24 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str)
|
|||
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
|
||||
/// whose properties or main were switched, or whose overrides were reset.
|
||||
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet<NodeId>) {
|
||||
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
|
||||
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
if n.children == b.children || owning_instance(doc, id).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = slot_scope(doc, id)
|
||||
&& !slot_edited(doc, slot)
|
||||
{
|
||||
own_slot_content(doc, slot);
|
||||
}
|
||||
}
|
||||
for &id in changed {
|
||||
if !before.contains(id) {
|
||||
relink_copy(doc, id);
|
||||
}
|
||||
}
|
||||
let mut resync: BTreeSet<NodeId> = BTreeSet::new();
|
||||
// 1. Overrides for layers edited inside instances.
|
||||
for &id in changed {
|
||||
|
|
@ -661,7 +896,64 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
|||
}
|
||||
}
|
||||
|
||||
/// Refuse edits that add, remove or reorder layers inside an instance (only its main can).
|
||||
/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
|
||||
/// main's default content until now) stop mirroring the main, and instances among them stand
|
||||
/// alone, linked to their own mains.
|
||||
fn own_slot_content(doc: &mut Document, slot: NodeId) {
|
||||
if let Some(n) = doc.node_mut(slot) {
|
||||
n.overrides.insert(SLOT.into());
|
||||
}
|
||||
for nested in unlink_layers(doc, slot, None) {
|
||||
rebase_instance(doc, nested);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the
|
||||
/// content of a slot): plain layers forget the main layer they mirrored and their property
|
||||
/// bindings. The instances among them are returned, to re-link to their own mains (with
|
||||
/// [`rebase_instance`]) once the caller is done.
|
||||
pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option<NodeId>) -> Vec<NodeId> {
|
||||
let picked: Vec<(NodeId, bool)> =
|
||||
doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect();
|
||||
let mut nested = Vec::new();
|
||||
for (d, instance) in picked {
|
||||
let Some(n) = doc.node_mut(d) else { continue };
|
||||
n.prop_refs.clear();
|
||||
if instance {
|
||||
nested.push(d);
|
||||
} else {
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
}
|
||||
}
|
||||
nested
|
||||
}
|
||||
|
||||
/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it
|
||||
/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept).
|
||||
fn relink_copy(doc: &mut Document, id: NodeId) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
match &n.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }));
|
||||
if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) {
|
||||
rebase_instance(doc, id);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if (n.source.is_some() || !n.overrides.is_empty())
|
||||
&& owning_instance(doc, id).is_none()
|
||||
&& let Some(m) = doc.node_mut(id)
|
||||
{
|
||||
m.source = None;
|
||||
m.overrides.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside
|
||||
/// its slots.
|
||||
pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> {
|
||||
for &id in after.dirty() {
|
||||
let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue };
|
||||
|
|
@ -669,7 +961,7 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S
|
|||
if n.children == b.children || after.is_remote(id) {
|
||||
continue;
|
||||
}
|
||||
if owning_instance(before, id).is_some() && owning_instance(after, id).is_some() {
|
||||
if structure_locked(before, id) && structure_locked(after, id) {
|
||||
let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
return Err(format!(
|
||||
"layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first"
|
||||
|
|
@ -680,7 +972,8 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S
|
|||
}
|
||||
|
||||
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
|
||||
/// values (after detaching an outer instance, or restoring a main): the look is kept exactly.
|
||||
/// 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.
|
||||
pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
|
||||
let mine = path_map(doc, inst, true);
|
||||
|
|
@ -706,7 +999,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
|||
let main = main.or_else(|| theirs.get(path).copied());
|
||||
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)) {
|
||||
overrides.extend(value_groups(m, n));
|
||||
let bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
|
||||
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
|
||||
if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) {
|
||||
overrides.insert("component".into());
|
||||
}
|
||||
|
|
@ -780,10 +1074,18 @@ pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap<String, Strin
|
|||
|
||||
/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
|
||||
/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
|
||||
/// own main. Returns the problems found.
|
||||
/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
|
||||
pub fn mirror_issues(doc: &Document) -> Vec<String> {
|
||||
doc.nodes()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id))
|
||||
.flat_map(|n| instance_issues(doc, n.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`mirror_issues`] for one instance.
|
||||
fn instance_issues(doc: &Document, id: NodeId) -> Vec<String> {
|
||||
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
|
||||
if depth > MAX_DEPTH {
|
||||
if depth > MAX_DEPTH || holds_content(inst) {
|
||||
return;
|
||||
}
|
||||
if inst.children.len() != main.children.len() {
|
||||
|
|
@ -807,21 +1109,18 @@ pub fn mirror_issues(doc: &Document) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for n in doc.nodes() {
|
||||
let NodeKind::Instance { component, .. } = &n.kind else { continue };
|
||||
if inside_instance(doc, n.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = doc.node(*component) {
|
||||
walk(doc, n, m, 0, &mut out);
|
||||
}
|
||||
if let Some(n) = doc.node(id)
|
||||
&& let NodeKind::Instance { component, .. } = &n.kind
|
||||
&& let Some(m) = doc.node(*component)
|
||||
{
|
||||
walk(doc, n, m, 0, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "components_tests.rs"]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
/// The nested instance copies inside instance `id` whose main-component counterpart is exposed
|
||||
/// (top-most first; an exposed instance's own exposed nested instances follow it).
|
||||
|
|
|
|||
|
|
@ -4,47 +4,47 @@ use super::*;
|
|||
use crate::{EngineError, Session, params::id_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
match s.execute(cmd, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{cmd} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
pub(crate) fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn kids(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
pub(crate) fn kids(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
node(s, id).children
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
pub(crate) fn fill(s: &Session, id: NodeId) -> String {
|
||||
node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn chars(s: &Session, id: NodeId) -> String {
|
||||
pub(crate) fn chars(s: &Session, id: NodeId) -> String {
|
||||
node(s, id).kind.text().map(|t| t.characters.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn healthy(s: &Session) {
|
||||
pub(crate) fn healthy(s: &Session) {
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert!(mirror_issues(doc).is_empty(), "{:?}", mirror_issues(doc));
|
||||
}
|
||||
|
||||
/// The instance layer mirroring main layer `src`.
|
||||
fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId {
|
||||
pub(crate) fn copy_of(s: &Session, inst: NodeId, src: NodeId) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(inst).into_iter().find(|i| doc.node(*i).and_then(|n| n.source) == Some(src)).unwrap()
|
||||
}
|
||||
|
||||
/// Card: 200×100 component with "Bg" rectangle, "Title" text and "Dot" ellipse.
|
||||
fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
pub(crate) fn card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let bg =
|
||||
idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}})));
|
||||
|
|
@ -164,7 +164,7 @@ fn instances_refuse_structure_edits() {
|
|||
}
|
||||
|
||||
/// Icon component (one "Shape" rectangle) and a Button component holding an Icon instance.
|
||||
fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
pub(crate) fn button(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 300, "width": 24, "height": 24, "name": "Icon"})));
|
||||
let shape = idv(&ex(
|
||||
s,
|
||||
|
|
|
|||
|
|
@ -125,8 +125,10 @@ pub fn grid_cell_at(doc: &Document, parent: NodeId, p: Point) -> Option<(u32, u3
|
|||
/// being dragged) and their descendants. Inside auto layout the deepest auto layout frame under the
|
||||
/// pointer wins: plain frames there are items of the flow, not destinations (dropping onto a card
|
||||
/// in a list inserts next to it). Elsewhere the deepest frame or section under the pointer wins.
|
||||
/// Inside an instance only its slots (and what's in them) take drops; a slot always does.
|
||||
pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -> Option<NodeId> {
|
||||
fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, depth: usize) -> Option<NodeId> {
|
||||
/// `locked`: inside an instance, outside its slots.
|
||||
fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, locked: bool, depth: usize) -> Option<NodeId> {
|
||||
if depth > 128 || exclude.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -135,19 +137,20 @@ pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -
|
|||
return None;
|
||||
}
|
||||
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
let kids_locked = crate::components::locks_children(doc, id, locked);
|
||||
if !container {
|
||||
// Groups: frames inside them can still take the drop.
|
||||
return n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al, depth + 1));
|
||||
return n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al, kids_locked, depth + 1));
|
||||
}
|
||||
let inside = doc.world_bounds(id).is_some_and(|b| b.contains(p)) && n.local_rect().contains(doc.world_transform(id).inverse() * p);
|
||||
if !inside || matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
if !inside {
|
||||
return None;
|
||||
}
|
||||
let auto_layout = n.layout.is_some();
|
||||
let deeper = n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al || auto_layout, depth + 1));
|
||||
deeper.or((auto_layout || !in_al).then_some(id))
|
||||
let deeper = n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al || auto_layout, kids_locked, depth + 1));
|
||||
deeper.or((!kids_locked && (auto_layout || !in_al || crate::components::is_slot(doc, id))).then_some(id))
|
||||
}
|
||||
doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, 0))
|
||||
doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0))
|
||||
}
|
||||
|
||||
/// Margin (page units) around the auto layout frame a dragged layer comes from that still keeps it.
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
|||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
|
|
|
|||
|
|
@ -887,6 +887,8 @@ mod import_export_tests;
|
|||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_multi_tests;
|
||||
|
|
@ -897,6 +899,8 @@ mod section_tests;
|
|||
#[cfg(test)]
|
||||
mod shapebuilder_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -64,10 +64,15 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet<NodeId> {
|
|||
}
|
||||
}
|
||||
NodeKind::Component { props, .. } => {
|
||||
out.extend(props.values().filter_map(|d| match d {
|
||||
PropDef::InstanceSwap { default } => Some(*default),
|
||||
_ => None,
|
||||
}));
|
||||
for d in props.values() {
|
||||
match d {
|
||||
PropDef::InstanceSwap { default } => {
|
||||
out.insert(*default);
|
||||
}
|
||||
PropDef::Slot { preferred } => out.extend(preferred.iter().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -452,10 +457,14 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap<NodeId, NodeId>, styles
|
|||
NodeKind::Component { props, .. } => {
|
||||
props.retain(|_, d| !matches!(d, PropDef::InstanceSwap { default } if !map.contains_key(default)));
|
||||
for d in props.values_mut() {
|
||||
if let PropDef::InstanceSwap { default } = d
|
||||
&& let Some(l) = map.get(default)
|
||||
{
|
||||
*default = *l;
|
||||
match d {
|
||||
PropDef::InstanceSwap { default } => {
|
||||
if let Some(l) = map.get(default) {
|
||||
*default = *l;
|
||||
}
|
||||
}
|
||||
PropDef::Slot { preferred } => *preferred = preferred.iter().filter_map(|c| map.get(c).copied()).collect(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
//! Overrides on and inside nested instances across swaps, variant switches, main updates, detach
|
||||
//! and copy/paste.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{button, copy_of, ex, fill, healthy, idv, kids, node};
|
||||
use crate::components::*;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 24×24 icon component at (`x`, 300) with one 20×20 rectangle named `layer`.
|
||||
fn icon(s: &mut Session, x: f64, name: &str, layer: &str) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name})));
|
||||
ex(s, "shape.rectangle", json!({"x": 2, "y": 2, "width": 20, "height": 20, "parent": f, "props": {"name": layer, "fill": "#111111"}}));
|
||||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
fn main_of(s: &Session, id: NodeId) -> NodeId {
|
||||
match node(s, id).kind {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("{id} is not an instance"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_a_nested_instance_keeps_overrides_on_matching_layers() {
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let unrelated = icon(&mut s, 300.0, "Unrelated", "Glyph");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let inested = copy_of(&s, i, nested);
|
||||
let ishape = kids(&s, inested)[0];
|
||||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff", "opacity": 0.5}}));
|
||||
// A main with a same-named layer: the overrides carry over.
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": other}));
|
||||
healthy(&s);
|
||||
let ishape = kids(&s, inested)[0];
|
||||
assert_eq!(main_of(&s, inested), other);
|
||||
assert_eq!(fill(&s, ishape), "FF00FF");
|
||||
assert_eq!(node(&s, ishape).opacity, 0.5);
|
||||
assert!(node(&s, ishape).overrides.contains("fills"));
|
||||
// ... and survive edits of the outer main and of the swapped-in main.
|
||||
ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
|
||||
let other_shape = kids(&s, other)[0];
|
||||
ex(&mut s, "node.set", json!({"id": other_shape, "props": {"cornerRadius": 5, "fill": "#222222"}}));
|
||||
healthy(&s);
|
||||
let ishape = kids(&s, copy_of(&s, i, nested))[0];
|
||||
assert_eq!(fill(&s, ishape), "FF00FF");
|
||||
assert_eq!(node(&s, ishape).corners.radii, [5.0; 4]);
|
||||
// No matching layer: the overrides go.
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": unrelated}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, kids(&s, inested)[0]), "111111");
|
||||
// Back to the original: it shows its main again.
|
||||
ex(&mut s, "component.resetOverride", json!({"id": inested, "group": "component"}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, kids(&s, inested)[0]), fill(&s, copy_of(&s, nested, shape)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_switch_keeps_nested_overrides_and_swaps() {
|
||||
let mut s = Session::new();
|
||||
let ic = icon(&mut s, 0.0, "Icon", "Shape");
|
||||
let star = icon(&mut s, 100.0, "Star", "Shape");
|
||||
let mut variants = Vec::new();
|
||||
for (k, state) in ["Default", "Hover"].iter().enumerate() {
|
||||
let f =
|
||||
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 120, "height": 40, "name": format!("State={state}")})));
|
||||
ex(
|
||||
&mut s,
|
||||
"shape.rectangle",
|
||||
json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg", "fill": if k == 0 { "#ffffff" } else { "#eeeeee" }}}),
|
||||
);
|
||||
ex(&mut s, "instance.create", json!({"component": ic, "x": 8, "y": 8, "parent": f}));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||||
let inested = kids(&s, i)[1];
|
||||
let ishape = kids(&s, inested)[0];
|
||||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff8800"}}));
|
||||
ex(&mut s, "node.set", json!({"id": inested, "props": {"opacity": 0.4}}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
healthy(&s);
|
||||
let inested = kids(&s, i)[1];
|
||||
assert_eq!(fill(&s, kids(&s, i)[0]), "EEEEEE", "the new variant shows");
|
||||
assert_eq!(fill(&s, kids(&s, inested)[0]), "FF8800", "override inside the nested instance kept");
|
||||
assert_eq!(node(&s, inested).opacity, 0.4, "override on the nested instance kept");
|
||||
// A swap override on the nested instance survives switching back.
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": star}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Default"}}));
|
||||
healthy(&s);
|
||||
let inested = kids(&s, i)[1];
|
||||
assert_eq!(main_of(&s, inested), star);
|
||||
assert_eq!(fill(&s, kids(&s, inested)[0]), "FF8800");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_overrides_survive_main_restructuring() {
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let ishape = copy_of(&s, i, copy_of(&s, nested, shape));
|
||||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}}));
|
||||
// The main wraps its nested instance in a frame, then reorders it.
|
||||
let wrap = idv(&ex(&mut s, "object.frameSelection", json!({"ids": [nested]})));
|
||||
healthy(&s);
|
||||
let ishape = copy_of(&s, i, copy_of(&s, nested, shape));
|
||||
assert_eq!(fill(&s, ishape), "FF00FF", "kept when the main moves the nested instance into a frame");
|
||||
ex(&mut s, "node.reorder", json!({"id": wrap, "index": 5}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, copy_of(&s, i, copy_of(&s, nested, shape))), "FF00FF");
|
||||
// The main swaps its nested instance to a main with the same layer.
|
||||
ex(&mut s, "instance.swap", json!({"ids": [nested], "component": other}));
|
||||
healthy(&s);
|
||||
let inested = copy_of(&s, i, nested);
|
||||
assert_eq!(main_of(&s, inested), other);
|
||||
assert_eq!(fill(&s, kids(&s, inested)[0]), "FF00FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_keeps_swapped_nested_instances_and_their_overrides() {
|
||||
let mut s = Session::new();
|
||||
let (_icon, _shape, button, nested) = button(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Glyph");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let inested = copy_of(&s, i, nested);
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": other}));
|
||||
let iglyph = kids(&s, inested)[0];
|
||||
ex(&mut s, "node.set", json!({"id": iglyph, "props": {"fill": "#00aaff"}}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
healthy(&s);
|
||||
assert_eq!(main_of(&s, inested), other);
|
||||
assert_eq!(node(&s, inested).source, Some(other));
|
||||
assert!(!node(&s, inested).overrides.contains("component"));
|
||||
let glyph = kids(&s, other)[0];
|
||||
assert_eq!(node(&s, iglyph).source, Some(glyph));
|
||||
assert_eq!(fill(&s, iglyph), "00AAFF");
|
||||
ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#000000", "cornerRadius": 3}}));
|
||||
assert_eq!(fill(&s, iglyph), "00AAFF");
|
||||
assert_eq!(node(&s, iglyph).corners.radii, [3.0; 4]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_instances_keep_their_overrides() {
|
||||
let mut s = Session::new();
|
||||
let (icon, shape, button, nested) = button(&mut s);
|
||||
ex(&mut s, "component.addProperty", json!({"id": icon, "name": "Filled", "type": "boolean", "default": true, "layer": shape}));
|
||||
ex(&mut s, "component.exposeInstance", json!({"id": nested}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let inested = copy_of(&s, i, nested);
|
||||
let ishape = kids(&s, inested)[0];
|
||||
let ilabel = kids(&s, i)[1];
|
||||
ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}}));
|
||||
ex(&mut s, "text.set", json!({"id": ilabel, "characters": "Copied"}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [inested], "properties": {"Filled": false}}));
|
||||
// The whole instance: overrides and property values come along, and survive main edits.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [i]}));
|
||||
s.set_selection(vec![]);
|
||||
let j = ex(&mut s, "edit.paste", json!({"x": 300, "y": 800}))["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
healthy(&s);
|
||||
ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 4}}));
|
||||
ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#eeeeee"}}));
|
||||
healthy(&s);
|
||||
let jnested = copy_of(&s, j, nested);
|
||||
let jshape = kids(&s, jnested)[0];
|
||||
assert_eq!(fill(&s, jshape), "FF00FF");
|
||||
assert!(!node(&s, jshape).visible, "property value of the nested instance kept");
|
||||
assert_eq!(node(&s, jshape).corners.radii, [4.0; 4]);
|
||||
assert_eq!(node(&s, kids(&s, j)[1]).kind.text().unwrap().characters, "Copied");
|
||||
assert_eq!(fill(&s, j), "EEEEEE");
|
||||
// A nested instance copied out on its own becomes an instance of its own main, same look.
|
||||
let inested = copy_of(&s, i, nested);
|
||||
ex(&mut s, "edit.copy", json!({"ids": [inested]}));
|
||||
s.set_selection(vec![]);
|
||||
let k = ex(&mut s, "edit.paste", json!({"x": 900, "y": 800}))["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
healthy(&s);
|
||||
assert!(!inside_instance(s.doc().unwrap(), k));
|
||||
assert_eq!(node(&s, k).source, Some(icon));
|
||||
let kshape = kids(&s, k)[0];
|
||||
assert_eq!(node(&s, kshape).source, Some(shape));
|
||||
assert_eq!(fill(&s, kshape), "FF00FF");
|
||||
assert!(node(&s, kshape).overrides.contains("fills"));
|
||||
assert!(!node(&s, kshape).visible);
|
||||
assert!(!node(&s, kshape).overrides.contains("visible"), "set by its property, not an override");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [k], "properties": {"Filled": true}}));
|
||||
assert!(node(&s, kids(&s, k)[0]).visible);
|
||||
ex(&mut s, "node.set", json!({"id": shape, "props": {"cornerRadius": 7}}));
|
||||
assert_eq!(node(&s, kids(&s, k)[0]).corners.radii, [7.0; 4]);
|
||||
assert_eq!(fill(&s, kids(&s, k)[0]), "FF00FF");
|
||||
// A plain layer copied out of an instance is a plain layer.
|
||||
let ilabel = kids(&s, i)[1];
|
||||
ex(&mut s, "edit.copy", json!({"ids": [ilabel]}));
|
||||
s.set_selection(vec![]);
|
||||
let t = ex(&mut s, "edit.paste", json!({"x": 900, "y": 1000}))["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty());
|
||||
healthy(&s);
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
//! Slots: frames of a main component whose instances take any content of their own.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{copy_of, ex, fill, healthy, idv, kids, node};
|
||||
use crate::components::*;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A "Card" component (300×200 at the origin) with a "Title" text and a "Content" slot (280×140 at
|
||||
/// 10,50) holding a default "Placeholder" rectangle. Returns (card, title, content, placeholder).
|
||||
fn slot_card(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Card"})));
|
||||
let title = idv(&ex(s, "text.create", json!({"text": "Title", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let content = idv(&ex(s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"})));
|
||||
let ph = idv(&ex(
|
||||
s,
|
||||
"shape.rectangle",
|
||||
json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": content, "props": {"name": "Placeholder", "fill": "#cccccc"}}),
|
||||
));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
ex(s, "component.makeSlot", json!({"id": content}));
|
||||
(c, title, content, ph)
|
||||
}
|
||||
|
||||
/// A 40×20 "Chip" component at (0, 400) with a red "Dot". Returns (chip, dot).
|
||||
fn chip(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Chip"})));
|
||||
let dot =
|
||||
idv(&ex(s, "shape.ellipse", json!({"x": 2, "y": 2, "width": 16, "height": 16, "parent": f, "props": {"name": "Dot", "fill": "#ff0000"}})));
|
||||
(idv(&ex(s, "component.create", json!({"ids": [f]}))), dot)
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused");
|
||||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||||
}
|
||||
|
||||
fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
|
||||
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_are_frames_of_a_main_with_a_slot_property() {
|
||||
let mut s = Session::new();
|
||||
let (c, title, content, ph) = slot_card(&mut s);
|
||||
assert_eq!(slot_name(s.doc().unwrap(), content).as_deref(), Some("Content"));
|
||||
let p = props_of(&mut s, c);
|
||||
assert_eq!(p[0]["type"], "slot");
|
||||
assert_eq!(p[0]["layers"], json!([content.0]));
|
||||
assert_eq!(slots_of(s.doc().unwrap(), c), vec![(content, "Content".to_string())]);
|
||||
// A second frame named "Content" gets a fresh property name.
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Content"})));
|
||||
let r = ex(&mut s, "component.makeSlot", json!({"id": other}));
|
||||
assert_eq!(r["name"], "Content 2");
|
||||
ex(&mut s, "component.removeSlot", json!({"id": other}));
|
||||
assert!(!is_slot(s.doc().unwrap(), other));
|
||||
// Preferred components.
|
||||
let (chip, _) = chip(&mut s);
|
||||
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Content", "preferred": [chip]}));
|
||||
assert_eq!(props_of(&mut s, c)[0]["preferred"], json!([chip.0]));
|
||||
// What isn't a slot, or can't become one.
|
||||
let loose = idv(&ex(&mut s, "frame.create", json!({"x": 900, "y": 0, "width": 50, "height": 50})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
let islot = copy_of(&s, i, content);
|
||||
for (cmd, p) in [
|
||||
("component.makeSlot", json!({"id": c})),
|
||||
("component.makeSlot", json!({"id": ph})),
|
||||
("component.makeSlot", json!({"id": loose})),
|
||||
("component.makeSlot", json!({"id": content})),
|
||||
("component.makeSlot", json!({"id": islot})),
|
||||
("component.removeSlot", json!({"id": islot})),
|
||||
("component.removeSlot", json!({"id": title})),
|
||||
("component.editProperty", json!({"id": c, "name": "Content", "preferred": [title]})),
|
||||
("component.editProperty", json!({"id": c, "name": "Content", "default": "x"})),
|
||||
("component.bindProperty", json!({"layer": ph, "property": "Content", "field": "slot"})),
|
||||
("component.bindProperty", json!({"layer": other, "property": "Content"})),
|
||||
("instance.setProperties", json!({"ids": [i], "properties": {"Content": 1}})),
|
||||
("instance.slotInsert", json!({"id": content, "component": chip})),
|
||||
("instance.slotInsert", json!({"id": i, "slot": "Nope", "component": chip})),
|
||||
("instance.resetSlot", json!({"id": i})),
|
||||
] {
|
||||
refused(&mut s, cmd, p);
|
||||
}
|
||||
// Slots hold content, not values: instances store none.
|
||||
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if props.is_empty()));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_fill_slots_with_their_own_content() {
|
||||
let mut s = Session::new();
|
||||
let (c, title, content, ph) = slot_card(&mut s);
|
||||
let (chip, dot) = chip(&mut s);
|
||||
let a = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
let b = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 800, "y": 0})));
|
||||
let (aslot, aph) = (copy_of(&s, a, content), copy_of(&s, a, ph));
|
||||
// The default content shows (and follows the main) until the slot is edited.
|
||||
assert!(!slot_edited(s.doc().unwrap(), aslot));
|
||||
ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#00ff00"}}));
|
||||
assert_eq!(fill(&s, aph), "00FF00");
|
||||
// Filling the slot: one undo step; the default content becomes the instance's own.
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let r = ex(&mut s, "instance.slotInsert", json!({"id": a, "slot": "Content", "component": chip}));
|
||||
let ichip = idv(&r);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(kids(&s, aslot), vec![aph, ichip]);
|
||||
assert!(slot_edited(s.doc().unwrap(), aslot));
|
||||
assert!(node(&s, aph).source.is_none() && node(&s, aph).overrides.is_empty());
|
||||
assert!(!inside_instance(s.doc().unwrap(), ichip), "an instance in a slot stands alone");
|
||||
assert_eq!(owning_instance(s.doc().unwrap(), aph), None);
|
||||
let v = props_of(&mut s, a);
|
||||
assert_eq!(v[0]["value"], json!({"layer": aslot.0, "edited": true, "layers": 2}));
|
||||
healthy(&s);
|
||||
// Main edits: the rest of the instance follows, the slot content stays as it is.
|
||||
ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#0000ff"}}));
|
||||
let extra = idv(&ex(&mut s, "shape.ellipse", json!({"x": 150, "y": 0, "width": 20, "height": 20, "parent": content})));
|
||||
ex(&mut s, "text.set", json!({"id": title, "characters": "New title"}));
|
||||
ex(&mut s, "node.set", json!({"id": c, "props": {"fill": "#fafafa"}}));
|
||||
healthy(&s);
|
||||
assert_eq!(kids(&s, aslot), vec![aph, ichip], "content and its ids survive main updates");
|
||||
assert_eq!(fill(&s, aph), "00FF00");
|
||||
assert_eq!(fill(&s, a), "FAFAFA");
|
||||
assert_eq!(node(&s, copy_of(&s, a, title)).kind.text().unwrap().characters, "New title");
|
||||
// The untouched instance still shows the main's default content.
|
||||
let bslot = copy_of(&s, b, content);
|
||||
assert_eq!(kids(&s, bslot).len(), 2);
|
||||
assert_eq!(fill(&s, copy_of(&s, b, ph)), "0000FF");
|
||||
assert!(s.doc().unwrap().contains(copy_of(&s, b, extra)));
|
||||
// An instance in a slot follows its own main.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"fill": "#123456"}}));
|
||||
assert_eq!(fill(&s, copy_of(&s, ichip, dot)), "123456");
|
||||
// Inside the slot, layers come and go freely; outside it the instance is locked.
|
||||
let r2 = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": aslot})));
|
||||
ex(&mut s, "node.reorder", json!({"id": r2, "index": 0}));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [aph]}));
|
||||
assert_eq!(kids(&s, aslot), vec![r2, ichip]);
|
||||
ex(&mut s, "edit.duplicate", json!({"ids": [ichip]}));
|
||||
assert_eq!(kids(&s, aslot).len(), 3);
|
||||
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": a}));
|
||||
refused(&mut s, "node.reparent", json!({"ids": [r2], "parent": a}));
|
||||
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": ichip}));
|
||||
let idot = copy_of(&s, ichip, dot);
|
||||
refused(&mut s, "node.reparent", json!({"ids": [idot], "parent": aslot}));
|
||||
healthy(&s);
|
||||
// Layers of the instance can't move into its slot either.
|
||||
let atitle = copy_of(&s, a, title);
|
||||
refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot}));
|
||||
// Reset: back to the main's default content.
|
||||
ex(&mut s, "instance.resetSlot", json!({"id": aslot}));
|
||||
healthy(&s);
|
||||
assert!(!slot_edited(s.doc().unwrap(), aslot));
|
||||
assert_eq!(kids(&s, aslot).len(), 2);
|
||||
assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF");
|
||||
assert!(!s.doc().unwrap().contains(ichip));
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().contains(ichip));
|
||||
healthy(&s);
|
||||
// Reset all changes resets slots too; `replace` clears the default content first.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [a]}));
|
||||
assert!(!slot_edited(s.doc().unwrap(), aslot));
|
||||
let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
|
||||
assert_eq!(kids(&s, aslot), vec![only]);
|
||||
ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
|
||||
assert_eq!(kids(&s, aslot).len(), 2);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_content_survives_variants_copies_and_detach() {
|
||||
let mut s = Session::new();
|
||||
let (chip, _) = chip(&mut s);
|
||||
// Two variants, each with a "Content" slot (the same property, shared by the set).
|
||||
let mut variants = Vec::new();
|
||||
let mut slots = Vec::new();
|
||||
for (k, state) in ["Default", "Hover"].iter().enumerate() {
|
||||
let f =
|
||||
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 400.0, "y": 0, "width": 300, "height": 200, "name": format!("State={state}")})));
|
||||
let content = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"})));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
slots.push(content);
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Body", "type": "slot", "layer": slots[0]}));
|
||||
ex(&mut s, "component.bindProperty", json!({"layer": slots[1], "property": "Body"}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
let ichip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Body", "component": chip})));
|
||||
ex(&mut s, "node.set", json!({"id": ichip, "props": {"fill": "#abcdef"}}));
|
||||
// Variant switch: the content moves along.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
healthy(&s);
|
||||
let islot = copy_of(&s, i, slots[1]);
|
||||
assert_eq!(kids(&s, islot), vec![ichip]);
|
||||
assert_eq!(fill(&s, ichip), "ABCDEF");
|
||||
// Copy and paste: the copy has its own copy of the content.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [i]}));
|
||||
s.set_selection(vec![]);
|
||||
let pasted = ex(&mut s, "edit.paste", json!({"x": 0, "y": 1000}))["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
healthy(&s);
|
||||
let pslot = copy_of(&s, pasted, slots[1]);
|
||||
let pchip = kids(&s, pslot)[0];
|
||||
assert_ne!(pchip, ichip);
|
||||
assert!(slot_edited(s.doc().unwrap(), pslot));
|
||||
assert_eq!(fill(&s, pchip), "ABCDEF");
|
||||
ex(&mut s, "node.set", json!({"id": pchip, "props": {"fill": "#000000"}}));
|
||||
assert_eq!(fill(&s, ichip), "ABCDEF");
|
||||
// Detach: the content stays, the instance in it stays an instance of its own main.
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
healthy(&s);
|
||||
assert_eq!(kids(&s, islot), vec![ichip]);
|
||||
assert!(matches!(node(&s, ichip).kind, NodeKind::Instance { component, .. } if component == chip));
|
||||
assert!(node(&s, islot).overrides.is_empty() && node(&s, islot).source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_slot_takes_the_instances_content_away() {
|
||||
let mut s = Session::new();
|
||||
let (c, _title, content, _ph) = slot_card(&mut s);
|
||||
let (chip, _) = chip(&mut s);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
let islot = copy_of(&s, i, content);
|
||||
let ichip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": islot, "component": chip})));
|
||||
ex(&mut s, "component.removeSlot", json!({"id": content}));
|
||||
healthy(&s);
|
||||
assert!(!is_slot(s.doc().unwrap(), islot));
|
||||
assert!(!s.doc().unwrap().contains(ichip));
|
||||
assert_eq!(kids(&s, islot).len(), 1, "mirrors the main again");
|
||||
assert!(props_of(&mut s, c).is_empty());
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().contains(ichip));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_land_in_slots() {
|
||||
let mut s = Session::new();
|
||||
let (c, title, content, _ph) = slot_card(&mut s);
|
||||
let (chip, _) = chip(&mut s);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
let islot = copy_of(&s, i, content);
|
||||
let page = s.page().unwrap();
|
||||
let doc = s.doc().unwrap().clone();
|
||||
// The slot (at 410..690, 50..190) takes drops; the rest of the instance doesn't.
|
||||
assert_eq!(crate::drop::slot(&doc, page, figcraft_geom::Point::new(600.0, 150.0)).parent, islot);
|
||||
assert_eq!(crate::drop::slot(&doc, page, figcraft_geom::Point::new(420.0, 20.0)).parent, page);
|
||||
let r = ex(&mut s, "instance.place", json!({"component": chip, "x": 600, "y": 150}));
|
||||
assert_eq!(r["parent"], json!(islot.0));
|
||||
assert!(slot_edited(s.doc().unwrap(), islot));
|
||||
healthy(&s);
|
||||
// Dragging a layer onto the slot moves it in; dragging a layer of the instance out of it
|
||||
// only moves it.
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 1000, "y": 0, "width": 20, "height": 20})));
|
||||
s.set_tool(ToolId::Move);
|
||||
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64), cmd: bool| {
|
||||
let mut down = PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0);
|
||||
down.cmd = cmd;
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
};
|
||||
drag(&mut s, (1010.0, 10.0), (650.0, 170.0), false);
|
||||
assert_eq!(node(&s, rect).parent, Some(islot));
|
||||
healthy(&s);
|
||||
// Into a slot still showing the main's default content: it becomes the instance's own.
|
||||
let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 300})));
|
||||
let jslot = copy_of(&s, j, content);
|
||||
let dot = idv(&ex(&mut s, "shape.ellipse", json!({"x": 1000, "y": 100, "width": 20, "height": 20})));
|
||||
s.set_selection(vec![]);
|
||||
drag(&mut s, (1010.0, 110.0), (650.0, 470.0), false);
|
||||
assert_eq!(node(&s, dot).parent, Some(jslot));
|
||||
assert!(slot_edited(s.doc().unwrap(), jslot));
|
||||
assert_eq!(kids(&s, jslot).len(), 2);
|
||||
healthy(&s);
|
||||
s.set_selection(vec![islot]);
|
||||
drag(&mut s, (450.0, 120.0), (1450.0, 620.0), true);
|
||||
assert_eq!(node(&s, islot).parent, Some(i), "instance layers stay in the instance");
|
||||
assert_eq!(node(&s, title).parent, Some(c));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drags_into_auto_layout_slots_take_their_place_in_the_flow() {
|
||||
let mut s = Session::new();
|
||||
let (c, _title, content, ph) = slot_card(&mut s);
|
||||
ex(&mut s, "node.set", json!({"id": content, "props": {"layout": "vertical", "gap": 8, "padding": 10, "sizingH": "fixed", "sizingV": "fixed"}}));
|
||||
ex(&mut s, "node.set", json!({"id": c, "props": {"layout": "vertical", "padding": 10, "gap": 10}}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
|
||||
let (islot, iph) = (copy_of(&s, i, content), copy_of(&s, i, ph));
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 1000, "y": 0, "width": 30, "height": 30})));
|
||||
s.set_tool(ToolId::Move);
|
||||
// Below the placeholder copy, inside the slot.
|
||||
let b = s.doc().unwrap().world_bounds(islot).unwrap();
|
||||
let to = (b.center().x, b.y1 - 5.0);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 1015.0, 15.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 800.0, 100.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
assert_eq!(kids(&s, islot), vec![iph, rect]);
|
||||
assert!(slot_edited(s.doc().unwrap(), islot));
|
||||
healthy(&s);
|
||||
// Its own content reorders by dragging within the slot, like any auto layout frame; the
|
||||
// instance's own layers don't.
|
||||
let first = s.doc().unwrap().world_bounds(kids(&s, islot)[0]).unwrap();
|
||||
let r = s.doc().unwrap().world_bounds(rect).unwrap().center();
|
||||
s.set_selection(vec![rect]);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, r.x, r.y, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, r.x, first.y0 + 6.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, r.x, first.y0 + 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, r.x, first.y0 + 2.0, 1.0)).unwrap();
|
||||
assert_eq!(kids(&s, islot)[0], rect);
|
||||
let order = kids(&s, i);
|
||||
let at = s.doc().unwrap().world_bounds(islot).unwrap().center();
|
||||
s.set_selection(vec![islot]);
|
||||
let mut down = PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0);
|
||||
down.cmd = true;
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, at.x, at.y - 60.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, at.x, at.y - 120.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y - 120.0, 1.0)).unwrap();
|
||||
assert_eq!(kids(&s, i), order, "the instance's layers keep their order");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_of_nested_instances_fill_from_the_outer_main_and_its_instances() {
|
||||
let mut s = Session::new();
|
||||
let (container, _title, content, ph) = slot_card(&mut s);
|
||||
let (chip, _) = chip(&mut s);
|
||||
// A "Page" component holding a Card instance whose slot the Page main fills.
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 800, "width": 400, "height": 300, "name": "Page"})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": container, "x": 20, "y": 20, "parent": f})));
|
||||
let pagec = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let nslot = copy_of(&s, nested, content);
|
||||
let mchip = idv(&ex(&mut s, "instance.slotInsert", json!({"id": nslot, "component": chip})));
|
||||
assert_eq!(owning_component(s.doc().unwrap(), mchip), Some(pagec));
|
||||
// Page instances mirror that content (it's the Page main's) ...
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": pagec, "x": 600, "y": 800})));
|
||||
healthy(&s);
|
||||
let inested = copy_of(&s, i, nested);
|
||||
let islot = copy_of(&s, inested, nslot);
|
||||
assert!(!slot_edited(s.doc().unwrap(), islot));
|
||||
assert_eq!(kids(&s, islot).len(), 2);
|
||||
// ... and a main edit of that content reaches them.
|
||||
ex(&mut s, "node.set", json!({"id": mchip, "props": {"opacity": 0.5}}));
|
||||
assert_eq!(node(&s, copy_of(&s, i, mchip)).opacity, 0.5);
|
||||
// The instance fills the nested slot with its own content, kept across both mains' edits.
|
||||
let own = idv(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 30, "height": 30, "parent": islot})));
|
||||
assert!(slot_edited(s.doc().unwrap(), islot));
|
||||
ex(&mut s, "node.set", json!({"id": ph, "props": {"fill": "#00ff00"}}));
|
||||
ex(&mut s, "node.set", json!({"id": pagec, "props": {"fill": "#eeeeee"}}));
|
||||
ex(&mut s, "node.set", json!({"id": container, "props": {"fill": "#dddddd"}}));
|
||||
healthy(&s);
|
||||
assert_eq!(kids(&s, islot).len(), 3);
|
||||
assert_eq!(*kids(&s, islot).last().unwrap(), own);
|
||||
assert_eq!(fill(&s, nested), "DDDDDD");
|
||||
assert_eq!(kids(&s, nslot).len(), 2, "the Page main keeps its own content in the nested slot");
|
||||
assert_eq!(fill(&s, *kids(&s, nslot).first().unwrap()), "CCCCCC", "content no longer follows the Card main");
|
||||
}
|
||||
|
||||
/// Random slot edits, main edits and resets keep every instance mirroring its main.
|
||||
#[test]
|
||||
fn random_slot_edits_keep_instances_consistent() {
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn pick<T: Copy>(&mut self, v: &[T]) -> Option<T> {
|
||||
if v.is_empty() { None } else { v.get((self.next() % v.len() as u64) as usize).copied() }
|
||||
}
|
||||
}
|
||||
for seed in 1..=std::env::var("FC_SEEDS").ok().and_then(|v| v.parse().ok()).unwrap_or(16u64) {
|
||||
let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
|
||||
let mut s = Session::new();
|
||||
let (card, _title, content, _ph) = slot_card(&mut s);
|
||||
let (chip, _) = chip(&mut s);
|
||||
for k in 0..3 {
|
||||
ex(&mut s, "instance.create", json!({"component": card, "x": 400 + k * 350, "y": 0}));
|
||||
}
|
||||
for step in 0..50 {
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let slots: Vec<NodeId> = doc.nodes().filter(|n| owning_instance(&doc, n.id).is_some() && is_slot(&doc, n.id)).map(|n| n.id).collect();
|
||||
let content_layers: Vec<NodeId> = slots.iter().flat_map(|sl| doc.node(*sl).map(|n| n.children.clone()).unwrap_or_default()).collect();
|
||||
let main_layers: Vec<NodeId> = doc.subtree(card).into_iter().filter(|i| *i != card).collect();
|
||||
let color = format!("#{:06x}", r.next() % 0xFF_FFFF);
|
||||
let (cmd, p) = match r.next() % 10 {
|
||||
0 | 1 => ("instance.slotInsert", json!({"id": r.pick(&slots), "component": chip, "index": r.next() % 3})),
|
||||
2 => ("shape.rectangle", json!({"x": 5, "y": 5, "width": 8, "height": 8, "parent": r.pick(&slots)})),
|
||||
3 => ("edit.delete", json!({"ids": [r.pick(&content_layers)]})),
|
||||
4 => ("node.reorder", json!({"id": r.pick(&content_layers), "index": r.next() % 3})),
|
||||
5 => ("instance.resetSlot", json!({"id": r.pick(&slots)})),
|
||||
6 => (
|
||||
"shape.ellipse",
|
||||
json!({"x": 5, "y": 5, "width": 8, "height": 8, "parent": if r.next().is_multiple_of(2) { content } else { card }}),
|
||||
),
|
||||
7 => ("node.set", json!({"id": r.pick(&main_layers), "props": {"fill": color}})),
|
||||
8 => ("edit.delete", json!({"ids": [r.pick(&main_layers).filter(|i| *i != content)]})),
|
||||
_ => ("node.set", json!({"id": r.pick(&content_layers), "props": {"fill": color, "width": 5 + r.next() % 40}})),
|
||||
};
|
||||
let res = s.execute(cmd, &p);
|
||||
if let Err(crate::EngineError::Internal { cmd, msg }) = &res {
|
||||
panic!("seed {seed} step {step}: {cmd} panicked: {msg}");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.validate().is_empty(), "seed {seed} step {step} {cmd} {p}: {:?}", doc.validate());
|
||||
assert!(mirror_issues(doc).is_empty(), "seed {seed} step {step} {cmd} {p} -> {res:?}: {:?}", mirror_issues(doc));
|
||||
}
|
||||
while s.undo().unwrap() {
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -720,8 +720,10 @@ impl Session {
|
|||
}
|
||||
let p = ev.p();
|
||||
let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent);
|
||||
// Absolute children of an auto layout frame stay in it; the rest may change parent.
|
||||
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute));
|
||||
// Absolute children of an auto layout frame stay in it, and so do the layers of
|
||||
// an instance (outside its slots: they only move); the rest may change parent.
|
||||
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute))
|
||||
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h));
|
||||
let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) };
|
||||
let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode);
|
||||
if let (true, Some(mode)) = (flow, mode) {
|
||||
|
|
@ -1173,12 +1175,26 @@ impl Session {
|
|||
let before = ds.doc.clone();
|
||||
let d = ds.doc_mut();
|
||||
for m in &moving {
|
||||
// Instance layers stay put (outside slots): they only move.
|
||||
let from = d.node(*m).and_then(|n| n.parent);
|
||||
if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::components::structure_locked(d, target) {
|
||||
continue;
|
||||
}
|
||||
// A layer can't go into itself; it stays where it is.
|
||||
let _ = d.reparent(*m, target, None);
|
||||
}
|
||||
crate::normalize(ds.doc_mut(), &before);
|
||||
}
|
||||
self.commit_interaction()?;
|
||||
// Reordering or dragging layers out of an instance (outside its slots) is a
|
||||
// structure edit only its main can make: the gesture is undone.
|
||||
let ds = self.ds()?;
|
||||
let allowed =
|
||||
ds.interaction.as_ref().is_none_or(|it| moving.iter().all(|m| crate::components::move_allowed(&it.doc, &ds.doc, *m)));
|
||||
if allowed {
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
// Plain click on an already-selected layer among several: select just it.
|
||||
|
|
|
|||
|
|
@ -418,6 +418,46 @@ fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
|
|||
painter.add(Shape::closed_line(q.to_vec(), s));
|
||||
}
|
||||
|
||||
/// The slots of the selected components and instances (and a slot being dropped onto): a dashed
|
||||
/// outline and the slot's name above its top-left corner, like a frame label (never over the
|
||||
/// content).
|
||||
fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option<NodeId>) {
|
||||
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
|
||||
let mut roots: Vec<NodeId> = Vec::new();
|
||||
for id in sel {
|
||||
let root = match doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) => Some(*id),
|
||||
_ => owning_instance(doc, *id).or_else(|| owning_component(doc, *id)),
|
||||
};
|
||||
if let Some(r) = root.filter(|r| !roots.contains(r)) {
|
||||
roots.push(r);
|
||||
}
|
||||
}
|
||||
let mut slots: Vec<(NodeId, String)> = roots.iter().flat_map(|r| slots_of(doc, *r)).collect();
|
||||
if let Some(d) = drop
|
||||
&& !slots.iter().any(|(s, _)| *s == d)
|
||||
&& let Some(name) = slot_name(doc, d)
|
||||
{
|
||||
slots.push((d, name));
|
||||
}
|
||||
for (id, name) in slots {
|
||||
let Some(q) = node_quad(app, doc, id) else { continue };
|
||||
if !Rect::from_points(&q).intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
|
||||
// The label, when the slot is wide enough on screen to hold it.
|
||||
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
|
||||
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
|
||||
if (q[1] - q[0]).length() < w {
|
||||
continue;
|
||||
}
|
||||
let at = q[0] - vec2(0.0, h + 2.0);
|
||||
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
|
||||
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline path of shapes (ellipses, vectors) for hover/selection outlines.
|
||||
fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos2>>> {
|
||||
let n = doc.node(id)?;
|
||||
|
|
@ -583,8 +623,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
|||
if let Some(d) = ov.drop_target
|
||||
&& let Some(q) = node_quad(app, &doc, d)
|
||||
{
|
||||
quad(painter, q, stroke(ACCENT, 1.0));
|
||||
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0));
|
||||
}
|
||||
slot_badges(app, painter, &doc, &sel, ov.drop_target);
|
||||
if let Some([x0, y0, x1, y1]) = ov.insert_line {
|
||||
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,16 @@
|
|||
//! the selected layer's overrides, and Reset / Detach / Go to main / Push changes.
|
||||
//! - A component or component set: its properties (add, rename, default, delete) and, for a set,
|
||||
//! the variant property matrix.
|
||||
//! - A layer inside a component: bind its visibility / text / instance to a property.
|
||||
//! - A layer inside a component: bind its visibility / text / instance to a property, or make a
|
||||
//! frame a slot.
|
||||
//! - A slot inside an instance (and an instance's slot properties): fill it with components
|
||||
//! (preferred ones first), select it, reset it to the main's default content.
|
||||
|
||||
use egui::{Id, Sense, Ui, vec2};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_engine::components::{inside_instance, owning_component, owning_instance, variant_options, variant_props, variant_set};
|
||||
use figcraft_engine::components::{
|
||||
inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_options, variant_props, variant_set,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
|
|
@ -33,6 +38,11 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
if owning_instance(doc, n.id).is_some()
|
||||
&& let Some((name, preferred)) = slot_def(doc, n.id)
|
||||
{
|
||||
slot_section(app, ui, doc, n.id, name, preferred);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Instance { .. }) && n.source.is_some() && owning_instance(doc, n.id).is_some() {
|
||||
overrides_ui(app, ui, n);
|
||||
}
|
||||
|
|
@ -129,9 +139,71 @@ fn type_icon(d: &PropDef) -> Icon {
|
|||
PropDef::Text { .. } => Icon::Text,
|
||||
PropDef::InstanceSwap { .. } => Icon::Instance,
|
||||
PropDef::Variant { .. } => Icon::ComponentSet,
|
||||
PropDef::Slot { .. } => Icon::Slot,
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected slot inside an instance: its fill menu.
|
||||
fn slot_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, name: &str, preferred: &[NodeId]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let comps = local_components(doc);
|
||||
widgets::separator(ui);
|
||||
row(ui, 36.0, |ui| {
|
||||
ui.label(egui::RichText::new("Slot").font(semibold(11.0)).color(t.text));
|
||||
});
|
||||
row(ui, 32.0, |ui| {
|
||||
prop_label(ui, name, Icon::Slot);
|
||||
let w = ui.available_width().max(60.0);
|
||||
slot_fill(app, ui, doc, slot, preferred, &comps, w);
|
||||
});
|
||||
}
|
||||
|
||||
/// The fill menu of slot `slot` inside an instance: its preferred components first, then every
|
||||
/// local component; select the slot; reset it to the main's default content.
|
||||
fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, preferred: &[NodeId], comps: &[(NodeId, String)], w: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let edited = slot_edited(doc, slot);
|
||||
let label = match (edited, doc.node(slot).map_or(0, |n| n.children.len())) {
|
||||
(false, 0) => "Empty".to_string(),
|
||||
(false, _) => "Default content".to_string(),
|
||||
(true, 1) => "1 layer".to_string(),
|
||||
(true, k) => format!("{k} layers"),
|
||||
};
|
||||
let resp = widgets::dropdown(ui, &label, w, Some(Icon::Slot)).on_hover_text("Add content to this slot");
|
||||
egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| {
|
||||
let insert = |app: &mut FigApp, c: NodeId| app.run("instance.slotInsert", json!({"id": slot, "component": c.0}));
|
||||
let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary));
|
||||
if !preferred.is_empty() {
|
||||
heading(ui, "Preferred");
|
||||
for c in preferred.iter().filter(|c| doc.contains(**c)) {
|
||||
let name = doc.node(*c).map(|n| n.name.clone()).unwrap_or_default();
|
||||
if menu_item(ui, &name, "") {
|
||||
insert(app, *c);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
heading(ui, "All components");
|
||||
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
|
||||
// Never a component the slot is part of.
|
||||
for (c, name) in comps.iter().filter(|(c, _)| !doc.is_ancestor(*c, slot)) {
|
||||
if menu_item(ui, name, "") {
|
||||
insert(app, *c);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if menu_item(ui, "Select slot", "") {
|
||||
app.run("select.set", json!({"ids": [slot.0]}));
|
||||
ui.close();
|
||||
}
|
||||
if edited && menu_item(ui, "Reset slot", "") {
|
||||
app.run("instance.resetSlot", json!({"id": slot.0}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn prop_defs(doc: &Document, c: NodeId) -> std::collections::BTreeMap<String, PropDef> {
|
||||
match doc.node(c).map(|n| &n.kind) {
|
||||
Some(NodeKind::Component { props, .. }) => props.clone(),
|
||||
|
|
@ -256,7 +328,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
|||
}
|
||||
});
|
||||
}
|
||||
PropDef::Variant { .. } => {}
|
||||
PropDef::Variant { .. } | PropDef::Slot { .. } => {}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, Icon::Minus, 22.0, false, "Delete property").clicked() {
|
||||
|
|
@ -264,6 +336,30 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
|||
}
|
||||
});
|
||||
});
|
||||
// A slot's preferred components, on a row of their own.
|
||||
if let PropDef::Slot { preferred } = d {
|
||||
row(ui, 30.0, |ui| {
|
||||
ui.add_space(22.0);
|
||||
let label = match preferred.len() {
|
||||
0 => "No preferred components".to_string(),
|
||||
1 => "1 preferred component".to_string(),
|
||||
k => format!("{k} preferred components"),
|
||||
};
|
||||
let w = (ui.available_width() - 8.0).max(60.0);
|
||||
let resp = widgets::dropdown(ui, &label, w, None).on_hover_text("Offered first when filling the slot (any content is allowed)");
|
||||
egui::Popup::menu(&resp).width(w.max(200.0)).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for (c, name) in comps.iter().filter(|(c, _)| *c != owner && first != Some(*c)) {
|
||||
let on = preferred.contains(c);
|
||||
if menu_item(ui, &format!("{}{name}", if on { "✓ " } else { "" }), "") {
|
||||
let next: Vec<u64> = preferred.iter().filter(|p| *p != c).map(|p| p.0).chain((!on).then_some(c.0)).collect();
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "preferred": next}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if is_set {
|
||||
row(ui, 32.0, |ui| {
|
||||
|
|
@ -286,6 +382,9 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
|||
if matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
fields.push(("mainComponent", "Instance", "instanceSwap"));
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Frame) {
|
||||
fields.push(("slot", "Slot", "slot"));
|
||||
}
|
||||
widgets::separator(ui);
|
||||
row(ui, 36.0, |ui| {
|
||||
ui.label(egui::RichText::new("Component property").font(semibold(11.0)).color(t.text));
|
||||
|
|
@ -296,7 +395,10 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
|||
.filter(|(_, d)| {
|
||||
matches!(
|
||||
(ty, d),
|
||||
("boolean", PropDef::Boolean { .. }) | ("text", PropDef::Text { .. }) | ("instanceSwap", PropDef::InstanceSwap { .. })
|
||||
("boolean", PropDef::Boolean { .. })
|
||||
| ("text", PropDef::Text { .. })
|
||||
| ("instanceSwap", PropDef::InstanceSwap { .. })
|
||||
| ("slot", PropDef::Slot { .. })
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| k)
|
||||
|
|
@ -307,6 +409,7 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
|||
let icon = match field {
|
||||
"visible" => Icon::Eye,
|
||||
"characters" => Icon::Text,
|
||||
"slot" => Icon::Slot,
|
||||
_ => Icon::Instance,
|
||||
};
|
||||
icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary });
|
||||
|
|
@ -320,7 +423,13 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
|||
ui.close();
|
||||
}
|
||||
}
|
||||
if menu_item(ui, "Create property…", "") {
|
||||
if ty == "slot" {
|
||||
if bound.is_none() && menu_item(ui, "Make slot", "") {
|
||||
// Named after the frame.
|
||||
app.run("component.makeSlot", json!({"id": n.id}));
|
||||
ui.close();
|
||||
}
|
||||
} else if menu_item(ui, "Create property…", "") {
|
||||
let existing: Vec<String> = defs.keys().cloned().collect();
|
||||
let name = (1..)
|
||||
.map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") })
|
||||
|
|
@ -329,7 +438,12 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
|||
app.run("component.addProperty", json!({"id": n.id, "name": name, "type": ty}));
|
||||
ui.close();
|
||||
}
|
||||
if bound.is_some() && menu_item(ui, "Detach property", "") {
|
||||
if ty == "slot" {
|
||||
if bound.is_some() && menu_item(ui, "Remove slot", "") {
|
||||
app.run("component.removeSlot", json!({"id": n.id}));
|
||||
ui.close();
|
||||
}
|
||||
} else if bound.is_some() && menu_item(ui, "Detach property", "") {
|
||||
app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field}));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -381,6 +495,7 @@ fn group_label(g: &str) -> String {
|
|||
"position" => "Position",
|
||||
"component" => "Swapped instance",
|
||||
"props" => "Instance properties",
|
||||
"slot" => "Slot content",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
|
|
@ -510,6 +625,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
|||
let NodeKind::Instance { component, props } = &n.kind else { return };
|
||||
let id = n.id;
|
||||
let set = variant_set(doc, *component);
|
||||
let slots = slots_of(doc, id);
|
||||
// Variant properties.
|
||||
if let Some(set) = set {
|
||||
let current = doc.node(*component).map(|c| variant_props(&c.name)).unwrap_or_default();
|
||||
|
|
@ -567,6 +683,11 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
|||
});
|
||||
}
|
||||
PropDef::Variant { .. } => {}
|
||||
PropDef::Slot { preferred } => {
|
||||
if let Some((slot, _)) = slots.iter().find(|(_, name)| *name == k) {
|
||||
slot_fill(app, ui, doc, *slot, &preferred, comps, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ pub enum Icon {
|
|||
Logo,
|
||||
/// Ignore auto layout: a layer pinned at an offset inside its frame.
|
||||
AbsolutePosition,
|
||||
/// A component slot: a dashed frame with a plus, a place that takes any content.
|
||||
Slot,
|
||||
/// Edit settings: two sliders.
|
||||
Adjust,
|
||||
/// Show as a list.
|
||||
|
|
@ -722,6 +724,16 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
p.line(10.5, 7.75, 13.5, 7.75);
|
||||
p.line(8.75, 2.5, 8.75, 6.0);
|
||||
}
|
||||
Slot => {
|
||||
for (a, b) in [(2.5, 5.0), (6.75, 9.25), (11.0, 13.5)] {
|
||||
p.line(a, 2.5, b, 2.5);
|
||||
p.line(a, 13.5, b, 13.5);
|
||||
p.line(2.5, a, 2.5, b);
|
||||
p.line(13.5, a, 13.5, b);
|
||||
}
|
||||
p.line(8.0, 5.5, 8.0, 10.5);
|
||||
p.line(5.5, 8.0, 10.5, 8.0);
|
||||
}
|
||||
Adjust => {
|
||||
p.line(2.5, 5.0, 13.5, 5.0);
|
||||
p.line(2.5, 11.0, 13.5, 11.0);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub fn node_icon(doc: &Document, id: NodeId) -> Icon {
|
|||
let Some(n) = doc.node(id) else { return Icon::Rectangle };
|
||||
match &n.kind {
|
||||
NodeKind::Page { .. } => Icon::Page,
|
||||
NodeKind::Frame if figcraft_engine::components::is_slot(doc, id) => Icon::Slot,
|
||||
NodeKind::Frame => {
|
||||
if let Some(l) = &n.layout {
|
||||
match l.mode {
|
||||
|
|
@ -531,14 +532,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
}
|
||||
let icon_col = if is_comp {
|
||||
let icon = node_icon(&doc, row.id);
|
||||
let icon_col = if is_comp || icon == Icon::Slot {
|
||||
COMPONENT
|
||||
} else if n.visible {
|
||||
t.icon
|
||||
} else {
|
||||
t.text_tertiary
|
||||
};
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), node_icon(&doc, row.id), icon_col);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col);
|
||||
let name_x = x0 + 36.0;
|
||||
let editing = app.ui.rename.as_ref().is_some_and(|(i, _)| *i == row.id);
|
||||
if editing {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, e
|
|||
layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxWidth` /
|
||||
`minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute
|
||||
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
|
||||
component/instance links, overrides, boundVariables, interactions, exports. The auto layout props
|
||||
component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports. The auto layout props
|
||||
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
|
||||
|
||||
### `ui.inspect {}`
|
||||
|
|
|
|||
26
docs/mcp.md
26
docs/mcp.md
|
|
@ -113,7 +113,7 @@ Or in a project's `.mcp.json`:
|
|||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |
|
||||
|
|
@ -186,6 +186,30 @@ read library files return an error).
|
|||
- **Update**: `library.updates` lists the components and styles with a newer published version;
|
||||
`library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides.
|
||||
|
||||
### Slots
|
||||
|
||||
A slot is a frame inside a main component whose instances take any content of their own (add,
|
||||
remove and reorder layers there, unlike the rest of an instance). It is a component property of type
|
||||
`slot` (`component.properties` lists it: on a main with its `layers`, on an instance with
|
||||
`value: {layer, edited, layers}`).
|
||||
|
||||
- **Define** (on the main): `component.makeSlot {id (a frame inside a main component), name?,
|
||||
preferred? [component or set ids]}` (the property is named after the frame by default);
|
||||
`component.editProperty {name, preferred}`; `component.removeSlot {id}` (instances lose their own
|
||||
content in it). `component.addProperty {type: "slot", layer}` and `component.bindProperty {field:
|
||||
"slot"}` work too (one frame per component for a slot).
|
||||
- **Fill** (on an instance): `instance.slotInsert {id (the slot layer, or the instance with `slot`
|
||||
(property name)), component, index?, replace?}` places an instance of a component in it; any
|
||||
structure command (`shape.*`, `node.reparent`, `edit.paste` with the slot selected, `edit.delete`,
|
||||
`node.reorder`, `instance.place` or a drag onto it) works inside it. The main's default content
|
||||
shows (and follows the main) until the slot is first edited; then the slot's layers are the
|
||||
instance's own (override group `slot`): kept across main updates, variant switches, copy/paste and
|
||||
detach, and instances placed there stand alone (their own overrides, properties and detach).
|
||||
- **Reset**: `instance.resetSlot {id}` (or `component.resetOverride {id, group: "slot"}`, or
|
||||
`component.resetInstance`) brings back the main's default content.
|
||||
|
||||
`get_node` shows `"slot": "<property>"` on slot layers.
|
||||
|
||||
### Shape builder
|
||||
|
||||
The selected shapes' outlines split the canvas into regions (areas covered by the same shapes).
|
||||
|
|
|
|||
Loading…
Reference in New Issue