diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 05d3a8e93e..bddbfba4ab 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -253,6 +253,7 @@ impl Dispatcher { menu_bar_message_handler.canvas_tilted = document.document_ptz.tilt() != 0.; menu_bar_message_handler.canvas_flipped = document.document_ptz.flip; menu_bar_message_handler.rulers_visible = document.rulers_visible; + menu_bar_message_handler.guide_lines_visible = document.guide_lines_message_handler.guide_lines_visible; menu_bar_message_handler.node_graph_open = document.is_graph_overlay_open(); menu_bar_message_handler.has_selected_nodes = selected_nodes.selected_nodes().next().is_some(); menu_bar_message_handler.has_selected_layers = selected_nodes.selected_visible_layers(&document.network_interface).next().is_some(); @@ -263,6 +264,7 @@ impl Dispatcher { menu_bar_message_handler.canvas_tilted = false; menu_bar_message_handler.canvas_flipped = false; menu_bar_message_handler.rulers_visible = false; + menu_bar_message_handler.guide_lines_visible = false; menu_bar_message_handler.node_graph_open = false; menu_bar_message_handler.has_selected_nodes = false; menu_bar_message_handler.has_selected_layers = false; diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index b52cc25907..b45c74e664 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -1,6 +1,7 @@ use crate::messages::debug::utility_types::MessageLoggingVerbosity; use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::portfolio::document::guide_lines_message::GuideLinesMessage; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType}; use crate::messages::prelude::*; use graphene_std::path_bool::BooleanOperation; @@ -11,6 +12,7 @@ pub struct MenuBarMessageHandler { pub canvas_tilted: bool, pub canvas_flipped: bool, pub rulers_visible: bool, + pub guide_lines_visible: bool, pub node_graph_open: bool, pub has_selected_nodes: bool, pub has_selected_layers: bool, @@ -616,6 +618,11 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleRulers)) .on_commit(|_| PortfolioMessage::ToggleRulers.into()) .disabled(no_active_document), + MenuListEntry::new("Guides") + .label("Guides") + .icon(if self.guide_lines_visible { "CheckboxChecked" } else { "CheckboxUnchecked" }) + .on_commit(|_| GuideLinesMessage::ToggleGuideLinesVisibility.into()) + .disabled(no_active_document), ], ]) .widget_instance(), diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 7fce9daa58..25a0d61d9f 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use super::utility_types::misc::{GroupFolderType, SnappingState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::portfolio::document::data_panel::DataPanelMessage; +use crate::messages::portfolio::document::guide_lines_message::GuideLinesMessage; use crate::messages::portfolio::document::overlays::utility_types::{OverlayContext, OverlaysType}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping}; @@ -37,6 +38,8 @@ pub enum DocumentMessage { PropertiesPanel(PropertiesPanelMessage), #[child] DataPanel(DataPanelMessage), + #[child] + GuideLines(GuideLinesMessage), // Messages AlignSelectedLayers { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 76fbfa4828..1dc6bc8c71 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -10,6 +10,7 @@ use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler}; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::guide_lines_message_handler::{GuideLinesMessageContext, GuideLinesMessageHandler}; use crate::messages::portfolio::document::node_graph::NodeGraphMessageContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType; @@ -76,6 +77,8 @@ pub struct DocumentMessageHandler { pub properties_panel_message_handler: PropertiesPanelMessageHandler, #[serde(skip)] pub data_panel_message_handler: DataPanelMessageHandler, + #[serde(flatten)] + pub guide_lines_message_handler: GuideLinesMessageHandler, // ============================================ // Fields that are saved in the document format @@ -154,6 +157,7 @@ impl Default for DocumentMessageHandler { overlays_message_handler: OverlaysMessageHandler::default(), properties_panel_message_handler: PropertiesPanelMessageHandler::default(), data_panel_message_handler: DataPanelMessageHandler::default(), + guide_lines_message_handler: GuideLinesMessageHandler::default(), // ============================================ // Fields that are saved in the document format // ============================================ @@ -179,6 +183,7 @@ impl Default for DocumentMessageHandler { saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, + is_loaded: false, } } @@ -215,6 +220,14 @@ impl MessageHandler> for DocumentMes self.navigation_handler.process_message(message, responses, context); } + DocumentMessage::GuideLines(message) => { + let context = GuideLinesMessageContext { + navigation_handler: &self.navigation_handler, + document_ptz: &self.document_ptz, + viewport, + }; + self.guide_lines_message_handler.process_message(message, responses, context); + } DocumentMessage::Overlays(message) => { let visibility_settings = self.overlays_visibility_settings; @@ -622,6 +635,7 @@ impl MessageHandler> for DocumentMes self.snapping_state.grid_snapping = visible; responses.add(OverlaysMessage::Draw); } + // Guide messages DocumentMessage::GroupSelectedLayers { group_folder_type } => { self.handle_group_selected_layers(group_folder_type, responses); } @@ -1433,6 +1447,7 @@ impl MessageHandler> for DocumentMes ZoomCanvasTo200Percent, ZoomCanvasToFitAll, ); + common.extend(self.guide_lines_message_handler.actions()); // Additional actions available on desktop #[cfg(not(target_family = "wasm"))] diff --git a/editor/src/messages/portfolio/document/guide_lines_message.rs b/editor/src/messages/portfolio/document/guide_lines_message.rs new file mode 100644 index 0000000000..6ff121254a --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_lines_message.rs @@ -0,0 +1,29 @@ +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide_line::{GuideLineDirection, GuideLineId}; +use crate::messages::prelude::*; + +#[impl_message(Message, DocumentMessage, GuideLines)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum GuideLinesMessage { + CreateGuideLine { + id: GuideLineId, + direction: GuideLineDirection, + mouse_x: f64, + mouse_y: f64, + }, + MoveGuideLine { + id: GuideLineId, + mouse_x: f64, + mouse_y: f64, + }, + DeleteGuideLine { + id: GuideLineId, + }, + GuideLineOverlays { + context: OverlayContext, + }, + ToggleGuideLinesVisibility, + SetHoveredGuideLine { + id: Option, + }, +} diff --git a/editor/src/messages/portfolio/document/guide_lines_message_handler.rs b/editor/src/messages/portfolio/document/guide_lines_message_handler.rs new file mode 100644 index 0000000000..3780c28875 --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_lines_message_handler.rs @@ -0,0 +1,102 @@ +use super::utility_types::guide_line::{GuideLine, GuideLineDirection, GuideLineId}; +use crate::messages::portfolio::document::guide_lines_message::{GuideLinesMessage, GuideLinesMessageDiscriminant}; +use crate::messages::portfolio::document::overlays::guide_line_overlays::guide_line_overlay; +use crate::messages::portfolio::document::utility_types::misc::PTZ; +use crate::messages::prelude::*; +use glam::DVec2; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)] +#[serde(default)] +pub struct GuideLinesMessageHandler { + pub guide_lines: Vec, + pub guide_lines_visible: bool, + #[serde(skip)] + pub hovered_guide_line_id: Option, +} + +impl Default for GuideLinesMessageHandler { + fn default() -> Self { + Self { + guide_lines: Vec::new(), + guide_lines_visible: true, + hovered_guide_line_id: None, + } + } +} + +#[derive(ExtractField)] +pub struct GuideLinesMessageContext<'a> { + pub navigation_handler: &'a NavigationMessageHandler, + pub document_ptz: &'a PTZ, + pub viewport: &'a ViewportMessageHandler, +} + +#[message_handler_data] +impl MessageHandler> for GuideLinesMessageHandler { + fn actions(&self) -> ActionList { + actions!(GuideLinesMessageDiscriminant; ToggleGuideLinesVisibility) + } + + fn process_message(&mut self, message: GuideLinesMessage, responses: &mut VecDeque, context: GuideLinesMessageContext) { + let GuideLinesMessageContext { + navigation_handler, + document_ptz, + viewport, + } = context; + + let viewport_to_document_point = |mouse_x: f64, mouse_y: f64| -> DVec2 { + let document_to_viewport = navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), document_ptz); + document_to_viewport.inverse().transform_point2(DVec2::new(mouse_x, mouse_y)) + }; + + match message { + GuideLinesMessage::CreateGuideLine { id, direction, mouse_x, mouse_y } => { + let document_point = viewport_to_document_point(mouse_x, mouse_y); + + let document_position = match direction { + GuideLineDirection::Horizontal => document_point.y, + GuideLineDirection::Vertical => document_point.x, + }; + + let guide = GuideLine::with_id(id, direction, document_position); + self.guide_lines.push(guide); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideLinesMessage::MoveGuideLine { id, mouse_x, mouse_y } => { + let document_point = viewport_to_document_point(mouse_x, mouse_y); + + if let Some(guide) = self.guide_lines.iter_mut().find(|guide| guide.id == id) { + guide.position = match guide.direction { + GuideLineDirection::Horizontal => document_point.y, + GuideLineDirection::Vertical => document_point.x, + }; + } + responses.add(OverlaysMessage::Draw); + } + GuideLinesMessage::DeleteGuideLine { id } => { + self.guide_lines.retain(|g| g.id != id); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideLinesMessage::GuideLineOverlays { context: mut overlay_context } => { + if self.guide_lines_visible { + let document_to_viewport = navigation_handler.calculate_offset_transform(overlay_context.viewport.center_in_viewport_space().into(), document_ptz); + guide_line_overlay(self, &mut overlay_context, document_to_viewport); + } + } + GuideLinesMessage::ToggleGuideLinesVisibility => { + self.guide_lines_visible = !self.guide_lines_visible; + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + responses.add(MenuBarMessage::SendLayout); + } + GuideLinesMessage::SetHoveredGuideLine { id } => { + if self.hovered_guide_line_id != id { + self.hovered_guide_line_id = id; + responses.add(OverlaysMessage::Draw); + } + } + } + } +} diff --git a/editor/src/messages/portfolio/document/mod.rs b/editor/src/messages/portfolio/document/mod.rs index 767126b248..6199fc338b 100644 --- a/editor/src/messages/portfolio/document/mod.rs +++ b/editor/src/messages/portfolio/document/mod.rs @@ -3,6 +3,8 @@ mod document_message_handler; pub mod data_panel; pub mod graph_operation; +pub mod guide_lines_message; +pub mod guide_lines_message_handler; pub mod navigation; pub mod node_graph; pub mod overlays; diff --git a/editor/src/messages/portfolio/document/overlays/guide_line_overlays.rs b/editor/src/messages/portfolio/document/overlays/guide_line_overlays.rs new file mode 100644 index 0000000000..c0592c737f --- /dev/null +++ b/editor/src/messages/portfolio/document/overlays/guide_line_overlays.rs @@ -0,0 +1,57 @@ +use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50}; +use crate::messages::portfolio::document::guide_lines_message_handler::GuideLinesMessageHandler; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide_line::GuideLineDirection; +use glam::{DAffine2, DVec2}; + +fn extend_line_to_viewport(point: DVec2, direction: DVec2, viewport_size: DVec2) -> Option<(DVec2, DVec2)> { + let dir = direction.try_normalize()?; + + // Calculates t values for intersections with viewport edges + let mut t_values = Vec::new(); + + let edges = graphene_std::renderer::Quad::from_box([DVec2::ZERO, viewport_size]).all_edges(); + for [start, end] in edges { + let t_along_viewport = (point - start).perp_dot(dir) / (end - start).perp_dot(dir); + let t_along_direction = (point - start).perp_dot(end - start) / (end - start).perp_dot(dir); + if 0. <= t_along_viewport && t_along_viewport <= 1. && t_along_direction.is_finite() { + t_values.push(t_along_direction); + } + } + + if t_values.len() < 2 { + return None; + } + + let t_min = t_values.iter().cloned().fold(f64::INFINITY, f64::min); + let t_max = t_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + let start = point + dir * t_min; + let end = point + dir * t_max; + + Some((start, end)) +} + +pub fn guide_line_overlay(guide_lines_message_handler: &GuideLinesMessageHandler, overlay_context: &mut OverlayContext, document_to_viewport: DAffine2) { + let viewport_size: DVec2 = overlay_context.viewport.size().into(); + + for guide in &guide_lines_message_handler.guide_lines { + let (doc_point, doc_direction) = match guide.direction { + GuideLineDirection::Horizontal => (DVec2::new(0.0, guide.position), DVec2::X), + GuideLineDirection::Vertical => (DVec2::new(guide.position, 0.0), DVec2::Y), + }; + + let viewport_point = document_to_viewport.transform_point2(doc_point); + let viewport_direction = document_to_viewport.transform_vector2(doc_direction); + + let color = if guide_lines_message_handler.hovered_guide_line_id == Some(guide.id) { + COLOR_OVERLAY_BLUE_50 + } else { + COLOR_OVERLAY_BLUE + }; + + if let Some((start, end)) = extend_line_to_viewport(viewport_point, viewport_direction, viewport_size) { + overlay_context.line(start, end, Some(color), None); + } + } +} diff --git a/editor/src/messages/portfolio/document/overlays/mod.rs b/editor/src/messages/portfolio/document/overlays/mod.rs index 4445dbfe84..88a777148e 100644 --- a/editor/src/messages/portfolio/document/overlays/mod.rs +++ b/editor/src/messages/portfolio/document/overlays/mod.rs @@ -1,4 +1,5 @@ pub mod grid_overlays; +pub mod guide_line_overlays; mod overlays_message; mod overlays_message_handler; pub mod utility_functions; diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index 3bf436251c..d4c0eba3da 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -1,4 +1,6 @@ use super::utility_types::{OverlayProvider, OverlaysVisibilitySettings}; +#[cfg(not(test))] +use crate::messages::portfolio::document::guide_lines_message::GuideLinesMessage; use crate::messages::prelude::*; #[derive(ExtractField)] @@ -57,6 +59,13 @@ impl MessageHandler> for OverlaysMes viewport: *viewport, }, }); + responses.add(GuideLinesMessage::GuideLineOverlays { + context: OverlayContext { + render_context: canvas_context.clone(), + visibility_settings: visibility_settings.clone(), + viewport: *viewport, + }, + }); for provider in &self.overlay_providers { responses.add(provider(OverlayContext { render_context: canvas_context.clone(), @@ -74,6 +83,7 @@ impl MessageHandler> for OverlaysMes if visibility_settings.all() { responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); + responses.add(GuideLinesMessage::GuideLineOverlays { context: overlay_context.clone() }); for provider in &self.overlay_providers { responses.add(provider(overlay_context.clone())); diff --git a/editor/src/messages/portfolio/document/utility_types/guide_line.rs b/editor/src/messages/portfolio/document/utility_types/guide_line.rs new file mode 100644 index 0000000000..ef9acc106e --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/guide_line.rs @@ -0,0 +1,61 @@ +use crate::application::generate_uuid; + +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct GuideLineId(u64); + +impl GuideLineId { + pub fn new() -> Self { + Self(generate_uuid()) + } + + pub fn from_raw(id: u64) -> Self { + Self(id) + } + + pub fn as_raw(&self) -> u64 { + self.0 + } +} + +impl Default for GuideLineId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum GuideLineDirection { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GuideLine { + pub id: GuideLineId, + pub direction: GuideLineDirection, + /// Position in document space (Y coordinate for horizontal guides, X coordinate for vertical guides) + pub position: f64, +} + +impl GuideLine { + pub fn new(direction: GuideLineDirection, position: f64) -> Self { + Self { + id: GuideLineId::new(), + direction, + position, + } + } + + pub fn with_id(id: GuideLineId, direction: GuideLineDirection, position: f64) -> Self { + Self { id, direction, position } + } + + pub fn horizontal(y: f64) -> Self { + Self::new(GuideLineDirection::Horizontal, y) + } + + pub fn vertical(x: f64) -> Self { + Self::new(GuideLineDirection::Vertical, x) + } +} diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index 8bed0dbb85..ab8f9275eb 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -1,6 +1,7 @@ pub mod clipboards; pub mod document_metadata; pub mod error; +pub mod guide_line; pub mod misc; pub mod network_interface; pub mod nodes; diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 98722c4d76..98e525d10e 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -1,6 +1,8 @@ -
+
{#each svgTexts as svgText} diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index e0353b1b65..4999fdf6dd 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -10,7 +10,9 @@ use editor::consts::FILE_EXTENSION; use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; +use editor::messages::portfolio::document::guide_lines_message::GuideLinesMessage; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use editor::messages::portfolio::document::utility_types::guide_line::{GuideLineDirection, GuideLineId}; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily}; use editor::messages::prelude::*; @@ -45,7 +47,18 @@ fn calculate_hash(t: &T) -> u64 { hasher.finish() } -/// Provides a handle to access the raw Wasm memory. +#[wasm_bindgen(js_name = setRandomSeed)] +pub fn set_random_seed(seed: u64) { + editor::application::set_uuid_seed(seed); +} + +/// Generates a unique guide ID +#[wasm_bindgen(js_name = generateGuideLineId)] +pub fn generate_guide_id() -> u64 { + editor::application::generate_uuid() +} + +/// Provides a handle to access the raw WASM memory. #[wasm_bindgen(js_name = wasmMemory)] pub fn wasm_memory() -> JsValue { wasm_bindgen::memory() @@ -885,6 +898,38 @@ impl EditorHandle { }; self.dispatch(message); } + + /// Create a new guide line from a ruler drag + #[wasm_bindgen(js_name = createGuideLine)] + pub fn create_guide(&self, id: u64, direction: String, mouse_x: f64, mouse_y: f64) { + let id = GuideLineId::from_raw(id); + let direction = match direction.as_str() { + "Horizontal" => GuideLineDirection::Horizontal, + "Vertical" => GuideLineDirection::Vertical, + _ => { + log::error!("Invalid guide direction: {}", direction); + return; + } + }; + let message = GuideLinesMessage::CreateGuideLine { id, direction, mouse_x, mouse_y }; + self.dispatch(message); + } + + /// Move an existing guide to a new position + #[wasm_bindgen(js_name = moveGuideLine)] + pub fn move_guide(&self, id: u64, mouse_x: f64, mouse_y: f64) { + let id = GuideLineId::from_raw(id); + let message = GuideLinesMessage::MoveGuideLine { id, mouse_x, mouse_y }; + self.dispatch(message); + } + + /// Delete a guide by its ID + #[wasm_bindgen(js_name = deleteGuideLine)] + pub fn delete_guide(&self, id: u64) { + let id = GuideLineId::from_raw(id); + let message = GuideLinesMessage::DeleteGuideLine { id }; + self.dispatch(message); + } } // ============================================================================