Fix panel docking bugs and polish its behavior (#4087)

* Fix panel docking bugs and polish its behavior

* Fix bug
This commit is contained in:
Keavon Chambers 2026-05-01 04:32:11 -07:00 committed by Timon
parent 4c1974c200
commit 83d03ad67d
14 changed files with 417 additions and 255 deletions

View File

@ -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);

View File

@ -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 => {

View File

@ -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>,

View File

@ -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);
}
}
}
}
}

View File

@ -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();
}
}
}

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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();
}

View File

@ -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}

View File

@ -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");
}

View File

@ -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();