diff --git a/editor/src/consts.rs b/editor/src/consts.rs index a014e8891f..313a39651e 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -66,6 +66,9 @@ pub const SELECTION_DRAG_ANGLE: f64 = 90.; pub const LAYER_ORIGIN_CROSS_DIAMETER: f64 = 10.; pub const LAYER_ORIGIN_CROSS_THICKNESS: f64 = 1.; +// GUIDES +pub const GUIDE_HIT_TOLERANCE: f64 = 5.; + // PIVOT pub const PIVOT_CROSSHAIR_THICKNESS: f64 = 1.; pub const PIVOT_CROSSHAIR_LENGTH: f64 = 9.; diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index f2c665648d..a6418a3e6c 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -287,6 +287,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(); @@ -297,6 +298,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 43946b2b7d..d44d6c4fee 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_message::GuideLineMessage; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType}; use crate::messages::prelude::*; use graphene_std::vector::misc::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, @@ -635,6 +637,11 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(PortfolioMessageDiscriminant::ToggleRulers)) .on_commit(|_| PortfolioMessage::ToggleRulers.into()) .disabled(no_active_document), + MenuListEntry::new("Guide Lines") + .label("Guide Lines") + .icon(if self.guide_lines_visible { "CheckboxChecked" } else { "CheckboxUnchecked" }) + .on_commit(|_| GuideLineMessage::ToggleGuideLinesVisibility.into()) + .disabled(no_active_document), ], ]) .widget_instance(), diff --git a/editor/src/messages/portfolio/document/document_history.rs b/editor/src/messages/portfolio/document/document_history.rs index 9758fd3846..2ef2ab518b 100644 --- a/editor/src/messages/portfolio/document/document_history.rs +++ b/editor/src/messages/portfolio/document/document_history.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, HashSet}; use document_graph_storage::Registry; use graph_craft::application_io::resource::{ResourceId, ResourceRegistry, ResourceStorage}; +use super::utility_types::guide::GuideLinesState; use super::utility_types::network_interface::NodeNetworkInterface; use super::utility_types::network_interface::storage_metadata::{StorageMetadataView, collect_network_view_settings}; @@ -20,6 +21,10 @@ pub struct DocumentHistory { legacy_undo_stack: VecDeque, /// Stack of document network snapshots for future history states. legacy_redo_stack: VecDeque, + /// Guide-line state paired with each legacy undo snapshot. + guide_undo_stack: VecDeque, + /// Guide-line state paired with each legacy redo snapshot. + guide_redo_stack: VecDeque, /// The `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` until the mount /// future built by `load_document` resolves. #[derivative(Debug = "ignore")] @@ -34,29 +39,52 @@ impl DocumentHistory { Self::push_capped(&mut self.legacy_undo_stack, snapshot); } + /// Push a guide-line snapshot alongside the most recent undo network snapshot. + pub fn push_guide_undo(&mut self, guide_state: GuideLinesState) { + Self::push_capped(&mut self.guide_undo_stack, guide_state); + } + /// Push a snapshot onto the redo stack, evicting the oldest entry past the history cap. pub fn push_redo(&mut self, snapshot: NodeNetworkInterface) { Self::push_capped(&mut self.legacy_redo_stack, snapshot); } + /// Push a guide-line snapshot alongside the most recent redo network snapshot. + pub fn push_guide_redo(&mut self, guide_state: GuideLinesState) { + Self::push_capped(&mut self.guide_redo_stack, guide_state); + } + /// Pop the most recent undo snapshot, or `None` when the stack is empty. pub fn pop_undo(&mut self) -> Option { self.legacy_undo_stack.pop_back() } + /// Pop the guide-line snapshot paired with the most recent undo entry. + pub fn pop_guide_undo(&mut self) -> Option { + self.guide_undo_stack.pop_back() + } + /// Pop the most recent redo snapshot, or `None` when the stack is empty. pub fn pop_redo(&mut self) -> Option { self.legacy_redo_stack.pop_back() } + /// Pop the guide-line snapshot paired with the most recent redo entry. + pub fn pop_guide_redo(&mut self) -> Option { + self.guide_redo_stack.pop_back() + } + /// Drop the most recently pushed undo snapshot (used to cancel a transaction that ended up unmodified). - pub fn discard_last_undo(&mut self) { + #[must_use] + pub fn discard_last_undo(&mut self) -> Option { self.legacy_undo_stack.pop_back(); + self.guide_undo_stack.pop_back() } /// Clear the redo stack, called when a fresh edit invalidates the redo future. pub fn clear_redo(&mut self) { self.legacy_redo_stack.clear(); + self.guide_redo_stack.clear(); } /// Add the resources referenced by every snapshot in both history stacks into `resources`, so @@ -258,7 +286,7 @@ impl DocumentHistory { } } - fn push_capped(stack: &mut VecDeque, snapshot: NodeNetworkInterface) { + fn push_capped(stack: &mut VecDeque, snapshot: T) { stack.push_back(snapshot); if stack.len() > crate::consts::MAX_UNDO_HISTORY_LEN { stack.pop_front(); diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index f6807909af..83df68980c 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_message::GuideLineMessage; 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}; @@ -41,6 +42,8 @@ pub enum DocumentMessage { DataPanel(DataPanelMessage), #[child] Resource(ResourceMessage), + #[child] + GuideLine(GuideLineMessage), // 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 1bc20e191c..2d0c0ac3fc 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -14,6 +14,8 @@ 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::{ModifyInputsContext, TransformIn}; +use crate::messages::portfolio::document::guide_message::GuideLineMessage; +use crate::messages::portfolio::document::guide_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; @@ -85,6 +87,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 @@ -159,6 +163,9 @@ pub struct DocumentMessageHandler { /// Whether or not the editor has executed the network to render the document yet. If this is opened as an inactive tab, it won't be loaded initially because the active tab is prioritized. #[serde(skip)] pub is_loaded: bool, + /// Snap manager used exclusively for snapping guide lines during create/move operations. + #[serde(skip)] + guide_snap_manager: crate::messages::tool::common_functionality::snapping::SnapManager, } impl Default for DocumentMessageHandler { @@ -172,6 +179,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 // ============================================ @@ -200,7 +208,9 @@ impl Default for DocumentMessageHandler { saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, + is_loaded: false, + guide_snap_manager: Default::default(), } } } @@ -237,6 +247,18 @@ impl MessageHandler> for DocumentMes self.navigation_handler.process_message(message, responses, context); } + DocumentMessage::GuideLine(message) => { + // Apply snapping to guide create/move before forwarding to the handler. + let message = if self.snapping_state.snapping_enabled { + self.snap_guide_message(message, ipp, viewport) + } else { + message + }; + let context = GuideLinesMessageContext { + document_to_viewport: self.metadata().document_to_viewport, + }; + self.guide_lines_message_handler.process_message(message, responses, context); + } DocumentMessage::Overlays(message) => { let visibility_settings = self.overlays_visibility_settings; @@ -1335,6 +1357,7 @@ impl MessageHandler> for DocumentMes self.network_interface.start_transaction(); self.history.push_undo(self.network_interface.clone()); + self.history.push_guide_undo(self.current_guide_state()); // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents responses.add(PortfolioMessage::UpdateOpenDocumentsList); } @@ -1350,7 +1373,10 @@ impl MessageHandler> for DocumentMes }, DocumentMessage::CancelTransaction => { self.network_interface.finish_transaction(); - self.history.discard_last_undo(); + if let Some(guide_state) = self.history.discard_last_undo() { + self.guide_lines_message_handler.guide_lines = guide_state.guide_lines; + responses.add(OverlaysMessage::Draw); + } } DocumentMessage::CommitTransaction => { if self.network_interface.transaction_status() == TransactionStatus::Finished { @@ -1751,6 +1777,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"))] @@ -1916,6 +1943,59 @@ impl DocumentMessageHandler { ClickXRayIter::new(&self.network_interface, XRayTarget::Point(point)) } + fn snap_guide_message(&mut self, message: GuideLineMessage, ipp: &InputPreprocessorMessageHandler, viewport: &ViewportMessageHandler) -> GuideLineMessage { + use crate::messages::portfolio::document::utility_types::guide::GuideLineDirection; + use crate::messages::portfolio::document::utility_types::misc::SnapSource; + use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapTypeConfiguration}; + + let mut snap_manager = std::mem::take(&mut self.guide_snap_manager); + + let mut snap_constrained = |document: &DocumentMessageHandler, raw_viewport: DVec2, direction: GuideLineDirection| -> DVec2 { + let document_to_viewport = document.metadata().document_to_viewport; + let raw_doc = document_to_viewport.inverse().transform_point2(raw_viewport); + let snap_data = SnapData::new(document, ipp, viewport); + let point = SnapCandidatePoint::new_source(raw_doc, SnapSource::None); + let constraint = match direction { + // Horizontal guide: position varies on Y, constrain snap search along Y + GuideLineDirection::Horizontal => SnapConstraint::Line { origin: raw_doc, direction: DVec2::Y }, + // Vertical guide: position varies on X, constrain snap search along X + GuideLineDirection::Vertical => SnapConstraint::Line { origin: raw_doc, direction: DVec2::X }, + }; + let snapped = snap_manager.constrained_snap(&snap_data, &point, constraint, SnapTypeConfiguration::default()); + snap_manager.update_indicator(snapped.clone()); + document_to_viewport.transform_point2(snapped.snapped_point_document) + }; + + let result = match message { + GuideLineMessage::CreateGuideLine { id, direction, mouse_x, mouse_y } => { + let snapped = snap_constrained(self, DVec2::new(mouse_x, mouse_y), direction); + GuideLineMessage::CreateGuideLine { + id, + direction, + mouse_x: snapped.x, + mouse_y: snapped.y, + } + } + GuideLineMessage::MoveGuideLine { id, mouse_x, mouse_y } => { + if let Some(guide_line) = self.guide_lines_message_handler.guide_lines.iter().find(|g| g.id == id) { + let direction = guide_line.direction; + let snapped = snap_constrained(self, DVec2::new(mouse_x, mouse_y), direction); + GuideLineMessage::MoveGuideLine { + id, + mouse_x: snapped.x, + mouse_y: snapped.y, + } + } else { + GuideLineMessage::MoveGuideLine { id, mouse_x, mouse_y } + } + } + other => other, + }; + + self.guide_snap_manager = snap_manager; + result + } + /// Find the deepest layer given in the sorted array (by returning the one which is not a folder from the list of layers under the click location). pub fn find_deepest(&self, node_list: &[LayerNodeIdentifier]) -> Option { node_list @@ -2403,6 +2483,13 @@ impl DocumentMessageHandler { paths } + pub fn current_guide_state(&self) -> crate::messages::portfolio::document::utility_types::guide::GuideLinesState { + crate::messages::portfolio::document::utility_types::guide::GuideLinesState { + guide_lines: self.guide_lines_message_handler.guide_lines.clone(), + guide_lines_visible: self.guide_lines_message_handler.guide_lines_visible, + } + } + pub fn undo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque) { let legacy_applied = if let Some(previous_network) = self.undo(viewport, responses) { self.history.push_redo(previous_network); @@ -2411,7 +2498,9 @@ impl DocumentMessageHandler { false }; - self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, true, responses); + if legacy_applied { + self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, true, responses); + } } /// Installs a history snapshot as the active network interface, carrying over the current view state and structure load, and returns the replaced interface. @@ -2431,11 +2520,28 @@ impl DocumentMessageHandler { } pub fn undo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { - // If there is no history return and don't broadcast SelectionChanged - let network_interface = self.history.pop_undo()?; - let previous_network = self.install_history_snapshot(network_interface, viewport); + let network_snapshot = self.history.pop_undo(); + let guide_snapshot = self.history.pop_guide_undo(); + + if network_snapshot.is_none() && guide_snapshot.is_none() { + return None; + } + + if let Some(guide_state) = guide_snapshot { + let current_state = self.current_guide_state(); + self.guide_lines_message_handler.guide_lines = guide_state.guide_lines; + self.guide_lines_message_handler.guide_lines_visible = guide_state.guide_lines_visible; + self.history.push_guide_redo(current_state); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + + let previous_network = if let Some(network_interface) = network_snapshot { + Some(self.install_history_snapshot(network_interface, viewport)) + } else { + None + }; - // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents responses.add(PortfolioMessage::UpdateOpenDocumentsList); responses.add(NodeGraphMessage::SelectedNodesUpdated); responses.add(NodeGraphMessage::ForceRunDocumentGraph); @@ -2443,8 +2549,9 @@ impl DocumentMessageHandler { // TODO: Remove once the footprint is used to load the imports/export distances from the edge responses.add(NodeGraphMessage::UnloadWires); - Some(previous_network) + previous_network } + pub fn redo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque) { let legacy_applied = if let Some(previous_network) = self.redo(viewport, responses) { self.history.push_undo(previous_network); @@ -2453,25 +2560,50 @@ impl DocumentMessageHandler { false }; - self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, false, responses); + if legacy_applied { + self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, false, responses); + } } pub fn redo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { - // If there is no history return and don't broadcast SelectionChanged - let network_interface = self.history.pop_redo()?; - let previous_network = self.install_history_snapshot(network_interface, viewport); + let network_snapshot = self.history.pop_redo(); + let guide_snapshot = self.history.pop_guide_redo(); + + if network_snapshot.is_none() && guide_snapshot.is_none() { + return None; + } + + if let Some(guide_state) = guide_snapshot { + let current_state = self.current_guide_state(); + self.guide_lines_message_handler.guide_lines = guide_state.guide_lines; + self.guide_lines_message_handler.guide_lines_visible = guide_state.guide_lines_visible; + self.history.push_guide_undo(current_state); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + + let previous_network = if let Some(network_interface) = network_snapshot { + Some(self.install_history_snapshot(network_interface, viewport)) + } else { + None + }; - // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents responses.add(PortfolioMessage::UpdateOpenDocumentsList); responses.add(NodeGraphMessage::SelectedNodesUpdated); responses.add(NodeGraphMessage::ForceRunDocumentGraph); responses.add(NodeGraphMessage::UnloadWires); responses.add(NodeGraphMessage::SendWires); - Some(previous_network) + + previous_network } pub fn current_hash(&self) -> u64 { - self.network_interface.document_network().current_hash() + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.network_interface.document_network().current_hash().hash(&mut hasher); + self.guide_lines_message_handler.guide_lines.hash(&mut hasher); + self.guide_lines_message_handler.guide_lines_visible.hash(&mut hasher); + hasher.finish() } pub fn is_auto_saved(&self) -> bool { diff --git a/editor/src/messages/portfolio/document/guide_message.rs b/editor/src/messages/portfolio/document/guide_message.rs new file mode 100644 index 0000000000..a0196e1776 --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_message.rs @@ -0,0 +1,29 @@ +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide::{GuideLineDirection, GuideLineId}; +use crate::messages::prelude::*; + +#[impl_message(Message, DocumentMessage, GuideLine)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum GuideLineMessage { + CreateGuideLine { + id: GuideLineId, + direction: GuideLineDirection, + mouse_x: f64, + mouse_y: f64, + }, + MoveGuideLine { + id: GuideLineId, + mouse_x: f64, + mouse_y: f64, + }, + DeleteGuideLine { + id: GuideLineId, + }, + GuideLinesOverlays { + context: OverlayContext, + }, + ToggleGuideLinesVisibility, + SetHoveredGuideLine { + id: Option, + }, +} diff --git a/editor/src/messages/portfolio/document/guide_message_handler.rs b/editor/src/messages/portfolio/document/guide_message_handler.rs new file mode 100644 index 0000000000..00f634592c --- /dev/null +++ b/editor/src/messages/portfolio/document/guide_message_handler.rs @@ -0,0 +1,127 @@ +use super::utility_types::guide::{GuideLine, GuideLineDirection, GuideLineId}; +use crate::messages::portfolio::document::guide_message::{GuideLineMessage, GuideLineMessageDiscriminant}; +use crate::messages::portfolio::document::overlays::guide_overlays::guide_lines_overlay; +use crate::messages::prelude::*; +use glam::{DAffine2, DVec2}; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)] +#[serde(default)] +pub struct GuideLinesMessageHandler { + #[serde(default)] + pub guide_lines: Vec, + #[serde(default = "default_guide_lines_visible")] + pub guide_lines_visible: bool, + #[serde(skip)] + pub hovered_guide_line_id: Option, +} + +fn default_guide_lines_visible() -> bool { + GuideLinesMessageHandler::default().guide_lines_visible +} + +impl GuideLinesMessageHandler { + pub fn hit_test(&self, viewport_position: DVec2, document_to_viewport: DAffine2) -> Option<(GuideLineId, GuideLineDirection)> { + if !self.guide_lines_visible { + return None; + } + let viewport_to_document = document_to_viewport.inverse(); + let document_position = viewport_to_document.transform_point2(viewport_position); + let document_scale = viewport_to_document.matrix2.determinant().abs().sqrt(); + let tolerance = crate::consts::GUIDE_HIT_TOLERANCE * document_scale; + + self.guide_lines + .iter() + .find(|guide_line| match guide_line.direction { + GuideLineDirection::Horizontal => (guide_line.position - document_position.y).abs() < tolerance, + GuideLineDirection::Vertical => (guide_line.position - document_position.x).abs() < tolerance, + }) + .map(|guide_line| (guide_line.id, guide_line.direction)) + } +} + +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 { + pub document_to_viewport: DAffine2, +} + +#[message_handler_data] +impl MessageHandler for GuideLinesMessageHandler { + fn actions(&self) -> ActionList { + actions!(GuideLineMessageDiscriminant; ToggleGuideLinesVisibility) + } + + fn process_message(&mut self, message: GuideLineMessage, responses: &mut VecDeque, context: GuideLinesMessageContext) { + let GuideLinesMessageContext { document_to_viewport } = context; + let viewport_to_document = document_to_viewport.inverse(); + + let document_point = |mouse_x, mouse_y| { + let viewport_point = DVec2::new(mouse_x, mouse_y); + viewport_to_document.transform_point2(viewport_point) + }; + + match message { + GuideLineMessage::CreateGuideLine { id, direction, mouse_x, mouse_y } => { + let document_point = document_point(mouse_x, mouse_y); + + let document_position = match direction { + GuideLineDirection::Horizontal => document_point.y, + GuideLineDirection::Vertical => document_point.x, + }; + + responses.add(DocumentMessage::StartTransaction); + let guide_line = GuideLine::with_id(id, direction, document_position); + self.guide_lines.push(guide_line); + responses.add(DocumentMessage::CommitTransaction); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideLineMessage::MoveGuideLine { id, mouse_x, mouse_y } => { + let document_point = document_point(mouse_x, mouse_y); + + if let Some(guide_line) = self.guide_lines.iter_mut().find(|guide_line| guide_line.id == id) { + guide_line.position = match guide_line.direction { + GuideLineDirection::Horizontal => document_point.y, + GuideLineDirection::Vertical => document_point.x, + }; + } + responses.add(OverlaysMessage::Draw); + } + GuideLineMessage::DeleteGuideLine { id } => { + responses.add(DocumentMessage::StartTransaction); + self.guide_lines.retain(|g| g.id != id); + responses.add(DocumentMessage::CommitTransaction); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + } + GuideLineMessage::GuideLinesOverlays { context: mut overlay_context } => { + if self.guide_lines_visible { + guide_lines_overlay(self, &mut overlay_context, document_to_viewport); + } + } + GuideLineMessage::ToggleGuideLinesVisibility => { + responses.add(DocumentMessage::StartTransaction); + self.guide_lines_visible = !self.guide_lines_visible; + responses.add(DocumentMessage::CommitTransaction); + responses.add(OverlaysMessage::Draw); + responses.add(PortfolioMessage::UpdateDocumentWidgets); + responses.add(MenuBarMessage::SendLayout); + } + GuideLineMessage::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 fb8749ce73..f44926b915 100644 --- a/editor/src/messages/portfolio/document/mod.rs +++ b/editor/src/messages/portfolio/document/mod.rs @@ -7,6 +7,8 @@ mod storage_tests; pub mod data_panel; pub mod graph_operation; +pub mod guide_message; +pub mod guide_message_handler; pub mod navigation; pub mod node_graph; pub mod overlays; diff --git a/editor/src/messages/portfolio/document/overlays/guide_overlays.rs b/editor/src/messages/portfolio/document/overlays/guide_overlays.rs new file mode 100644 index 0000000000..0a5a87ec28 --- /dev/null +++ b/editor/src/messages/portfolio/document/overlays/guide_overlays.rs @@ -0,0 +1,57 @@ +use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50}; +use crate::messages::portfolio::document::guide_message_handler::GuideLinesMessageHandler; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::utility_types::guide::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_lines_overlay(guide_lines_handler: &GuideLinesMessageHandler, overlay_context: &mut OverlayContext, document_to_viewport: DAffine2) { + let viewport_size: DVec2 = overlay_context.viewport.size().into(); + + for guide_line in &guide_lines_handler.guide_lines { + let (doc_point, doc_direction) = match guide_line.direction { + GuideLineDirection::Horizontal => (DVec2::new(0.0, guide_line.position), DVec2::X), + GuideLineDirection::Vertical => (DVec2::new(guide_line.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_handler.hovered_guide_line_id == Some(guide_line.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 514ccd7b63..6961cfee97 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_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 c7acb50c59..73aa5c529a 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}; +#[allow(unused_imports)] +use crate::messages::portfolio::document::guide_message::GuideLineMessage; use crate::messages::prelude::*; #[derive(ExtractField)] @@ -63,6 +65,13 @@ impl MessageHandler> for OverlaysMes viewport: *viewport, }, }); + responses.add(GuideLineMessage::GuideLinesOverlays { + 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(), @@ -80,6 +89,7 @@ impl MessageHandler> for OverlaysMes if visibility_settings.all() { responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); + responses.add(GuideLineMessage::GuideLinesOverlays { 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.rs b/editor/src/messages/portfolio/document/utility_types/guide.rs new file mode 100644 index 0000000000..4169319ce4 --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/guide.rs @@ -0,0 +1,75 @@ +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 std::hash::Hash for GuideLine { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.direction.hash(state); + self.position.to_bits().hash(state); + } +} + +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) + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GuideLinesState { + pub guide_lines: Vec, + pub guide_lines_visible: bool, +} diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 30e6a1e013..824bb714d0 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -63,6 +63,7 @@ pub enum AlignAggregate { pub struct SnappingState { pub snapping_enabled: bool, pub grid_snapping: bool, + pub guide_lines: bool, pub artboards: bool, pub tolerance: f64, pub bounding_box: BoundingBoxSnapping, @@ -75,6 +76,7 @@ impl Default for SnappingState { Self { snapping_enabled: true, grid_snapping: false, + guide_lines: true, artboards: true, tolerance: 8., bounding_box: BoundingBoxSnapping::default(), @@ -106,6 +108,7 @@ impl SnappingState { }, SnapTarget::Artboard(_) => self.artboards, SnapTarget::Grid(_) => self.grid_snapping, + SnapTarget::GuideLine(_) => self.guide_lines, SnapTarget::Alignment(AlignmentSnapTarget::AlignWithAnchorPoint) => self.path.align_with_anchor_point, SnapTarget::Alignment(_) => self.bounding_box.align_with_edges, SnapTarget::DistributeEvenly(_) => self.bounding_box.distribute_evenly, @@ -549,6 +552,23 @@ impl fmt::Display for GridSnapTarget { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuideLineSnapTarget { + Horizontal, + Vertical, + Intersection, +} + +impl fmt::Display for GuideLineSnapTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GuideLineSnapTarget::Horizontal => write!(f, "Guide Line: Horizontal"), + GuideLineSnapTarget::Vertical => write!(f, "Guide Line: Vertical"), + GuideLineSnapTarget::Intersection => write!(f, "Guide Line: Intersection"), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AlignmentSnapTarget { BoundingBoxCornerPoint, @@ -616,6 +636,7 @@ pub enum SnapTarget { Path(PathSnapTarget), Artboard(ArtboardSnapTarget), Grid(GridSnapTarget), + GuideLine(GuideLineSnapTarget), Alignment(AlignmentSnapTarget), DistributeEvenly(DistributionSnapTarget), } @@ -637,6 +658,7 @@ impl fmt::Display for SnapTarget { SnapTarget::Path(path_snap_target) => write!(f, "{path_snap_target}"), SnapTarget::Artboard(artboard_snap_target) => write!(f, "{artboard_snap_target}"), SnapTarget::Grid(grid_snap_target) => write!(f, "{grid_snap_target}"), + SnapTarget::GuideLine(guide_snap_target) => write!(f, "{guide_snap_target}"), SnapTarget::Alignment(alignment_snap_target) => write!(f, "{alignment_snap_target}"), SnapTarget::DistributeEvenly(distribution_snap_target) => write!(f, "{distribution_snap_target}"), } diff --git a/editor/src/messages/portfolio/document/utility_types/mod.rs b/editor/src/messages/portfolio/document/utility_types/mod.rs index a38ca2c545..06d8ecbdd0 100644 --- a/editor/src/messages/portfolio/document/utility_types/mod.rs +++ b/editor/src/messages/portfolio/document/utility_types/mod.rs @@ -1,5 +1,6 @@ pub mod document_metadata; pub mod error; +pub mod guide; pub mod misc; pub mod network_interface; pub mod nodes; diff --git a/editor/src/messages/tool/common_functionality/snapping.rs b/editor/src/messages/tool/common_functionality/snapping.rs index 00cbe7e216..ae30e208b5 100644 --- a/editor/src/messages/tool/common_functionality/snapping.rs +++ b/editor/src/messages/tool/common_functionality/snapping.rs @@ -1,6 +1,7 @@ mod alignment_snapper; mod distribution_snapper; mod grid_snapper; +mod guide_snapper; mod layer_snapper; mod snap_results; @@ -19,6 +20,7 @@ use graphene_std::vector::PointId; use graphene_std::vector::algorithms::intersection::filtered_segment_intersections; use graphene_std::vector::misc::point_to_dvec2; pub use grid_snapper::*; +pub use guide_snapper::*; use kurbo::ParamCurve; pub use layer_snapper::*; pub use snap_results::*; @@ -39,6 +41,7 @@ pub struct SnapManager { indicator: Option, layer_snapper: LayerSnapper, grid_snapper: GridSnapper, + guide_line_snapper: GuideLineSnapper, alignment_snapper: AlignmentSnapper, distribution_snapper: DistributionSnapper, candidates: Option>, @@ -173,6 +176,10 @@ fn get_closest_intersection(snap_to: DVec2, curves: &[SnappedCurve]) -> Option Option { + get_line_intersection(snap_to, lines, SnapTarget::Grid(GridSnapTarget::Intersection)) +} + +pub fn get_line_intersection(snap_to: DVec2, lines: &[SnappedLine], target: SnapTarget) -> Option { let mut best = None; for line_i in lines { for line_j in lines { @@ -182,7 +189,7 @@ fn get_grid_intersection(snap_to: DVec2, lines: &[SnappedLine]) -> Option Vec<(DVec2, DVec2, GuideLineSnapTarget)> { + let document = snap_data.document; + let mut lines = Vec::new(); + + if !document.guide_lines_message_handler.guide_lines_visible || !document.snapping_state.guide_lines { + return lines; + } + + for guide_line in &document.guide_lines_message_handler.guide_lines { + let (point, direction, snap_target) = match guide_line.direction { + GuideLineDirection::Horizontal => (DVec2::new(0.0, guide_line.position), DVec2::X, GuideLineSnapTarget::Horizontal), + GuideLineDirection::Vertical => (DVec2::new(guide_line.position, 0.0), DVec2::Y, GuideLineSnapTarget::Vertical), + }; + lines.push((point, direction, snap_target)); + } + + lines + } + + pub fn free_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults) { + let lines = self.get_snap_lines(snap_data); + let tolerance = snap_tolerance(snap_data.document); + + for (line_point, line_direction, snap_target) in lines { + let projected = (point.document_point - line_point).project_onto(line_direction) + line_point; + let distance = point.document_point.distance(projected); + + if !distance.is_finite() || distance > tolerance { + continue; + } + + let target = SnapTarget::GuideLine(snap_target); + if snap_data.document.snapping_state.target_enabled(target) { + snap_results.points.push(SnappedPoint { + snapped_point_document: projected, + source: point.source, + target, + source_bounds: point.quad, + distance, + tolerance, + ..Default::default() + }); + } + } + + let document = snap_data.document; + if document.guide_lines_message_handler.guide_lines_visible && document.snapping_state.target_enabled(SnapTarget::GuideLine(GuideLineSnapTarget::Intersection)) { + let tolerance = snap_tolerance(document); + let mut guide_lines: Vec = Vec::new(); + + for guide_line in &document.guide_lines_message_handler.guide_lines { + let (snapped_point_document, direction) = match guide_line.direction { + GuideLineDirection::Horizontal => (DVec2::new(0.0, guide_line.position), DVec2::X), + GuideLineDirection::Vertical => (DVec2::new(guide_line.position, 0.0), DVec2::Y), + }; + guide_lines.push(SnappedLine { + point: SnappedPoint { + snapped_point_document, + source: point.source, + tolerance, + ..Default::default() + }, + direction, + }); + } + + if let Some(intersection) = super::get_line_intersection(point.document_point, &guide_lines, SnapTarget::GuideLine(GuideLineSnapTarget::Intersection)) { + if intersection.distance <= tolerance { + snap_results.points.push(intersection); + } + } + } + } + + pub fn constrained_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults, constraint: SnapConstraint) { + let tolerance = snap_tolerance(snap_data.document); + let projected = constraint.projection(point.document_point); + let lines = self.get_snap_lines(snap_data); + + let (constraint_start, constraint_direction) = match constraint { + SnapConstraint::Line { origin, direction } => (origin, direction.normalize_or_zero()), + SnapConstraint::Direction(direction) => (projected, direction.normalize_or_zero()), + _ => { + warn!("Circle constraint not supported for guide snapping"); + return; + } + }; + + for (line_point, line_direction, snap_target) in lines { + let Some(intersection) = Quad::intersect_rays(line_point, line_direction, constraint_start, constraint_direction) else { + continue; + }; + + let distance = intersection.distance(point.document_point); + let target = SnapTarget::GuideLine(snap_target); + + if distance < tolerance && snap_data.document.snapping_state.target_enabled(target) { + snap_results.points.push(SnappedPoint { + snapped_point_document: intersection, + source: point.source, + target, + at_intersection: false, + constrained: true, + source_bounds: point.quad, + distance, + tolerance, + ..Default::default() + }); + } + } + } +} diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 0bfb750ee0..6d8d65fc32 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -4,8 +4,10 @@ use super::tool_prelude::*; use crate::consts::*; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::guide_message::GuideLineMessage; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::guide::{GuideLineDirection, GuideLineId}; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface, NodeTemplate}; use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes; @@ -477,6 +479,10 @@ enum SelectToolFsmState { }, RotatingBounds, DraggingPivot, + DraggingGuideLine { + guide_line_id: GuideLineId, + direction: GuideLineDirection, + }, } impl Default for SelectToolFsmState { @@ -1175,6 +1181,9 @@ impl Fsm for SelectToolFsmState { // tool_data.snap_manager.add_all_document_handles(document, input, &[], &[], &[]); state + } else if let Some((guide_line_id, direction)) = document.guide_lines_message_handler.hit_test(input.mouse.position, document.metadata().document_to_viewport) { + responses.add(DocumentMessage::StartTransaction); + SelectToolFsmState::DraggingGuideLine { guide_line_id, direction } } // Dragging one (or two, forming a corner) of the transform cage bounding box edges else if resize { @@ -1266,6 +1275,54 @@ impl Fsm for SelectToolFsmState { let selection = tool_data.nested_selection_behavior; SelectToolFsmState::Ready { selection } } + (SelectToolFsmState::DraggingGuideLine { .. }, SelectToolMessage::Abort) => { + responses.add(DocumentMessage::AbortTransaction); + let selection = tool_data.nested_selection_behavior; + SelectToolFsmState::Ready { selection } + } + (SelectToolFsmState::DraggingGuideLine { guide_line_id, direction }, SelectToolMessage::PointerMove { .. }) => { + tool_data.drag_current = input.mouse.position; + + responses.add(GuideLineMessage::MoveGuideLine { + id: guide_line_id, + mouse_x: input.mouse.position.x, + mouse_y: input.mouse.position.y, + }); + + let cursor = match direction { + GuideLineDirection::Horizontal => MouseCursorIcon::NSResize, + GuideLineDirection::Vertical => MouseCursorIcon::EWResize, + }; + if tool_data.cursor != cursor { + tool_data.cursor = cursor; + responses.add(FrontendMessage::UpdateMouseCursor { cursor }); + } + + SelectToolFsmState::DraggingGuideLine { guide_line_id, direction } + } + (SelectToolFsmState::DraggingGuideLine { guide_line_id, direction: _ }, SelectToolMessage::DragStop { .. }) => { + tool_data.drag_current = input.mouse.position; + + // Checks if dragged outside viewport - deletes the guide line + let viewport_size = viewport.size().into_dvec2(); + let outside_viewport = input.mouse.position.x < 0.0 || input.mouse.position.y < 0.0 || input.mouse.position.x > viewport_size.x || input.mouse.position.y > viewport_size.y; + + if outside_viewport { + responses.add(DocumentMessage::AbortTransaction); + responses.add(GuideLineMessage::DeleteGuideLine { id: guide_line_id }); + } else { + responses.add(GuideLineMessage::MoveGuideLine { + id: guide_line_id, + mouse_x: input.mouse.position.x, + mouse_y: input.mouse.position.y, + }); + responses.add(DocumentMessage::CommitTransaction); + } + + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); + let selection = tool_data.nested_selection_behavior; + SelectToolFsmState::Ready { selection } + } ( SelectToolFsmState::Dragging { axis, @@ -1450,6 +1507,18 @@ impl Fsm for SelectToolFsmState { cursor = MouseCursorIcon::Move; } + // Check if hovering over a guide line and update hover state + let hovered_guide_line = document.guide_lines_message_handler.hit_test(input.mouse.position, document.metadata().document_to_viewport); + if let Some((guide_line_id, direction)) = hovered_guide_line { + cursor = match direction { + GuideLineDirection::Horizontal => MouseCursorIcon::NSResize, + GuideLineDirection::Vertical => MouseCursorIcon::EWResize, + }; + responses.add(GuideLineMessage::SetHoveredGuideLine { id: Some(guide_line_id) }); + } else { + responses.add(GuideLineMessage::SetHoveredGuideLine { id: None }); + } + // Generate the hover outline responses.add(OverlaysMessage::Draw); @@ -1903,6 +1972,13 @@ impl Fsm for SelectToolFsmState { let hint_data = HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]); hint_data.send_layout(responses); } + SelectToolFsmState::DraggingGuideLine { .. } => { + let hint_data = HintData(vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Guide Line")]), + ]); + hint_data.send_layout(responses); + } } } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 5044490212..265e85e70a 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -51,6 +51,10 @@ let rulerSelectionQuad: [number, number][] | undefined; let viewportBounds: DOMRect | undefined; + // Guide drag state + let draggingGuideId: bigint | undefined = undefined; + let draggingGuideDirection: "Horizontal" | "Vertical" | undefined = undefined; + // Rendered SVG viewport data let artworkSvg = ""; @@ -160,6 +164,118 @@ editor.panCanvas(0, -delta * scrollbarMultiplier.y); } + type GuideDirection = "Horizontal" | "Vertical"; + + type GuideEditor = { + createGuideLine: (id: bigint, direction: string, mouseX: number, mouseY: number) => void; + moveGuideLine: (id: bigint, mouseX: number, mouseY: number) => void; + deleteGuideLine: (id: bigint) => void; + }; + + function isGuideEditor(x: unknown): x is GuideEditor { + return ( + typeof x === "object" && + x !== null && + "createGuideLine" in x && + typeof x.createGuideLine === "function" && + "moveGuideLine" in x && + typeof x.moveGuideLine === "function" && + "deleteGuideLine" in x && + typeof x.deleteGuideLine === "function" + ); + } + + // Access the underlying EditorHandle for guide-specific methods + // EditorWrapper delegates to EditorHandle internally, so this cast is safe in the current architecture + function getEditorHandle(): GuideEditor { + const handle = editor; + if (isGuideEditor(handle)) return handle; + throw new Error("Expected editor to support guide line operations."); + } + + function isInRulerArea(event: PointerEvent, viewportRect: DOMRect, _direction: GuideDirection): boolean { + return event.clientX < viewportRect.left || event.clientX > viewportRect.right || event.clientY < viewportRect.top || event.clientY > viewportRect.bottom; + } + + function createGuideDragHandlers(options: { deleteOnCancel: boolean }) { + const viewportEl = viewport; + if (!viewportEl) return null; + + const onMove = (event: PointerEvent) => { + if (draggingGuideId === undefined || !draggingGuideDirection) return; + const rect = viewportEl.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + getEditorHandle().moveGuideLine(draggingGuideId, mouseX, mouseY); + }; + + const onRelease = (event: PointerEvent) => { + if (draggingGuideId === undefined || !draggingGuideDirection) return; + const rect = viewportEl.getBoundingClientRect(); + if (isInRulerArea(event, rect, draggingGuideDirection)) { + getEditorHandle().deleteGuideLine(draggingGuideId); + } + cleanup(); + }; + + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape" || draggingGuideId === undefined) return; + if (options.deleteOnCancel) getEditorHandle().deleteGuideLine(draggingGuideId); + cleanup(); + }; + + const onRightClick = (event: MouseEvent) => { + if (draggingGuideId === undefined) return; + event.preventDefault(); + if (options.deleteOnCancel) getEditorHandle().deleteGuideLine(draggingGuideId); + cleanup(); + }; + + const onCancel = () => { + if (draggingGuideId === undefined) return; + if (options.deleteOnCancel) getEditorHandle().deleteGuideLine(draggingGuideId); + cleanup(); + }; + + const cleanup = () => { + draggingGuideId = undefined; + draggingGuideDirection = undefined; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onRelease); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onEscape); + window.removeEventListener("contextmenu", onRightClick); + }; + + return { onMove, onRelease, onCancel, onEscape, onRightClick }; + } + + function startGuideDrag(options: { deleteOnCancel: boolean }) { + const handlers = createGuideDragHandlers(options); + if (!handlers) return; + + window.addEventListener("pointermove", handlers.onMove); + window.addEventListener("pointerup", handlers.onRelease); + window.addEventListener("pointercancel", handlers.onCancel); + window.addEventListener("keydown", handlers.onEscape); + window.addEventListener("contextmenu", handlers.onRightClick); + } + + // Guide Event Handlers + + function handleGuideDragStart(e: CustomEvent<{ direction: GuideDirection; mouseX: number; mouseY: number }>) { + const { direction, mouseX, mouseY } = e.detail; + + const array = new BigUint64Array(1); + window.crypto.getRandomValues(array); + const guideId = array[0]; + draggingGuideId = guideId; + draggingGuideDirection = direction; + + getEditorHandle().createGuideLine(guideId, direction, mouseX, mouseY); + startGuideDrag({ deleteOnCancel: true }); + } + function canvasPointerDown(e: PointerEvent) { const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable; @@ -581,6 +697,14 @@ subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxTransform"); subscriptions.unsubscribeFrontendMessage("DisplayRemoveEditableTextbox"); + + // Cancel any in-progress guide drag so global window listeners don't leak + if (draggingGuideId !== undefined) { + const editorHandle = getEditorHandle(); + if (isGuideEditor(editorHandle)) editorHandle.deleteGuideLine(draggingGuideId); + draggingGuideId = undefined; + draggingGuideDirection = undefined; + } }); @@ -630,6 +754,8 @@ cursorPosition={rulerCursorPosition} selectionQuad={rulerSelectionQuad} bind:this={rulerHorizontal} + on:guideLineDragStart={handleGuideDragStart} + viewportEl={viewport} /> {/if} @@ -647,6 +773,8 @@ cursorPosition={rulerCursorPosition} selectionQuad={rulerSelectionQuad} bind:this={rulerVertical} + on:guideLineDragStart={handleGuideDragStart} + viewportEl={viewport} /> {/if} diff --git a/frontend/src/components/widgets/inputs/RulerInput.svelte b/frontend/src/components/widgets/inputs/RulerInput.svelte index 428609ce74..86c301c3ef 100644 --- a/frontend/src/components/widgets/inputs/RulerInput.svelte +++ b/frontend/src/components/widgets/inputs/RulerInput.svelte @@ -1,5 +1,5 @@
-
+
{#each svgTexts as svgText} diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index ad923e7594..e5229a0056 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -658,6 +658,40 @@ mod editor_commands { } .into() } + + fn create_guide_line(id: u64, direction: String, mouse_x: f64, mouse_y: f64) -> Message { + use editor::messages::portfolio::document::guide_message::GuideLineMessage; + use editor::messages::portfolio::document::utility_types::guide::{GuideLineDirection, GuideLineId}; + let direction = match direction.as_str() { + "Horizontal" => GuideLineDirection::Horizontal, + "Vertical" => GuideLineDirection::Vertical, + other => panic!("Invalid guide line direction: {other:?}"), + }; + GuideLineMessage::CreateGuideLine { + id: GuideLineId::from_raw(id), + direction, + mouse_x, + mouse_y, + } + .into() + } + + fn move_guide_line(id: u64, mouse_x: f64, mouse_y: f64) -> Message { + use editor::messages::portfolio::document::guide_message::GuideLineMessage; + use editor::messages::portfolio::document::utility_types::guide::GuideLineId; + GuideLineMessage::MoveGuideLine { + id: GuideLineId::from_raw(id), + mouse_x, + mouse_y, + } + .into() + } + + fn delete_guide_line(id: u64) -> Message { + use editor::messages::portfolio::document::guide_message::GuideLineMessage; + use editor::messages::portfolio::document::utility_types::guide::GuideLineId; + GuideLineMessage::DeleteGuideLine { id: GuideLineId::from_raw(id) }.into() + } } #[derive(Debug, Clone, Serialize, Deserialize, Tsify)]