mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
M5: overlay positions relative to the layer that opens them
From the Figma forum request "Set overlay position relative to element, not to page": an open overlay action takes anchor: screen (default) | triggerInside | triggerOutside. With a trigger anchor the position preset is measured on the layer that opens the overlay instead of the screen. Outside puts top presets above it and bottom presets below it with the edges lined up (a dropdown is Bottom left + Outside); Center centres it on that layer. Manual is unchanged. The interaction editor gets Relative to (Whole screen / Trigger layer) and Inside / Outside. The player, the command (and so MCP and the control channel) carry it; the command's params doc lists it. Figma has no such setting, so .fig export writes it as a manual offset. Old files and JSON without anchor read as screen and write unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70227e30fc
commit
545cd630fd
36 files changed
+439
-29
No files matched your search
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.add",
|
||||
"Add interaction",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), 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}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), 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}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
|
||||
@@ -1821,6 +1821,8 @@ mod proto_delay_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_multi_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_overlay_anchor_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_polish_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_present_tests;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition};
|
||||
|
||||
fn frame(s: &mut Session, x: f64, w: f64, h: f64, name: &str) -> NodeId {
|
||||
id_of(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": w, "height": h, "name": name})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn button(s: &mut Session, parent: NodeId) -> NodeId {
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 30, "width": 100, "height": 40, "parent": parent, "name": "Button"}));
|
||||
id_of(&r.unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn action(s: &Session, id: NodeId) -> Action {
|
||||
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].clone()
|
||||
}
|
||||
|
||||
fn dropdown(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let screen = frame(s, 0.0, 400.0, 800.0, "Screen");
|
||||
let menu = frame(s, 600.0, 60.0, 200.0, "Menu");
|
||||
let b = button(s, screen);
|
||||
let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"});
|
||||
s.execute("proto.interaction.add", &json!({"id": b, "action": open})).unwrap();
|
||||
(b, menu)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anchor_is_set_read_and_undone() {
|
||||
let mut s = Session::new();
|
||||
let (b, menu) = dropdown(&mut s);
|
||||
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, position: OverlayPosition::BottomLeft, .. }));
|
||||
s.execute(
|
||||
"proto.interaction.set",
|
||||
&json!({"id": b, "index": 0, "action": {"type": "openOverlay", "dest": menu, "position": "topCenter", "anchor": "triggerInside"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerInside, position: OverlayPosition::TopCenter, .. }));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_writes_a_manual_offset_from_the_trigger() {
|
||||
let mut s = Session::new();
|
||||
let (b, _) = dropdown(&mut s);
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
|
||||
let hot = back.nodes().find(|n| n.name == "Button").unwrap();
|
||||
assert!(
|
||||
matches!(hot.interactions[0].actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([0.0, 40.0]), .. }),
|
||||
"{:?} (from {b:?})",
|
||||
hot.interactions[0].actions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_anchors_are_refused_not_fatal() {
|
||||
let junk = [json!(true), json!(-1), json!("above"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let (a, menu) = (frame(&mut s, 0.0, 100.0, 100.0, "A"), frame(&mut s, 200.0, 50.0, 50.0, "M"));
|
||||
let p: Value = json!({"id": a, "action": {"type": "openOverlay", "dest": menu, "anchor": j}});
|
||||
let r = s.execute("proto.interaction.add", &p);
|
||||
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}");
|
||||
assert!(s.doc().unwrap().node(a).unwrap().interactions.is_empty(), "{j}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user