Refactor panel layouts to generalize recursive panel group subdivision splits (#4014)
* Generalize recursive panel group splits * Code review
This commit is contained in:
parent
0eb440db14
commit
39656d4c73
|
|
@ -1,3 +1,4 @@
|
||||||
|
use graphite_desktop_wrapper::DOUBLE_CLICK_MILLISECONDS;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub(crate) const RESOURCE_SCHEME: &str = "resources";
|
pub(crate) const RESOURCE_SCHEME: &str = "resources";
|
||||||
|
|
@ -18,5 +19,5 @@ pub(crate) const SCROLL_SPEED_Y: f32 = 1.0;
|
||||||
|
|
||||||
pub(crate) const PINCH_ZOOM_SPEED: f64 = 300.0;
|
pub(crate) const PINCH_ZOOM_SPEED: f64 = 300.0;
|
||||||
|
|
||||||
pub(crate) const MULTICLICK_TIMEOUT: Duration = Duration::from_millis(500);
|
pub(crate) const MULTICLICK_TIMEOUT: Duration = Duration::from_millis(DOUBLE_CLICK_MILLISECONDS);
|
||||||
pub(crate) const MULTICLICK_ALLOWED_TRAVEL: usize = 4;
|
pub(crate) const MULTICLICK_ALLOWED_TRAVEL: usize = 4;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use graphite_editor::messages::prelude::{FrontendMessage, Message};
|
||||||
use message_dispatcher::DesktopWrapperMessageDispatcher;
|
use message_dispatcher::DesktopWrapperMessageDispatcher;
|
||||||
use messages::{DesktopFrontendMessage, DesktopWrapperMessage};
|
use messages::{DesktopFrontendMessage, DesktopWrapperMessage};
|
||||||
|
|
||||||
pub use graphite_editor::consts::FILE_EXTENSION;
|
pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION};
|
||||||
pub use wgpu_executor::TargetTexture;
|
pub use wgpu_executor::TargetTexture;
|
||||||
pub use wgpu_executor::WgpuContext;
|
pub use wgpu_executor::WgpuContext;
|
||||||
pub use wgpu_executor::WgpuContextBuilder;
|
pub use wgpu_executor::WgpuContextBuilder;
|
||||||
|
|
|
||||||
|
|
@ -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::{PanelGroupId, PanelType, PersistentData, WorkspacePanelLayout};
|
use super::utility_types::{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;
|
||||||
|
|
@ -469,26 +469,37 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_state = self.workspace_panel_layout.panel_group(source_group);
|
let Some(source_state) = self.workspace_panel_layout.panel_group(source_group) else { return };
|
||||||
let Some(panel_type) = source_state.active_panel_type() else { return };
|
let Some(panel_type) = source_state.active_panel_type() else { return };
|
||||||
|
|
||||||
|
// Validate that the target group exists before modifying the source
|
||||||
|
if self.workspace_panel_layout.panel_group(target_group).is_none() {
|
||||||
|
log::error!("Target panel group {target_group:?} not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Destroy layouts for the moved panel (so backend and frontend start in sync when it remounts)
|
// 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)
|
// 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);
|
Self::destroy_panel_layouts(panel_type, responses);
|
||||||
if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).active_panel_type() {
|
if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
|
||||||
Self::destroy_panel_layouts(old_target_panel, responses);
|
Self::destroy_panel_layouts(old_target_panel, responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from source panel group
|
// Remove from source panel group
|
||||||
let source = self.workspace_panel_layout.panel_group_mut(source_group);
|
if let Some(source) = self.workspace_panel_layout.panel_group_mut(source_group) {
|
||||||
source.tabs.retain(|&t| t != panel_type);
|
source.tabs.retain(|&t| t != panel_type);
|
||||||
source.active_tab_index = source.active_tab_index.min(source.tabs.len().saturating_sub(1));
|
source.active_tab_index = source.active_tab_index.min(source.tabs.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
|
||||||
// Insert into target panel group
|
// Insert into target panel group
|
||||||
let target = self.workspace_panel_layout.panel_group_mut(target_group);
|
if let Some(target) = self.workspace_panel_layout.panel_group_mut(target_group) {
|
||||||
let index = insert_index.min(target.tabs.len());
|
let index = insert_index.min(target.tabs.len());
|
||||||
target.tabs.insert(index, panel_type);
|
target.tabs.insert(index, panel_type);
|
||||||
target.active_tab_index = index;
|
target.active_tab_index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty panel groups from the tree
|
||||||
|
self.workspace_panel_layout.prune();
|
||||||
|
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
responses.add(MenuBarMessage::SendLayout);
|
||||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
|
|
@ -497,7 +508,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
self.refresh_panel_content(panel_type, responses);
|
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
|
// 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() {
|
if let Some(new_source_active) = self.workspace_panel_layout.panel_group(source_group).and_then(|g| g.active_panel_type()) {
|
||||||
Self::destroy_panel_layouts(new_source_active, responses);
|
Self::destroy_panel_layouts(new_source_active, responses);
|
||||||
self.refresh_panel_content(new_source_active, responses);
|
self.refresh_panel_content(new_source_active, responses);
|
||||||
}
|
}
|
||||||
|
|
@ -1110,7 +1121,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index } => {
|
PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index } => {
|
||||||
let group_state = self.workspace_panel_layout.panel_group_mut(group);
|
let Some(group_state) = self.workspace_panel_layout.panel_group_mut(group) else { return };
|
||||||
|
|
||||||
if old_index < group_state.tabs.len() && new_index < group_state.tabs.len() && old_index != new_index {
|
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);
|
let tab = group_state.tabs.remove(old_index);
|
||||||
|
|
@ -1189,7 +1200,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
PortfolioMessage::SetPanelGroupActiveTab { group, tab_index } => {
|
PortfolioMessage::SetPanelGroupActiveTab { group, tab_index } => {
|
||||||
let group_state = self.workspace_panel_layout.panel_group(group);
|
let Some(group_state) = self.workspace_panel_layout.panel_group(group) else { return };
|
||||||
if tab_index < group_state.tabs.len() && tab_index != group_state.active_tab_index {
|
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
|
// 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() {
|
if let Some(old_panel_type) = group_state.active_panel_type() {
|
||||||
|
|
@ -1199,12 +1210,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
Self::destroy_panel_layouts(new_panel_type, responses);
|
Self::destroy_panel_layouts(new_panel_type, responses);
|
||||||
|
|
||||||
// Update the active tab index for the panel
|
// Update the active tab index for the panel
|
||||||
self.workspace_panel_layout.panel_group_mut(group).active_tab_index = tab_index;
|
if let Some(group_state) = self.workspace_panel_layout.panel_group_mut(group) {
|
||||||
|
group_state.active_tab_index = tab_index;
|
||||||
|
}
|
||||||
|
|
||||||
// Send the layout update first so the frontend mounts the new panel component before it receives content
|
// Send the layout update first so the frontend mounts the new panel component before it receives content
|
||||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
|
|
||||||
if let Some(panel_type) = self.workspace_panel_layout.panel_group(group).active_panel_type() {
|
if let Some(panel_type) = self.workspace_panel_layout.panel_group(group).and_then(|g| g.active_panel_type()) {
|
||||||
self.refresh_panel_content(panel_type, responses);
|
self.refresh_panel_content(panel_type, responses);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1433,6 +1446,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PortfolioMessage::UpdateWorkspacePanelLayout => {
|
PortfolioMessage::UpdateWorkspacePanelLayout => {
|
||||||
|
self.workspace_panel_layout.recalculate_default_sizes();
|
||||||
|
|
||||||
responses.add(FrontendMessage::UpdateWorkspacePanelLayout {
|
responses.add(FrontendMessage::UpdateWorkspacePanelLayout {
|
||||||
panel_layout: self.workspace_panel_layout.clone(),
|
panel_layout: self.workspace_panel_layout.clone(),
|
||||||
});
|
});
|
||||||
|
|
@ -1652,34 +1667,37 @@ impl PortfolioMessageHandler {
|
||||||
selected_nodes.first().copied()
|
selected_nodes.first().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a dockable panel type from whichever panel group currently contains it.
|
/// Remove a dockable panel type from whichever panel group currently contains it, then prune empty groups.
|
||||||
fn remove_panel_from_layout(&mut self, panel_type: PanelType) {
|
fn remove_panel_from_layout(&mut self, panel_type: PanelType) {
|
||||||
for group_id in [PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup] {
|
// Save the panel's current position so it can be restored there later
|
||||||
let group = self.workspace_panel_layout.panel_group_mut(group_id);
|
self.workspace_panel_layout.save_panel_position(panel_type);
|
||||||
if let Some(index) = group.tabs.iter().position(|&t| t == panel_type) {
|
|
||||||
group.tabs.remove(index);
|
if let Some(group_id) = self.workspace_panel_layout.find_panel(panel_type)
|
||||||
|
&& let Some(group) = self.workspace_panel_layout.panel_group_mut(group_id)
|
||||||
|
{
|
||||||
|
group.tabs.retain(|&t| t != panel_type);
|
||||||
group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1));
|
group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.workspace_panel_layout.prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a dockable panel on or off. When toggling off, refresh the newly active tab in its panel group (if any).
|
/// 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>) {
|
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) {
|
if let Some(group_id) = self.workspace_panel_layout.find_panel(panel_type) {
|
||||||
// Panel is present, remove it
|
// Panel is present, remove it
|
||||||
let was_visible = self.workspace_panel_layout.panel_group(group_id).is_visible(panel_type);
|
let was_visible = self.workspace_panel_layout.panel_group(group_id).is_some_and(|g| g.is_visible(panel_type));
|
||||||
Self::destroy_panel_layouts(panel_type, responses);
|
Self::destroy_panel_layouts(panel_type, responses);
|
||||||
self.remove_panel_from_layout(panel_type);
|
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 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() {
|
if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
|
||||||
Self::destroy_panel_layouts(new_active, responses);
|
Self::destroy_panel_layouts(new_active, responses);
|
||||||
self.refresh_panel_content(new_active, responses);
|
self.refresh_panel_content(new_active, responses);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Panel is not present, add it to its default panel group
|
// Panel is not present, restore it to its default position in the layout tree
|
||||||
self.add_panel_to_its_default_group(panel_type);
|
self.workspace_panel_layout.restore_panel(panel_type);
|
||||||
self.refresh_panel_content(panel_type, responses);
|
self.refresh_panel_content(panel_type, responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1687,15 +1705,6 @@ impl PortfolioMessageHandler {
|
||||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
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.
|
/// 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.
|
/// 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>) {
|
fn destroy_panel_layouts(panel_type: PanelType, responses: &mut VecDeque<Message>) {
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ impl FontCatalogStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
#[derive(PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum PanelType {
|
pub enum PanelType {
|
||||||
Welcome,
|
Welcome,
|
||||||
Document,
|
Document,
|
||||||
|
|
@ -93,19 +93,6 @@ pub enum PanelType {
|
||||||
Data,
|
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() {
|
||||||
|
|
@ -119,29 +106,14 @@ impl From<String> for PanelType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies a panel group in the workspace that can hold tabbed panels.
|
/// Unique identifier for a panel group (a leaf subdivision in the layout tree that holds tabs).
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
#[repr(transparent)]
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
||||||
pub enum PanelGroupId {
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
DocumentGroup,
|
pub struct PanelGroupId(pub u64);
|
||||||
PropertiesGroup,
|
|
||||||
LayersGroup,
|
|
||||||
DataGroup,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for PanelGroupId {
|
/// State of a single panel group (leaf subdivision) in the workspace layout tree.
|
||||||
fn from(value: String) -> Self {
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
||||||
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)]
|
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PanelGroupState {
|
pub struct PanelGroupState {
|
||||||
pub tabs: Vec<PanelType>,
|
pub tabs: Vec<PanelType>,
|
||||||
|
|
@ -163,68 +135,352 @@ impl PanelGroupState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The complete workspace panel layout describing which dockable panels are in which panel groups.
|
/// A subdivision in the workspace layout tree. The root is always a row (horizontal).
|
||||||
|
/// Direction alternates at each depth: row, column, row, column, etc.
|
||||||
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct WorkspacePanelLayout {
|
pub enum PanelLayoutSubdivision {
|
||||||
#[serde(rename = "propertiesGroup")]
|
/// A leaf subdivision: a panel group with tabbed panels.
|
||||||
pub properties_group: PanelGroupState,
|
PanelGroup { id: PanelGroupId, state: PanelGroupState },
|
||||||
#[serde(rename = "layersGroup")]
|
/// A container subdivision that splits its space among children. Direction is implicit from depth (even = row, odd = column).
|
||||||
pub layers_group: PanelGroupState,
|
Split { children: Vec<SplitChild> },
|
||||||
#[serde(rename = "dataGroup")]
|
|
||||||
pub data_group: PanelGroupState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WorkspacePanelLayout {
|
/// A child within a split container, with a proportional size weight.
|
||||||
fn default() -> Self {
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
Self {
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
properties_group: PanelGroupState {
|
pub struct SplitChild {
|
||||||
tabs: vec![PanelType::Properties],
|
pub subdivision: PanelLayoutSubdivision,
|
||||||
active_tab_index: 0,
|
/// Flex-grow weight for proportional sizing.
|
||||||
},
|
pub size: f64,
|
||||||
layers_group: PanelGroupState {
|
|
||||||
tabs: vec![PanelType::Layers],
|
|
||||||
active_tab_index: 0,
|
|
||||||
},
|
|
||||||
data_group: PanelGroupState { tabs: vec![], active_tab_index: 0 },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The complete workspace panel layout as a tree of nested rows and columns.
|
||||||
|
/// The root subdivision is always a row (horizontal split). Direction alternates at each depth.
|
||||||
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct WorkspacePanelLayout {
|
||||||
|
pub root: PanelLayoutSubdivision,
|
||||||
|
/// Counter for generating unique panel group IDs.
|
||||||
|
#[serde(rename = "nextGroupId")]
|
||||||
|
next_group_id: PanelGroupId,
|
||||||
|
/// Remembers where a panel was before being removed (panel type, group ID, and tab index), so it can be restored there.
|
||||||
|
#[serde(default, rename = "savedPositions")]
|
||||||
|
saved_positions: Vec<(PanelType, PanelGroupId, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspacePanelLayout {
|
impl WorkspacePanelLayout {
|
||||||
pub fn panel_group(&self, panel_group_id: PanelGroupId) -> &PanelGroupState {
|
/// Generate a new unique panel group ID.
|
||||||
match panel_group_id {
|
pub fn next_id(&mut self) -> PanelGroupId {
|
||||||
PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"),
|
let id = self.next_group_id;
|
||||||
PanelGroupId::PropertiesGroup => &self.properties_group,
|
self.next_group_id.0 += 1;
|
||||||
PanelGroupId::LayersGroup => &self.layers_group,
|
id
|
||||||
PanelGroupId::DataGroup => &self.data_group,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn panel_group_mut(&mut self, panel_group_id: PanelGroupId) -> &mut PanelGroupState {
|
/// Find the panel group state for a given ID.
|
||||||
match panel_group_id {
|
pub fn panel_group(&self, id: PanelGroupId) -> Option<&PanelGroupState> {
|
||||||
PanelGroupId::DocumentGroup => panic!("PanelGroupId::{panel_group_id:?} is not a dockable panel group"),
|
self.root.find_group(id)
|
||||||
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.
|
/// Find the panel group state for a given ID (mutable).
|
||||||
|
pub fn panel_group_mut(&mut self, id: PanelGroupId) -> Option<&mut PanelGroupState> {
|
||||||
|
self.root.find_group_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find which panel group contains a given panel type, returning its ID.
|
||||||
pub fn find_panel(&self, panel_type: PanelType) -> Option<PanelGroupId> {
|
pub fn find_panel(&self, panel_type: PanelType) -> Option<PanelGroupId> {
|
||||||
[PanelGroupId::PropertiesGroup, PanelGroupId::LayersGroup, PanelGroupId::DataGroup]
|
self.root.find_panel(panel_type)
|
||||||
.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.
|
/// Check if a panel type is the active (visible) tab in any panel group.
|
||||||
pub fn is_panel_visible(&self, panel_type: PanelType) -> bool {
|
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))
|
self.find_panel(panel_type).and_then(|id| self.panel_group(id)).is_some_and(|group| group.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.
|
/// 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 {
|
pub fn is_panel_present(&self, panel_type: PanelType) -> bool {
|
||||||
self.find_panel(panel_type).is_some()
|
self.find_panel(panel_type).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove empty panel groups and collapse unnecessary single-child splits.
|
||||||
|
pub fn prune(&mut self) {
|
||||||
|
self.root.prune();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalculate the default sizes for all splits in the tree based on document panel proximity.
|
||||||
|
pub fn recalculate_default_sizes(&mut self) {
|
||||||
|
self.root.recalculate_default_sizes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember which panel group and tab index a panel was in before removal, so it can be restored there later.
|
||||||
|
pub fn save_panel_position(&mut self, panel_type: PanelType) {
|
||||||
|
if let Some(group_id) = self.find_panel(panel_type) {
|
||||||
|
let tab_index = self.panel_group(group_id).and_then(|g| g.tabs.iter().position(|&t| t == panel_type)).unwrap_or(0);
|
||||||
|
|
||||||
|
// Replace any existing saved position for this panel type
|
||||||
|
self.saved_positions.retain(|(pt, _, _)| *pt != panel_type);
|
||||||
|
self.saved_positions.push((panel_type, group_id, tab_index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a panel to its previous position if available, otherwise to its default position.
|
||||||
|
/// If the panel was previously in a group that still exists, it's added back as a tab at its original index.
|
||||||
|
/// Otherwise, it's placed at its default structural position in the tree.
|
||||||
|
pub fn restore_panel(&mut self, panel_type: PanelType) {
|
||||||
|
// Try to restore to the previously saved group and tab position
|
||||||
|
let saved = self.saved_positions.iter().find(|(pt, _, _)| *pt == panel_type).copied();
|
||||||
|
if let Some((_, saved_group_id, saved_tab_index)) = saved
|
||||||
|
&& let Some(group) = self.panel_group_mut(saved_group_id)
|
||||||
|
{
|
||||||
|
let insert_index = saved_tab_index.min(group.tabs.len());
|
||||||
|
group.tabs.insert(insert_index, panel_type);
|
||||||
|
group.active_tab_index = insert_index;
|
||||||
|
self.saved_positions.retain(|(pt, _, _)| *pt != panel_type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.saved_positions.retain(|(pt, _, _)| *pt != panel_type);
|
||||||
|
|
||||||
|
self.restore_panel_to_default_position(panel_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a panel at its default structural position in the layout tree.
|
||||||
|
/// - Data: below the document in the left column (root child 0)
|
||||||
|
/// - Properties: top of the right column (root child 1)
|
||||||
|
/// - Layers: bottom of the right column (root child 1)
|
||||||
|
fn restore_panel_to_default_position(&mut self, panel_type: PanelType) {
|
||||||
|
let new_id = self.next_id();
|
||||||
|
let new_group = SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||||
|
id: new_id,
|
||||||
|
state: PanelGroupState {
|
||||||
|
tabs: vec![panel_type],
|
||||||
|
active_tab_index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: match panel_type {
|
||||||
|
PanelType::Data => 30.,
|
||||||
|
PanelType::Properties => 45.,
|
||||||
|
PanelType::Layers => 55.,
|
||||||
|
_ => 50.,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which root child column to insert into and at which position
|
||||||
|
let (root_child_index, insert_at_end) = match panel_type {
|
||||||
|
PanelType::Data => (0, true), // Left column, after document
|
||||||
|
PanelType::Properties => (1, false), // Right column, at top
|
||||||
|
PanelType::Layers => (1, true), // Right column, at bottom
|
||||||
|
_ => (1, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure the root is a split
|
||||||
|
if !matches!(&self.root, PanelLayoutSubdivision::Split { .. }) {
|
||||||
|
let old_root = std::mem::replace(&mut self.root, PanelLayoutSubdivision::Split { children: vec![] });
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = &mut self.root {
|
||||||
|
children.push(SplitChild { subdivision: old_root, size: 80. });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let PanelLayoutSubdivision::Split { children: root_children } = &mut self.root else { return };
|
||||||
|
|
||||||
|
// Ensure the target root child exists
|
||||||
|
while root_children.len() <= root_child_index {
|
||||||
|
root_children.push(SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::Split { children: vec![] },
|
||||||
|
size: 20.,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target should be a split (column at depth 1) so we can add children to it
|
||||||
|
let target = &mut root_children[root_child_index].subdivision;
|
||||||
|
if !matches!(target, PanelLayoutSubdivision::Split { .. }) {
|
||||||
|
let old_subdivision = std::mem::replace(target, PanelLayoutSubdivision::Split { children: vec![] });
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = target {
|
||||||
|
children.push(SplitChild {
|
||||||
|
subdivision: old_subdivision,
|
||||||
|
size: 50.,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = target {
|
||||||
|
if insert_at_end {
|
||||||
|
children.push(new_group);
|
||||||
|
} else {
|
||||||
|
children.insert(0, new_group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkspacePanelLayout {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Default layout (sizes are recalculated by `recalculate_default_sizes` before being sent to the frontend):
|
||||||
|
// Row [
|
||||||
|
// Column [Document]
|
||||||
|
// Column [Properties, Layers]
|
||||||
|
// ]
|
||||||
|
Self {
|
||||||
|
root: PanelLayoutSubdivision::Split {
|
||||||
|
children: vec![
|
||||||
|
SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::Split {
|
||||||
|
children: vec![SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||||
|
id: PanelGroupId(0),
|
||||||
|
state: PanelGroupState {
|
||||||
|
tabs: vec![PanelType::Document],
|
||||||
|
active_tab_index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 100.,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
size: 80.,
|
||||||
|
},
|
||||||
|
SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::Split {
|
||||||
|
children: vec![
|
||||||
|
SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||||
|
id: PanelGroupId(1),
|
||||||
|
state: PanelGroupState {
|
||||||
|
tabs: vec![PanelType::Properties],
|
||||||
|
active_tab_index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 50.,
|
||||||
|
},
|
||||||
|
SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||||
|
id: PanelGroupId(2),
|
||||||
|
state: PanelGroupState {
|
||||||
|
tabs: vec![PanelType::Layers],
|
||||||
|
active_tab_index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 50.,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 20.,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
next_group_id: PanelGroupId(3),
|
||||||
|
saved_positions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelLayoutSubdivision {
|
||||||
|
/// Find the panel group state for a given ID.
|
||||||
|
pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { id, state } if *id == target_id => Some(state),
|
||||||
|
PanelLayoutSubdivision::PanelGroup { .. } => None,
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter().find_map(|child| child.subdivision.find_group(target_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the panel group state for a given ID (mutable).
|
||||||
|
pub fn find_group_mut(&mut self, target_id: PanelGroupId) -> Option<&mut PanelGroupState> {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { id, state } if *id == target_id => Some(state),
|
||||||
|
PanelLayoutSubdivision::PanelGroup { .. } => None,
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter_mut().find_map(|child| child.subdivision.find_group_mut(target_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the panel group ID that contains a given panel type.
|
||||||
|
pub fn find_panel(&self, panel_type: PanelType) -> Option<PanelGroupId> {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { id, state } if state.contains(panel_type) => Some(*id),
|
||||||
|
PanelLayoutSubdivision::PanelGroup { .. } => None,
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter().find_map(|child| child.subdivision.find_panel(panel_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all panel group IDs in the tree.
|
||||||
|
pub fn all_group_ids(&self) -> Vec<PanelGroupId> {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { id, .. } => vec![*id],
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter().flat_map(|child| child.subdivision.all_group_ids()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove empty panel groups and collapse single-child splits.
|
||||||
|
pub fn prune(&mut self) {
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = self {
|
||||||
|
// Recursively prune children first
|
||||||
|
children.iter_mut().for_each(|child| child.subdivision.prune());
|
||||||
|
|
||||||
|
// Remove empty panel groups
|
||||||
|
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()));
|
||||||
|
|
||||||
|
// Remove empty splits (splits that lost all their children after pruning)
|
||||||
|
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
|
||||||
|
|
||||||
|
// If a split has exactly one child, replace this subdivision with that child's subdivision
|
||||||
|
if children.len() == 1 {
|
||||||
|
*self = children.remove(0).subdivision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this subtree contains the document panel.
|
||||||
|
pub fn contains_document(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { state, .. } => state.contains(PanelType::Document) || state.contains(PanelType::Welcome),
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter().any(|child| child.subdivision.contains_document()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalculate the default sizes for this subdivision's children based on proximity to the document panel.
|
||||||
|
/// Splits directly surrounding the document panel use 80-20 weighting.
|
||||||
|
/// All other splits use equal division.
|
||||||
|
pub fn recalculate_default_sizes(&mut self) {
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = self {
|
||||||
|
let child_count = children.len();
|
||||||
|
if child_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any child directly contains (or is) the document panel
|
||||||
|
let document_child_index = children.iter().position(|child| child.subdivision.contains_document());
|
||||||
|
|
||||||
|
if let Some(document_index) = document_child_index {
|
||||||
|
// This split directly surrounds the document panel, so use 80-20 weighting
|
||||||
|
let non_document_count = child_count - 1;
|
||||||
|
let document_share = if non_document_count > 0 { 80. } else { 100. };
|
||||||
|
let other_share = if non_document_count > 0 { 20. / non_document_count as f64 } else { 0. };
|
||||||
|
|
||||||
|
for (i, child) in children.iter_mut().enumerate() {
|
||||||
|
child.size = if i == document_index { document_share } else { other_share };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This split doesn't directly contain the document, use equal division
|
||||||
|
let equal_share = 100. / child_count as f64;
|
||||||
|
for child in children.iter_mut() {
|
||||||
|
child.size = equal_share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into children
|
||||||
|
for child in children.iter_mut() {
|
||||||
|
child.subdivision.recalculate_default_sizes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a panel group by ID from the tree. Does not prune.
|
||||||
|
pub fn remove_group(&mut self, target_id: PanelGroupId) {
|
||||||
|
if let PanelLayoutSubdivision::Split { children } = self {
|
||||||
|
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id));
|
||||||
|
|
||||||
|
children.iter_mut().for_each(|child| child.subdivision.remove_group(target_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum FileContent {
|
pub enum FileContent {
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,29 @@
|
||||||
import Dialog from "/src/components/floating-menus/Dialog.svelte";
|
import Dialog from "/src/components/floating-menus/Dialog.svelte";
|
||||||
import Tooltip from "/src/components/floating-menus/Tooltip.svelte";
|
import Tooltip from "/src/components/floating-menus/Tooltip.svelte";
|
||||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||||
|
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||||
|
import PanelSubdivision from "/src/components/window/PanelSubdivision.svelte";
|
||||||
import StatusBar from "/src/components/window/StatusBar.svelte";
|
import StatusBar from "/src/components/window/StatusBar.svelte";
|
||||||
import TitleBar from "/src/components/window/TitleBar.svelte";
|
import TitleBar from "/src/components/window/TitleBar.svelte";
|
||||||
import Workspace from "/src/components/window/Workspace.svelte";
|
|
||||||
import type { AppWindowStore } from "/src/stores/app-window";
|
import type { AppWindowStore } from "/src/stores/app-window";
|
||||||
import type { DialogStore } from "/src/stores/dialog";
|
import type { DialogStore } from "/src/stores/dialog";
|
||||||
|
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||||
import type { TooltipStore } from "/src/stores/tooltip";
|
import type { TooltipStore } from "/src/stores/tooltip";
|
||||||
|
|
||||||
const dialog = getContext<DialogStore>("dialog");
|
const dialog = getContext<DialogStore>("dialog");
|
||||||
const tooltip = getContext<TooltipStore>("tooltip");
|
const tooltip = getContext<TooltipStore>("tooltip");
|
||||||
const appWindow = getContext<AppWindowStore>("appWindow");
|
const appWindow = getContext<AppWindowStore>("appWindow");
|
||||||
|
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutCol class="main-window" classes={{ "viewport-hole-punch": $appWindow.viewportHolePunch }}>
|
<LayoutCol class="main-window" classes={{ "viewport-hole-punch": $appWindow.viewportHolePunch }}>
|
||||||
{#if !($appWindow.platform == "Mac" && $appWindow.fullscreen)}
|
{#if !($appWindow.platform == "Mac" && $appWindow.fullscreen)}
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
{/if}
|
{/if}
|
||||||
<Workspace />
|
<LayoutRow class="workspace" data-workspace>
|
||||||
|
<PanelSubdivision subdivision={$portfolio.panelLayout.root} depth={0} />
|
||||||
|
</LayoutRow>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
{#if $dialog.visible}
|
{#if $dialog.visible}
|
||||||
<Dialog />
|
<Dialog />
|
||||||
|
|
@ -46,6 +51,43 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
|
||||||
|
.workspace-grid-subdivision {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 28px;
|
||||||
|
|
||||||
|
&.folded {
|
||||||
|
flex-grow: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid-resize-gutter {
|
||||||
|
flex: 0 0 4px;
|
||||||
|
|
||||||
|
&.layout-row {
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-col {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needed for the viewport hole punch on desktop
|
||||||
|
.viewport-hole-punch .workspace .workspace-grid-subdivision:has(.panel.document-panel)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-candidate-expiry {
|
.release-candidate-expiry {
|
||||||
|
|
@ -67,4 +109,5 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
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 { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag";
|
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag";
|
||||||
import type { EditorWrapper, PanelType, PanelGroupId } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
const PANEL_COMPONENTS = {
|
const PANEL_COMPONENTS = {
|
||||||
Welcome,
|
Welcome,
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
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 panelTypes: PanelType[];
|
export let panelTypes: PanelType[];
|
||||||
export let panelId: PanelGroupId;
|
export let panelId: string;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext, onDestroy } from "svelte";
|
||||||
|
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||||
|
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||||
|
import Panel from "/src/components/window/Panel.svelte";
|
||||||
|
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||||
|
import type { EditorWrapper, OpenDocument, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
|
const MIN_PANEL_SIZE = 100;
|
||||||
|
const DOUBLE_CLICK_MILLISECONDS = 500;
|
||||||
|
|
||||||
|
const editor = getContext<EditorWrapper>("editor");
|
||||||
|
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||||
|
|
||||||
|
export let subdivision: PanelLayoutSubdivision;
|
||||||
|
export let depth: number;
|
||||||
|
|
||||||
|
// Local size overrides for gutter resizing (keyed by child index)
|
||||||
|
let sizeOverrides: Record<number, number> = {};
|
||||||
|
// Gutter resize state
|
||||||
|
let gutterResizeRestore: [number, number] | undefined = undefined;
|
||||||
|
let pointerCaptureId: number | undefined = undefined;
|
||||||
|
let activeResizeCleanup: (() => void) | undefined = undefined;
|
||||||
|
let lastGutterClickTarget: EventTarget | undefined = undefined;
|
||||||
|
let lastGutterClickTime = 0;
|
||||||
|
|
||||||
|
// At even depths (0, 2, 4...) children are in a row, at odd depths (1, 3, 5...) in a column
|
||||||
|
$: horizontal = depth % 2 === 0;
|
||||||
|
// Reset overrides when the subdivision changes (e.g., backend sends a new layout)
|
||||||
|
$: if (subdivision) sizeOverrides = {};
|
||||||
|
// Reactive array of resolved sizes (merging backend defaults with local overrides)
|
||||||
|
$: resolvedSizes = "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||||
|
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
|
||||||
|
const name = doc.details.name;
|
||||||
|
const unsaved = !doc.details.isSaved;
|
||||||
|
if (!editor.inDevelopmentMode()) return { name, unsaved };
|
||||||
|
|
||||||
|
const tooltipDescription = `Document ID: ${doc.id}`;
|
||||||
|
return { name, unsaved, tooltipLabel: name, tooltipDescription };
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
activeResizeCleanup?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resizePanel(e: PointerEvent, prevIndex: number, nextIndex: number) {
|
||||||
|
if (!("Split" in subdivision)) return;
|
||||||
|
|
||||||
|
const gutter = e.target;
|
||||||
|
if (!(gutter instanceof HTMLDivElement)) return;
|
||||||
|
|
||||||
|
const nextSibling = gutter.nextElementSibling;
|
||||||
|
const prevSibling = gutter.previousElementSibling;
|
||||||
|
const parentElement = gutter.parentElement;
|
||||||
|
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
|
||||||
|
|
||||||
|
// Double-click resets both adjacent panels to their default sizes
|
||||||
|
const children = subdivision.Split.children;
|
||||||
|
const now = Date.now();
|
||||||
|
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
|
||||||
|
lastGutterClickTime = now;
|
||||||
|
lastGutterClickTarget = gutter;
|
||||||
|
if (isDoubleClick) {
|
||||||
|
sizeOverrides = { ...sizeOverrides, [prevIndex]: children[prevIndex].size, [nextIndex]: children[nextIndex].size };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHorizontal = horizontal;
|
||||||
|
|
||||||
|
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
|
||||||
|
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
||||||
|
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
||||||
|
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
|
||||||
|
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
|
||||||
|
|
||||||
|
pointerCaptureId = e.pointerId;
|
||||||
|
gutter.setPointerCapture(pointerCaptureId);
|
||||||
|
|
||||||
|
const mouseStart = isHorizontal ? e.clientX : e.clientY;
|
||||||
|
|
||||||
|
const abortResize = () => {
|
||||||
|
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||||
|
pointerCaptureId = undefined;
|
||||||
|
removeListeners();
|
||||||
|
activeResizeCleanup = undefined;
|
||||||
|
|
||||||
|
if (gutterResizeRestore !== undefined) {
|
||||||
|
sizeOverrides = { ...sizeOverrides, [nextIndex]: gutterResizeRestore[0], [prevIndex]: gutterResizeRestore[1] };
|
||||||
|
gutterResizeRestore = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
|
||||||
|
let mouseDelta = mouseStart - mouseCurrent;
|
||||||
|
|
||||||
|
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
||||||
|
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
||||||
|
|
||||||
|
if (gutterResizeRestore === undefined) gutterResizeRestore = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]];
|
||||||
|
|
||||||
|
sizeOverrides = {
|
||||||
|
...sizeOverrides,
|
||||||
|
[nextIndex]: ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
|
||||||
|
[prevIndex]: ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
gutterResizeRestore = undefined;
|
||||||
|
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
||||||
|
removeListeners();
|
||||||
|
activeResizeCleanup = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
const BUTTONS_RIGHT = 0b0000_0010;
|
||||||
|
if (e.buttons & BUTTONS_RIGHT) abortResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") abortResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addListeners = () => {
|
||||||
|
document.addEventListener("pointermove", onPointerMove);
|
||||||
|
document.addEventListener("pointerup", onPointerUp);
|
||||||
|
document.addEventListener("mousedown", onMouseDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeListeners = () => {
|
||||||
|
document.removeEventListener("pointermove", onPointerMove);
|
||||||
|
document.removeEventListener("pointerup", onPointerUp);
|
||||||
|
document.removeEventListener("mousedown", onMouseDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
addListeners();
|
||||||
|
activeResizeCleanup = removeListeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crossPanelDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
|
||||||
|
editor.movePanelTab(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentGroup(state: PanelGroupState): boolean {
|
||||||
|
return state.tabs.some((t) => t === "Document" || t === "Welcome");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if "PanelGroup" in subdivision}
|
||||||
|
{@const group = subdivision.PanelGroup}
|
||||||
|
{#if isDocumentGroup(group.state)}
|
||||||
|
<Panel
|
||||||
|
class="document-panel"
|
||||||
|
panelId={String(group.id)}
|
||||||
|
panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]}
|
||||||
|
tabCloseButtons={true}
|
||||||
|
tabMinWidths={true}
|
||||||
|
tabLabels={documentTabLabels}
|
||||||
|
emptySpaceAction={() => editor.newDocumentDialog()}
|
||||||
|
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
|
||||||
|
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
||||||
|
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Panel
|
||||||
|
panelId={String(group.id)}
|
||||||
|
panelTypes={group.state.tabs}
|
||||||
|
tabLabels={group.state.tabs.map((name) => ({ name }))}
|
||||||
|
tabActiveIndex={Number(group.state.activeTabIndex)}
|
||||||
|
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
|
||||||
|
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
|
||||||
|
crossPanelDropAction={crossPanelDrop}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else if "Split" in subdivision}
|
||||||
|
{#each subdivision.Split.children as child, index}
|
||||||
|
{#if index > 0}
|
||||||
|
{#if horizontal}
|
||||||
|
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
|
||||||
|
{:else}
|
||||||
|
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical on:pointerdown={(e) => resizePanel(e, index - 1, index)} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if horizontal}
|
||||||
|
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||||
|
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
|
||||||
|
</LayoutCol>
|
||||||
|
{:else}
|
||||||
|
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": resolvedSizes[index] }}>
|
||||||
|
<svelte:self subdivision={child.subdivision} depth={depth + 1} />
|
||||||
|
</LayoutRow>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { getContext, onDestroy } from "svelte";
|
|
||||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
|
||||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
|
||||||
import Panel from "/src/components/window/Panel.svelte";
|
|
||||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
|
||||||
import type { EditorWrapper, OpenDocument } from "/wrapper/pkg/graphite_wasm_wrapper";
|
|
||||||
|
|
||||||
const MIN_PANEL_SIZE = 100;
|
|
||||||
const PANEL_SIZES = {
|
|
||||||
/**/ root: 100,
|
|
||||||
/* ├─ */ content: 80,
|
|
||||||
/* │ ├─ */ document: 70,
|
|
||||||
/* │ └─ */ data: 30,
|
|
||||||
/* └─ */ details: 20,
|
|
||||||
/* ├─ */ properties: 45,
|
|
||||||
/* └─ */ layers: 55,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let panelSizes: Record<string, number> = { ...PANEL_SIZES };
|
|
||||||
let documentPanel: Panel | undefined;
|
|
||||||
let gutterResizeRestore: [number, number] | undefined = undefined;
|
|
||||||
let pointerCaptureId: number | undefined = undefined;
|
|
||||||
let activeResizeCleanup: (() => void) | undefined = undefined;
|
|
||||||
|
|
||||||
// Reactive panel layout derived from backend state
|
|
||||||
$: panelLayout = $portfolio.panelLayout;
|
|
||||||
$: propertiesGroup = panelLayout.propertiesGroup;
|
|
||||||
$: layersGroup = panelLayout.layersGroup;
|
|
||||||
$: dataGroup = panelLayout.dataGroup;
|
|
||||||
|
|
||||||
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
|
|
||||||
|
|
||||||
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
|
|
||||||
const name = doc.details.name;
|
|
||||||
const unsaved = !doc.details.isSaved;
|
|
||||||
if (!editor.inDevelopmentMode()) return { name, unsaved };
|
|
||||||
|
|
||||||
const tooltipDescription = `Document ID: ${doc.id}`;
|
|
||||||
return { name, unsaved, tooltipLabel: name, tooltipDescription };
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = getContext<EditorWrapper>("editor");
|
|
||||||
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 {
|
|
||||||
return name in PANEL_SIZES;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPanelSizes(e: MouseEvent) {
|
|
||||||
const gutter = e.currentTarget;
|
|
||||||
if (!(gutter instanceof HTMLDivElement)) return;
|
|
||||||
|
|
||||||
const nextSibling = gutter.nextElementSibling;
|
|
||||||
const prevSibling = gutter.previousElementSibling;
|
|
||||||
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement)) return;
|
|
||||||
|
|
||||||
const nextSiblingName = nextSibling.getAttribute("data-subdivision-name") || undefined;
|
|
||||||
const prevSiblingName = prevSibling.getAttribute("data-subdivision-name") || undefined;
|
|
||||||
if (!nextSiblingName || !prevSiblingName || !isPanelName(nextSiblingName) || !isPanelName(prevSiblingName)) return;
|
|
||||||
|
|
||||||
panelSizes = { ...panelSizes, [nextSiblingName]: PANEL_SIZES[nextSiblingName], [prevSiblingName]: PANEL_SIZES[prevSiblingName] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizePanel(e: PointerEvent) {
|
|
||||||
const gutter = e.target;
|
|
||||||
if (!(gutter instanceof HTMLDivElement)) return;
|
|
||||||
|
|
||||||
const nextSibling = gutter.nextElementSibling;
|
|
||||||
const prevSibling = gutter.previousElementSibling;
|
|
||||||
|
|
||||||
const parentElement = gutter.parentElement;
|
|
||||||
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
|
|
||||||
|
|
||||||
const nextSiblingName = nextSibling.getAttribute("data-subdivision-name") || undefined;
|
|
||||||
const prevSiblingName = prevSibling.getAttribute("data-subdivision-name") || undefined;
|
|
||||||
|
|
||||||
if (!nextSiblingName || !prevSiblingName || !(nextSiblingName in PANEL_SIZES) || !(prevSiblingName in PANEL_SIZES)) return;
|
|
||||||
|
|
||||||
// Are we resizing horizontally?
|
|
||||||
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
|
|
||||||
|
|
||||||
// Get the current size in px of the panels being resized and the gutter
|
|
||||||
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
|
|
||||||
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
|
|
||||||
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
|
|
||||||
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
// Measure the resizing panels as a percentage of all sibling panels
|
|
||||||
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
|
|
||||||
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
|
|
||||||
|
|
||||||
// Prevent cursor flicker as mouse temporarily leaves the gutter
|
|
||||||
pointerCaptureId = e.pointerId;
|
|
||||||
gutter.setPointerCapture(pointerCaptureId);
|
|
||||||
|
|
||||||
const mouseStart = isHorizontal ? e.clientX : e.clientY;
|
|
||||||
|
|
||||||
const abortResize = () => {
|
|
||||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
|
||||||
removeListeners();
|
|
||||||
activeResizeCleanup = undefined;
|
|
||||||
|
|
||||||
pointerCaptureId = e.pointerId;
|
|
||||||
gutter.setPointerCapture(pointerCaptureId);
|
|
||||||
|
|
||||||
if (gutterResizeRestore !== undefined) {
|
|
||||||
panelSizes[nextSiblingName] = gutterResizeRestore[0];
|
|
||||||
panelSizes[prevSiblingName] = gutterResizeRestore[1];
|
|
||||||
gutterResizeRestore = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (e: PointerEvent) => {
|
|
||||||
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
|
|
||||||
let mouseDelta = mouseStart - mouseCurrent;
|
|
||||||
|
|
||||||
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
|
|
||||||
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
|
|
||||||
|
|
||||||
if (gutterResizeRestore === undefined) gutterResizeRestore = [panelSizes[nextSiblingName], panelSizes[prevSiblingName]];
|
|
||||||
|
|
||||||
panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
|
||||||
panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
gutterResizeRestore = undefined;
|
|
||||||
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
|
|
||||||
removeListeners();
|
|
||||||
activeResizeCleanup = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
|
||||||
const BUTTONS_RIGHT = 0b0000_0010;
|
|
||||||
if (e.buttons & BUTTONS_RIGHT) abortResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") abortResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
const addListeners = () => {
|
|
||||||
document.addEventListener("pointermove", onPointerMove);
|
|
||||||
document.addEventListener("pointerup", onPointerUp);
|
|
||||||
document.addEventListener("mousedown", onMouseDown);
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeListeners = () => {
|
|
||||||
document.removeEventListener("pointermove", onPointerMove);
|
|
||||||
document.removeEventListener("pointerup", onPointerUp);
|
|
||||||
document.removeEventListener("mousedown", onMouseDown);
|
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
|
|
||||||
addListeners();
|
|
||||||
activeResizeCleanup = removeListeners;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
activeResizeCleanup?.();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<LayoutRow class="workspace" data-workspace>
|
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["root"] }} data-subdivision-name="root">
|
|
||||||
<LayoutCol class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["content"] }} data-subdivision-name="content">
|
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["document"] }} data-subdivision-name="document">
|
|
||||||
<Panel
|
|
||||||
class="document-panel"
|
|
||||||
panelId="DocumentGroup"
|
|
||||||
panelTypes={$portfolio.documents.length > 0 ? $portfolio.documents.map(() => "Document") : ["Welcome"]}
|
|
||||||
tabCloseButtons={true}
|
|
||||||
tabMinWidths={true}
|
|
||||||
tabLabels={documentTabLabels}
|
|
||||||
emptySpaceAction={() => editor.newDocumentDialog()}
|
|
||||||
clickAction={(tabIndex) => editor.selectDocument($portfolio.documents[tabIndex].id)}
|
|
||||||
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
|
||||||
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
|
||||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
|
||||||
bind:this={documentPanel}
|
|
||||||
/>
|
|
||||||
</LayoutRow>
|
|
||||||
{#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-subdivision" styles={{ "flex-grow": panelSizes["data"] }} data-subdivision-name="data">
|
|
||||||
<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>
|
|
||||||
{/if}
|
|
||||||
</LayoutCol>
|
|
||||||
{#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-subdivision" styles={{ "flex-grow": panelSizes["details"] }} data-subdivision-name="details">
|
|
||||||
{#if propertiesGroup.tabs.length > 0}
|
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["properties"] }} data-subdivision-name="properties">
|
|
||||||
<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>
|
|
||||||
{/if}
|
|
||||||
{#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)} />
|
|
||||||
{/if}
|
|
||||||
{#if layersGroup.tabs.length > 0}
|
|
||||||
<LayoutRow class="workspace-grid-subdivision" styles={{ "flex-grow": panelSizes["layers"] }} data-subdivision-name="layers">
|
|
||||||
<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>
|
|
||||||
{/if}
|
|
||||||
</LayoutCol>
|
|
||||||
{/if}
|
|
||||||
</LayoutRow>
|
|
||||||
</LayoutRow>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.workspace {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 100%;
|
|
||||||
|
|
||||||
.workspace-grid-subdivision {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 28px;
|
|
||||||
|
|
||||||
&.folded {
|
|
||||||
flex-grow: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-grid-resize-gutter {
|
|
||||||
flex: 0 0 4px;
|
|
||||||
|
|
||||||
&.layout-row {
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.layout-col {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needed for the viewport hole punch on desktop
|
|
||||||
.viewport-hole-punch .workspace .workspace-grid-subdivision:has(.panel.document-panel)::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 0 0 calc(100vw + 100vh) var(--color-2-mildblack);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -4,21 +4,10 @@ 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, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper, OpenDocument, WorkspacePanelLayout } 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[];
|
||||||
|
|
@ -29,11 +18,7 @@ const initialState: PortfolioStoreState = {
|
||||||
unsaved: false,
|
unsaved: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
activeDocumentIndex: 0,
|
activeDocumentIndex: 0,
|
||||||
panelLayout: {
|
panelLayout: { root: { Split: { children: [] } }, nextGroupId: 0n },
|
||||||
propertiesGroup: { tabs: ["Properties"], activeTabIndex: 0 },
|
|
||||||
layersGroup: { tabs: ["Layers"], activeTabIndex: 0 },
|
|
||||||
dataGroup: { tabs: [], activeTabIndex: 0 },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||||
|
|
@ -115,14 +100,8 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => {
|
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);
|
|
||||||
|
|
||||||
update((state) => {
|
update((state) => {
|
||||||
state.panelLayout = layout;
|
state.panelLayout = data.panelLayout;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
|
||||||
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
|
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
|
||||||
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||||
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
||||||
use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily};
|
use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, PanelGroupId};
|
||||||
use editor::messages::prelude::*;
|
use editor::messages::prelude::*;
|
||||||
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
||||||
use graph_craft::document::NodeId;
|
use graph_craft::document::NodeId;
|
||||||
|
|
@ -435,25 +435,31 @@ impl EditorWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = reorderPanelGroupTab)]
|
#[wasm_bindgen(js_name = reorderPanelGroupTab)]
|
||||||
pub fn reorder_panel_group_tab(&self, group: String, old_index: usize, new_index: usize) {
|
pub fn reorder_panel_group_tab(&self, group: u64, old_index: usize, new_index: usize) {
|
||||||
let group = group.into();
|
let message = PortfolioMessage::ReorderPanelGroupTab {
|
||||||
let message = PortfolioMessage::ReorderPanelGroupTab { group, old_index, new_index };
|
group: PanelGroupId(group),
|
||||||
|
old_index,
|
||||||
|
new_index,
|
||||||
|
};
|
||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = movePanelTab)]
|
#[wasm_bindgen(js_name = movePanelTab)]
|
||||||
pub fn move_panel_tab(&self, source_group: String, target_group: String, insert_index: usize) {
|
pub fn move_panel_tab(&self, source_group: u64, target_group: u64, insert_index: usize) {
|
||||||
let message = PortfolioMessage::MovePanelTab {
|
let message = PortfolioMessage::MovePanelTab {
|
||||||
source_group: source_group.into(),
|
source_group: PanelGroupId(source_group),
|
||||||
target_group: target_group.into(),
|
target_group: PanelGroupId(target_group),
|
||||||
insert_index,
|
insert_index,
|
||||||
};
|
};
|
||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = setPanelGroupActiveTab)]
|
#[wasm_bindgen(js_name = setPanelGroupActiveTab)]
|
||||||
pub fn set_panel_group_active_tab(&self, group: String, tab_index: usize) {
|
pub fn set_panel_group_active_tab(&self, group: u64, tab_index: usize) {
|
||||||
let message = PortfolioMessage::SetPanelGroupActiveTab { group: group.into(), tab_index };
|
let message = PortfolioMessage::SetPanelGroupActiveTab {
|
||||||
|
group: PanelGroupId(group),
|
||||||
|
tab_index,
|
||||||
|
};
|
||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue