From 2615d86934220580d6920fa118c9bcf04c8a1f86 Mon Sep 17 00:00:00 2001 From: Sidharth-Singh10 <70999945+Sidharth-Singh10@users.noreply.github.com> Date: Sun, 18 May 2025 11:46:00 +0530 Subject: [PATCH] Add PTZ support for flipping the canvas (#2394) * feat: flip canvas * move canvas_flipped from NavigationMessageHandler to PTZ * fix artboard overlay flip * Code review * Improvements --------- Co-authored-by: hypercube <0hypercube@gmail.com> Co-authored-by: Keavon Chambers --- .../messages/input_mapper/input_mappings.rs | 1 + .../document/document_message_handler.rs | 73 ++++++++++--------- .../document/navigation/navigation_message.rs | 1 + .../navigation/navigation_message_handler.rs | 31 ++++++-- .../node_graph/node_graph_message_handler.rs | 2 +- .../portfolio/document/utility_types/misc.rs | 9 ++- .../menu_bar/menu_bar_message_handler.rs | 16 +++- .../portfolio/portfolio_message_handler.rs | 4 + website/content/learn/interface/menu-bar.md | 5 +- 9 files changed, 95 insertions(+), 47 deletions(-) diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 5131af4f..bd3c6285 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -400,6 +400,7 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(NumpadAdd); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }), entry!(KeyDown(Equal); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }), entry!(KeyDown(Minus); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }), + entry!(KeyDown(KeyF); modifiers=[Alt], action_dispatch=NavigationMessage::CanvasFlip), entry!(WheelScroll; modifiers=[Control], action_dispatch=NavigationMessage::CanvasZoomMouseWheel), entry!(WheelScroll; modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }), entry!(WheelScroll; action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index d324c493..df9adc69 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -359,13 +359,29 @@ impl MessageHandler> for DocumentMessag continue; } let Some(bounds) = self.metadata().bounding_box_document(layer) else { continue }; + let min = bounds[0].min(bounds[1]); + let max = bounds[0].max(bounds[1]); let name = self.network_interface.display_name(&layer.to_node(), &[]); + // Calculate position of the text + let corner_pos = if !self.document_ptz.flip { + // Use the top-left corner + min + } else { + // Use the top-right corner, which appears to be the top-left due to being flipped + DVec2::new(max.x, min.y) + }; + + // When the canvas is flipped, mirror the text so it appears correctly + let scale = if !self.document_ptz.flip { DVec2::ONE } else { DVec2::new(-1., 1.) }; + + // Create a transform that puts the text at the true top-left regardless of flip let transform = self.metadata().document_to_viewport - * DAffine2::from_translation(bounds[0].min(bounds[1])) + * DAffine2::from_translation(corner_pos) * DAffine2::from_scale(DVec2::splat(self.document_ptz.zoom().recip())) - * DAffine2::from_translation(-DVec2::Y * 4.); + * DAffine2::from_translation(-DVec2::Y * 4.) + * DAffine2::from_scale(scale); overlay_context.text(&name, COLOR_OVERLAY_GRAY, None, transform, 0., [Pivot::Start, Pivot::End]); } @@ -1477,6 +1493,9 @@ impl MessageHandler> for DocumentMessag self.network_interface.document_bounds_document_space(true) }; if let Some(bounds) = bounds { + if self.document_ptz.flip { + responses.add(NavigationMessage::CanvasFlip); + } responses.add(NavigationMessage::CanvasTiltSet { angle_radians: 0. }); responses.add(NavigationMessage::FitViewportToBounds { bounds, prevent_zoom_past_100: true }); } else { @@ -2365,7 +2384,7 @@ impl DocumentMessageHandler { Separator::new(SeparatorType::Unrelated).widget_holder(), ]; - widgets.extend(navigation_controls(&self.document_ptz, &self.navigation_handler, "Canvas")); + widgets.extend(navigation_controls(&self.document_ptz, &self.navigation_handler, false)); let tilt_value = self.navigation_handler.snapped_tilt(self.document_ptz.tilt()) / (std::f64::consts::PI / 180.); if tilt_value.abs() > 0.00001 { @@ -2817,8 +2836,8 @@ impl<'a> ClickXRayIter<'a> { } } -pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHandler, tooltip_name: &str) -> [WidgetHolder; 5] { - [ +pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHandler, node_graph: bool) -> Vec { + let mut list = vec![ IconButton::new("ZoomIn", 24) .tooltip("Zoom In") .tooltip_shortcut(action_keys!(NavigationMessageDiscriminant::CanvasZoomIncrease)) @@ -2835,40 +2854,23 @@ pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHand .on_update(|_| NavigationMessage::CanvasTiltResetAndZoomTo100Percent.into()) .disabled(ptz.tilt().abs() < 1e-4 && (ptz.zoom() - 1.).abs() < 1e-4) .widget_holder(), - // PopoverButton::new() - // .popover_layout(vec![ - // LayoutGroup::Row { - // widgets: vec![TextLabel::new(format!("{tooltip_name} Navigation")).bold(true).widget_holder()], - // }, - // LayoutGroup::Row { - // widgets: vec![TextLabel::new({ - // let tilt = if tooltip_name == "Canvas" { "Tilt:\n• Alt + Middle Click Drag\n\n" } else { "" }; - // format!( - // " - // Interactive controls in this\n\ - // menu are coming soon.\n\ - // \n\ - // Pan:\n\ - // • Middle Click Drag\n\ - // \n\ - // {tilt}Zoom:\n\ - // • Shift + Middle Click Drag\n\ - // • Ctrl + Scroll Wheel Roll - // " - // ) - // .trim() - // }) - // .multiline(true) - // .widget_holder()], - // }, - // ]) - // .widget_holder(), + ]; + if ptz.flip && !node_graph { + list.push( + IconButton::new("Reverse", 24) + .tooltip("Flip the canvas back to its standard orientation") + .tooltip_shortcut(action_keys!(NavigationMessageDiscriminant::CanvasFlip)) + .on_update(|_| NavigationMessage::CanvasFlip.into()) + .widget_holder(), + ); + } + list.extend([ Separator::new(SeparatorType::Related).widget_holder(), NumberInput::new(Some(navigation_handler.snapped_zoom(ptz.zoom()) * 100.)) .unit("%") .min(0.000001) .max(1000000.) - .tooltip(format!("{tooltip_name} Zoom")) + .tooltip(if node_graph { "Node Graph Zoom" } else { "Canvas Zoom" }) .on_update(|number_input: &NumberInput| { NavigationMessage::CanvasZoomSet { zoom_factor: number_input.value.unwrap() / 100., @@ -2879,7 +2881,8 @@ pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHand .increment_callback_decrease(|_| NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }.into()) .increment_callback_increase(|_| NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }.into()) .widget_holder(), - ] + ]); + list } impl Iterator for ClickXRayIter<'_> { diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message.rs b/editor/src/messages/portfolio/document/navigation/navigation_message.rs index 8a1a0cdc..a55f8dbe 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message.rs @@ -20,6 +20,7 @@ pub enum NavigationMessage { CanvasZoomIncrease { center_on_mouse: bool }, CanvasZoomMouseWheel, CanvasZoomSet { zoom_factor: f64 }, + CanvasFlip, EndCanvasPTZ { abort_transform: bool }, EndCanvasPTZWithClick { commit_key: Key }, FitViewportToBounds { bounds: [DVec2; 2], prevent_zoom_past_100: bool }, diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs index 88150570..929c8501 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs @@ -204,6 +204,7 @@ impl MessageHandler> for Navigation responses.add(DocumentMessage::PTZUpdate); if !graph_view_overlay_open { responses.add(PortfolioMessage::UpdateDocumentWidgets); + responses.add(MenuBarMessage::SendLayout); } } NavigationMessage::CanvasZoomDecrease { center_on_mouse } => { @@ -273,6 +274,22 @@ impl MessageHandler> for Navigation responses.add(DocumentMessage::PTZUpdate); responses.add(NodeGraphMessage::SetGridAlignedEdges); } + NavigationMessage::CanvasFlip => { + if graph_view_overlay_open { + return; + } + let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else { + log::error!("Could not get mutable PTZ in CanvasFlip"); + return; + }; + + ptz.flip = !ptz.flip; + + responses.add(DocumentMessage::PTZUpdate); + responses.add(BroadcastEvent::CanvasTransformed); + responses.add(MenuBarMessage::SendLayout); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } NavigationMessage::EndCanvasPTZ { abort_transform } => { let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else { log::error!("Could not get mutable PTZ in EndCanvasPTZ"); @@ -393,9 +410,11 @@ impl MessageHandler> for Navigation .. } => { let tilt_raw_not_snapped = { + // Compute the angle in document space to counter for the canvas being flipped + let viewport_to_document = network_interface.document_metadata().document_to_viewport.inverse(); let half_viewport = ipp.viewport_bounds.size() / 2.; - let start_offset = self.mouse_position - half_viewport; - let end_offset = ipp.mouse.position - half_viewport; + let start_offset = viewport_to_document.transform_vector2(self.mouse_position - half_viewport); + let end_offset = viewport_to_document.transform_vector2(ipp.mouse.position - half_viewport); let angle = start_offset.angle_to(end_offset); tilt_raw_not_snapped + angle @@ -471,6 +490,7 @@ impl MessageHandler> for Navigation CanvasZoomDecrease, CanvasZoomIncrease, CanvasZoomMouseWheel, + CanvasFlip, FitViewportToSelection, ); @@ -513,15 +533,16 @@ impl NavigationMessageHandler { let tilt = ptz.tilt(); let zoom = ptz.zoom(); - let scaled_center = viewport_center / self.snapped_zoom(zoom); + let scale = self.snapped_zoom(zoom); + let scale_vec = if ptz.flip { DVec2::new(-scale, scale) } else { DVec2::splat(scale) }; + let scaled_center = viewport_center / scale_vec; // Try to avoid fractional coordinates to reduce anti aliasing. - let scale = self.snapped_zoom(zoom); let rounded_pan = ((pan + scaled_center) * scale).round() / scale - scaled_center; // TODO: replace with DAffine2::from_scale_angle_translation and fix the errors let offset_transform = DAffine2::from_translation(scaled_center); - let scale_transform = DAffine2::from_scale(DVec2::splat(scale)); + let scale_transform = DAffine2::from_scale(scale_vec); let angle_transform = DAffine2::from_angle(self.snapped_tilt(tilt)); let translation_transform = DAffine2::from_translation(rounded_pan); scale_transform * offset_transform * angle_transform * translation_transform diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 2a178f4f..e9f267e7 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1969,7 +1969,7 @@ impl NodeGraphMessageHandler { .widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), ]; - widgets.extend(navigation_controls(node_graph_ptz, navigation_handler, "Node Graph")); + widgets.extend(navigation_controls(node_graph_ptz, navigation_handler, true)); widgets.extend([ Separator::new(SeparatorType::Unrelated).widget_holder(), TextButton::new("Node Graph") diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 64633f19..562cb68e 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -648,11 +648,18 @@ pub struct PTZ { tilt: f64, /// Scale factor. zoom: f64, + /// Flipped status. + pub flip: bool, } impl Default for PTZ { fn default() -> Self { - Self { pan: DVec2::ZERO, tilt: 0., zoom: 1. } + Self { + pan: DVec2::ZERO, + tilt: 0., + zoom: 1., + flip: false, + } } } diff --git a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs index 589840d7..e1f4dc33 100644 --- a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs @@ -9,6 +9,8 @@ use graphene_std::vector::misc::BooleanOperation; #[derive(Debug, Clone, Default)] pub struct MenuBarMessageHandler { pub has_active_document: bool, + pub canvas_tilted: bool, + pub canvas_flipped: bool, pub rulers_visible: bool, pub node_graph_open: bool, pub has_selected_nodes: bool, @@ -503,7 +505,7 @@ impl LayoutHolder for MenuBarMessageHandler { icon: Some("TiltReset".into()), shortcut: action_keys!(NavigationMessageDiscriminant::CanvasTiltSet), action: MenuBarEntry::create_action(|_| NavigationMessage::CanvasTiltSet { angle_radians: 0.into() }.into()), - disabled: no_active_document || node_graph_open, + disabled: no_active_document || node_graph_open || !self.canvas_tilted, ..MenuBarEntry::default() }, ], @@ -525,7 +527,7 @@ impl LayoutHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, MenuBarEntry { - label: "Zoom to Fit Selection".into(), + label: "Zoom to Selection".into(), icon: Some("FrameSelected".into()), shortcut: action_keys!(NavigationMessageDiscriminant::FitViewportToSelection), action: MenuBarEntry::create_action(|_| NavigationMessage::FitViewportToSelection.into()), @@ -533,7 +535,7 @@ impl LayoutHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, MenuBarEntry { - label: "Zoom to Fit All".into(), + label: "Zoom to Fit".into(), icon: Some("FrameAll".into()), shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasToFitAll), action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasToFitAll.into()), @@ -557,6 +559,14 @@ impl LayoutHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, ], + vec![MenuBarEntry { + label: "Flip".into(), + icon: Some(if self.canvas_flipped { "CheckboxChecked" } else { "CheckboxUnchecked" }.into()), + shortcut: action_keys!(NavigationMessageDiscriminant::CanvasFlip), + action: MenuBarEntry::create_action(|_| NavigationMessage::CanvasFlip.into()), + disabled: no_active_document || node_graph_open, + ..MenuBarEntry::default() + }], vec![MenuBarEntry { label: "Rulers".into(), icon: Some(if self.rulers_visible { "CheckboxChecked" } else { "CheckboxUnchecked" }.into()), diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ea80b51e..2afb06d6 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -69,6 +69,8 @@ impl MessageHandler> for PortfolioMes // Sub-messages PortfolioMessage::MenuBar(message) => { self.menu_bar_message_handler.has_active_document = false; + self.menu_bar_message_handler.canvas_tilted = false; + self.menu_bar_message_handler.canvas_flipped = false; self.menu_bar_message_handler.rulers_visible = false; self.menu_bar_message_handler.node_graph_open = false; self.menu_bar_message_handler.has_selected_nodes = false; @@ -80,6 +82,8 @@ impl MessageHandler> for PortfolioMes if let Some(document) = self.active_document_id.and_then(|document_id| self.documents.get_mut(&document_id)) { self.menu_bar_message_handler.has_active_document = true; + self.menu_bar_message_handler.canvas_tilted = document.document_ptz.tilt() != 0.; + self.menu_bar_message_handler.canvas_flipped = document.document_ptz.flip; self.menu_bar_message_handler.rulers_visible = document.rulers_visible; self.menu_bar_message_handler.node_graph_open = document.is_graph_overlay_open(); let selected_nodes = document.network_interface.selected_nodes(); diff --git a/website/content/learn/interface/menu-bar.md b/website/content/learn/interface/menu-bar.md index e929a680..a48caa55 100644 --- a/website/content/learn/interface/menu-bar.md +++ b/website/content/learn/interface/menu-bar.md @@ -96,10 +96,11 @@ The **View menu** lists actions related to the view of the canvas within the vie | Reset Tilt |

Sets the viewport tilt angle back to 0°.

| | Zoom In |

Narrows the view to the next whole zoom increment, such as:

25%, 33.33%, 40%, 50%, 66.67%, 80%, 100%, 125%, 160%, 200%, 250%, 320%, 400%, 500%

| | Zoom Out |

Widens the view to the next whole zoom increment, such as above.

| -| Zoom to Fit Selection |

Zooms and frames the viewport to the bounding box of the selected layer(s).

| -| Zoom to Fit All |

Zooms and frames the viewport to fit all artboards, or all artwork if using infinite canvas.

| +| Zoom to Selection |

Zooms and frames the viewport to the bounding box of the selected layer(s).

| +| Zoom to Fit |

Zooms and frames the viewport to fit all artboards, or all artwork if using infinite canvas.

| | Zoom to 100% |

Zooms the viewport in or out to 100% scale, making the document and viewport scales match 1:1.

| | Zoom to 200% |

Zooms the viewport in or out to 200% scale, displaying the artwork at twice the actual size.

| +| Flip |

Mirrors the viewport horizontally, flipping the view of the artwork until deactivated.

| | Rulers |

Toggles visibility of the rulers along the top/left edges of the viewport.

| ## Help