Files
figcraft/crates/engine/src/cmd/proto.rs
T
BFlatandClaude Opus 5.5 c662e56de4 M5: drag to reorder prototype actions; variable mode lookups in expressions (#94)
- Interaction editor: actions (top level and inside conditional blocks) reorder by dragging
  their row's grip (list_drag, now also for lists shown in model order), one proto.action.move
  per drop; the open action stays open where it lands; Escape cancels a row drag without
  deselecting or closing what holds the list. The ... menu keeps Move up / Move down.
- Prototype expressions: `accent[Dark]` reads a variable's value in one mode of its collection
  (Expr::InMode) and `mode(Theme)` is the name of the mode a collection is in where the
  interaction runs (Expr::Mode); parsed, type-checked, displayed, stored by id (names accepted in
  JSON), evaluated with the layer's modes. REST import maps VAR_MODE_LOOKUP over a variable and
  one of its modes.
- Overlays opened through commands close on a click outside unless told not to, as in the editor.
- Collection::find_mode / current_mode replace repeated lookups.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:21:53 +07:00

600 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Prototype commands: interactions, flows, settings, presenting.
use figcraft_doc::proto::*;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate {preserveScroll?}|back|scrollTo|openOverlay {position?, close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
spec!("proto.interaction.clear", "Remove all interactions", "{ids?}", true, |s, p| {
let ids = req_targets("proto.interaction.clear", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.interactions.clear();
}
ok()
}),
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
let cmd = "proto.interaction.remove";
let k = req_num(cmd, p, "index")?;
if k < 0.0 {
return Err(bad(cmd, "`index` must be ≥ 0"));
}
let k = k as usize;
let mut hit = false;
for i in req_targets(cmd, s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
if k < n.interactions.len() {
n.interactions.remove(k);
hit = true;
}
}
if !hit {
return Err(bad(cmd, format!("no interaction {k}")));
}
ok()
}),
spec!(
"proto.interaction.set",
"Edit interaction",
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?}",
true,
set
),
spec!("proto.flow.add", "Add flow starting point", "{start? (top-level frame id; default selection), name?}", true, |s, p| {
let cmd = "proto.flow.add";
let start = id(p, "start").or_else(|| s.selection().first().copied()).ok_or_else(|| bad(cmd, "select a top-level frame"))?;
let page = s.page()?;
let doc = s.doc_mut()?;
if doc.top_level_frame(start) != Some(start) {
return Err(bad(cmd, "a flow starts at a top-level frame"));
}
let flows = match &doc.get(page)?.kind {
NodeKind::Page { flows, .. } => flows.clone(),
_ => vec![],
};
if let Some(f) = flows.iter().find(|f| f.start == start) {
return Ok(json!({"name": f.name, "start": start}));
}
let name = string(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
let mut k = flows.len() + 1;
while flows.iter().any(|f| f.name == format!("Flow {k}")) {
k += 1;
}
format!("Flow {k}")
});
if let NodeKind::Page { flows, .. } = &mut doc.get_mut(page)?.kind {
flows.push(Flow { name: name.clone(), start, description: String::new() });
}
Ok(json!({"name": name, "start": start}))
}),
spec!("proto.flow.rename", "Rename flow", "{start, name?, description?}", true, |s, p| {
let cmd = "proto.flow.rename";
let start = req_id(cmd, p, "start")?;
let name = string(p, "name").map(|n| n.trim().to_string());
if name.as_deref() == Some("") {
return Err(bad(cmd, "`name` can't be empty"));
}
let desc = string(p, "description");
let page = s.page()?;
let NodeKind::Page { flows, .. } = &mut s.doc_mut()?.get_mut(page)?.kind else { return Err(bad(cmd, "not a page")) };
let f = flows.iter_mut().find(|f| f.start == start).ok_or_else(|| bad(cmd, "no flow starts at that frame"))?;
if let Some(n) = name {
f.name = n;
}
if let Some(d) = desc {
f.description = d;
}
ok()
}),
spec!("proto.flow.remove", "Remove flow", "{start}", true, |s, p| {
let start = req_id("proto.flow.remove", p, "start")?;
let page = s.page()?;
if let NodeKind::Page { flows, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
flows.retain(|f| f.start != start);
}
ok()
}),
spec!(
"proto.settings",
"Prototype settings",
"{device? (a device name, \"Custom size\" or \"None\"), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)}",
true,
settings
),
spec!(
"proto.present",
"Present",
"{start? (frame id; default: the selected frame, else the first flow, else the first frame)}",
false,
|s, p| { open_player(s, p, "proto.present", "present") }
),
spec!(
"proto.preview",
"Open prototype preview",
"{start? (frame id; default as proto.present)} a floating preview pane over the canvas that follows edits live",
false,
|s, p| open_player(s, p, "proto.preview", "preview")
),
spec!("proto.closePreview", "Close prototype preview", "{}", false, |s, _| {
s.request_ui("preview.close", json!({}));
ok()
}),
spec!(
"proto.presentOptions",
"Presentation options",
"{scale? fit|fitWidth|fill|actual, deviceFrame? bool, hotspotHints? bool, inspect? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%), whether it draws the device, whether a click where nothing reacts flashes the hotspots (on by default) and whether the Inspect panel shows the properties of the layer under the pointer; kept for later presentations",
false,
|s, p| {
let cmd = "proto.presentOptions";
let mut data = serde_json::Map::new();
if let Some(v) = p.get("scale").filter(|v| !v.is_null()) {
let scale = v.as_str().filter(|x| PRESENT_SCALES.contains(x)).ok_or_else(|| bad(cmd, "scale: fit|fitWidth|fill|actual"))?;
data.insert("scale".into(), json!(scale));
}
for key in ["deviceFrame", "hotspotHints", "inspect"] {
if let Some(v) = p.get(key).filter(|v| !v.is_null()) {
data.insert(key.into(), json!(v.as_bool().ok_or_else(|| bad(cmd, format!("{key}: true|false")))?));
}
}
if data.is_empty() {
return Err(bad(cmd, "nothing to set: scale, deviceFrame, hotspotHints or inspect"));
}
s.request_ui("present.options", Value::Object(data));
ok()
}
),
]
}
/// `proto.settings`: everything is checked before anything changes.
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.settings";
let page = s.page()?;
let bg = color(p, "background");
let dev = string(p, "device");
let rot = match p.get("rotation") {
None | Some(Value::Null) => None,
Some(v) => Some(rotation_of(v).ok_or_else(|| bad(cmd, "rotation: portrait|landscape"))?),
};
let side = |key: &str| -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().and_then(PrototypeSettings::custom_side).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 1–10000 points"))),
}
};
let (w, h) = (side("width")?, side("height")?);
let responsive = match p.get("responsive") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "responsive: true|false"))?),
};
let col = match p.get("color") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "color: one of the device's colour names"))?.trim().to_string()),
};
let NodeKind::Page { prototype, .. } = &s.doc()?.get(page)?.kind else { return Err(bad(cmd, "not a page")) };
let mut next = prototype.clone();
if let Some(c) = bg {
next.background = c;
}
if let Some(d) = dev {
next.device = d;
}
if let Some(r) = rot {
next.rotation = r;
}
// A width or height is the custom size's: it picks "Custom size".
if w.is_some() || h.is_some() {
next.custom_size = [w.unwrap_or(next.custom_size[0]), h.unwrap_or(next.custom_size[1])];
next.device = PrototypeSettings::CUSTOM_DEVICE.into();
}
if let Some(r) = responsive {
next.responsive = r;
}
let colors = next.model().map_or(&[][..], |m| m.colors);
match col {
Some(c) if c.is_empty() => next.color.clear(),
Some(c) => {
let found = colors.iter().find(|x| x.name.eq_ignore_ascii_case(&c)).ok_or_else(|| {
let names: Vec<&str> = colors.iter().map(|x| x.name).collect();
bad(cmd, if names.is_empty() { "this device has no colours".to_string() } else { format!("color: {}", names.join("|")) })
})?;
next.color = found.name.into();
}
// Another device keeps the colour only if it comes in it.
None if !colors.iter().any(|x| x.name == next.color) => next.color.clear(),
None => {}
}
if next != *prototype
&& let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind
{
*prototype = next;
}
ok()
}
/// Resize top-level `frame` to a screen of `(w, h)` as a responsive prototype does: its children
/// follow their constraints and auto layout flows again (fill layers stretch, text wraps), exactly
/// as when the frame is resized in the editor; an axis the frame hugs keeps hugging its content.
/// Used by the player on its own copy of the document.
pub fn respond(doc: &mut Document, frame: NodeId, (w, h): (f64, f64)) {
if !(w.is_finite() && h.is_finite()) || doc.node(frame).is_none_or(|n| !n.kind.is_frame_like()) {
return;
}
let (w, h) = (w.clamp(1.0, 100_000.0), h.clamp(1.0, 100_000.0));
if doc.node(frame).is_some_and(|n| (n.width - w).abs() < 1e-9 && (n.height - h).abs() < 1e-9) {
return;
}
doc.take_dirty();
let before = doc.clone();
if super::props::resize_node(doc, frame, w, h).is_ok() {
crate::normalize(doc, &before);
}
}
/// The presenter's scale options (`proto.presentOptions {scale}`).
pub const PRESENT_SCALES: [&str; 4] = ["fit", "fitWidth", "fill", "actual"];
/// A device rotation from a command value: `portrait` / `landscape`, or 0 / 90 degrees.
fn rotation_of(v: &Value) -> Option<DeviceRotation> {
match v {
Value::String(s) => DeviceRotation::parse(s),
Value::Number(n) => n.as_f64().and_then(|a| DeviceRotation::parse(&format!("{a}"))),
_ => None,
}
}
/// Ask the UI to present (full screen or in the preview pane) from `start`, else the selected
/// top-level frame, the page's first flow or its first frame.
fn open_player(s: &mut Session, p: &Value, cmd: &str, ui: &str) -> Result<Value> {
let ds = s.ds()?;
let start = match id(p, "start") {
// A layer inside a frame presents its top-level frame.
Some(f) => ds.doc.top_level_frame(f).ok_or_else(|| bad(cmd, "`start` isn't a frame or inside one"))?,
None => ds
.selection
.first()
.and_then(|i| ds.doc.top_level_frame(*i))
.or_else(|| match &ds.doc.node(ds.page)?.kind {
NodeKind::Page { flows, .. } => flows.first().map(|f| f.start),
_ => None,
})
.or_else(|| ds.doc.top_frames(ds.page).first().copied())
.ok_or_else(|| bad(cmd, "no frame to present"))?,
};
s.request_ui(ui, json!({"start": start}));
Ok(json!({"start": start}))
}
/// Every node an action points at must exist.
fn check_dests(cmd: &str, s: &Session, it: &Interaction) -> Result<()> {
check_action_dests(cmd, s.doc()?, &it.actions)
}
/// Every node `actions` (and what they nest) point at must exist in `doc`.
pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action]) -> Result<()> {
for a in figcraft_doc::proto::walk(actions) {
if let Some(d) = a.dest()
&& !doc.contains(d)
{
return Err(bad(cmd, format!("destination {d} doesn't exist")));
}
}
Ok(())
}
/// Variables, collections and modes that actions name by name, stored by id.
fn canonical_ids(doc: &Document, it: &mut Interaction) {
canonical_actions(&doc.variables, &mut it.actions);
}
/// Variables (also those expressions read), collections and modes that `actions` name by name,
/// stored by id.
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
walk_mut(actions, &mut |a| match a {
Action::SetVariable { variable, expr, .. } => {
if let Some(v) = vars.get(variable) {
*variable = v.id.clone();
}
if let Some(e) = expr {
canonical_expr(vars, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
if let Some(c) = vars.collection(collection) {
if let Some(m) = c.find_mode(mode) {
*mode = m.id.clone();
}
*collection = c.id.clone();
}
}
Action::Conditional { blocks } => {
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
canonical_expr(vars, e, 0);
}
}
_ => {}
});
}
/// Variables, collections and modes an expression names by name, stored by id.
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
match e {
Expr::Value(VariableValue::Alias(name)) => {
if let Some(v) = vars.get(name) {
*name = v.id.clone();
}
}
Expr::Mode { collection } => {
if let Some(c) = vars.collection(collection) {
*collection = c.id.clone();
}
}
Expr::InMode { variable, mode } => {
if let Some(v) = vars.get(variable) {
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
*mode = m.id.clone();
}
*variable = v.id.clone();
}
}
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| canonical_expr(vars, a, depth + 1)),
_ => {}
}
}
fn duration(p: &Value) -> Option<u32> {
num(p, "durationMs").or_else(|| num(p, "duration_ms")).map(|d| d.clamp(0.0, 60_000.0) as u32)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.interaction.add";
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
let it = if let Some(d) = id(p, "dest")
&& get(p, "action").is_none()
{
Interaction {
trigger: parse(cmd, p, "trigger")?.unwrap_or_default(),
actions: vec![Action::Navigate { dest: d, preserve_scroll: false }],
transition: parse(cmd, p, "transition")?.unwrap_or_default(),
duration_ms: duration(p).unwrap_or(300),
easing: parse(cmd, p, "easing")?.unwrap_or_default(),
}
} else {
let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
let actions: Vec<Action> = match parse::<Vec<Action>>(cmd, p, "actions")? {
Some(a) if !a.is_empty() => a,
_ => vec![parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?],
};
let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing }
};
let mut it = it;
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
let mut index = 0;
for i in req_targets(cmd, s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
n.interactions.push(it.clone());
index = n.interactions.len() - 1;
}
Ok(json!({"index": index}))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.interaction.set";
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
let k = req_num(cmd, p, "index")?;
if k < 0.0 {
return Err(bad(cmd, "`index` must be ≥ 0"));
}
let k = k as usize;
let whole: Option<Interaction> = parse(cmd, p, "interaction")?;
let trigger: Option<Trigger> = parse(cmd, p, "trigger")?;
let action: Option<Action> = parse(cmd, p, "action")?;
let actions: Option<Vec<Action>> = parse(cmd, p, "actions")?;
let transition: Option<Transition> = parse(cmd, p, "transition")?;
let easing: Option<Easing> = parse(cmd, p, "easing")?;
let dur = duration(p);
let mut hit = false;
for i in req_targets(cmd, s, p)? {
let Some(cur) = s.doc()?.get(i)?.interactions.get(k).cloned() else { continue };
let mut it = whole.clone().unwrap_or(cur);
if let Some(t) = &trigger {
it.trigger = t.clone();
}
if let Some(a) = &actions {
it.actions = a.clone();
}
if let Some(a) = &action {
match it.actions.first_mut() {
Some(slot) => *slot = a.clone(),
None => it.actions.push(a.clone()),
}
}
if let Some(t) = transition {
it.transition = t;
}
if let Some(e) = easing {
it.easing = e;
}
if let Some(d) = dur {
it.duration_ms = d;
}
if it.actions.is_empty() {
return Err(bad(cmd, "an interaction needs an action"));
}
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
*slot = it;
hit = true;
}
}
if !hit {
return Err(bad(cmd, format!("no interaction {k}")));
}
ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::id_of;
fn frame(s: &mut Session, x: f64) -> NodeId {
let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap();
id_of(&r["id"]).unwrap()
}
fn page_flows(s: &Session) -> Vec<Flow> {
let ds = s.ds().unwrap();
match &ds.doc.node(ds.page).unwrap().kind {
NodeKind::Page { flows, .. } => flows.clone(),
_ => vec![],
}
}
#[test]
fn add_set_patch_remove() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
let b = frame(&mut s, 200.0);
let r = s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
assert_eq!(r["index"], 0);
let r = s
.execute(
"proto.interaction.add",
&json!({"id": a, "trigger": {"type": "keyPress", "key": "Space"}, "action": {"type": "back"}, "transition": {"type": "moveIn"}, "durationMs": 450, "easing": "bouncy"}),
)
.unwrap();
assert_eq!(r["index"], 1);
let it = s.doc().unwrap().node(a).unwrap().interactions[1].clone();
assert_eq!(it.trigger, Trigger::KeyPress { key: "Space".into() });
assert_eq!(it.transition, Transition::MoveIn { direction: Direction::Left });
assert_eq!(it.duration_ms, 450);
assert_eq!(it.easing, Easing::Bouncy);
// Patch only the transition and easing; the action stays.
s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "transition": {"type": "push", "direction": "top"}, "easing": "linear"}))
.unwrap();
let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
assert_eq!(it.actions, vec![Action::Navigate { dest: b, preserve_scroll: false }]);
assert_eq!(it.transition, Transition::Push { direction: Direction::Top });
assert_eq!(it.easing, Easing::Linear);
// Overlay action with settings; camelCase accepted.
s.execute(
"proto.interaction.set",
&json!({"id": a, "index": 0, "action": {"type": "openOverlay", "dest": b, "position": "bottomCenter", "closeOnOutside": true, "background": "#00000040"}}),
)
.unwrap();
let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
let Action::OpenOverlay { position, close_on_outside, background, .. } = &it.actions[0] else { panic!("{it:?}") };
assert_eq!(*position, OverlayPosition::BottomCenter);
assert!(*close_on_outside);
assert!(background.is_some());
// Whole replacement round-trips through serde.
let mut whole = it.clone();
whole.trigger = Trigger::AfterDelay { ms: 800 };
s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "interaction": whole})).unwrap();
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions[0].trigger, Trigger::AfterDelay { ms: 800 });
// Errors leave the document untouched.
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 9, "easing": "linear"})).is_err());
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "action": {"type": "navigate", "dest": 99999}})).is_err());
assert!(s.execute("proto.interaction.add", &json!({"id": a, "action": {"type": "navigate", "dest": 99999}})).is_err());
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "actions": []})).is_err());
assert!(s.execute("proto.interaction.remove", &json!({"id": a, "index": -1})).is_err());
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
s.execute("proto.interaction.remove", &json!({"id": a, "index": 0})).unwrap();
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 1);
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
}
#[test]
fn flows_add_rename_remove() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
let b = frame(&mut s, 200.0);
let r = s.execute("proto.flow.add", &json!({"start": a})).unwrap();
assert_eq!(r["name"], "Flow 1");
// Adding again keeps the existing flow.
s.execute("proto.flow.add", &json!({"start": a, "name": "Other"})).unwrap();
assert_eq!(page_flows(&s).len(), 1);
s.execute("proto.flow.add", &json!({"start": b})).unwrap();
s.execute("proto.flow.rename", &json!({"start": b, "name": "Checkout", "description": "Pay"})).unwrap();
let flows = page_flows(&s);
assert_eq!(flows.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Flow 1", "Checkout"]);
assert_eq!(flows[1].description, "Pay");
assert!(s.execute("proto.flow.rename", &json!({"start": b, "name": " "})).is_err());
assert!(s.execute("proto.flow.rename", &json!({"start": 4242, "name": "x"})).is_err());
// Only top-level frames start flows.
let r = s.execute("node.create", &json!({"type": "rectangle", "parent": a, "x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
let child = id_of(&r["id"]).unwrap();
assert!(s.execute("proto.flow.add", &json!({"start": child})).is_err());
s.execute("proto.flow.remove", &json!({"start": a})).unwrap();
assert_eq!(page_flows(&s).len(), 1);
// A new flow gets an unused default name.
let r = s.execute("proto.flow.add", &json!({"start": a})).unwrap();
assert_eq!(r["name"], "Flow 2");
}
#[test]
fn present_and_preview_pick_a_top_level_frame() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
let b = frame(&mut s, 200.0);
let r = s.execute("node.create", &json!({"type": "rectangle", "parent": b, "x": 205, "y": 5, "width": 10, "height": 10})).unwrap();
let child = id_of(&r["id"]).unwrap();
let last = |s: &mut Session| {
let r = s.ui_requests.pop().unwrap();
s.ui_requests.clear();
(r.kind, id_of(&r.data["start"]))
};
// A layer inside a frame previews its frame; the selection is the default.
assert_eq!(s.execute("proto.preview", &json!({"start": child})).unwrap()["start"], json!(b));
assert_eq!(last(&mut s), ("preview".to_string(), Some(b)));
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("proto.present", &json!({})).unwrap();
assert_eq!(last(&mut s), ("present".to_string(), Some(a)));
// A start that isn't in a frame is an error, not a silent fallback.
assert!(s.execute("proto.preview", &json!({"start": 987_654})).is_err());
s.execute("proto.closePreview", &json!({})).unwrap();
assert_eq!(last(&mut s).0, "preview.close");
}
#[test]
fn navigate_can_preserve_scroll() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
let b = frame(&mut s, 200.0);
s.execute("proto.interaction.add", &json!({"id": a, "action": {"type": "navigate", "dest": b, "preserveScroll": true}})).unwrap();
let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
assert_eq!(it.actions, vec![Action::Navigate { dest: b, preserve_scroll: true }]);
// Off is the default and isn't written out.
let off = serde_json::to_value(Action::Navigate { dest: b, preserve_scroll: false }).unwrap();
assert!(off.get("preserve_scroll").is_none(), "{off}");
}
#[test]
fn scroll_props_via_node_set() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
s.execute("node.set", &json!({"id": a, "props": {"overflow": "vertical", "scrollPosition": "fixed"}})).unwrap();
let n = s.doc().unwrap().node(a).unwrap().clone();
assert_eq!(n.overflow, Overflow::Vertical);
assert_eq!(n.scroll_position, ScrollPosition::Fixed);
}
}