diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 6c312ed6a2..c6dd3f7aae 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -2,6 +2,7 @@ use crate::messages::debug::utility_types::MessageLoggingVerbosity; use crate::messages::defer::DeferMessageContext; use crate::messages::dialog::DialogMessageContext; use crate::messages::layout::layout_message_handler::LayoutMessageContext; +use crate::messages::portfolio::utility_types::PanelType; use crate::messages::preferences::preferences_message_handler::PreferencesMessageContext; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed; @@ -234,9 +235,10 @@ impl Dispatcher { let menu_bar_message_handler = &mut self.message_handlers.menu_bar_message_handler; menu_bar_message_handler.focus_document = self.message_handlers.portfolio_message_handler.focus_document; - menu_bar_message_handler.data_panel_open = self.message_handlers.portfolio_message_handler.data_panel_open; - menu_bar_message_handler.layers_panel_open = self.message_handlers.portfolio_message_handler.layers_panel_open; - menu_bar_message_handler.properties_panel_open = self.message_handlers.portfolio_message_handler.properties_panel_open; + let layout = &self.message_handlers.portfolio_message_handler.workspace_panel_layout; + menu_bar_message_handler.data_panel_open = layout.is_panel_present(PanelType::Data); + menu_bar_message_handler.layers_panel_open = layout.is_panel_present(PanelType::Layers); + menu_bar_message_handler.properties_panel_open = layout.is_panel_present(PanelType::Properties); menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity; menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 15aa9cb3b4..dffd9e9c92 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -9,6 +9,7 @@ use crate::messages::portfolio::document::node_graph::utility_types::{ }; use crate::messages::portfolio::document::utility_types::nodes::{LayerPanelEntry, LayerStructureEntry}; use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate}; +use crate::messages::portfolio::utility_types::WorkspacePanelLayout; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use graph_craft::document::NodeId; @@ -194,14 +195,9 @@ pub enum FrontendMessage { UpdateGraphViewOverlay { open: bool, }, - UpdateDataPanelState { - open: bool, - }, - UpdatePropertiesPanelState { - open: bool, - }, - UpdateLayersPanelState { - open: bool, + UpdateWorkspacePanelLayout { + #[serde(rename = "panelLayout")] + panel_layout: WorkspacePanelLayout, }, UpdateLayout { #[serde(rename = "layoutTarget")] diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 7865e44a97..8654fbdfec 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1021,8 +1021,8 @@ impl MessageHandler> for DocumentMes } } } - DocumentMessage::SetActivePanel { active_panel: panel } => { - match panel { + DocumentMessage::SetActivePanel { active_panel } => { + match active_panel { PanelType::Document => { if self.graph_view_overlay_open { self.selection_network_path.clone_from(&self.breadcrumb_network_path); diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 48379b8992..76c5cfc672 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,5 +1,5 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; -use super::utility_types::PanelType; +use super::utility_types::PanelGroupId; use crate::messages::frontend::utility_types::{ExportBounds, FileType}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::portfolio::utility_types::FontCatalog; @@ -61,6 +61,11 @@ pub enum PortfolioMessage { LoadDocumentResources { document_id: DocumentId, }, + MovePanelTab { + source_group: PanelGroupId, + target_group: PanelGroupId, + insert_index: usize, + }, NewDocumentWithName { name: String, }, @@ -130,10 +135,16 @@ pub enum PortfolioMessage { document_id: DocumentId, new_index: usize, }, + ReorderPanelGroupTab { + group: PanelGroupId, + old_index: usize, + new_index: usize, + }, RequestWelcomeScreenButtonsLayout, RequestStatusBarInfoLayout, - SetActivePanel { - panel: PanelType, + SetPanelGroupActiveTab { + group: PanelGroupId, + tab_index: usize, }, SelectDocument { document_id: DocumentId, @@ -161,4 +172,5 @@ pub enum PortfolioMessage { ToggleRulers, UpdateDocumentWidgets, UpdateOpenDocumentsList, + UpdateWorkspacePanelLayout, } diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ba72bfdfbd..1f90f6f09e 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1,6 +1,6 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::document::utility_types::network_interface; -use super::utility_types::{PanelType, PersistentData}; +use super::utility_types::{PanelGroupId, PanelType, PersistentData, WorkspacePanelLayout}; use crate::application::{Editor, generate_uuid}; use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION}; use crate::messages::animation::TimingInformation; @@ -24,7 +24,6 @@ use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::utility_types::{HintData, ToolType}; use crate::messages::viewport::ToPhysical; use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor}; -use derivative::*; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; use graphene_std::Color; @@ -48,12 +47,10 @@ pub struct PortfolioMessageContext<'a> { pub viewport: &'a ViewportMessageHandler, } -#[derive(Debug, Derivative, ExtractField)] -#[derivative(Default)] +#[derive(Debug, Default, ExtractField)] pub struct PortfolioMessageHandler { pub documents: HashMap, document_ids: VecDeque, - active_panel: PanelType, pub(crate) active_document_id: Option, copy_buffer: [Vec; INTERNAL_CLIPBOARD_COUNT as usize], pub persistent_data: PersistentData, @@ -61,11 +58,7 @@ pub struct PortfolioMessageHandler { pub selection_mode: SelectionMode, pub reset_node_definitions_on_open: bool, pub focus_document: bool, - #[derivative(Default(value = "true"))] - pub properties_panel_open: bool, - #[derivative(Default(value = "true"))] - pub layers_panel_open: bool, - pub data_panel_open: bool, + pub workspace_panel_layout: WorkspacePanelLayout, } #[message_handler_data] @@ -95,9 +88,9 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, - data_panel_open: self.data_panel_open && !self.focus_document, - layers_panel_open: self.layers_panel_open && !self.focus_document, - properties_panel_open: self.properties_panel_open && !self.focus_document, + data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document, + layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, + properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.focus_document, }; document.process_message(message, responses, document_inputs) } @@ -122,6 +115,9 @@ impl MessageHandler> for Portfolio // Display the menu bar at the top of the window responses.add(MenuBarMessage::SendLayout); + // Send the initial workspace panel layout to the frontend + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + // Send the information for tooltips and categories for each node/input. responses.add(FrontendMessage::SendUIMetadata { node_descriptions: document_node_definitions::collect_node_descriptions(), @@ -159,9 +155,9 @@ impl MessageHandler> for Portfolio current_tool, preferences, viewport, - data_panel_open: self.data_panel_open && !self.focus_document, - layers_panel_open: self.layers_panel_open && !self.focus_document, - properties_panel_open: self.properties_panel_open && !self.focus_document, + data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document, + layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, + properties_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Properties) && !self.focus_document, }; document.process_message(message, responses, document_inputs) } @@ -464,6 +460,48 @@ impl MessageHandler> for Portfolio self.load_document(new_document, document_id, responses, false); responses.add(PortfolioMessage::SelectDocument { document_id }); } + PortfolioMessage::MovePanelTab { + source_group, + target_group, + insert_index, + } => { + if source_group == target_group { + return; + } + + let source_state = self.workspace_panel_layout.panel_group(source_group); + let Some(panel_type) = source_state.active_panel_type() else { return }; + + // Destroy layouts for the moved panel (so backend and frontend start in sync when it remounts) + // and for the panel that was previously active in the target panel group (it will be displaced by the incoming tab) + Self::destroy_panel_layouts(panel_type, responses); + if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).active_panel_type() { + Self::destroy_panel_layouts(old_target_panel, responses); + } + + // Remove from source panel group + let source = self.workspace_panel_layout.panel_group_mut(source_group); + source.tabs.retain(|&t| t != panel_type); + source.active_tab_index = source.active_tab_index.min(source.tabs.len().saturating_sub(1)); + + // Insert into target panel group + let target = self.workspace_panel_layout.panel_group_mut(target_group); + let index = insert_index.min(target.tabs.len()); + target.tabs.insert(index, panel_type); + target.active_tab_index = index; + + responses.add(MenuBarMessage::SendLayout); + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + + // Refresh the moved panel's content in its new location + self.refresh_panel_content(panel_type, responses); + + // Refresh the source panel group's newly active tab (if any remain) so it's not left stale + if let Some(new_source_active) = self.workspace_panel_layout.panel_group(source_group).active_panel_type() { + Self::destroy_panel_layouts(new_source_active, responses); + self.refresh_panel_content(new_source_active, responses); + } + } PortfolioMessage::NextDocument => { if let Some(active_document_id) = self.active_document_id { let current_index = self.document_index(active_document_id); @@ -1071,6 +1109,25 @@ impl MessageHandler> for Portfolio } } } + PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index } => { + let group_state = self.workspace_panel_layout.panel_group_mut(group); + + if old_index < group_state.tabs.len() && new_index < group_state.tabs.len() && old_index != new_index { + let tab = group_state.tabs.remove(old_index); + group_state.tabs.insert(new_index, tab); + + // Keep the active tab following the reorder + if group_state.active_tab_index == old_index { + group_state.active_tab_index = new_index; + } else if old_index < group_state.active_tab_index && new_index >= group_state.active_tab_index { + group_state.active_tab_index = group_state.active_tab_index.saturating_sub(1); + } else if old_index > group_state.active_tab_index && new_index <= group_state.active_tab_index { + group_state.active_tab_index += 1; + } + + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + } + } PortfolioMessage::RequestWelcomeScreenButtonsLayout => { let donate = "https://graphite.art/donate/"; @@ -1131,9 +1188,26 @@ impl MessageHandler> for Portfolio layout_target: LayoutTarget::StatusBarInfo, }); } - PortfolioMessage::SetActivePanel { panel } => { - self.active_panel = panel; - responses.add(DocumentMessage::SetActivePanel { active_panel: self.active_panel }); + PortfolioMessage::SetPanelGroupActiveTab { group, tab_index } => { + let group_state = self.workspace_panel_layout.panel_group(group); + if tab_index < group_state.tabs.len() && tab_index != group_state.active_tab_index { + // Destroy layouts for the old and new panels so the backend's diffing state is in sync with the frontend's fresh mount + if let Some(old_panel_type) = group_state.active_panel_type() { + Self::destroy_panel_layouts(old_panel_type, responses); + } + let new_panel_type = group_state.tabs[tab_index]; + Self::destroy_panel_layouts(new_panel_type, responses); + + // Update the active tab index for the panel + self.workspace_panel_layout.panel_group_mut(group).active_tab_index = tab_index; + + // Send the layout update first so the frontend mounts the new panel component before it receives content + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + + if let Some(panel_type) = self.workspace_panel_layout.panel_group(group).active_panel_type() { + self.refresh_panel_content(panel_type, responses); + } + } } PortfolioMessage::SelectDocument { document_id } => { // Auto-save the document we are leaving @@ -1289,113 +1363,61 @@ impl MessageHandler> for Portfolio self.focus_document = !self.focus_document; responses.add(MenuBarMessage::SendLayout); + let properties_present = self.workspace_panel_layout.is_panel_present(PanelType::Properties); + let layers_present = self.workspace_panel_layout.is_panel_present(PanelType::Layers); + let data_present = self.workspace_panel_layout.is_panel_present(PanelType::Data); + if self.focus_document { - if self.properties_panel_open { - responses.add(PropertiesPanelMessage::Clear); - responses.add(FrontendMessage::UpdatePropertiesPanelState { open: false }); + if properties_present { + Self::destroy_panel_layouts(PanelType::Properties, responses); } - - if self.layers_panel_open { - responses.add(DocumentMessage::ClearLayersPanel); - responses.add(FrontendMessage::UpdateLayersPanelState { open: false }); + if layers_present { + Self::destroy_panel_layouts(PanelType::Layers, responses); } - - if self.data_panel_open { - responses.add(DataPanelMessage::ClearLayout); - responses.add(FrontendMessage::UpdateDataPanelState { open: false }); + if data_present { + Self::destroy_panel_layouts(PanelType::Data, responses); } } else { - if self.properties_panel_open { - responses.add(FrontendMessage::UpdatePropertiesPanelState { open: true }); - } - if self.layers_panel_open { - responses.add(FrontendMessage::UpdateLayersPanelState { open: true }); - } - if self.data_panel_open { - responses.add(FrontendMessage::UpdateDataPanelState { open: true }); - } - // Run the graph to grab the data - if self.properties_panel_open || self.layers_panel_open || self.data_panel_open { + if properties_present || layers_present || data_present { responses.add(NodeGraphMessage::RunDocumentGraph); } - if self.properties_panel_open { + if properties_present { responses.add(PropertiesPanelMessage::Refresh); } - if self.layers_panel_open && self.active_document_id.is_some() { + if layers_present && self.active_document_id.is_some() { responses.add(DeferMessage::AfterGraphRun { messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()], }); } } + + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); } PortfolioMessage::TogglePropertiesPanelOpen => { if self.focus_document { return; } - self.properties_panel_open = !self.properties_panel_open; - responses.add(MenuBarMessage::SendLayout); - - // Run the graph to grab the data - if self.properties_panel_open { - responses.add(FrontendMessage::UpdatePropertiesPanelState { open: self.properties_panel_open }); - responses.add(NodeGraphMessage::RunDocumentGraph); - responses.add(PropertiesPanelMessage::Refresh); - } else { - responses.add(PropertiesPanelMessage::Clear); - responses.add(FrontendMessage::UpdatePropertiesPanelState { open: self.properties_panel_open }); - } + let panel_type = PanelType::Properties; + self.toggle_dockable_panel(panel_type, responses); } PortfolioMessage::ToggleLayersPanelOpen => { if self.focus_document { return; } - self.layers_panel_open = !self.layers_panel_open; - responses.add(MenuBarMessage::SendLayout); - - // Run the graph to grab the data - if self.layers_panel_open { - // When opening, we make the frontend show the panel first so it can start receiving its message subscriptions for the data it will display - responses.add(FrontendMessage::UpdateLayersPanelState { open: self.layers_panel_open }); - - responses.add(NodeGraphMessage::RunDocumentGraph); - if self.active_document_id.is_some() { - responses.add(DeferMessage::AfterGraphRun { - messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()], - }); - } - } else { - // If we don't clear the panel, the layout diffing system will assume widgets still exist when it attempts to update the layers panel next time it is opened - responses.add(DocumentMessage::ClearLayersPanel); - - // When closing, we make the frontend hide the panel last so it can finish receiving its message subscriptions before it is destroyed - responses.add(FrontendMessage::UpdateLayersPanelState { open: self.layers_panel_open }); - } + let panel_type = PanelType::Layers; + self.toggle_dockable_panel(panel_type, responses); } PortfolioMessage::ToggleDataPanelOpen => { if self.focus_document { return; } - self.data_panel_open = !self.data_panel_open; - responses.add(MenuBarMessage::SendLayout); - - // Run the graph to grab the data - if self.data_panel_open { - // When opening, we make the frontend show the panel first so it can start receiving its message subscriptions for the data it will display - responses.add(FrontendMessage::UpdateDataPanelState { open: self.data_panel_open }); - - responses.add(NodeGraphMessage::RunDocumentGraph); - } else { - // If we don't clear the panel, the layout diffing system will assume widgets still exist when it attempts to update the data panel next time it is opened - responses.add(DataPanelMessage::ClearLayout); - - // When closing, we make the frontend hide the panel last so it can finish receiving its message subscriptions before it is destroyed - responses.add(FrontendMessage::UpdateDataPanelState { open: self.data_panel_open }); - } + let panel_type = PanelType::Data; + self.toggle_dockable_panel(panel_type, responses); } PortfolioMessage::ToggleRulers => { if let Some(document) = self.active_document_mut() { @@ -1410,6 +1432,11 @@ impl MessageHandler> for Portfolio document.update_document_widgets(responses, animation.is_playing(), timing_information.animation_time); } } + PortfolioMessage::UpdateWorkspacePanelLayout => { + responses.add(FrontendMessage::UpdateWorkspacePanelLayout { + panel_layout: self.workspace_panel_layout.clone(), + }); + } PortfolioMessage::UpdateOpenDocumentsList => { // Send the list of document tab names let open_documents = self @@ -1562,8 +1589,8 @@ impl PortfolioMessageHandler { } else { self.document_ids.push_back(document_id); } - new_document.update_layers_panel_control_bar_widgets(self.layers_panel_open && !self.focus_document, responses); - new_document.update_layers_panel_bottom_bar_widgets(self.layers_panel_open && !self.focus_document, responses); + new_document.update_layers_panel_control_bar_widgets(self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, responses); + new_document.update_layers_panel_bottom_bar_widgets(self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document, responses); self.documents.insert(document_id, new_document); @@ -1610,7 +1637,7 @@ impl PortfolioMessageHandler { /// Get the ID of the selected node that should be used as the current source for the Data panel. pub fn node_to_inspect(&self) -> Option { // Skip if the Data panel is not open - if !self.data_panel_open || self.focus_document { + if !self.workspace_panel_layout.is_panel_visible(PanelType::Data) || self.focus_document { return None; } @@ -1624,4 +1651,85 @@ impl PortfolioMessageHandler { selected_nodes.first().copied() } + + /// Remove a dockable panel type from whichever panel group currently contains it. + fn remove_panel_from_layout(&mut self, panel_type: PanelType) { + for group_id in [PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup] { + let group = self.workspace_panel_layout.panel_group_mut(group_id); + if let Some(index) = group.tabs.iter().position(|&t| t == panel_type) { + group.tabs.remove(index); + group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1)); + break; + } + } + } + + /// Toggle a dockable panel on or off. When toggling off, refresh the newly active tab in its panel group (if any). + fn toggle_dockable_panel(&mut self, panel_type: PanelType, responses: &mut VecDeque) { + if let Some(group_id) = self.workspace_panel_layout.find_panel(panel_type) { + // Panel is present, remove it + let was_visible = self.workspace_panel_layout.panel_group(group_id).is_visible(panel_type); + Self::destroy_panel_layouts(panel_type, responses); + self.remove_panel_from_layout(panel_type); + + // If the removed panel was the active tab, refresh whichever panel is now active in that panel group + if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).active_panel_type() { + Self::destroy_panel_layouts(new_active, responses); + self.refresh_panel_content(new_active, responses); + } + } else { + // Panel is not present, add it to its default panel group + self.add_panel_to_its_default_group(panel_type); + self.refresh_panel_content(panel_type, responses); + } + + responses.add(MenuBarMessage::SendLayout); + responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + } + + /// Add a dockable panel type to its default panel group. + fn add_panel_to_its_default_group(&mut self, panel_type: PanelType) { + let group = self.workspace_panel_layout.panel_group_mut(panel_type.default_panel_group()); + if !group.tabs.contains(&panel_type) { + group.tabs.push(panel_type); + group.active_tab_index = group.tabs.len() - 1; + } + } + + /// Destroy the stored layout for a panel that is no longer the active tab. + /// This resets the backend's diffing state so it won't try to send updates to a frontend component that has been unmounted. + fn destroy_panel_layouts(panel_type: PanelType, responses: &mut VecDeque) { + let targets: &[LayoutTarget] = match panel_type { + PanelType::Properties => &[LayoutTarget::PropertiesPanel], + PanelType::Layers => &[LayoutTarget::LayersPanelControlLeftBar, LayoutTarget::LayersPanelControlRightBar, LayoutTarget::LayersPanelBottomBar], + PanelType::Data => &[LayoutTarget::DataPanel], + PanelType::Document | PanelType::Welcome => return, + }; + + for &layout_target in targets { + responses.add(LayoutMessage::DestroyLayout { layout_target }); + } + } + + /// Trigger a content refresh for a panel that just became the active tab. + fn refresh_panel_content(&self, panel_type: PanelType, responses: &mut VecDeque) { + responses.add(NodeGraphMessage::RunDocumentGraph); + + match panel_type { + PanelType::Properties => { + responses.add(PropertiesPanelMessage::Refresh); + } + PanelType::Layers => { + if self.active_document_id.is_some() { + responses.add(DeferMessage::AfterGraphRun { + messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()], + }); + } + } + PanelType::Data => { + // The Data panel's content is populated automatically as a side effect of the graph run completing, so there's nothing to do here + } + PanelType::Document | PanelType::Welcome => {} + } + } } diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 0626cabf0a..180c494d4f 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -83,29 +83,150 @@ impl FontCatalogStyle { } } -#[derive(PartialEq, Eq, Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub enum PanelType { - #[default] - Document, Welcome, + Document, Layers, Properties, - DataPanel, + Data, +} + +impl PanelType { + /// Returns the default panel group for this panel type. + pub fn default_panel_group(self) -> PanelGroupId { + match self { + PanelType::Document => PanelGroupId::DocumentGroup, + PanelType::Properties => PanelGroupId::PropertiesGroup, + PanelType::Layers => PanelGroupId::LayersGroup, + PanelType::Data => PanelGroupId::DataGroup, + PanelType::Welcome => panic!("PanelType::{self:?} has no default panel group (not a dockable panel)"), + } + } } impl From for PanelType { fn from(value: String) -> Self { match value.as_str() { - "Document" => PanelType::Document, "Welcome" => PanelType::Welcome, + "Document" => PanelType::Document, "Layers" => PanelType::Layers, "Properties" => PanelType::Properties, - "Data" => PanelType::DataPanel, + "Data" => PanelType::Data, _ => panic!("Unknown panel type: {value}"), } } } +/// Identifies a panel group in the workspace that can hold tabbed panels. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum PanelGroupId { + DocumentGroup, + PropertiesGroup, + LayersGroup, + DataGroup, +} + +impl From for PanelGroupId { + fn from(value: String) -> Self { + match value.as_str() { + "DocumentGroup" => PanelGroupId::DocumentGroup, + "PropertiesGroup" => PanelGroupId::PropertiesGroup, + "LayersGroup" => PanelGroupId::LayersGroup, + "DataGroup" => PanelGroupId::DataGroup, + _ => panic!("Unknown panel group: {value}"), + } + } +} + +/// State of a single panel group in the workspace. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PanelGroupState { + pub tabs: Vec, + #[serde(rename = "activeTabIndex")] + pub active_tab_index: usize, +} + +impl PanelGroupState { + pub fn active_panel_type(&self) -> Option { + self.tabs.get(self.active_tab_index).copied() + } + + pub fn contains(&self, panel_type: PanelType) -> bool { + self.tabs.contains(&panel_type) + } + + pub fn is_visible(&self, panel_type: PanelType) -> bool { + self.active_panel_type() == Some(panel_type) + } +} + +/// The complete workspace panel layout describing which dockable panels are in which panel groups. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WorkspacePanelLayout { + #[serde(rename = "propertiesGroup")] + pub properties_group: PanelGroupState, + #[serde(rename = "layersGroup")] + pub layers_group: PanelGroupState, + #[serde(rename = "dataGroup")] + pub data_group: PanelGroupState, +} + +impl Default for WorkspacePanelLayout { + fn default() -> Self { + Self { + properties_group: PanelGroupState { + tabs: vec![PanelType::Properties], + active_tab_index: 0, + }, + layers_group: PanelGroupState { + tabs: vec![PanelType::Layers], + active_tab_index: 0, + }, + data_group: PanelGroupState { tabs: vec![], active_tab_index: 0 }, + } + } +} + +impl WorkspacePanelLayout { + pub fn panel_group(&self, panel_group_id: PanelGroupId) -> &PanelGroupState { + match panel_group_id { + PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"), + PanelGroupId::PropertiesGroup => &self.properties_group, + PanelGroupId::LayersGroup => &self.layers_group, + PanelGroupId::DataGroup => &self.data_group, + } + } + + pub fn panel_group_mut(&mut self, panel_group_id: PanelGroupId) -> &mut PanelGroupState { + match panel_group_id { + PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"), + PanelGroupId::PropertiesGroup => &mut self.properties_group, + PanelGroupId::LayersGroup => &mut self.layers_group, + PanelGroupId::DataGroup => &mut self.data_group, + } + } + + /// Find which panel group contains a given panel type. + pub fn find_panel(&self, panel_type: PanelType) -> Option { + [PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup] + .into_iter() + .find(|&group_id| self.panel_group(group_id).contains(panel_type)) + } + + /// Check if a panel type is the active (visible) tab in any panel group. + pub fn is_panel_visible(&self, panel_type: PanelType) -> bool { + self.find_panel(panel_type).is_some_and(|group_id| self.panel_group(group_id).is_visible(panel_type)) + } + + /// Check if a panel type is present (as any tab) in any panel group, whether or not it's the active tab. + pub fn is_panel_present(&self, panel_type: PanelType) -> bool { + self.find_panel(panel_type).is_some() + } +} + pub enum FileContent { /// A Graphite document. Document(String), diff --git a/frontend/src/components/panels/Data.svelte b/frontend/src/components/panels/Data.svelte index d92d22c899..d4876e4fe5 100644 --- a/frontend/src/components/panels/Data.svelte +++ b/frontend/src/components/panels/Data.svelte @@ -32,6 +32,7 @@ .data-panel { flex-grow: 1; padding: 4px; + padding-top: 0; table { margin: -4px; @@ -42,7 +43,7 @@ } &:not(:first-child) { - margin-top: 0; + margin-top: -4px; } tr:first-child:has(td:first-child label:empty) ~ tr td:first-child { diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte index 20d713edf7..5207978288 100644 --- a/frontend/src/components/widgets/WidgetSection.svelte +++ b/frontend/src/components/widgets/WidgetSection.svelte @@ -72,7 +72,10 @@ .widget-section { flex: 0 0 auto; margin: 0 4px; - margin-top: 4px; + + + .widget-section { + margin-top: 4px; + } .header { text-align: left; diff --git a/frontend/src/components/window/Panel.svelte b/frontend/src/components/window/Panel.svelte index 61499ab446..a65d954741 100644 --- a/frontend/src/components/window/Panel.svelte +++ b/frontend/src/components/window/Panel.svelte @@ -9,9 +9,8 @@ import Welcome from "/src/components/panels/Welcome.svelte"; import IconButton from "/src/components/widgets/buttons/IconButton.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; - import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; - - type PanelType = keyof typeof PANEL_COMPONENTS; + import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag"; + import type { EditorWrapper, PanelType, PanelGroupId } from "/wrapper/pkg/graphite_wasm_wrapper"; const PANEL_COMPONENTS = { Welcome, @@ -31,11 +30,13 @@ export let tabCloseButtons = false; export let tabLabels: { name: string; unsaved?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: string }[]; export let tabActiveIndex: number; - export let panelType: PanelType | undefined = undefined; + export let panelTypes: PanelType[]; + export let panelId: PanelGroupId; export let clickAction: ((index: number) => void) | undefined = undefined; export let closeAction: ((index: number) => void) | undefined = undefined; export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined; export let emptySpaceAction: (() => void) | undefined = undefined; + export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined; let className = ""; export { className as class }; @@ -55,7 +56,7 @@ let tabGroupElement: LayoutRow | undefined = undefined; onDestroy(() => { - removeDragListeners(); + endDrag(); }); function onEmptySpaceAction(e: MouseEvent) { @@ -77,7 +78,10 @@ // Activate the tab upon pointer down clickAction?.(tabIndex); - if (!reorderAction || tabLabels.length < 2) return; + // Allow within-panel reorder if there are multiple tabs, or cross-panel drag if this panel supports docking + const canReorder = reorderAction && tabLabels.length > 1; + const canCrossPanelDrag = crossPanelDropAction !== undefined; + if (!canReorder && !canCrossPanelDrag) return; dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY }; dragging = false; @@ -95,30 +99,72 @@ const deltaX = Math.abs(e.clientX - dragStartState.pointerX); const deltaY = Math.abs(e.clientY - dragStartState.pointerY); if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return; + dragging = true; + + if (crossPanelDropAction) { + // Notify the shared store that a cross-panel drag has started + startCrossPanelDrag(panelId, tabLabels[dragStartState.tabIndex].name, dragStartState.tabIndex); + } } lastPointerX = e.clientX; - // Only show insertion line while the cursor is within the tab bar + // Exit early in here after we show the insertion marker, if we're within our own tab bar if (pointerIsInsideTabBar(e)) { calculateInsertionIndex(lastPointerX); - } else { - insertionIndex = undefined; - insertionMarkerLeft = undefined; + updateCrossPanelHover(undefined, undefined, undefined); + return; + } + + // Clear local insertion marker since we're outside our own tab bar + insertionIndex = undefined; + insertionMarkerLeft = undefined; + + // Check if the pointer is over any other dockable panel's tab bar + if (crossPanelDropAction) { + const target = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => { + const targetPanelId = element.getAttribute("data-panel-tab-bar"); + if (!targetPanelId || targetPanelId === panelId) return false; + + const rect = element.getBoundingClientRect(); + return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; + }); + + const targetPanelId = target?.getAttribute("data-panel-tab-bar"); + if (target instanceof HTMLDivElement && targetPanelId) { + calculateForeignInsertionIndex(e.clientX, targetPanelId, target); + } else { + updateCrossPanelHover(undefined, undefined, undefined); + } } } function dragPointerUp() { - if (dragging && dragStartState && insertionIndex !== undefined) { - const oldIndex = dragStartState.tabIndex; + if (dragging && dragStartState) { + const crossPanelState = $panelDrag; + + // Cross-panel drop: the pointer is over a different panel's tab bar + if ( + crossPanelDropAction && + crossPanelState.active && + crossPanelState.hoverTargetPanelId && + crossPanelState.hoverTargetPanelId !== panelId && + crossPanelState.hoverInsertionIndex !== undefined + ) { + crossPanelDropAction?.(panelId, crossPanelState.hoverTargetPanelId, crossPanelState.hoverInsertionIndex); + } + // Within-panel reorder + else if (insertionIndex !== undefined) { + const oldIndex = dragStartState.tabIndex; - // Adjust for the fact that removing the dragged tab shifts indices - let newIndex = insertionIndex; - if (newIndex > oldIndex) newIndex -= 1; + // Adjust for the fact that removing the dragged tab shifts indices + let newIndex = insertionIndex; + if (newIndex > oldIndex) newIndex -= 1; - if (oldIndex !== newIndex) { - reorderAction?.(oldIndex, newIndex); + if (oldIndex !== newIndex) { + reorderAction?.(oldIndex, newIndex); + } } } @@ -141,6 +187,7 @@ dragging = false; insertionIndex = undefined; insertionMarkerLeft = undefined; + if (crossPanelDropAction) endCrossPanelDrag(); removeDragListeners(); } @@ -152,6 +199,30 @@ return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; } + // Calculate the insertion position for a foreign panel's tab bar + function calculateForeignInsertionIndex(pointerX: number, targetPanelId: string, tabBarDiv: HTMLDivElement) { + const tabBarRect = tabBarDiv.getBoundingClientRect(); + const tabs = tabBarDiv.querySelectorAll(":scope > [data-tab]"); + let bestIndex = 0; + let bestMarkerLeft = 0; + + for (let i = 0; i < tabs.length; i++) { + const tabRect = tabs[i].getBoundingClientRect(); + const tabCenter = tabRect.left + tabRect.width / 2; + + if (pointerX > tabCenter) { + bestIndex = i + 1; + bestMarkerLeft = tabRect.right - tabBarRect.left; + } else { + bestMarkerLeft = tabRect.left - tabBarRect.left; + break; + } + } + + // Must be at least 2px from the left so its left half doesn't get cut off along the left of the tab bar + updateCrossPanelHover(targetPanelId, bestIndex, Math.max(2, bestMarkerLeft)); + } + function calculateInsertionIndex(pointerX: number) { const groupDiv = tabGroupElement?.div?.(); if (!dragStartState || !groupDiv) return; @@ -199,13 +270,21 @@ } - panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}> + panelTypes[tabActiveIndex] && editor.setActivePanel(panelTypes[tabActiveIndex])} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}> - + {#each tabLabels as tabLabel, tabIndex} tabPointerDown(e, tabIndex)} @@ -242,10 +321,13 @@ {#if dragging && insertionMarkerLeft !== undefined}
{/if} + {#if !dragging && crossPanelDropAction && $panelDrag.active && $panelDrag.hoverTargetPanelId === panelId && $panelDrag.hoverInsertionMarkerLeft !== undefined} +
+ {/if}
- {#if panelType} - + {#if panelTypes[tabActiveIndex]} + {/if}
diff --git a/frontend/src/components/window/Workspace.svelte b/frontend/src/components/window/Workspace.svelte index 486f69b44e..d1fdd9b282 100644 --- a/frontend/src/components/window/Workspace.svelte +++ b/frontend/src/components/window/Workspace.svelte @@ -23,9 +23,11 @@ let pointerCaptureId: number | undefined = undefined; let activeResizeCleanup: (() => void) | undefined = undefined; - onDestroy(() => { - activeResizeCleanup?.(); - }); + // Reactive panel layout derived from backend state + $: panelLayout = $portfolio.panelLayout; + $: propertiesGroup = panelLayout.propertiesGroup; + $: layersGroup = panelLayout.layersGroup; + $: dataGroup = panelLayout.dataGroup; $: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex); @@ -41,6 +43,10 @@ const editor = getContext("editor"); const portfolio = getContext("portfolio"); + function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) { + editor.movePanelTab(sourcePanelId, targetPanelId, insertIndex); + } + function isPanelName(name: string): name is keyof typeof PANEL_SIZES { return name in PANEL_SIZES; } @@ -155,6 +161,10 @@ addListeners(); activeResizeCleanup = removeListeners; } + + onDestroy(() => { + activeResizeCleanup?.(); + }); @@ -163,7 +173,8 @@ 0 ? "Document" : "Welcome"} + panelId="DocumentGroup" + panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]} tabCloseButtons={true} tabMinWidths={true} tabLabels={documentTabLabels} @@ -175,27 +186,51 @@ bind:this={documentPanel} /> - {#if $portfolio.dataPanelOpen} + {#if dataGroup.tabs.length > 0} resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> - + ({ name }))} + tabActiveIndex={dataGroup.activeTabIndex} + clickAction={(tabIndex) => editor.setPanelGroupActiveTab("DataGroup", tabIndex)} + reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("DataGroup", oldIndex, newIndex)} + crossPanelDropAction={crossPanelDrop} + /> {/if}
- {#if $portfolio.propertiesPanelOpen || $portfolio.layersPanelOpen} + {#if propertiesGroup.tabs.length > 0 || layersGroup.tabs.length > 0} resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> - {#if $portfolio.propertiesPanelOpen} + {#if propertiesGroup.tabs.length > 0} - + ({ name }))} + tabActiveIndex={propertiesGroup.activeTabIndex} + clickAction={(tabIndex) => editor.setPanelGroupActiveTab("PropertiesGroup", tabIndex)} + reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("PropertiesGroup", oldIndex, newIndex)} + crossPanelDropAction={crossPanelDrop} + /> {/if} - {#if $portfolio.propertiesPanelOpen && $portfolio.layersPanelOpen} + {#if propertiesGroup.tabs.length > 0 && layersGroup.tabs.length > 0} resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} /> {/if} - {#if $portfolio.layersPanelOpen} + {#if layersGroup.tabs.length > 0} - + ({ name }))} + tabActiveIndex={layersGroup.activeTabIndex} + clickAction={(tabIndex) => editor.setPanelGroupActiveTab("LayersGroup", tabIndex)} + reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("LayersGroup", oldIndex, newIndex)} + crossPanelDropAction={crossPanelDrop} + /> {/if} diff --git a/frontend/src/stores/panel-drag.ts b/frontend/src/stores/panel-drag.ts new file mode 100644 index 0000000000..9a39e6331f --- /dev/null +++ b/frontend/src/stores/panel-drag.ts @@ -0,0 +1,61 @@ +import { writable } from "svelte/store"; +import type { Writable } from "svelte/store"; + +export type PanelDragState = { + active: boolean; + sourcePanelId: string | undefined; + draggedTabLabel: string | undefined; + sourceTabIndex: number; + // Which panel's tab bar the pointer is currently hovering over (undefined if none) + hoverTargetPanelId: string | undefined; + hoverInsertionIndex: number | undefined; + hoverInsertionMarkerLeft: number | undefined; +}; + +const initialState: PanelDragState = { + active: false, + sourcePanelId: undefined, + draggedTabLabel: undefined, + sourceTabIndex: 0, + hoverTargetPanelId: undefined, + hoverInsertionIndex: undefined, + hoverInsertionMarkerLeft: undefined, +}; + +// Store state persisted across HMR to maintain reactive subscriptions in the component tree +const store: Writable = import.meta.hot?.data?.store || writable(initialState); +if (import.meta.hot) import.meta.hot.data.store = store; + +export const panelDrag = store; + +export function startCrossPanelDrag(sourcePanelId: string, draggedTabLabel: string, sourceTabIndex: number) { + store.update((state) => { + state.active = true; + state.sourcePanelId = sourcePanelId; + state.draggedTabLabel = draggedTabLabel; + state.sourceTabIndex = sourceTabIndex; + return state; + }); +} + +export function endCrossPanelDrag() { + store.update((state) => { + state.active = false; + state.sourcePanelId = undefined; + state.draggedTabLabel = undefined; + state.sourceTabIndex = 0; + state.hoverTargetPanelId = undefined; + state.hoverInsertionIndex = undefined; + state.hoverInsertionMarkerLeft = undefined; + return state; + }); +} + +export function updateCrossPanelHover(hoverTargetPanelId: string | undefined, hoverInsertionIndex: number | undefined, hoverInsertionMarkerLeft: number | undefined) { + store.update((state) => { + state.hoverTargetPanelId = hoverTargetPanelId; + state.hoverInsertionIndex = hoverInsertionIndex; + state.hoverInsertionMarkerLeft = hoverInsertionMarkerLeft; + return state; + }); +} diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 5651b3309e..61411c0f01 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -4,25 +4,36 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { storeDocumentTabOrder } from "/src/utility-functions/persistence"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; -import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { EditorWrapper, OpenDocument, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; export type PortfolioStore = ReturnType; +export type PanelGroupState = { + tabs: PanelType[]; + activeTabIndex: number; +}; + +export type WorkspacePanelLayout = { + propertiesGroup: PanelGroupState; + layersGroup: PanelGroupState; + dataGroup: PanelGroupState; +}; + type PortfolioStoreState = { unsaved: boolean; documents: OpenDocument[]; activeDocumentIndex: number; - dataPanelOpen: boolean; - propertiesPanelOpen: boolean; - layersPanelOpen: boolean; + panelLayout: WorkspacePanelLayout; }; const initialState: PortfolioStoreState = { unsaved: false, documents: [], activeDocumentIndex: 0, - dataPanelOpen: false, - propertiesPanelOpen: true, - layersPanelOpen: true, + panelLayout: { + propertiesGroup: { tabs: ["Properties"], activeTabIndex: 0 }, + layersGroup: { tabs: ["Layers"], activeTabIndex: 0 }, + dataGroup: { tabs: [], activeTabIndex: 0 }, + }, }; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; @@ -103,23 +114,15 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); - subscriptions.subscribeFrontendMessage("UpdateDataPanelState", async (data) => { - update((state) => { - state.dataPanelOpen = data.open; - return state; - }); - }); - - subscriptions.subscribeFrontendMessage("UpdatePropertiesPanelState", async (data) => { - update((state) => { - state.propertiesPanelOpen = data.open; - return state; - }); - }); + subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => { + // Coerce activeTabIndex from BigInt (produced by serde_wasm_bindgen for usize) to number + const layout = data.panelLayout; + layout.propertiesGroup.activeTabIndex = Number(layout.propertiesGroup.activeTabIndex); + layout.layersGroup.activeTabIndex = Number(layout.layersGroup.activeTabIndex); + layout.dataGroup.activeTabIndex = Number(layout.dataGroup.activeTabIndex); - subscriptions.subscribeFrontendMessage("UpdateLayersPanelState", async (data) => { update((state) => { - state.layersPanelOpen = data.open; + state.panelLayout = layout; return state; }); }); @@ -139,7 +142,5 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); - subscriptions.unsubscribeFrontendMessage("UpdateDataPanelState"); - subscriptions.unsubscribeFrontendMessage("UpdatePropertiesPanelState"); - subscriptions.unsubscribeFrontendMessage("UpdateLayersPanelState"); + subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 190a1cbfeb..6cd530cb68 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -434,6 +434,29 @@ impl EditorWrapper { self.dispatch(message); } + #[wasm_bindgen(js_name = reorderPanelGroupTab)] + pub fn reorder_panel_group_tab(&self, group: String, old_index: usize, new_index: usize) { + let group = group.into(); + let message = PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = movePanelTab)] + pub fn move_panel_tab(&self, source_group: String, target_group: String, insert_index: usize) { + let message = PortfolioMessage::MovePanelTab { + source_group: source_group.into(), + target_group: target_group.into(), + insert_index, + }; + self.dispatch(message); + } + + #[wasm_bindgen(js_name = setPanelGroupActiveTab)] + pub fn set_panel_group_active_tab(&self, group: String, tab_index: usize) { + let message = PortfolioMessage::SetPanelGroupActiveTab { group: group.into(), tab_index }; + self.dispatch(message); + } + #[wasm_bindgen(js_name = closeDocumentWithConfirmation)] pub fn close_document_with_confirmation(&self, document_id: u64) { let document_id = DocumentId(document_id); @@ -874,7 +897,7 @@ impl EditorWrapper { /// Set the active panel to the most recently clicked panel #[wasm_bindgen(js_name = setActivePanel)] pub fn set_active_panel(&self, panel: String) { - let message = PortfolioMessage::SetActivePanel { panel: panel.into() }; + let message = DocumentMessage::SetActivePanel { active_panel: panel.into() }; self.dispatch(message); } diff --git a/website/content/features.md b/website/content/features.md index f7749065b3..9c4d38a43e 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -142,6 +142,10 @@ Marrying vector and raster under one roof enables both art forms to complement e Blend tool to morph between shapes +
+ + Dockable and multi-window panels +
Stable document format @@ -250,10 +254,6 @@ Marrying vector and raster under one roof enables both art forms to complement e

— Beta 2 —

-
- - Dockable and multi-window panels -
Command palette