Fix panel docking bugs and polish its behavior (#4087)
* Fix panel docking bugs and polish its behavior * Fix bug
This commit is contained in:
parent
4c1974c200
commit
83d03ad67d
|
|
@ -58,12 +58,10 @@ impl MessageHandler<NewDocumentDialogMessage, ()> for NewDocumentDialogMessageHa
|
|||
responses.add(NodeGraphMessage::RunDocumentGraph);
|
||||
responses.add(ViewportMessage::RepropagateUpdate);
|
||||
|
||||
responses.add(DocumentMessage::DeselectAllLayers);
|
||||
|
||||
responses.add(DeferMessage::AfterNavigationReady {
|
||||
messages: vec![
|
||||
DocumentMessage::ZoomCanvasToFitAll.into(),
|
||||
DocumentMessage::DeselectAllLayers.into(),
|
||||
PortfolioMessage::AutoSaveActiveDocument.into(),
|
||||
],
|
||||
messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), PortfolioMessage::AutoSaveActiveDocument.into()],
|
||||
});
|
||||
|
||||
responses.add(DocumentMessage::MarkAsSaved);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageContext<'_>> for OverlaysMes
|
|||
use crate::messages::viewport::{Position, ToPhysical};
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// Discard detached canvas after a panel reorganization remounts the DOM
|
||||
if self.canvas.as_ref().is_some_and(|canvas| !canvas.is_connected()) {
|
||||
self.canvas = None;
|
||||
self.context = None;
|
||||
}
|
||||
|
||||
let canvas = match &self.canvas {
|
||||
Some(canvas) => canvas,
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -195,10 +195,6 @@ pub enum PortfolioMessage {
|
|||
UpdateOpenDocumentsList,
|
||||
UpdateWorkspacePanelLayout,
|
||||
ResetWorkspaceLayout,
|
||||
ResetPanelGroupSizes {
|
||||
/// Path of child indices from the root to the split node whose children's sizes should be reset to defaults.
|
||||
split_path: Vec<usize>,
|
||||
},
|
||||
SetPanelGroupSizes {
|
||||
/// Path of child indices from the root to the split node whose children's sizes are being set.
|
||||
split_path: Vec<usize>,
|
||||
|
|
|
|||
|
|
@ -453,6 +453,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||
if let Some(layout) = state.workspace_layout {
|
||||
self.workspace_panel_layout = layout;
|
||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||
|
||||
// Refill panels whose content was lost when the layout load remounted their frontend components
|
||||
for group_id in self.workspace_panel_layout.root.all_group_ids() {
|
||||
if let Some(panel_type) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
|
||||
self.refresh_panel_content(panel_type, responses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let PersistedState {
|
||||
|
|
@ -1330,13 +1337,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||
Self::destroy_panel_layouts(target_active, responses);
|
||||
}
|
||||
|
||||
// Preserve the source panel's visual weight at its new location
|
||||
let source_slot_size = self.workspace_panel_layout.find_source_slot_size(&tabs);
|
||||
|
||||
// Remove the dragged tabs from their current panel groups (without pruning, so the target group survives)
|
||||
for &panel_type in &tabs {
|
||||
self.remove_panel_from_layout(panel_type);
|
||||
}
|
||||
|
||||
// Create the new panel group adjacent to the target, then prune empty groups
|
||||
let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index) else {
|
||||
let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index, source_slot_size) else {
|
||||
log::error!("Failed to insert split adjacent to panel group {target_group:?}");
|
||||
return;
|
||||
};
|
||||
|
|
@ -1611,20 +1621,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||
responses.add(MenuBarMessage::SendLayout);
|
||||
}
|
||||
PortfolioMessage::ResetPanelGroupSizes { split_path } => {
|
||||
// Walk the tree to the target split node using the path
|
||||
let mut node = &mut self.workspace_panel_layout.root;
|
||||
for &index in &split_path {
|
||||
let PanelLayoutSubdivision::Split { children } = node else { return };
|
||||
let Some(child) = children.get_mut(index) else { return };
|
||||
node = &mut child.subdivision;
|
||||
}
|
||||
|
||||
// Recalculate default sizes for this split node
|
||||
node.recalculate_default_sizes();
|
||||
|
||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||
}
|
||||
PortfolioMessage::SetPanelGroupSizes { split_path, sizes } => {
|
||||
// Walk the tree to the target split node using the path
|
||||
let mut node = &mut self.workspace_panel_layout.root;
|
||||
|
|
@ -1977,7 +1973,12 @@ impl PortfolioMessageHandler {
|
|||
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 => {}
|
||||
PanelType::Document | PanelType::Welcome => {
|
||||
// Re-send the welcome screen buttons layout to repopulate after a remount
|
||||
if self.document_ids.is_empty() {
|
||||
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ use graphene_std::Color;
|
|||
use graphene_std::raster::Image;
|
||||
use graphene_std::text::{Font, FontCache};
|
||||
|
||||
/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels.
|
||||
const DOCUMENT_PANEL_SHARE: f64 = 0.8;
|
||||
/// Proportional share for each side when neither (or both) contain the document panel.
|
||||
const EQUAL_PANEL_SHARE: f64 = 0.5;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CachedData {
|
||||
pub font_cache: FontCache,
|
||||
|
|
@ -244,25 +249,54 @@ impl WorkspacePanelLayout {
|
|||
/// The direction determines where the new group goes relative to the target.
|
||||
/// Left/Right creates a horizontal (row) split, Top/Bottom creates a vertical (column) split.
|
||||
/// Returns the ID of the newly created panel group, or `None` if insertion failed.
|
||||
pub fn split_panel_group(&mut self, target_group_id: PanelGroupId, direction: DockingSplitDirection, tabs: Vec<PanelType>, active_tab_index: usize) -> Option<PanelGroupId> {
|
||||
///
|
||||
/// `source_slot_size` overrides the new panel's size (for moves where the old slot will be pruned).
|
||||
/// If `None`, target's slot is split in the default ratio.
|
||||
pub fn split_panel_group(
|
||||
&mut self,
|
||||
target_group_id: PanelGroupId,
|
||||
direction: DockingSplitDirection,
|
||||
tabs: Vec<PanelType>,
|
||||
active_tab_index: usize,
|
||||
source_slot_size: Option<f64>,
|
||||
) -> Option<PanelGroupId> {
|
||||
let new_id = self.next_id();
|
||||
let new_group = SplitChild {
|
||||
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||
id: new_id,
|
||||
state: PanelGroupState { tabs, active_tab_index },
|
||||
},
|
||||
size: 50.,
|
||||
size: source_slot_size.unwrap_or(EQUAL_PANEL_SHARE),
|
||||
};
|
||||
|
||||
let insert_before = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Top);
|
||||
let needs_horizontal = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Right);
|
||||
|
||||
self.root.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0).then_some(new_id)
|
||||
self.root
|
||||
.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0, source_slot_size)
|
||||
.then_some(new_id)
|
||||
}
|
||||
|
||||
/// Find the slot size of the panel group whose entire content is exactly the given tabs.
|
||||
/// Returns `None` if the tabs span multiple groups or don't fill their group exactly.
|
||||
pub fn find_source_slot_size(&self, tabs: &[PanelType]) -> Option<f64> {
|
||||
if tabs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let group_id = self.find_panel(tabs[0])?;
|
||||
if !tabs.iter().all(|&t| self.find_panel(t) == Some(group_id)) {
|
||||
return None;
|
||||
}
|
||||
let group = self.panel_group(group_id)?;
|
||||
if group.tabs.len() != tabs.len() {
|
||||
return None;
|
||||
}
|
||||
self.root.find_slot_size_by_group_id(group_id)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
self.root.recalculate_default_sizes_recursive();
|
||||
}
|
||||
|
||||
/// Remember which panel group and tab index a panel was in before removal, so it can be restored there later.
|
||||
|
|
@ -311,10 +345,10 @@ impl WorkspacePanelLayout {
|
|||
},
|
||||
},
|
||||
size: match panel_type {
|
||||
PanelType::Data => 30.,
|
||||
PanelType::Properties => 45.,
|
||||
PanelType::Layers => 55.,
|
||||
_ => 50.,
|
||||
PanelType::Data => 0.3,
|
||||
PanelType::Properties => 0.45,
|
||||
PanelType::Layers => 0.55,
|
||||
_ => EQUAL_PANEL_SHARE,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -330,7 +364,10 @@ impl WorkspacePanelLayout {
|
|||
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. });
|
||||
children.push(SplitChild {
|
||||
subdivision: old_root,
|
||||
size: DOCUMENT_PANEL_SHARE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +377,7 @@ impl WorkspacePanelLayout {
|
|||
while root_children.len() <= root_child_index {
|
||||
root_children.push(SplitChild {
|
||||
subdivision: PanelLayoutSubdivision::Split { children: vec![] },
|
||||
size: 20.,
|
||||
size: (1. - DOCUMENT_PANEL_SHARE),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +388,7 @@ impl WorkspacePanelLayout {
|
|||
if let PanelLayoutSubdivision::Split { children } = target {
|
||||
children.push(SplitChild {
|
||||
subdivision: old_subdivision,
|
||||
size: 50.,
|
||||
size: EQUAL_PANEL_SHARE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -386,10 +423,10 @@ impl Default for WorkspacePanelLayout {
|
|||
active_tab_index: 0,
|
||||
},
|
||||
},
|
||||
size: 100.,
|
||||
size: 1.,
|
||||
}],
|
||||
},
|
||||
size: 80.,
|
||||
size: DOCUMENT_PANEL_SHARE,
|
||||
},
|
||||
SplitChild {
|
||||
subdivision: PanelLayoutSubdivision::Split {
|
||||
|
|
@ -402,7 +439,7 @@ impl Default for WorkspacePanelLayout {
|
|||
active_tab_index: 0,
|
||||
},
|
||||
},
|
||||
size: 50.,
|
||||
size: EQUAL_PANEL_SHARE,
|
||||
},
|
||||
SplitChild {
|
||||
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||
|
|
@ -412,11 +449,11 @@ impl Default for WorkspacePanelLayout {
|
|||
active_tab_index: 0,
|
||||
},
|
||||
},
|
||||
size: 50.,
|
||||
size: EQUAL_PANEL_SHARE,
|
||||
},
|
||||
],
|
||||
},
|
||||
size: 20.,
|
||||
size: (1. - DOCUMENT_PANEL_SHARE),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -427,6 +464,15 @@ impl Default for WorkspacePanelLayout {
|
|||
}
|
||||
}
|
||||
|
||||
/// The share of the slot that should go to the old side when splitting it with a new side.
|
||||
fn document_split_share(old_side: &PanelLayoutSubdivision, new_side: &PanelLayoutSubdivision) -> f64 {
|
||||
match (old_side.contains_document(), new_side.contains_document()) {
|
||||
(true, false) => DOCUMENT_PANEL_SHARE,
|
||||
(false, true) => 1. - DOCUMENT_PANEL_SHARE,
|
||||
_ => EQUAL_PANEL_SHARE,
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelLayoutSubdivision {
|
||||
/// Find the panel group state for a given ID.
|
||||
pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> {
|
||||
|
|
@ -463,9 +509,10 @@ impl PanelLayoutSubdivision {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove empty panel groups and collapse unnecessary nesting.
|
||||
/// Does NOT collapse single-child splits into their child, as that would change subdivision depths
|
||||
/// and break the direction-by-depth alternation system.
|
||||
/// Remove empty groups/splits and flatten single-child `Split`-in-`Split` nesting (which docking sequences can create).
|
||||
///
|
||||
/// Flattening preserves depth parity (and therefore direction). `PanelGroup`-only single-child splits are left
|
||||
/// alone since collapsing would change the panel's depth and alter future wrap orientation.
|
||||
pub fn prune(&mut self) {
|
||||
let PanelLayoutSubdivision::Split { children } = self else { return };
|
||||
|
||||
|
|
@ -477,6 +524,54 @@ impl PanelLayoutSubdivision {
|
|||
|
||||
// Remove empty splits (splits that lost all their children after pruning)
|
||||
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
|
||||
|
||||
// Flatten single-child `Split`-in-`Split` nesting, rescaling sizes to preserve visual proportions
|
||||
let mut i = 0;
|
||||
while i < children.len() {
|
||||
// Must be a `Split`...
|
||||
let PanelLayoutSubdivision::Split { children: outer } = &children[i].subdivision else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// ...with exactly one child...
|
||||
let [only_child] = outer.as_slice() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// ...that is itself a `Split`
|
||||
let PanelLayoutSubdivision::Split { .. } = &only_child.subdivision else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
// Remove the redundant wrapper
|
||||
let removed = children.remove(i);
|
||||
let outer_size = removed.size;
|
||||
|
||||
// Extract the inner grandchildren
|
||||
let PanelLayoutSubdivision::Split { children: mut outer_children } = removed.subdivision else {
|
||||
continue;
|
||||
};
|
||||
let Some(inner_split) = outer_children.pop() else { continue };
|
||||
let PanelLayoutSubdivision::Split { children: inner_children } = inner_split.subdivision else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Splice grandchildren in at the same position, scaling their sizes to fill the removed slot
|
||||
let inner_total: f64 = inner_children.iter().map(|c| c.size).sum();
|
||||
for (offset, mut grandchild) in inner_children.into_iter().enumerate() {
|
||||
grandchild.size = if inner_total > 0. { grandchild.size / inner_total * outer_size } else { outer_size };
|
||||
children.insert(i + offset, grandchild);
|
||||
}
|
||||
}
|
||||
|
||||
// Renormalize to sum=1 since dock/prune cycles can compound shrinkage
|
||||
let total: f64 = children.iter().map(|c| c.size).sum();
|
||||
if total > 0. && (total - 1.).abs() > 0.001 {
|
||||
for child in children.iter_mut() {
|
||||
child.size /= total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all non-document/non-welcome tabs from panel groups, leaving only document-related panels.
|
||||
|
|
@ -503,7 +598,9 @@ impl PanelLayoutSubdivision {
|
|||
/// Inserts a new split child adjacent to a target panel group and returns whether the insertion was successful.
|
||||
/// Recurses to the deepest split closest to the target that matches the requested split direction.
|
||||
/// If the target is a direct child of a mismatched-direction split, this wraps it in a new sub-split.
|
||||
pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize) -> bool {
|
||||
///
|
||||
/// `source_slot_size` preserves the moved panel's visual weight. If `None`, uses the default split ratio.
|
||||
pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize, source_slot_size: Option<f64>) -> bool {
|
||||
let PanelLayoutSubdivision::Split { children } = self else { return false };
|
||||
|
||||
let is_horizontal = depth.is_multiple_of(2);
|
||||
|
|
@ -517,18 +614,35 @@ impl PanelLayoutSubdivision {
|
|||
// If the target is a direct child: we can certainly insert the new split, either as a sibling (if direction matches) or wrapping the target in a new split (if direction is mismatched)
|
||||
let target_is_direct_child = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id);
|
||||
if target_is_direct_child {
|
||||
// Direction matches and target is right here: insert as a sibling
|
||||
// Direction matches: insert as sibling, sizing based on whether target will be pruned, source size hint, or default ratio
|
||||
if direction_matches {
|
||||
let mut new_child = new_child;
|
||||
let target_will_be_pruned = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty());
|
||||
if target_will_be_pruned {
|
||||
new_child.size = children[containing_index].size;
|
||||
} else if let Some(hint) = source_slot_size {
|
||||
new_child.size = hint;
|
||||
} else {
|
||||
let target_share = document_split_share(&children[containing_index].subdivision, &new_child.subdivision);
|
||||
let total = children[containing_index].size;
|
||||
children[containing_index].size = total * target_share;
|
||||
new_child.size = total * (1. - target_share);
|
||||
}
|
||||
|
||||
let insert_index = if insert_before { containing_index } else { containing_index + 1 };
|
||||
children.insert(insert_index, new_child);
|
||||
}
|
||||
// Direction mismatch: wrap the target in a new sub-split (at depth+1, which has the opposite direction of this and thus is the requested direction)
|
||||
// Direction mismatch: wrap target in a sub-split at depth+1, sharing the slot in the default ratio
|
||||
else {
|
||||
let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] });
|
||||
|
||||
let old_share = document_split_share(&old_child_subdivision, &new_child.subdivision);
|
||||
let old_child = SplitChild {
|
||||
subdivision: old_child_subdivision,
|
||||
size: 50.,
|
||||
size: old_share,
|
||||
};
|
||||
let mut new_child = new_child;
|
||||
new_child.size = 1. - old_share;
|
||||
|
||||
if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision {
|
||||
if insert_before {
|
||||
|
|
@ -547,7 +661,25 @@ impl PanelLayoutSubdivision {
|
|||
// The target is deeper, so recurse into the containing child's subtree and return its insertion outcome
|
||||
children[containing_index]
|
||||
.subdivision
|
||||
.insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1)
|
||||
.insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1, source_slot_size)
|
||||
}
|
||||
|
||||
/// Find the size of the `SplitChild` slot whose subdivision is the panel group with the given ID, if it exists.
|
||||
pub fn find_slot_size_by_group_id(&self, group_id: PanelGroupId) -> Option<f64> {
|
||||
let PanelLayoutSubdivision::Split { children } = self else { return None };
|
||||
for child in children {
|
||||
if let PanelLayoutSubdivision::PanelGroup { id, .. } = &child.subdivision
|
||||
&& *id == group_id
|
||||
{
|
||||
return Some(child.size);
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
if let Some(size) = child.subdivision.find_slot_size_by_group_id(group_id) {
|
||||
return Some(size);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if this subtree contains the document panel.
|
||||
|
|
@ -558,39 +690,46 @@ impl PanelLayoutSubdivision {
|
|||
}
|
||||
}
|
||||
|
||||
/// Recalculate the default sizes for this subdivision's children based on proximity to the document panel.
|
||||
/// Recalculate the default sizes for this subdivision's direct children based on proximity to the document panel.
|
||||
/// Splits directly surrounding the document panel use 80-20 weighting.
|
||||
/// All other splits use equal division.
|
||||
/// Does not recurse into descendants: use [`Self::recalculate_default_sizes_recursive`] for that.
|
||||
pub fn recalculate_default_sizes(&mut self) {
|
||||
if let PanelLayoutSubdivision::Split { children } = self {
|
||||
let child_count = children.len();
|
||||
if child_count == 0 {
|
||||
return;
|
||||
let PanelLayoutSubdivision::Split { children } = self else { return };
|
||||
|
||||
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
|
||||
let non_document_count = child_count - 1;
|
||||
let document_share = if non_document_count > 0 { DOCUMENT_PANEL_SHARE } else { 1. };
|
||||
let other_share = if non_document_count > 0 { (1. - DOCUMENT_PANEL_SHARE) / 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 };
|
||||
}
|
||||
|
||||
// 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
|
||||
} else {
|
||||
// This split doesn't directly contain the document, use equal division
|
||||
let equal_share = 1. / child_count as f64;
|
||||
for child in children.iter_mut() {
|
||||
child.subdivision.recalculate_default_sizes();
|
||||
child.size = equal_share;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recalculate the default sizes for this subdivision and all its descendant splits.
|
||||
pub fn recalculate_default_sizes_recursive(&mut self) {
|
||||
self.recalculate_default_sizes();
|
||||
|
||||
if let PanelLayoutSubdivision::Split { children } = self {
|
||||
for child in children.iter_mut() {
|
||||
child.subdivision.recalculate_default_sizes_recursive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1430,9 +1430,15 @@ impl Fsm for SelectToolFsmState {
|
|||
NestedSelectionBehavior::Deepest if remove => drag_deepest_manipulation(responses, selected, tool_data, document, true),
|
||||
NestedSelectionBehavior::Shallowest if !deepest => drag_shallowest_manipulation(responses, selected, tool_data, document, false, true),
|
||||
_ => {
|
||||
responses.add(DocumentMessage::DeselectAllLayers);
|
||||
tool_data.layers_dragging.clear();
|
||||
drag_deepest_manipulation(responses, selected, tool_data, document, false)
|
||||
// Narrow multi-selection to just the clicked layer (no-op if it's already the sole selection)
|
||||
let currently_selected = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect::<Vec<_>>();
|
||||
let already_only_selection = currently_selected.as_slice() == [intersection];
|
||||
|
||||
if !already_only_selection {
|
||||
responses.add(DocumentMessage::DeselectAllLayers);
|
||||
tool_data.layers_dragging.clear();
|
||||
drag_deepest_manipulation(responses, selected, tool_data, document, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import { getContext } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
|
||||
let dataPanelLayout: Layout = [];
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("DataPanel", (data) => {
|
||||
patchLayout(dataPanelLayout, data);
|
||||
dataPanelLayout = dataPanelLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("DataPanel");
|
||||
});
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
</script>
|
||||
|
||||
<LayoutCol class="data-panel">
|
||||
<LayoutCol class="body" scrollableY={true}>
|
||||
<WidgetLayout layout={dataPanelLayout} layoutTarget="DataPanel" />
|
||||
<WidgetLayout layout={$portfolio.dataPanelLayout} layoutTarget="DataPanel" />
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy, tick } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
|
|
@ -8,12 +7,11 @@
|
|||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { NodeGraphStore } from "/src/stores/node-graph";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { pasteFile } from "/src/utility-functions/files";
|
||||
import { operatingSystem } from "/src/utility-functions/platform";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, LayerPanelEntry, LayerStructureEntry, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { EditorWrapper, LayerPanelEntry, LayerStructureEntry } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
type LayerListingInfo = {
|
||||
folderIndex: number;
|
||||
|
|
@ -48,15 +46,13 @@
|
|||
startY: number;
|
||||
};
|
||||
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
let list: LayoutCol | undefined;
|
||||
|
||||
// Layer data
|
||||
let layerCache = new SvelteMap<string, LayerPanelEntry>(); // TODO: replace with BigUint64Array as index
|
||||
let layers: LayerListingInfo[] = [];
|
||||
|
||||
// Interactive dragging
|
||||
|
|
@ -71,38 +67,9 @@
|
|||
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
|
||||
let layerToClipAltKeyPressed = false;
|
||||
|
||||
// Layouts
|
||||
let layersPanelControlBarLeftLayout: Layout = [];
|
||||
let layersPanelControlBarRightLayout: Layout = [];
|
||||
let layersPanelBottomBarLayout: Layout = [];
|
||||
$: rebuildLayerHierarchy($portfolio.layerStructure, $portfolio.layerCache);
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", (data) => {
|
||||
patchLayout(layersPanelControlBarLeftLayout, data);
|
||||
layersPanelControlBarLeftLayout = layersPanelControlBarLeftLayout;
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", (data) => {
|
||||
patchLayout(layersPanelControlBarRightLayout, data);
|
||||
layersPanelControlBarRightLayout = layersPanelControlBarRightLayout;
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", (data) => {
|
||||
patchLayout(layersPanelBottomBarLayout, data);
|
||||
layersPanelBottomBarLayout = layersPanelBottomBarLayout;
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => {
|
||||
rebuildLayerHierarchy(data.layerStructure);
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => {
|
||||
const targetLayer = data.data;
|
||||
const targetId = targetLayer.id;
|
||||
|
||||
updateLayerInTree(targetId, targetLayer);
|
||||
});
|
||||
|
||||
addEventListener("pointerup", draggingPointerUp);
|
||||
addEventListener("pointermove", draggingPointerMove);
|
||||
addEventListener("mousedown", draggingMouseDown);
|
||||
|
|
@ -115,12 +82,6 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails");
|
||||
|
||||
removeEventListener("pointerup", draggingPointerUp);
|
||||
removeEventListener("pointermove", draggingPointerMove);
|
||||
removeEventListener("mousedown", draggingMouseDown);
|
||||
|
|
@ -504,7 +465,7 @@
|
|||
dragInPanel = false;
|
||||
}
|
||||
|
||||
function rebuildLayerHierarchy(layerStructure: LayerStructureEntry[]) {
|
||||
function rebuildLayerHierarchy(layerStructure: LayerStructureEntry[], cache: Map<string, LayerPanelEntry>) {
|
||||
// Track the editing state by flat list index, not layer ID, since a layer can appear at multiple positions
|
||||
const editingIndex = layers.findIndex((layer: LayerListingInfo) => layer.editingName);
|
||||
|
||||
|
|
@ -515,7 +476,7 @@
|
|||
const recurse = (children: LayerStructureEntry[], depth: number, parentId: bigint | undefined, parentPath: bigint[], parentsVisible: boolean, parentsUnlocked: boolean) => {
|
||||
children.forEach((item, index) => {
|
||||
const treePath = [...parentPath, item.layerId];
|
||||
const mapping = layerCache.get(String(item.layerId));
|
||||
const mapping = cache.get(String(item.layerId));
|
||||
|
||||
if (mapping) {
|
||||
mapping.id = item.layerId;
|
||||
|
|
@ -544,28 +505,15 @@
|
|||
recurse(layerStructure, 1, undefined, [], true, true);
|
||||
layers = layers;
|
||||
}
|
||||
|
||||
function updateLayerInTree(targetId: bigint, targetLayer: LayerPanelEntry) {
|
||||
layerCache.set(String(targetId), targetLayer);
|
||||
|
||||
let changed = false;
|
||||
layers.forEach((layer) => {
|
||||
if (layer.entry.id === targetId) {
|
||||
layer.entry = targetLayer;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) layers = layers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="layers" on:dragleave={() => (dragInPanel = false)}>
|
||||
<LayoutRow class="control-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={layersPanelControlBarLeftLayout} layoutTarget="LayersPanelControlLeftBar" />
|
||||
{#if layersPanelControlBarLeftLayout?.length > 0 && layersPanelControlBarRightLayout?.length > 0}
|
||||
<WidgetLayout layout={$portfolio.layersPanelControlBarLeftLayout} layoutTarget="LayersPanelControlLeftBar" />
|
||||
{#if $portfolio.layersPanelControlBarLeftLayout?.length > 0 && $portfolio.layersPanelControlBarRightLayout?.length > 0}
|
||||
<Separator />
|
||||
{/if}
|
||||
<WidgetLayout layout={layersPanelControlBarRightLayout} layoutTarget="LayersPanelControlRightBar" />
|
||||
<WidgetLayout layout={$portfolio.layersPanelControlBarRightLayout} layoutTarget="LayersPanelControlRightBar" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="list-area" classes={{ "drag-ongoing": Boolean(internalDragState?.active && draggingData) }} scrollableY={true}>
|
||||
<LayoutCol
|
||||
|
|
@ -685,7 +633,7 @@
|
|||
{/if}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="bottom-bar" scrollableX={true}>
|
||||
<WidgetLayout layout={layersPanelBottomBarLayout} layoutTarget="LayersPanelBottomBar" />
|
||||
<WidgetLayout layout={$portfolio.layersPanelBottomBarLayout} layoutTarget="LayersPanelBottomBar" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import { getContext } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
|
||||
let propertiesPanelLayout: Layout = [];
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("PropertiesPanel", (data) => {
|
||||
patchLayout(propertiesPanelLayout, data);
|
||||
propertiesPanelLayout = propertiesPanelLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("PropertiesPanel");
|
||||
});
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
</script>
|
||||
|
||||
<LayoutCol class="properties">
|
||||
<LayoutCol class="sections" scrollableY={true}>
|
||||
<WidgetLayout layout={propertiesPanelLayout} layoutTarget="PropertiesPanel" />
|
||||
<WidgetLayout layout={$portfolio.propertiesPanelLayout} layoutTarget="PropertiesPanel" />
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import { getContext } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||
import { pasteFile } from "/src/utility-functions/files";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
let welcomePanelButtonsLayout: Layout = [];
|
||||
|
||||
onMount(() => {
|
||||
subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", (data) => {
|
||||
patchLayout(welcomePanelButtonsLayout, data);
|
||||
welcomePanelButtonsLayout = welcomePanelButtonsLayout;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons");
|
||||
});
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
||||
function dropFile(e: DragEvent) {
|
||||
if (!e.dataTransfer) return;
|
||||
|
|
@ -43,7 +29,7 @@
|
|||
<IconLabel icon="GraphiteLogotypeSolid" />
|
||||
</LayoutRow>
|
||||
<LayoutRow class="actions">
|
||||
<WidgetLayout layout={welcomePanelButtonsLayout} layoutTarget="WelcomeScreenButtons" />
|
||||
<WidgetLayout layout={$portfolio.welcomeScreenButtonsLayout} layoutTarget="WelcomeScreenButtons" />
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
// Only start a group drag from the tab bar background (not from a tab or button)
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (!crossPanelDropAction) return;
|
||||
if (!crossPanelDropAction && !splitDropAction) return;
|
||||
|
||||
dragStartState = { tabIndex: tabActiveIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: true };
|
||||
dragging = false;
|
||||
|
|
@ -142,13 +142,12 @@
|
|||
|
||||
dragging = true;
|
||||
|
||||
if (crossPanelDropAction) {
|
||||
if (dragStartState.isGroupDrag) {
|
||||
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
|
||||
} else {
|
||||
const draggedTab = panelTypes[dragStartState.tabIndex];
|
||||
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
|
||||
}
|
||||
// Group drags enter cross-panel state for edge docking even without crossPanelDropAction
|
||||
if (dragStartState.isGroupDrag && (crossPanelDropAction || splitDropAction)) {
|
||||
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
|
||||
} else if (!dragStartState.isGroupDrag && crossPanelDropAction) {
|
||||
const draggedTab = panelTypes[dragStartState.tabIndex];
|
||||
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +164,9 @@
|
|||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
|
||||
// Check if the pointer is over any other dockable panel's tab bar
|
||||
// Skip cross-panel hover detection for sources that can't dock anywhere
|
||||
if (!crossPanelDropAction && !splitDropAction) return;
|
||||
|
||||
if (crossPanelDropAction) {
|
||||
const tabBarTarget = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => {
|
||||
const targetPanelId = element.getAttribute("data-panel-tab-bar");
|
||||
|
|
@ -180,35 +181,35 @@
|
|||
calculateForeignInsertionIndex(e.clientX, tabBarTargetId, tabBarTarget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the pointer is over any panel body's edge zone for split docking
|
||||
const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
});
|
||||
// Check for edge-zone split docking
|
||||
const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
});
|
||||
|
||||
const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body");
|
||||
if (bodyPanelId) {
|
||||
const rect = panelBody.getBoundingClientRect();
|
||||
let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect);
|
||||
const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body");
|
||||
if (bodyPanelId) {
|
||||
const rect = panelBody.getBoundingClientRect();
|
||||
let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect);
|
||||
|
||||
// Block center drops between document and non-document panels
|
||||
if (edge === "Center") {
|
||||
const targetIsDockable = panelBody.hasAttribute("data-panel-dockable");
|
||||
const sourceIsDockable = crossPanelDropAction !== undefined;
|
||||
if (targetIsDockable !== sourceIsDockable) edge = undefined;
|
||||
}
|
||||
|
||||
if (edge) {
|
||||
updateDockingHover(bodyPanelId, edge);
|
||||
return;
|
||||
}
|
||||
// Center drops between different panels require both to be cross-panel-dockable (self-drops are always allowed as a no-op)
|
||||
if (edge === "Center" && bodyPanelId !== panelId) {
|
||||
const targetIsDockable = panelBody.hasAttribute("data-panel-dockable");
|
||||
const sourceIsDockable = crossPanelDropAction !== undefined;
|
||||
if (!sourceIsDockable || !targetIsDockable) edge = undefined;
|
||||
}
|
||||
|
||||
// Not hovering any drop target
|
||||
updateCrossPanelHover(undefined, undefined, undefined);
|
||||
updateDockingHover(undefined, undefined);
|
||||
if (edge) {
|
||||
updateDockingHover(bodyPanelId, edge);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not hovering any drop target
|
||||
updateCrossPanelHover(undefined, undefined, undefined);
|
||||
updateDockingHover(undefined, undefined);
|
||||
}
|
||||
|
||||
function dragPointerUp() {
|
||||
|
|
@ -273,7 +274,7 @@
|
|||
dragging = false;
|
||||
insertionIndex = undefined;
|
||||
insertionMarkerLeft = undefined;
|
||||
if (crossPanelDropAction) endCrossPanelDrag();
|
||||
endCrossPanelDrag();
|
||||
removeDragListeners();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
|
||||
const MIN_PANEL_SIZE = 100;
|
||||
const DOUBLE_CLICK_MILLISECONDS = 500;
|
||||
// Must match DOCUMENT_PANEL_SHARE / NON_DOCUMENT_PANEL_SHARE in utility_types.rs
|
||||
const DOCUMENT_PANEL_SHARE = 0.8;
|
||||
const EQUAL_PANEL_SHARE = 0.5;
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const portfolio = getContext<PortfolioStore>("portfolio");
|
||||
|
|
@ -24,11 +27,15 @@
|
|||
let activeResizeCleanup: (() => void) | undefined = undefined;
|
||||
let lastGutterClickTarget: EventTarget | undefined = undefined;
|
||||
let lastGutterClickTime = 0;
|
||||
let lastSubdivisionRef: PanelLayoutSubdivision | undefined = undefined;
|
||||
|
||||
// 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 = {};
|
||||
// Compare by reference because `safe_not_equal` treats any store update as changed, which would wipe drag overrides
|
||||
$: if (subdivision !== lastSubdivisionRef) {
|
||||
sizeOverrides = {};
|
||||
lastSubdivisionRef = subdivision;
|
||||
}
|
||||
// Reactive array of resolved sizes (merging backend defaults with local overrides)
|
||||
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||
$: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => {
|
||||
|
|
@ -55,26 +62,41 @@
|
|||
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
|
||||
// Double-click resets the two adjacent panels to the default ratio (80:20 near document, otherwise 50:50)
|
||||
const now = Date.now();
|
||||
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
|
||||
|
||||
lastGutterClickTime = now;
|
||||
lastGutterClickTarget = gutter;
|
||||
|
||||
if (isDoubleClick) {
|
||||
sizeOverrides = {};
|
||||
editor.resetPanelGroupSizes(splitPath);
|
||||
const children = subdivision.Split.children;
|
||||
const adjacentSum = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
|
||||
|
||||
const prevHasDocument = subtreeContainsDocument(children[prevIndex].subdivision);
|
||||
const nextHasDocument = subtreeContainsDocument(children[nextIndex].subdivision);
|
||||
|
||||
let prevShare = EQUAL_PANEL_SHARE;
|
||||
if (prevHasDocument && !nextHasDocument) prevShare = DOCUMENT_PANEL_SHARE;
|
||||
else if (!prevHasDocument && nextHasDocument) prevShare = 1 - DOCUMENT_PANEL_SHARE;
|
||||
|
||||
sizeOverrides[prevIndex] = adjacentSum * prevShare;
|
||||
sizeOverrides[nextIndex] = adjacentSum * (1 - prevShare);
|
||||
sizeOverrides = sizeOverrides;
|
||||
|
||||
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
|
||||
editor.setPanelGroupSizes(splitPath, allSizes);
|
||||
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;
|
||||
// Only redistribute within the two adjacent panels' combined flex-grow total
|
||||
const adjacentFlexGrowTotal = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
|
||||
const adjacentPixelTotal = prevSiblingSize + nextSiblingSize;
|
||||
|
||||
pointerCaptureId = e.pointerId;
|
||||
gutter.setPointerCapture(pointerCaptureId);
|
||||
|
|
@ -88,7 +110,9 @@
|
|||
activeResizeCleanup = undefined;
|
||||
|
||||
if (gutterResizeRestore !== undefined) {
|
||||
sizeOverrides = { ...sizeOverrides, [nextIndex]: gutterResizeRestore[0], [prevIndex]: gutterResizeRestore[1] };
|
||||
sizeOverrides[nextIndex] = gutterResizeRestore[0];
|
||||
sizeOverrides[prevIndex] = gutterResizeRestore[1];
|
||||
sizeOverrides = sizeOverrides;
|
||||
gutterResizeRestore = undefined;
|
||||
}
|
||||
};
|
||||
|
|
@ -102,11 +126,9 @@
|
|||
|
||||
if (gutterResizeRestore === undefined) gutterResizeRestore = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]];
|
||||
|
||||
sizeOverrides = {
|
||||
...sizeOverrides,
|
||||
[nextIndex]: ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
|
||||
[prevIndex]: ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
|
||||
};
|
||||
sizeOverrides[nextIndex] = (adjacentFlexGrowTotal * (nextSiblingSize + mouseDelta)) / adjacentPixelTotal;
|
||||
sizeOverrides[prevIndex] = (adjacentFlexGrowTotal * (prevSiblingSize - mouseDelta)) / adjacentPixelTotal;
|
||||
sizeOverrides = sizeOverrides;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
|
|
@ -164,6 +186,12 @@
|
|||
function isDocumentGroup(state: PanelGroupState): boolean {
|
||||
return state.tabs.some((t) => t === "Document" || t === "Welcome");
|
||||
}
|
||||
|
||||
function subtreeContainsDocument(node: PanelLayoutSubdivision): boolean {
|
||||
if ("PanelGroup" in node) return isDocumentGroup(node.PanelGroup.state);
|
||||
if ("Split" in node) return node.Split.children.some((child) => subtreeContainsDocument(child.subdivision));
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if subdivision && "PanelGroup" in subdivision}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { tick } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
|
||||
import { rasterizeSVG } from "/src/utility-functions/rasterization";
|
||||
import type { EditorWrapper, DocumentInfo, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
|
||||
|
||||
|
|
@ -12,12 +15,28 @@ type PortfolioStoreState = {
|
|||
documents: DocumentInfo[];
|
||||
activeDocumentIndex: number;
|
||||
panelLayout: WorkspacePanelLayout;
|
||||
welcomeScreenButtonsLayout: Layout;
|
||||
propertiesPanelLayout: Layout;
|
||||
dataPanelLayout: Layout;
|
||||
layersPanelControlBarLeftLayout: Layout;
|
||||
layersPanelControlBarRightLayout: Layout;
|
||||
layersPanelBottomBarLayout: Layout;
|
||||
layerCache: SvelteMap<string, LayerPanelEntry>;
|
||||
layerStructure: LayerStructureEntry[];
|
||||
};
|
||||
const initialState: PortfolioStoreState = {
|
||||
unsaved: false,
|
||||
documents: [],
|
||||
activeDocumentIndex: 0,
|
||||
panelLayout: {},
|
||||
welcomeScreenButtonsLayout: [],
|
||||
propertiesPanelLayout: [],
|
||||
dataPanelLayout: [],
|
||||
layersPanelControlBarLeftLayout: [],
|
||||
layersPanelControlBarRightLayout: [],
|
||||
layersPanelBottomBarLayout: [],
|
||||
layerCache: new SvelteMap<string, LayerPanelEntry>(),
|
||||
layerStructure: [],
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
|
@ -104,6 +123,69 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
|||
});
|
||||
});
|
||||
|
||||
// All panel layouts below live in this store so panels that remount during a panel-tree change keep their contents
|
||||
subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.welcomeScreenButtonsLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("PropertiesPanel", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.propertiesPanelLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.dataPanelLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.layersPanelControlBarLeftLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.layersPanelControlBarRightLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", async (data) => {
|
||||
await tick();
|
||||
update((state) => {
|
||||
patchLayout(state.layersPanelBottomBarLayout, data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => {
|
||||
update((state) => {
|
||||
state.layerStructure = data.layerStructure;
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => {
|
||||
update((state) => {
|
||||
state.layerCache.set(String(data.data.id), data.data);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return { subscribe };
|
||||
}
|
||||
|
||||
|
|
@ -120,4 +202,12 @@ export function destroyPortfolioStore() {
|
|||
subscriptions.unsubscribeFrontendMessage("TriggerSaveFile");
|
||||
subscriptions.unsubscribeFrontendMessage("TriggerExportImage");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout");
|
||||
subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons");
|
||||
subscriptions.unsubscribeLayoutUpdate("PropertiesPanel");
|
||||
subscriptions.unsubscribeLayoutUpdate("DataPanel");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar");
|
||||
subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure");
|
||||
subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,13 +498,6 @@ impl EditorWrapper {
|
|||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = resetPanelGroupSizes)]
|
||||
pub fn reset_panel_group_sizes(&self, split_path: JsValue) {
|
||||
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
|
||||
let message = PortfolioMessage::ResetPanelGroupSizes { split_path };
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = setPanelGroupSizes)]
|
||||
pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) {
|
||||
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap();
|
||||
|
|
|
|||
Loading…
Reference in New Issue