M5: an Open link action can open in place of the prototype, with Open in new tab off (#1685)

This commit is contained in:
BFlat
2026-10-31 02:37:54 +07:00
committed by GitHub
parent 77778cad9d
commit 760aece238
43 changed files with 161 additions and 35 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -225,6 +225,14 @@ impl Services for WebServices {
log::error!("couldn't open {url}: {e:?}");
}
}
/// The page itself goes to `url` (an Open link action that doesn't open a new tab). Only web
/// and mail links reach here ([`figcraft_engine::doc_urls::openable`]).
fn open_url_here(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.location().set_href(url) {
log::error!("couldn't open {url}: {e:?}");
}
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
+8
View File
@@ -589,6 +589,10 @@ pub enum Action {
CloseOverlay,
OpenUrl {
url: String,
/// In a new tab or window (the default), or in place of the prototype where the viewer
/// can be sent elsewhere (a browser's own tab; the desktop app always opens the browser).
#[serde(default = "crate::node::yes", alias = "newTab", alias = "openInNewTab", skip_serializing_if = "is_true")]
new_tab: bool,
},
/// Change an instance to another variant (interactive components).
ChangeTo {
@@ -788,6 +792,10 @@ fn is_false(b: &bool) -> bool {
!*b
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_origin(p: &[f64; 2]) -> bool {
*p == [0.0, 0.0]
}
+1 -1
View File
@@ -16,7 +16,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?, 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? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (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, openInNewTab? (default true; false opens it in place of the prototype where the viewer's tab can go there: the web build)}|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? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (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
),
+1 -1
View File
@@ -119,7 +119,7 @@ impl<'d> Exporter<'d> {
}
Action::Back => plain("BACK"),
Action::CloseOverlay => plain("CLOSE"),
Action::OpenUrl { url } => Some(o(vec![("connectionType", e("URL")), ("connectionURL", s(url))])),
Action::OpenUrl { url, .. } => Some(o(vec![("connectionType", e("URL")), ("connectionURL", s(url))])),
Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::Conditional { .. } => {
self.warn("variable and conditional prototype actions were left out");
None
+1 -1
View File
@@ -519,7 +519,7 @@ impl Gen {
4 => Action::ChangeTo { dest },
5 => Action::Back,
6 => Action::CloseOverlay,
_ => Action::OpenUrl { url: "https://example.com".into() },
_ => Action::OpenUrl { url: "https://example.com".into(), new_tab: true },
};
let d = self.r.pick(&[Direction::Left, Direction::Right, Direction::Top, Direction::Bottom]);
let transition = self.r.pick(&[
+1 -1
View File
@@ -1475,7 +1475,7 @@ impl<'a> Exporter<'a> {
Action::Back => {
out.insert("actionType".into(), json!("prev-screen"));
}
Action::OpenUrl { url } => {
Action::OpenUrl { url, .. } => {
out.insert("actionType".into(), json!("open-url"));
out.insert("url".into(), json!(url));
}
+1 -1
View File
@@ -183,7 +183,7 @@ fn penpot_action(item: &Value, kind: &PenpotActionKind) -> Option<(Action, Optio
if url.is_empty() || url.len() > 2048 {
return None;
}
Action::OpenUrl { url: url.chars().take(2048).collect() }
Action::OpenUrl { url: url.chars().take(2048).collect(), new_tab: true }
}
};
let needs_destination = matches!(kind, PenpotActionKind::Navigate | PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay);
+5 -1
View File
@@ -228,7 +228,11 @@ impl Ctx<'_> {
let mapped = match text(a, "type") {
Some("BACK") => Some(Action::Back),
Some("CLOSE") => Some(Action::CloseOverlay),
Some("URL") => text(a, "url").filter(|u| !u.trim().is_empty()).map(|u| Action::OpenUrl { url: u.chars().take(MAX_URL).collect() }),
Some("URL") => text(a, "url").filter(|u| !u.trim().is_empty()).map(|u| Action::OpenUrl {
url: u.chars().take(MAX_URL).collect(),
// A new tab unless the action says otherwise.
new_tab: a.get("openInNewTab").and_then(Value::as_bool).unwrap_or(true),
}),
Some("NODE") => {
let mapped = self.node_action(a);
if mapped.is_some() && transition.is_none() {
@@ -73,7 +73,7 @@ fn file_json() -> Value {
{"trigger": {"type": "AFTER_TIMEOUT", "timeout": 800}, "actions": [node_action("1:30", "OVERLAY", json!({"type": "MOVE_IN", "direction": "RIGHT",
"duration": 250, "matchLayers": false, "easing": {"type": "CUSTOM_SPRING", "easingFunctionSpring": {"mass": 1, "stiffness": 200, "damping": 20}}}))]},
{"trigger": {"type": "ON_KEY_DOWN", "device": "KEYBOARD", "keyCodes": [16, 65]}, "actions": [{"type": "BACK"}]},
{"trigger": {"type": "MOUSE_ENTER", "delay": 0}, "actions": [{"type": "URL", "url": "https://example.com"}]},
{"trigger": {"type": "MOUSE_ENTER", "delay": 0}, "actions": [{"type": "URL", "url": "https://example.com"}, {"type": "URL", "url": "https://example.org", "openInNewTab": false}]},
{"trigger": {"type": "ON_HOVER"}, "actions": [{"type": "SET_VARIABLE", "variableId": FLAG, "variableValue": {"type": "BOOLEAN", "value": false}}]},
{"trigger": {"type": "ON_PRESS"}, "actions": [{"type": "CONDITIONAL", "conditionalBlocks": [{"actions": [{"type": "CLOSE"}]}]}]},
{"trigger": {"type": "ON_DRAG"}, "actions": [{"type": "SET_VARIABLE_MODE", "variableCollectionId": THEME, "variableModeId": "1:0"}]},
@@ -235,7 +235,13 @@ fn assert_interactions(doc: &Document, warnings: &[String]) {
assert_eq!(its[2].trigger, Trigger::KeyPress { key: "Shift+A".into() }, "modifier keys make a combination");
assert_eq!(its[2].actions, [Action::Back]);
assert_eq!(its[3].trigger, Trigger::MouseEnter);
assert_eq!(its[3].actions, [Action::OpenUrl { url: "https://example.com".into() }]);
let in_place = Action::OpenUrl { url: "https://example.org".into(), new_tab: false };
assert_eq!(its[3].actions, [Action::OpenUrl { url: "https://example.com".into(), new_tab: true }, in_place.clone()]);
// Saved only when off, so files from before the option open with links in a new tab.
assert_eq!(serde_json::to_value(&its[3].actions[0]).unwrap(), json!({"type": "openUrl", "url": "https://example.com"}));
let saved = serde_json::to_value(&in_place).unwrap();
assert_eq!(saved["new_tab"], json!(false));
assert_eq!(serde_json::from_value::<Action>(saved).unwrap(), in_place);
assert_eq!(its[4].trigger, Trigger::OnHover);
assert_eq!(its[4].actions, [Action::SetVariable { variable: FLAG.into(), value: json!(false), expr: None }]);
assert_eq!((its[4].transition, its[4].duration_ms), (Transition::Instant, 300));
+1
View File
@@ -1005,6 +1005,7 @@ status = "draft — needs native review"
"Open in new window" = "فتح في نافذة جديدة"
"Open invite links in FigCraft" = "فتح روابط الدعوة في FigCraft"
"Open link" = "فتح الرابط"
"Open in new tab" = "فتح في علامة تبويب جديدة"
"Open link {url}" = "فتح الرابط {url}"
"Open overlay" = "فتح طبقة عائمة"
"Open prototype preview" = "فتح معاينة النموذج التفاعلي"
+1
View File
@@ -971,6 +971,7 @@ status = "draft — needs native review"
"Open in new window" = "নতুন উইন্ডোতে খুলুন"
"Open invite links in FigCraft" = "FigCraft-এ আমন্ত্রণ লিংক খুলুন"
"Open link" = "লিংক খুলুন"
"Open in new tab" = "নতুন ট্যাবে খুলুন"
"Open link {url}" = "লিংক খুলুন {url}"
"Open overlay" = "ওভারলে খুলুন"
"Open prototype preview" = "প্রোটোটাইপ প্রিভিউ খুলুন"
+1
View File
@@ -1011,6 +1011,7 @@ status = "draft — needs native review"
"Open in new window" = "Otvori u novom prozoru"
"Open invite links in FigCraft" = "Otvaraj pozivne linkove u FigCraftu"
"Open link" = "Otvori link"
"Open in new tab" = "Otvori u novoj kartici"
"Open link {url}" = "Otvori link {url}"
"Open overlay" = "Otvori preklop"
"Open prototype preview" = "Otvori pregled prototipa"
+1
View File
@@ -954,6 +954,7 @@ status = "draft — needs native review"
"Open in new window" = "Otevřít v novém okně"
"Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft"
"Open link" = "Otevřít odkaz"
"Open in new tab" = "Otevřít na nové kartě"
"Open link {url}" = "Otevřít odkaz {url}"
"Open overlay" = "Otevřít překryv"
"Open prototype preview" = "Otevřít náhled prototypu"
+1
View File
@@ -933,6 +933,7 @@ status = "draft — needs native review"
"Open in new window" = "In neuem Fenster öffnen"
"Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen"
"Open link" = "Link öffnen"
"Open in new tab" = "In neuem Tab öffnen"
"Open link {url}" = "Link {url} öffnen"
"Open overlay" = "Overlay öffnen"
"Open prototype preview" = "Prototyp-Vorschau öffnen"
+1
View File
@@ -1020,6 +1020,7 @@ name = "English"
"Open in new window" = "Open in new window"
"Open invite links in FigCraft" = "Open invite links in FigCraft"
"Open link" = "Open link"
"Open in new tab" = "Open in new tab"
"Open link {url}" = "Open link {url}"
"Open overlay" = "Open overlay"
"Open prototype preview" = "Open prototype preview"
+1
View File
@@ -999,6 +999,7 @@ status = "draft — needs native review"
"Open in new window" = "Abrir en una ventana nueva"
"Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft"
"Open link" = "Abrir enlace"
"Open in new tab" = "Abrir en una pestaña nueva"
"Open link {url}" = "Abrir enlace {url}"
"Open overlay" = "Abrir superposición"
"Open prototype preview" = "Abrir vista previa del prototipo"
+1
View File
@@ -1025,6 +1025,7 @@ status = "draft — needs native review"
"Open in new window" = "Avaa uudessa ikkunassa"
"Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa"
"Open link" = "Avaa linkki"
"Open in new tab" = "Avaa uudessa välilehdessä"
"Open link {url}" = "Avaa linkki {url}"
"Open overlay" = "Avaa peittokuva"
"Open prototype preview" = "Avaa prototyypin esikatselu"
+1
View File
@@ -973,6 +973,7 @@ status = "draft — needs native review"
"Open in new window" = "Ouvrir dans une nouvelle fenêtre"
"Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft"
"Open link" = "Ouvrir le lien"
"Open in new tab" = "Ouvrir dans un nouvel onglet"
"Open link {url}" = "Ouvrir le lien {url}"
"Open overlay" = "Ouvrir une superposition"
"Open prototype preview" = "Ouvrir l’aperçu du prototype"
+1
View File
@@ -961,6 +961,7 @@ status = "draft — needs native review"
"Open in new window" = "Buka di jendela baru"
"Open invite links in FigCraft" = "Buka link undangan di FigCraft"
"Open link" = "Buka link"
"Open in new tab" = "Buka di tab baru"
"Open link {url}" = "Buka link {url}"
"Open overlay" = "Buka overlay"
"Open prototype preview" = "Buka pratinjau prototype"
+1
View File
@@ -942,6 +942,7 @@ status = "draft — needs native review"
"Open in new window" = "Apri in una nuova finestra"
"Open invite links in FigCraft" = "Apri i link di invito in FigCraft"
"Open link" = "Apri link"
"Open in new tab" = "Apri in una nuova scheda"
"Open link {url}" = "Apri link {url}"
"Open overlay" = "Apri sovrapposizione"
"Open prototype preview" = "Apri anteprima del prototipo"
+1
View File
@@ -966,6 +966,7 @@ status = "draft — needs native review"
"Open in new window" = "新しいウィンドウで開く"
"Open invite links in FigCraft" = "招待リンクを FigCraft で開く"
"Open link" = "リンクを開く"
"Open in new tab" = "新しいタブで開く"
"Open link {url}" = "リンク {url} を開く"
"Open overlay" = "オーバーレイを開く"
"Open prototype preview" = "プロトタイプのプレビューを開く"
+1
View File
@@ -981,6 +981,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Open in new window" = "გახსნა ახალ ფანჯარაში"
"Open invite links in FigCraft" = "მოსაწვევი ბმულების FigCraft-ში გახსნა"
"Open link" = "ბმულის გახსნა"
"Open in new tab" = "ახალ ჩანართში გახსნა"
"Open link {url}" = "ბმულის გახსნა {url}"
"Open overlay" = "გადაფარვის გახსნა"
"Open presentations" = "გახსნილი პრეზენტაციები"
+1
View File
@@ -921,6 +921,7 @@ status = "draft — needs native review"
"Open in new window" = "새 창에서 열기"
"Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기"
"Open link" = "링크 열기"
"Open in new tab" = "새 탭에서 열기"
"Open link {url}" = "링크 {url} 열기"
"Open overlay" = "오버레이 열기"
"Open prototype preview" = "프로토타입 미리 보기 열기"
+1
View File
@@ -974,6 +974,7 @@ status = "draft — needs native review"
"Open in new window" = "Openen in een nieuw venster"
"Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft"
"Open link" = "Link openen"
"Open in new tab" = "Openen in nieuw tabblad"
"Open link {url}" = "Link {url} openen"
"Open overlay" = "Overlay openen"
"Open prototype preview" = "Voorvertoning van prototype openen"
+1
View File
@@ -976,6 +976,7 @@ name = "Polski"
"Open in new window" = "Otwórz w nowym oknie"
"Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft"
"Open link" = "Otwórz łącze"
"Open in new tab" = "Otwórz w nowej karcie"
"Open link {url}" = "Otwórz link: {url}"
"Open overlay" = "Otwórz nakładkę"
"Open prototype preview" = "Otwórz podgląd prototypu"
+1
View File
@@ -920,6 +920,7 @@ status = "draft — needs native review"
"Open in new window" = "Abrir em nova janela"
"Open invite links in FigCraft" = "Abrir links de convite no FigCraft"
"Open link" = "Abrir link"
"Open in new tab" = "Abrir em nova aba"
"Open link {url}" = "Abrir link {url}"
"Open overlay" = "Abrir sobreposição"
"Open prototype preview" = "Abrir visualização do protótipo"
+1
View File
@@ -976,6 +976,7 @@ name = "Русский"
"Open in new window" = "Открыть в новом окне"
"Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft"
"Open link" = "Открыть ссылку"
"Open in new tab" = "Открыть в новой вкладке"
"Open link {url}" = "Открыть ссылку {url}"
"Open overlay" = "Открыть оверлей"
"Open prototype preview" = "Открыть предпросмотр прототипа"
+1
View File
@@ -1010,6 +1010,7 @@ name = "Svenska"
"Open in new window" = "Öppna i nytt fönster"
"Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft"
"Open link" = "Öppna länk"
"Open in new tab" = "Öppna i ny flik"
"Open link {url}" = "Öppna länken {url}"
"Open overlay" = "Öppna överlägg"
"Open prototype preview" = "Öppna prototypförhandsgranskning"
+1
View File
@@ -969,6 +969,7 @@ status = "draft — needs native review"
"Open in new window" = "เปิดในหน้าต่างใหม่"
"Open invite links in FigCraft" = "เปิดลิงก์เชิญใน FigCraft"
"Open link" = "เปิดลิงก์"
"Open in new tab" = "เปิดในแท็บใหม่"
"Open link {url}" = "เปิดลิงก์ {url}"
"Open overlay" = "เปิดโอเวอร์เลย์"
"Open prototype preview" = "เปิดตัวอย่างโปรโตไทป์"
+1
View File
@@ -958,6 +958,7 @@ name = "Türkçe"
"Open in new window" = "Yeni pencerede aç"
"Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç"
"Open link" = "Bağlantıyı aç"
"Open in new tab" = "Yeni sekmede aç"
"Open link {url}" = "Bağlantıyı aç: {url}"
"Open overlay" = "Bindirmeyi aç"
"Open prototype preview" = "Prototip önizlemesini aç"
+1
View File
@@ -943,6 +943,7 @@ status = "draft — needs native review"
"Open in new window" = "Mở trong cửa sổ mới"
"Open invite links in FigCraft" = "Mở liên kết mời trong FigCraft"
"Open link" = "Mở liên kết"
"Open in new tab" = "Mở trong tab mới"
"Open link {url}" = "Mở liên kết {url}"
"Open overlay" = "Mở lớp phủ"
"Open prototype preview" = "Mở xem trước nguyên mẫu"
+1
View File
@@ -926,6 +926,7 @@ status = "draft — needs native review"
"Open in new window" = "在新窗口中打开"
"Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接"
"Open link" = "打开链接"
"Open in new tab" = "在新标签页中打开"
"Open link {url}" = "打开链接 {url}"
"Open overlay" = "打开浮层"
"Open prototype preview" = "打开原型预览"
+10 -4
View File
@@ -196,7 +196,7 @@ fn name_of(doc: &Document, id: NodeId) -> String {
pub(crate) fn action_summary(doc: &Document, a: &Action) -> String {
let vars = &doc.variables;
match a {
Action::OpenUrl { url } if !url.is_empty() => trf("Open link {url}", &[("url", url)]),
Action::OpenUrl { url, .. } if !url.is_empty() => trf("Open link {url}", &[("url", url)]),
Action::SetVariable { variable, expr, .. } => {
let name = vars.get_by_id(variable).map_or(variable.as_str(), |v| v.name.as_str());
match expr {
@@ -252,7 +252,7 @@ pub(crate) fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId,
let dest = doc.subtree(root).into_iter().skip(1).find(|i| *i != node)?;
Action::ScrollTo { dest, offset: [0.0, 0.0] }
}
"Open link" => Action::OpenUrl { url: String::new() },
"Open link" => Action::OpenUrl { url: String::new(), new_tab: true },
"Open overlay" => Action::OpenOverlay {
dest: frame_dest?,
position: OverlayPosition::Center,
@@ -943,13 +943,19 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
}
});
}
Action::OpenUrl { url } => {
Action::OpenUrl { url, new_tab } => {
widgets::sublabels(ui, tr("Link"), None);
row(ui, 32.0, |ui| {
if let Some(u) = widgets::text_field(ui, slot.id("proto_url"), url, ui.available_width(), "https://")
&& &u != url
{
set_action(app, slot, Action::OpenUrl { url: u.trim().to_string() });
set_action(app, slot, Action::OpenUrl { url: u.trim().to_string(), new_tab: *new_tab });
}
});
// Unticked, a web viewer's own tab goes to the link and leaves the prototype.
row(ui, 28.0, |ui| {
if widgets::checkbox(ui, *new_tab, tr("Open in new tab")) {
set_action(app, slot, Action::OpenUrl { url: url.clone(), new_tab: !*new_tab });
}
});
}
+9 -8
View File
@@ -299,14 +299,15 @@ pub struct Presenter {
clock: f64,
/// The layer the Inspect panel shows (the last one under the pointer).
inspected: Option<NodeId>,
/// The links the last frame's actions opened, and whether the viewer's own click, press, drag
/// or key fired each; the frame-end queues them behind the notice, but for one the viewer
/// fired while choosing to skip the notice (#1096).
urls: Vec<(String, bool)>,
/// A link waiting for the viewer to open it ([`link_notice`]), and whether "Don't show this
/// again" is ticked; `None` when the notice doesn't offer it (the viewer didn't fire the link,
/// so skipping the notice wouldn't cover it, or they skip it already).
link_notice: Option<(String, Option<bool>)>,
/// The links the last frame's actions opened, whether the viewer's own click, press, drag or
/// key fired each, and whether it opens in a new tab; the frame-end queues them behind the
/// notice, but for one the viewer fired while choosing to skip the notice (#1096).
urls: Vec<(String, bool, bool)>,
/// A link waiting for the viewer to open it ([`link_notice`]), whether "Don't show this
/// again" is ticked (`None` when the notice doesn't offer it: the viewer didn't fire the link,
/// so skipping the notice wouldn't cover it, or they skip it already), and whether it opens in
/// a new tab.
link_notice: Option<(String, Option<bool>, bool)>,
/// The editor's document as last copied (the player copies it again when it changes).
source: Option<Arc<Document>>,
/// Scrolling by dragging: what it scrolls and its offset when the drag began.
+2 -2
View File
@@ -194,12 +194,12 @@ impl Presenter {
true
}
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
Action::OpenUrl { url, new_tab } => {
// The URL comes from the document and this action fires from whatever trigger the
// prototype sets, including a delay, so no click is implied: only a web or mail
// link is kept, as not the viewer's doing until `fire_from` says otherwise.
if figcraft_engine::doc_urls::openable(url) {
self.urls.push((url.clone(), false));
self.urls.push((url.clone(), false, *new_tab));
}
true
}
+16 -6
View File
@@ -247,12 +247,12 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
fn finish(app: &mut FigApp, p: &mut Presenter) {
let skipped = app.session.prefs.link_notice_skipped;
let mut opened = false;
for (u, asked) in p.urls.drain(..) {
for (u, asked, new_tab) in p.urls.drain(..) {
if skipped && asked && !opened && p.link_notice.is_none() {
app.services.open_url(&u);
open_link(app, &u, new_tab);
opened = true;
} else if p.link_notice.is_none() {
p.link_notice = Some((u, (asked && !skipped).then_some(false)));
p.link_notice = Some((u, (asked && !skipped).then_some(false), new_tab));
// A press under way ends here: its release would otherwise reach the prototype
// behind the notice, which takes no input while it shows.
p.press = None;
@@ -263,6 +263,16 @@ fn finish(app: &mut FigApp, p: &mut Presenter) {
}
}
/// Open `url` as its Open link action says: in a new tab, or in place of the prototype where the
/// platform can send the viewer elsewhere (the web build's own tab).
fn open_link(app: &mut FigApp, url: &str, new_tab: bool) {
if new_tab {
app.services.open_url(url);
} else {
app.services.open_url_here(url);
}
}
/// Whether `url` is an `http` or `https` link (of the openable URLs, the others are `mailto:`;
/// [`figcraft_engine::doc_urls`]).
fn goes_to_a_website(url: &str) -> bool {
@@ -273,7 +283,7 @@ fn goes_to_a_website(url: &str) -> bool {
/// "Don't show this again" for a link the viewer fired (saved with the preferences when Open link
/// is pressed), Open link and Cancel. Escape or the close button cancels.
fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) {
let Some((url, skip)) = p.link_notice.as_mut() else { return };
let Some((url, skip, _)) = p.link_notice.as_mut() else { return };
let t = Tokens::get(ctx);
let (mut open, mut go, mut cancel) = (true, false, false);
crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| {
@@ -299,11 +309,11 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
});
cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if go {
let Some((url, skip)) = p.link_notice.take() else { return };
let Some((url, skip, new_tab)) = p.link_notice.take() else { return };
if skip == Some(true) {
app.run("proto.linkNotice", json!({"show": false}));
}
app.services.open_url(&url);
open_link(app, &url, new_tab);
} else if cancel {
p.link_notice = None;
}
+1 -1
View File
@@ -138,7 +138,7 @@ impl Presenter {
"zoom": self.zoom,
"flinging": self.fling.is_some(),
"gamepad": self.uses_pads,
"linkNotice": self.link_notice.as_ref().map(|(url, _)| url),
"linkNotice": self.link_notice.as_ref().map(|(url, ..)| url),
})
}
+50
View File
@@ -28,6 +28,13 @@ impl Services for Opened {
}
}
/// Recorded apart from [`Services::open_url`], so a test tells the two apart.
fn open_url_here(&mut self, url: &str) {
if let Ok(mut v) = self.0.lock() {
v.push(format!("here {url}"));
}
}
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
if let Ok(mut v) = self.1.lock() {
v.push(prefs.clone());
@@ -485,3 +492,46 @@ fn a_press_that_raised_a_notice_doesnt_release_behind_it() {
u.frames(3, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the release navigated behind the notice");
}
/// An Open link action with "Open in new tab" off opens in place of the prototype (the web build's
/// own tab), through the notice or with it skipped; on, the default, it opens a new tab.
#[test]
fn a_link_can_open_in_place() {
for skip in [true, false] {
let mut s = Session::new();
s.prefs.link_notice_skipped = skip;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let action = json!({"type": "openUrl", "url": "https://example.com/a", "openInNewTab": false});
ex(&mut s, "proto.interaction.add", json!({"id": f, "trigger": {"type": "onClick"}, "actions": [action], "transition": {"type": "instant"}}));
let (mut u, seen) = present("proto.present", s, f);
click_the_frame(&mut u);
if !skip {
click(&mut u, "Open link");
}
assert_eq!(opened(&seen), ["here https://example.com/a"], "notice skipped: {skip}");
}
assert_eq!(opens_on_a_click("https://example.com/b"), ["https://example.com/b"]);
}
/// The interaction editor's "Open in new tab" box turns the setting off and back on, keeping the
/// link.
#[test]
fn the_editor_turns_open_in_new_tab_off_and_on() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let action = json!({"type": "openUrl", "url": "https://example.com/a"});
ex(&mut s, "proto.interaction.add", json!({"id": f, "trigger": {"type": "onClick"}, "actions": [action]}));
ex(&mut s, "select.set", json!({"ids": [f]}));
let mut u = Harness::new(s);
u.ctx.enable_accesskit();
u.app.ui.right_tab = crate::RightTab::Prototype;
crate::proto_panel::open_editor(&u.ctx, Some((f, 0)));
u.frames(3, Vec::new);
for new_tab in [false, true] {
click(&mut u, "Open in new tab");
u.frames(2, Vec::new);
let doc = u.app.session.doc().unwrap();
let url = "https://example.com/a".to_string();
assert_eq!(doc.node(f).unwrap().interactions[0].actions, [figcraft_doc::proto::Action::OpenUrl { url, new_tab }]);
}
}
+5
View File
@@ -46,6 +46,11 @@ pub trait Services {
None
}
fn open_url(&mut self, _url: &str) {}
/// Open `url` in place of the app where the platform has a place for it (a browser's own tab:
/// the prototype is left); elsewhere as [`open_url`](Self::open_url) does.
fn open_url_here(&mut self, url: &str) {
self.open_url(url);
}
/// Show the file at `path` in the platform's file manager (Finder, Explorer), selected where the
/// platform can.
fn reveal_path(&mut self, _path: &std::path::Path) {}
+3 -1
View File
@@ -849,7 +849,9 @@ interactions, on by default; also in the presenter's Options menu) and whether i
shows the size, colours and typography of the layer under the pointer (also the Inspect button in
its bar); the options are kept for later presentations (in the presenter Z cycles them and R
restarts). An Open link to an `http` or `https` address first shows a notice that the link leaves
the prototype, with Open link, Cancel and "Don't show this again"; `proto.linkNotice {show?}`
the prototype, with Open link, Cancel and "Don't show this again" (an `openUrl` action with
`openInNewTab: false` then opens in the web build's own tab, leaving the prototype; the desktop app
opens the browser either way); `proto.linkNotice {show?}`
turns the notice on (the default) or off, kept with the desktop app's preferences (the web build
keeps it until the page reloads). The player's state is
in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`, the screen's
+4 -3
View File
@@ -1,6 +1,6 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** patch (a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor ("Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -53,7 +53,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor, or drag the overlay's ghost on the canvas while the action is open, one undo step; without one, below that layer); `present_delay_tests`, `overlay_ghost` tests |
| Overlay position presets relative to the layer that opens it, inside or outside (not in Figma: [forum request](https://forum.figma.com/suggest-a-feature-11/set-overlay-position-relative-to-element-not-to-page-35366)) | **extra** | `anchor: triggerInside \| triggerOutside` on the open overlay action; Relative to (Whole screen / Trigger layer) and Inside / Outside in the interaction editor. Outside: top presets above the layer, bottom presets below it, edges lined up (a dropdown is Bottom left + Outside); Center centres it on the layer. No flipping at the screen edge. `.fig` export writes a manual offset; `proto_overlay_anchor_tests`, `present_overlay_anchor_tests`, MCP `overlay_anchor` |
| Delay on mouse enter / leave / down / up | **added** | `delayMs` on the interaction, a Delay field in the editor; fires once after the delay while its screen or overlay still shows (a pointer that leaves early: silent, it still fires); `mouse_triggers_wait_out_their_delay` |
| Open link | pass | always a new tab or browser window (opening in place would leave the prototype) |
| Open link | pass | a new tab or browser window by default; with "Open in new tab" off, the web build's own tab goes to the link (the desktop app has no tab of its own to replace, so it opens the browser either way) |
| Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` |
| Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` |
| Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` |
@@ -95,7 +95,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame and never while a notice shows — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; a press under way when the notice appears ends there, so its release doesn't reach the prototype behind it; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Opening a link in place | done | "Open in new tab" under the link, on by default; off, the web build's tab leaves the prototype for the link (after the notice, unless skipped); the desktop app opens the browser. Kept in files (`openInNewTab`) and read from REST files |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
| Video in prototypes | missing | needs video fills ([gaps](gaps.md) #12, #15) |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Opening a link in place: Open link has "Open in new tab", on by default; off, the web build's own tab goes to the link after the notice (or at once with it skipped); the desktop app has no tab of its own and opens the browser either way. Saved in files only when off (`openInNewTab`) and read from REST files. |
| 2026-10-30 | patch | A manual overlay can be placed on the canvas (#405): while the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed, outlined, where it opens from the top-left of the layer that opens it (right below that layer without an offset), and dragging it moves it there. The X and Y fields follow while dragging, and the drag is one undo step (`crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs`). Numbers unchanged |
| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged |
+2 -1
View File
File diff suppressed because one or more lines are too long