Add support for dragging panel tabs docked into other panel tab bars (#4006)
* Add support for dragging panel tabs docked into other panel tab bars * Fix terminology * Add Group suffix to PanelGroupId enums variants * Code review
This commit is contained in:
parent
19aaeb374b
commit
848ff5fd52
|
|
@ -2,6 +2,7 @@ use crate::messages::debug::utility_types::MessageLoggingVerbosity;
|
||||||
use crate::messages::defer::DeferMessageContext;
|
use crate::messages::defer::DeferMessageContext;
|
||||||
use crate::messages::dialog::DialogMessageContext;
|
use crate::messages::dialog::DialogMessageContext;
|
||||||
use crate::messages::layout::layout_message_handler::LayoutMessageContext;
|
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::preferences::preferences_message_handler::PreferencesMessageContext;
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed;
|
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;
|
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.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;
|
let layout = &self.message_handlers.portfolio_message_handler.workspace_panel_layout;
|
||||||
menu_bar_message_handler.layers_panel_open = self.message_handlers.portfolio_message_handler.layers_panel_open;
|
menu_bar_message_handler.data_panel_open = layout.is_panel_present(PanelType::Data);
|
||||||
menu_bar_message_handler.properties_panel_open = self.message_handlers.portfolio_message_handler.properties_panel_open;
|
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.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;
|
menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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::nodes::{LayerPanelEntry, LayerStructureEntry};
|
||||||
use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate};
|
use crate::messages::portfolio::document::utility_types::wires::{WirePath, WirePathUpdate};
|
||||||
|
use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
|
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
|
||||||
use graph_craft::document::NodeId;
|
use graph_craft::document::NodeId;
|
||||||
|
|
@ -194,14 +195,9 @@ pub enum FrontendMessage {
|
||||||
UpdateGraphViewOverlay {
|
UpdateGraphViewOverlay {
|
||||||
open: bool,
|
open: bool,
|
||||||
},
|
},
|
||||||
UpdateDataPanelState {
|
UpdateWorkspacePanelLayout {
|
||||||
open: bool,
|
#[serde(rename = "panelLayout")]
|
||||||
},
|
panel_layout: WorkspacePanelLayout,
|
||||||
UpdatePropertiesPanelState {
|
|
||||||
open: bool,
|
|
||||||
},
|
|
||||||
UpdateLayersPanelState {
|
|
||||||
open: bool,
|
|
||||||
},
|
},
|
||||||
UpdateLayout {
|
UpdateLayout {
|
||||||
#[serde(rename = "layoutTarget")]
|
#[serde(rename = "layoutTarget")]
|
||||||
|
|
|
||||||
|
|
@ -1021,8 +1021,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DocumentMessage::SetActivePanel { active_panel: panel } => {
|
DocumentMessage::SetActivePanel { active_panel } => {
|
||||||
match panel {
|
match active_panel {
|
||||||
PanelType::Document => {
|
PanelType::Document => {
|
||||||
if self.graph_view_overlay_open {
|
if self.graph_view_overlay_open {
|
||||||
self.selection_network_path.clone_from(&self.breadcrumb_network_path);
|
self.selection_network_path.clone_from(&self.breadcrumb_network_path);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
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::frontend::utility_types::{ExportBounds, FileType};
|
||||||
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
||||||
use crate::messages::portfolio::utility_types::FontCatalog;
|
use crate::messages::portfolio::utility_types::FontCatalog;
|
||||||
|
|
@ -61,6 +61,11 @@ pub enum PortfolioMessage {
|
||||||
LoadDocumentResources {
|
LoadDocumentResources {
|
||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
},
|
},
|
||||||
|
MovePanelTab {
|
||||||
|
source_group: PanelGroupId,
|
||||||
|
target_group: PanelGroupId,
|
||||||
|
insert_index: usize,
|
||||||
|
},
|
||||||
NewDocumentWithName {
|
NewDocumentWithName {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
|
@ -130,10 +135,16 @@ pub enum PortfolioMessage {
|
||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
new_index: usize,
|
new_index: usize,
|
||||||
},
|
},
|
||||||
|
ReorderPanelGroupTab {
|
||||||
|
group: PanelGroupId,
|
||||||
|
old_index: usize,
|
||||||
|
new_index: usize,
|
||||||
|
},
|
||||||
RequestWelcomeScreenButtonsLayout,
|
RequestWelcomeScreenButtonsLayout,
|
||||||
RequestStatusBarInfoLayout,
|
RequestStatusBarInfoLayout,
|
||||||
SetActivePanel {
|
SetPanelGroupActiveTab {
|
||||||
panel: PanelType,
|
group: PanelGroupId,
|
||||||
|
tab_index: usize,
|
||||||
},
|
},
|
||||||
SelectDocument {
|
SelectDocument {
|
||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
|
|
@ -161,4 +172,5 @@ pub enum PortfolioMessage {
|
||||||
ToggleRulers,
|
ToggleRulers,
|
||||||
UpdateDocumentWidgets,
|
UpdateDocumentWidgets,
|
||||||
UpdateOpenDocumentsList,
|
UpdateOpenDocumentsList,
|
||||||
|
UpdateWorkspacePanelLayout,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||||
use super::document::utility_types::network_interface;
|
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::application::{Editor, generate_uuid};
|
||||||
use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION};
|
use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION};
|
||||||
use crate::messages::animation::TimingInformation;
|
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::tool::utility_types::{HintData, ToolType};
|
||||||
use crate::messages::viewport::ToPhysical;
|
use crate::messages::viewport::ToPhysical;
|
||||||
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
|
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
|
||||||
use derivative::*;
|
|
||||||
use glam::{DAffine2, DVec2};
|
use glam::{DAffine2, DVec2};
|
||||||
use graph_craft::document::NodeId;
|
use graph_craft::document::NodeId;
|
||||||
use graphene_std::Color;
|
use graphene_std::Color;
|
||||||
|
|
@ -48,12 +47,10 @@ pub struct PortfolioMessageContext<'a> {
|
||||||
pub viewport: &'a ViewportMessageHandler,
|
pub viewport: &'a ViewportMessageHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Derivative, ExtractField)]
|
#[derive(Debug, Default, ExtractField)]
|
||||||
#[derivative(Default)]
|
|
||||||
pub struct PortfolioMessageHandler {
|
pub struct PortfolioMessageHandler {
|
||||||
pub documents: HashMap<DocumentId, DocumentMessageHandler>,
|
pub documents: HashMap<DocumentId, DocumentMessageHandler>,
|
||||||
document_ids: VecDeque<DocumentId>,
|
document_ids: VecDeque<DocumentId>,
|
||||||
active_panel: PanelType,
|
|
||||||
pub(crate) active_document_id: Option<DocumentId>,
|
pub(crate) active_document_id: Option<DocumentId>,
|
||||||
copy_buffer: [Vec<CopyBufferEntry>; INTERNAL_CLIPBOARD_COUNT as usize],
|
copy_buffer: [Vec<CopyBufferEntry>; INTERNAL_CLIPBOARD_COUNT as usize],
|
||||||
pub persistent_data: PersistentData,
|
pub persistent_data: PersistentData,
|
||||||
|
|
@ -61,11 +58,7 @@ pub struct PortfolioMessageHandler {
|
||||||
pub selection_mode: SelectionMode,
|
pub selection_mode: SelectionMode,
|
||||||
pub reset_node_definitions_on_open: bool,
|
pub reset_node_definitions_on_open: bool,
|
||||||
pub focus_document: bool,
|
pub focus_document: bool,
|
||||||
#[derivative(Default(value = "true"))]
|
pub workspace_panel_layout: WorkspacePanelLayout,
|
||||||
pub properties_panel_open: bool,
|
|
||||||
#[derivative(Default(value = "true"))]
|
|
||||||
pub layers_panel_open: bool,
|
|
||||||
pub data_panel_open: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message_handler_data]
|
#[message_handler_data]
|
||||||
|
|
@ -95,9 +88,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
current_tool,
|
current_tool,
|
||||||
preferences,
|
preferences,
|
||||||
viewport,
|
viewport,
|
||||||
data_panel_open: self.data_panel_open && !self.focus_document,
|
data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document,
|
||||||
layers_panel_open: self.layers_panel_open && !self.focus_document,
|
layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document,
|
||||||
properties_panel_open: self.properties_panel_open && !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)
|
document.process_message(message, responses, document_inputs)
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +115,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
// Display the menu bar at the top of the window
|
// Display the menu bar at the top of the window
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
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.
|
// Send the information for tooltips and categories for each node/input.
|
||||||
responses.add(FrontendMessage::SendUIMetadata {
|
responses.add(FrontendMessage::SendUIMetadata {
|
||||||
node_descriptions: document_node_definitions::collect_node_descriptions(),
|
node_descriptions: document_node_definitions::collect_node_descriptions(),
|
||||||
|
|
@ -159,9 +155,9 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
current_tool,
|
current_tool,
|
||||||
preferences,
|
preferences,
|
||||||
viewport,
|
viewport,
|
||||||
data_panel_open: self.data_panel_open && !self.focus_document,
|
data_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Data) && !self.focus_document,
|
||||||
layers_panel_open: self.layers_panel_open && !self.focus_document,
|
layers_panel_open: self.workspace_panel_layout.is_panel_visible(PanelType::Layers) && !self.focus_document,
|
||||||
properties_panel_open: self.properties_panel_open && !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)
|
document.process_message(message, responses, document_inputs)
|
||||||
}
|
}
|
||||||
|
|
@ -464,6 +460,48 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
self.load_document(new_document, document_id, responses, false);
|
self.load_document(new_document, document_id, responses, false);
|
||||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
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 => {
|
PortfolioMessage::NextDocument => {
|
||||||
if let Some(active_document_id) = self.active_document_id {
|
if let Some(active_document_id) = self.active_document_id {
|
||||||
let current_index = self.document_index(active_document_id);
|
let current_index = self.document_index(active_document_id);
|
||||||
|
|
@ -1071,6 +1109,25 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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 => {
|
PortfolioMessage::RequestWelcomeScreenButtonsLayout => {
|
||||||
let donate = "https://graphite.art/donate/";
|
let donate = "https://graphite.art/donate/";
|
||||||
|
|
||||||
|
|
@ -1131,9 +1188,26 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
layout_target: LayoutTarget::StatusBarInfo,
|
layout_target: LayoutTarget::StatusBarInfo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
PortfolioMessage::SetActivePanel { panel } => {
|
PortfolioMessage::SetPanelGroupActiveTab { group, tab_index } => {
|
||||||
self.active_panel = panel;
|
let group_state = self.workspace_panel_layout.panel_group(group);
|
||||||
responses.add(DocumentMessage::SetActivePanel { active_panel: self.active_panel });
|
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 } => {
|
PortfolioMessage::SelectDocument { document_id } => {
|
||||||
// Auto-save the document we are leaving
|
// Auto-save the document we are leaving
|
||||||
|
|
@ -1289,113 +1363,61 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
self.focus_document = !self.focus_document;
|
self.focus_document = !self.focus_document;
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
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.focus_document {
|
||||||
if self.properties_panel_open {
|
if properties_present {
|
||||||
responses.add(PropertiesPanelMessage::Clear);
|
Self::destroy_panel_layouts(PanelType::Properties, responses);
|
||||||
responses.add(FrontendMessage::UpdatePropertiesPanelState { open: false });
|
|
||||||
}
|
}
|
||||||
|
if layers_present {
|
||||||
if self.layers_panel_open {
|
Self::destroy_panel_layouts(PanelType::Layers, responses);
|
||||||
responses.add(DocumentMessage::ClearLayersPanel);
|
|
||||||
responses.add(FrontendMessage::UpdateLayersPanelState { open: false });
|
|
||||||
}
|
}
|
||||||
|
if data_present {
|
||||||
if self.data_panel_open {
|
Self::destroy_panel_layouts(PanelType::Data, responses);
|
||||||
responses.add(DataPanelMessage::ClearLayout);
|
|
||||||
responses.add(FrontendMessage::UpdateDataPanelState { open: false });
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// 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);
|
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.properties_panel_open {
|
if properties_present {
|
||||||
responses.add(PropertiesPanelMessage::Refresh);
|
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 {
|
responses.add(DeferMessage::AfterGraphRun {
|
||||||
messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()],
|
messages: vec![NodeGraphMessage::UpdateLayerPanel.into(), DocumentMessage::DocumentStructureChanged.into()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
}
|
}
|
||||||
PortfolioMessage::TogglePropertiesPanelOpen => {
|
PortfolioMessage::TogglePropertiesPanelOpen => {
|
||||||
if self.focus_document {
|
if self.focus_document {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.properties_panel_open = !self.properties_panel_open;
|
let panel_type = PanelType::Properties;
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
self.toggle_dockable_panel(panel_type, responses);
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
PortfolioMessage::ToggleLayersPanelOpen => {
|
PortfolioMessage::ToggleLayersPanelOpen => {
|
||||||
if self.focus_document {
|
if self.focus_document {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.layers_panel_open = !self.layers_panel_open;
|
let panel_type = PanelType::Layers;
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
self.toggle_dockable_panel(panel_type, responses);
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
PortfolioMessage::ToggleDataPanelOpen => {
|
PortfolioMessage::ToggleDataPanelOpen => {
|
||||||
if self.focus_document {
|
if self.focus_document {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.data_panel_open = !self.data_panel_open;
|
let panel_type = PanelType::Data;
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
self.toggle_dockable_panel(panel_type, responses);
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
PortfolioMessage::ToggleRulers => {
|
PortfolioMessage::ToggleRulers => {
|
||||||
if let Some(document) = self.active_document_mut() {
|
if let Some(document) = self.active_document_mut() {
|
||||||
|
|
@ -1410,6 +1432,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
document.update_document_widgets(responses, animation.is_playing(), timing_information.animation_time);
|
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 => {
|
PortfolioMessage::UpdateOpenDocumentsList => {
|
||||||
// Send the list of document tab names
|
// Send the list of document tab names
|
||||||
let open_documents = self
|
let open_documents = self
|
||||||
|
|
@ -1562,8 +1589,8 @@ impl PortfolioMessageHandler {
|
||||||
} else {
|
} else {
|
||||||
self.document_ids.push_back(document_id);
|
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_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.layers_panel_open && !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);
|
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.
|
/// 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<NodeId> {
|
pub fn node_to_inspect(&self) -> Option<NodeId> {
|
||||||
// Skip if the Data panel is not open
|
// 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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1624,4 +1651,85 @@ impl PortfolioMessageHandler {
|
||||||
|
|
||||||
selected_nodes.first().copied()
|
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<Message>) {
|
||||||
|
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<Message>) {
|
||||||
|
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<Message>) {
|
||||||
|
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 => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub enum PanelType {
|
||||||
#[default]
|
|
||||||
Document,
|
|
||||||
Welcome,
|
Welcome,
|
||||||
|
Document,
|
||||||
Layers,
|
Layers,
|
||||||
Properties,
|
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<String> for PanelType {
|
impl From<String> for PanelType {
|
||||||
fn from(value: String) -> Self {
|
fn from(value: String) -> Self {
|
||||||
match value.as_str() {
|
match value.as_str() {
|
||||||
"Document" => PanelType::Document,
|
|
||||||
"Welcome" => PanelType::Welcome,
|
"Welcome" => PanelType::Welcome,
|
||||||
|
"Document" => PanelType::Document,
|
||||||
"Layers" => PanelType::Layers,
|
"Layers" => PanelType::Layers,
|
||||||
"Properties" => PanelType::Properties,
|
"Properties" => PanelType::Properties,
|
||||||
"Data" => PanelType::DataPanel,
|
"Data" => PanelType::Data,
|
||||||
_ => panic!("Unknown panel type: {value}"),
|
_ => 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<String> 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<PanelType>,
|
||||||
|
#[serde(rename = "activeTabIndex")]
|
||||||
|
pub active_tab_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelGroupState {
|
||||||
|
pub fn active_panel_type(&self) -> Option<PanelType> {
|
||||||
|
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> {
|
||||||
|
[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 {
|
pub enum FileContent {
|
||||||
/// A Graphite document.
|
/// A Graphite document.
|
||||||
Document(String),
|
Document(String),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
.data-panel {
|
.data-panel {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
table {
|
table {
|
||||||
margin: -4px;
|
margin: -4px;
|
||||||
|
|
@ -42,7 +43,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
margin-top: 0;
|
margin-top: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:first-child:has(td:first-child label:empty) ~ tr td:first-child {
|
tr:first-child:has(td:first-child label:empty) ~ tr td:first-child {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,10 @@
|
||||||
.widget-section {
|
.widget-section {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
margin-top: 4px;
|
|
||||||
|
+ .widget-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@
|
||||||
import Welcome from "/src/components/panels/Welcome.svelte";
|
import Welcome from "/src/components/panels/Welcome.svelte";
|
||||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag";
|
||||||
|
import type { EditorWrapper, PanelType, PanelGroupId } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
type PanelType = keyof typeof PANEL_COMPONENTS;
|
|
||||||
|
|
||||||
const PANEL_COMPONENTS = {
|
const PANEL_COMPONENTS = {
|
||||||
Welcome,
|
Welcome,
|
||||||
|
|
@ -31,11 +30,13 @@
|
||||||
export let tabCloseButtons = false;
|
export let tabCloseButtons = false;
|
||||||
export let tabLabels: { name: string; unsaved?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: string }[];
|
export let tabLabels: { name: string; unsaved?: boolean; tooltipLabel?: string; tooltipDescription?: string; tooltipShortcut?: string }[];
|
||||||
export let tabActiveIndex: number;
|
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 clickAction: ((index: number) => void) | undefined = undefined;
|
||||||
export let closeAction: ((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 reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
|
||||||
export let emptySpaceAction: (() => void) | undefined = undefined;
|
export let emptySpaceAction: (() => void) | undefined = undefined;
|
||||||
|
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
||||||
|
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
@ -55,7 +56,7 @@
|
||||||
let tabGroupElement: LayoutRow | undefined = undefined;
|
let tabGroupElement: LayoutRow | undefined = undefined;
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
removeDragListeners();
|
endDrag();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onEmptySpaceAction(e: MouseEvent) {
|
function onEmptySpaceAction(e: MouseEvent) {
|
||||||
|
|
@ -77,7 +78,10 @@
|
||||||
// Activate the tab upon pointer down
|
// Activate the tab upon pointer down
|
||||||
clickAction?.(tabIndex);
|
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 };
|
dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY };
|
||||||
dragging = false;
|
dragging = false;
|
||||||
|
|
@ -95,30 +99,72 @@
|
||||||
const deltaX = Math.abs(e.clientX - dragStartState.pointerX);
|
const deltaX = Math.abs(e.clientX - dragStartState.pointerX);
|
||||||
const deltaY = Math.abs(e.clientY - dragStartState.pointerY);
|
const deltaY = Math.abs(e.clientY - dragStartState.pointerY);
|
||||||
if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return;
|
if (deltaX < DRAG_ACTIVATION_DISTANCE && deltaY < DRAG_ACTIVATION_DISTANCE) return;
|
||||||
|
|
||||||
dragging = true;
|
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;
|
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)) {
|
if (pointerIsInsideTabBar(e)) {
|
||||||
calculateInsertionIndex(lastPointerX);
|
calculateInsertionIndex(lastPointerX);
|
||||||
} else {
|
updateCrossPanelHover(undefined, undefined, undefined);
|
||||||
insertionIndex = undefined;
|
return;
|
||||||
insertionMarkerLeft = undefined;
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function dragPointerUp() {
|
||||||
if (dragging && dragStartState && insertionIndex !== undefined) {
|
if (dragging && dragStartState) {
|
||||||
const oldIndex = dragStartState.tabIndex;
|
const crossPanelState = $panelDrag;
|
||||||
|
|
||||||
// Adjust for the fact that removing the dragged tab shifts indices
|
// Cross-panel drop: the pointer is over a different panel's tab bar
|
||||||
let newIndex = insertionIndex;
|
if (
|
||||||
if (newIndex > oldIndex) newIndex -= 1;
|
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;
|
||||||
|
|
||||||
if (oldIndex !== newIndex) {
|
// Adjust for the fact that removing the dragged tab shifts indices
|
||||||
reorderAction?.(oldIndex, newIndex);
|
let newIndex = insertionIndex;
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
reorderAction?.(oldIndex, newIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +187,7 @@
|
||||||
dragging = false;
|
dragging = false;
|
||||||
insertionIndex = undefined;
|
insertionIndex = undefined;
|
||||||
insertionMarkerLeft = undefined;
|
insertionMarkerLeft = undefined;
|
||||||
|
if (crossPanelDropAction) endCrossPanelDrag();
|
||||||
removeDragListeners();
|
removeDragListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +199,30 @@
|
||||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
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) {
|
function calculateInsertionIndex(pointerX: number) {
|
||||||
const groupDiv = tabGroupElement?.div?.();
|
const groupDiv = tabGroupElement?.div?.();
|
||||||
if (!dragStartState || !groupDiv) return;
|
if (!dragStartState || !groupDiv) return;
|
||||||
|
|
@ -199,13 +270,21 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutCol on:pointerdown={() => panelType && editor.setActivePanel(panelType)} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
<LayoutCol on:pointerdown={() => panelTypes[tabActiveIndex] && editor.setActivePanel(panelTypes[tabActiveIndex])} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
||||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||||
<LayoutRow class="tab-group" scrollableX={true} on:click={onEmptySpaceAction} on:auxclick={onEmptySpaceAction} bind:this={tabGroupElement}>
|
<LayoutRow
|
||||||
|
class="tab-group"
|
||||||
|
scrollableX={true}
|
||||||
|
data-panel-tab-bar={crossPanelDropAction ? panelId : undefined}
|
||||||
|
on:click={onEmptySpaceAction}
|
||||||
|
on:auxclick={onEmptySpaceAction}
|
||||||
|
bind:this={tabGroupElement}
|
||||||
|
>
|
||||||
{#each tabLabels as tabLabel, tabIndex}
|
{#each tabLabels as tabLabel, tabIndex}
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
class="tab"
|
class="tab"
|
||||||
classes={{ active: tabIndex === tabActiveIndex }}
|
classes={{ active: tabIndex === tabActiveIndex }}
|
||||||
|
data-tab
|
||||||
tooltipLabel={tabLabel.tooltipLabel}
|
tooltipLabel={tabLabel.tooltipLabel}
|
||||||
tooltipDescription={tabLabel.tooltipDescription}
|
tooltipDescription={tabLabel.tooltipDescription}
|
||||||
on:pointerdown={(e) => tabPointerDown(e, tabIndex)}
|
on:pointerdown={(e) => tabPointerDown(e, tabIndex)}
|
||||||
|
|
@ -242,10 +321,13 @@
|
||||||
{#if dragging && insertionMarkerLeft !== undefined}
|
{#if dragging && insertionMarkerLeft !== undefined}
|
||||||
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
|
<div class="tab-insertion-mark" style:left={`${insertionMarkerLeft}px`}></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !dragging && crossPanelDropAction && $panelDrag.active && $panelDrag.hoverTargetPanelId === panelId && $panelDrag.hoverInsertionMarkerLeft !== undefined}
|
||||||
|
<div class="tab-insertion-mark" style:left={`${$panelDrag.hoverInsertionMarkerLeft}px`}></div>
|
||||||
|
{/if}
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
<LayoutCol class="panel-body">
|
<LayoutCol class="panel-body">
|
||||||
{#if panelType}
|
{#if panelTypes[tabActiveIndex]}
|
||||||
<svelte:component this={PANEL_COMPONENTS[panelType]} />
|
<svelte:component this={PANEL_COMPONENTS[panelTypes[tabActiveIndex]]} />
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@
|
||||||
let pointerCaptureId: number | undefined = undefined;
|
let pointerCaptureId: number | undefined = undefined;
|
||||||
let activeResizeCleanup: (() => void) | undefined = undefined;
|
let activeResizeCleanup: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
onDestroy(() => {
|
// Reactive panel layout derived from backend state
|
||||||
activeResizeCleanup?.();
|
$: panelLayout = $portfolio.panelLayout;
|
||||||
});
|
$: propertiesGroup = panelLayout.propertiesGroup;
|
||||||
|
$: layersGroup = panelLayout.layersGroup;
|
||||||
|
$: dataGroup = panelLayout.dataGroup;
|
||||||
|
|
||||||
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
|
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
|
||||||
|
|
||||||
|
|
@ -41,6 +43,10 @@
|
||||||
const editor = getContext<EditorWrapper>("editor");
|
const editor = getContext<EditorWrapper>("editor");
|
||||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||||
|
|
||||||
|
function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
|
||||||
|
editor.movePanelTab(sourcePanelId, targetPanelId, insertIndex);
|
||||||
|
}
|
||||||
|
|
||||||
function isPanelName(name: string): name is keyof typeof PANEL_SIZES {
|
function isPanelName(name: string): name is keyof typeof PANEL_SIZES {
|
||||||
return name in PANEL_SIZES;
|
return name in PANEL_SIZES;
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +161,10 @@
|
||||||
addListeners();
|
addListeners();
|
||||||
activeResizeCleanup = removeListeners;
|
activeResizeCleanup = removeListeners;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
activeResizeCleanup?.();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutRow class="workspace" data-workspace>
|
<LayoutRow class="workspace" data-workspace>
|
||||||
|
|
@ -163,7 +173,8 @@
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
||||||
<Panel
|
<Panel
|
||||||
class="document-panel"
|
class="document-panel"
|
||||||
panelType={$portfolio.documents.length > 0 ? "Document" : "Welcome"}
|
panelId="DocumentGroup"
|
||||||
|
panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]}
|
||||||
tabCloseButtons={true}
|
tabCloseButtons={true}
|
||||||
tabMinWidths={true}
|
tabMinWidths={true}
|
||||||
tabLabels={documentTabLabels}
|
tabLabels={documentTabLabels}
|
||||||
|
|
@ -175,27 +186,51 @@
|
||||||
bind:this={documentPanel}
|
bind:this={documentPanel}
|
||||||
/>
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{#if $portfolio.dataPanelOpen}
|
{#if dataGroup.tabs.length > 0}
|
||||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["data"] }} data-subdivision-name="data">
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["data"] }} data-subdivision-name="data">
|
||||||
<Panel panelType="Data" tabLabels={[{ name: "Data" }]} tabActiveIndex={0} />
|
<Panel
|
||||||
|
panelId="DataGroup"
|
||||||
|
panelTypes={dataGroup.tabs}
|
||||||
|
tabLabels={dataGroup.tabs.map((name) => ({ name }))}
|
||||||
|
tabActiveIndex={dataGroup.activeTabIndex}
|
||||||
|
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("DataGroup", tabIndex)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("DataGroup", oldIndex, newIndex)}
|
||||||
|
crossPanelDropAction={crossPanelDrop}
|
||||||
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
{#if $portfolio.propertiesPanelOpen || $portfolio.layersPanelOpen}
|
{#if propertiesGroup.tabs.length > 0 || layersGroup.tabs.length > 0}
|
||||||
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
||||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
||||||
{#if $portfolio.propertiesPanelOpen}
|
{#if propertiesGroup.tabs.length > 0}
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
||||||
<Panel panelType="Properties" tabLabels={[{ name: "Properties" }]} tabActiveIndex={0} />
|
<Panel
|
||||||
|
panelId="PropertiesGroup"
|
||||||
|
panelTypes={propertiesGroup.tabs}
|
||||||
|
tabLabels={propertiesGroup.tabs.map((name) => ({ name }))}
|
||||||
|
tabActiveIndex={propertiesGroup.activeTabIndex}
|
||||||
|
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("PropertiesGroup", tabIndex)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("PropertiesGroup", oldIndex, newIndex)}
|
||||||
|
crossPanelDropAction={crossPanelDrop}
|
||||||
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $portfolio.propertiesPanelOpen && $portfolio.layersPanelOpen}
|
{#if propertiesGroup.tabs.length > 0 && layersGroup.tabs.length > 0}
|
||||||
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e)} on:dblclick={(e) => resetPanelSizes(e)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $portfolio.layersPanelOpen}
|
{#if layersGroup.tabs.length > 0}
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
||||||
<Panel panelType="Layers" tabLabels={[{ name: "Layers" }]} tabActiveIndex={0} />
|
<Panel
|
||||||
|
panelId="LayersGroup"
|
||||||
|
panelTypes={layersGroup.tabs}
|
||||||
|
tabLabels={layersGroup.tabs.map((name) => ({ name }))}
|
||||||
|
tabActiveIndex={layersGroup.activeTabIndex}
|
||||||
|
clickAction={(tabIndex) => editor.setPanelGroupActiveTab("LayersGroup", tabIndex)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab("LayersGroup", oldIndex, newIndex)}
|
||||||
|
crossPanelDropAction={crossPanelDrop}
|
||||||
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
|
|
|
||||||
|
|
@ -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<PanelDragState> = import.meta.hot?.data?.store || writable<PanelDragState>(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,25 +4,36 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||||
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
||||||
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
|
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
|
||||||
import { rasterizeSVG } from "/src/utility-functions/rasterization";
|
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<typeof createPortfolioStore>;
|
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
|
||||||
|
|
||||||
|
export type PanelGroupState = {
|
||||||
|
tabs: PanelType[];
|
||||||
|
activeTabIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspacePanelLayout = {
|
||||||
|
propertiesGroup: PanelGroupState;
|
||||||
|
layersGroup: PanelGroupState;
|
||||||
|
dataGroup: PanelGroupState;
|
||||||
|
};
|
||||||
|
|
||||||
type PortfolioStoreState = {
|
type PortfolioStoreState = {
|
||||||
unsaved: boolean;
|
unsaved: boolean;
|
||||||
documents: OpenDocument[];
|
documents: OpenDocument[];
|
||||||
activeDocumentIndex: number;
|
activeDocumentIndex: number;
|
||||||
dataPanelOpen: boolean;
|
panelLayout: WorkspacePanelLayout;
|
||||||
propertiesPanelOpen: boolean;
|
|
||||||
layersPanelOpen: boolean;
|
|
||||||
};
|
};
|
||||||
const initialState: PortfolioStoreState = {
|
const initialState: PortfolioStoreState = {
|
||||||
unsaved: false,
|
unsaved: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
activeDocumentIndex: 0,
|
activeDocumentIndex: 0,
|
||||||
dataPanelOpen: false,
|
panelLayout: {
|
||||||
propertiesPanelOpen: true,
|
propertiesGroup: { tabs: ["Properties"], activeTabIndex: 0 },
|
||||||
layersPanelOpen: true,
|
layersGroup: { tabs: ["Layers"], activeTabIndex: 0 },
|
||||||
|
dataGroup: { tabs: [], activeTabIndex: 0 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||||
|
|
@ -103,23 +114,15 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("UpdateDataPanelState", async (data) => {
|
subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => {
|
||||||
update((state) => {
|
// Coerce activeTabIndex from BigInt (produced by serde_wasm_bindgen for usize) to number
|
||||||
state.dataPanelOpen = data.open;
|
const layout = data.panelLayout;
|
||||||
return state;
|
layout.propertiesGroup.activeTabIndex = Number(layout.propertiesGroup.activeTabIndex);
|
||||||
});
|
layout.layersGroup.activeTabIndex = Number(layout.layersGroup.activeTabIndex);
|
||||||
});
|
layout.dataGroup.activeTabIndex = Number(layout.dataGroup.activeTabIndex);
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("UpdatePropertiesPanelState", async (data) => {
|
|
||||||
update((state) => {
|
update((state) => {
|
||||||
state.propertiesPanelOpen = data.open;
|
state.panelLayout = layout;
|
||||||
return state;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("UpdateLayersPanelState", async (data) => {
|
|
||||||
update((state) => {
|
|
||||||
state.layersPanelOpen = data.open;
|
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -139,7 +142,5 @@ export function destroyPortfolioStore() {
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
|
subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
||||||
subscriptions.unsubscribeFrontendMessage("UpdateDataPanelState");
|
subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout");
|
||||||
subscriptions.unsubscribeFrontendMessage("UpdatePropertiesPanelState");
|
|
||||||
subscriptions.unsubscribeFrontendMessage("UpdateLayersPanelState");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,29 @@ impl EditorWrapper {
|
||||||
self.dispatch(message);
|
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)]
|
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
||||||
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
||||||
let document_id = DocumentId(document_id);
|
let document_id = DocumentId(document_id);
|
||||||
|
|
@ -874,7 +897,7 @@ impl EditorWrapper {
|
||||||
/// Set the active panel to the most recently clicked panel
|
/// Set the active panel to the most recently clicked panel
|
||||||
#[wasm_bindgen(js_name = setActivePanel)]
|
#[wasm_bindgen(js_name = setActivePanel)]
|
||||||
pub fn set_active_panel(&self, panel: String) {
|
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);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,10 @@ Marrying vector and raster under one roof enables both art forms to complement e
|
||||||
<img class="atlas" style="--atlas-index: 73" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 73" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Blend tool to morph between shapes</span>
|
<span>Blend tool to morph between shapes</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-icon ongoing" title="Development Ongoing">
|
||||||
|
<img class="atlas" style="--atlas-index: 24" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
|
<span>Dockable and multi-window panels</span>
|
||||||
|
</div>
|
||||||
<div class="feature-icon ongoing" title="Development Ongoing">
|
<div class="feature-icon ongoing" title="Development Ongoing">
|
||||||
<img class="atlas" style="--atlas-index: 17" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 17" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Stable document format</span>
|
<span>Stable document format</span>
|
||||||
|
|
@ -250,10 +254,6 @@ Marrying vector and raster under one roof enables both art forms to complement e
|
||||||
<div class="feature-icon heading" data-year="2027">
|
<div class="feature-icon heading" data-year="2027">
|
||||||
<h3>— Beta 2 —</h3>
|
<h3>— Beta 2 —</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-icon">
|
|
||||||
<img class="atlas" style="--atlas-index: 24" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
|
||||||
<span>Dockable and multi-window panels</span>
|
|
||||||
</div>
|
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<img class="atlas" style="--atlas-index: 52" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 52" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Command palette</span>
|
<span>Command palette</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue