Add support for interactive panel docking (#4015)
* Add interactive panel docking * Preserve active tab when a panel group is docked * Add inter-panel gutter hover color * Code review fixes * More code review
This commit is contained in:
parent
39656d4c73
commit
b099e2faca
|
|
@ -1,5 +1,5 @@
|
||||||
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||||
use super::utility_types::PanelGroupId;
|
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
|
||||||
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
|
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
|
||||||
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
||||||
use crate::messages::portfolio::utility_types::FontCatalog;
|
use crate::messages::portfolio::utility_types::FontCatalog;
|
||||||
|
|
@ -61,6 +61,11 @@ pub enum PortfolioMessage {
|
||||||
LoadDocumentResources {
|
LoadDocumentResources {
|
||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
},
|
},
|
||||||
|
MoveAllPanelTabs {
|
||||||
|
source_group: PanelGroupId,
|
||||||
|
target_group: PanelGroupId,
|
||||||
|
insert_index: usize,
|
||||||
|
},
|
||||||
MovePanelTab {
|
MovePanelTab {
|
||||||
source_group: PanelGroupId,
|
source_group: PanelGroupId,
|
||||||
target_group: PanelGroupId,
|
target_group: PanelGroupId,
|
||||||
|
|
@ -146,6 +151,12 @@ pub enum PortfolioMessage {
|
||||||
group: PanelGroupId,
|
group: PanelGroupId,
|
||||||
tab_index: usize,
|
tab_index: usize,
|
||||||
},
|
},
|
||||||
|
SplitPanelGroup {
|
||||||
|
target_group: PanelGroupId,
|
||||||
|
direction: DockingSplitDirection,
|
||||||
|
tabs: Vec<PanelType>,
|
||||||
|
active_tab_index: usize,
|
||||||
|
},
|
||||||
SelectDocument {
|
SelectDocument {
|
||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,59 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
self.load_document(new_document, document_id, responses, false);
|
self.load_document(new_document, document_id, responses, false);
|
||||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
responses.add(PortfolioMessage::SelectDocument { document_id });
|
||||||
}
|
}
|
||||||
|
PortfolioMessage::MoveAllPanelTabs {
|
||||||
|
source_group,
|
||||||
|
target_group,
|
||||||
|
insert_index,
|
||||||
|
} => {
|
||||||
|
if source_group == target_group {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(source_state) = self.workspace_panel_layout.panel_group(source_group) else { return };
|
||||||
|
let tabs: Vec<PanelType> = source_state.tabs.clone();
|
||||||
|
let source_active_tab_index = source_state.active_tab_index;
|
||||||
|
if tabs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the target group exists before modifying the source
|
||||||
|
if self.workspace_panel_layout.panel_group(target_group).is_none() {
|
||||||
|
log::error!("Target panel group {target_group:?} not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy layouts for all moved tabs and the displaced target tab
|
||||||
|
for &panel_type in &tabs {
|
||||||
|
Self::destroy_panel_layouts(panel_type, responses);
|
||||||
|
}
|
||||||
|
if let Some(old_target_panel) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
|
||||||
|
Self::destroy_panel_layouts(old_target_panel, responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the source group
|
||||||
|
if let Some(source) = self.workspace_panel_layout.panel_group_mut(source_group) {
|
||||||
|
source.tabs.clear();
|
||||||
|
source.active_tab_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert all tabs into the target group, preserving which tab was active in the source
|
||||||
|
if let Some(target) = self.workspace_panel_layout.panel_group_mut(target_group) {
|
||||||
|
let index = insert_index.min(target.tabs.len());
|
||||||
|
target.tabs.splice(index..index, tabs.iter().copied());
|
||||||
|
target.active_tab_index = index + source_active_tab_index.min(tabs.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.workspace_panel_layout.prune();
|
||||||
|
|
||||||
|
responses.add(MenuBarMessage::SendLayout);
|
||||||
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
|
|
||||||
|
// Refresh the new active tab
|
||||||
|
if let Some(panel_type) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
|
||||||
|
self.refresh_panel_content(panel_type, responses);
|
||||||
|
}
|
||||||
|
}
|
||||||
PortfolioMessage::MovePanelTab {
|
PortfolioMessage::MovePanelTab {
|
||||||
source_group,
|
source_group,
|
||||||
target_group,
|
target_group,
|
||||||
|
|
@ -1222,6 +1275,45 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PortfolioMessage::SplitPanelGroup {
|
||||||
|
target_group,
|
||||||
|
direction,
|
||||||
|
tabs,
|
||||||
|
active_tab_index,
|
||||||
|
} => {
|
||||||
|
// Destroy layouts for the dragged tabs and the target group's active panel (it may get remounted by the frontend)
|
||||||
|
for &panel_type in &tabs {
|
||||||
|
Self::destroy_panel_layouts(panel_type, responses);
|
||||||
|
}
|
||||||
|
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
|
||||||
|
Self::destroy_panel_layouts(target_active, responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
log::error!("Failed to insert split adjacent to panel group {target_group:?}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.workspace_panel_layout.prune();
|
||||||
|
|
||||||
|
responses.add(MenuBarMessage::SendLayout);
|
||||||
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
|
|
||||||
|
// Refresh the new panel group's active tab
|
||||||
|
if let Some(panel_type) = self.workspace_panel_layout.panel_group(new_id).and_then(|g| g.active_panel_type()) {
|
||||||
|
self.refresh_panel_content(panel_type, responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the target group's active panel since its component may have been remounted
|
||||||
|
if let Some(target_active) = self.workspace_panel_layout.panel_group(target_group).and_then(|g| g.active_panel_type()) {
|
||||||
|
self.refresh_panel_content(target_active, responses);
|
||||||
|
}
|
||||||
|
}
|
||||||
PortfolioMessage::SelectDocument { document_id } => {
|
PortfolioMessage::SelectDocument { document_id } => {
|
||||||
// Auto-save the document we are leaving
|
// Auto-save the document we are leaving
|
||||||
let mut node_graph_open = false;
|
let mut node_graph_open = false;
|
||||||
|
|
@ -1667,7 +1759,7 @@ impl PortfolioMessageHandler {
|
||||||
selected_nodes.first().copied()
|
selected_nodes.first().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a dockable panel type from whichever panel group currently contains it, then prune empty groups.
|
/// Remove a dockable panel type from whichever panel group currently contains it. Does not prune empty groups.
|
||||||
fn remove_panel_from_layout(&mut self, panel_type: PanelType) {
|
fn remove_panel_from_layout(&mut self, panel_type: PanelType) {
|
||||||
// Save the panel's current position so it can be restored there later
|
// Save the panel's current position so it can be restored there later
|
||||||
self.workspace_panel_layout.save_panel_position(panel_type);
|
self.workspace_panel_layout.save_panel_position(panel_type);
|
||||||
|
|
@ -1678,8 +1770,6 @@ impl PortfolioMessageHandler {
|
||||||
group.tabs.retain(|&t| t != panel_type);
|
group.tabs.retain(|&t| t != panel_type);
|
||||||
group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1));
|
group.active_tab_index = group.active_tab_index.min(group.tabs.len().saturating_sub(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.workspace_panel_layout.prune();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a dockable panel on or off. When toggling off, refresh the newly active tab in its panel group (if any).
|
/// Toggle a dockable panel on or off. When toggling off, refresh the newly active tab in its panel group (if any).
|
||||||
|
|
@ -1689,6 +1779,7 @@ impl PortfolioMessageHandler {
|
||||||
let was_visible = self.workspace_panel_layout.panel_group(group_id).is_some_and(|g| g.is_visible(panel_type));
|
let was_visible = self.workspace_panel_layout.panel_group(group_id).is_some_and(|g| g.is_visible(panel_type));
|
||||||
Self::destroy_panel_layouts(panel_type, responses);
|
Self::destroy_panel_layouts(panel_type, responses);
|
||||||
self.remove_panel_from_layout(panel_type);
|
self.remove_panel_from_layout(panel_type);
|
||||||
|
self.workspace_panel_layout.prune();
|
||||||
|
|
||||||
// If the removed panel was the active tab, refresh whichever panel is now active in that panel group
|
// If the removed panel was the active tab, refresh whichever panel is now active in that panel group
|
||||||
if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
|
if was_visible && let Some(new_active) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,16 @@ impl From<String> for PanelType {
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PanelGroupId(pub u64);
|
pub struct PanelGroupId(pub u64);
|
||||||
|
|
||||||
|
/// Which edge of a panel group to split on when docking a dragged panel.
|
||||||
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum DockingSplitDirection {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
/// State of a single panel group (leaf subdivision) in the workspace layout tree.
|
/// State of a single panel group (leaf subdivision) in the workspace layout tree.
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
||||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -207,6 +217,26 @@ impl WorkspacePanelLayout {
|
||||||
self.root.prune();
|
self.root.prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split a panel group by inserting a new panel group adjacent to it.
|
||||||
|
/// 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> {
|
||||||
|
let new_id = self.next_id();
|
||||||
|
let new_group = SplitChild {
|
||||||
|
subdivision: PanelLayoutSubdivision::PanelGroup {
|
||||||
|
id: new_id,
|
||||||
|
state: PanelGroupState { tabs, active_tab_index },
|
||||||
|
},
|
||||||
|
size: 50.,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Recalculate the default sizes for all splits in the tree based on document panel proximity.
|
/// Recalculate the default sizes for all splits in the tree based on document panel proximity.
|
||||||
pub fn recalculate_default_sizes(&mut self) {
|
pub fn recalculate_default_sizes(&mut self) {
|
||||||
self.root.recalculate_default_sizes();
|
self.root.recalculate_default_sizes();
|
||||||
|
|
@ -409,25 +439,80 @@ impl PanelLayoutSubdivision {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove empty panel groups and collapse single-child splits.
|
/// 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.
|
||||||
pub fn prune(&mut self) {
|
pub fn prune(&mut self) {
|
||||||
if let PanelLayoutSubdivision::Split { children } = self {
|
let PanelLayoutSubdivision::Split { children } = self else { return };
|
||||||
// Recursively prune children first
|
|
||||||
children.iter_mut().for_each(|child| child.subdivision.prune());
|
|
||||||
|
|
||||||
// Remove empty panel groups
|
// Recursively prune children
|
||||||
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()));
|
children.iter_mut().for_each(|child| child.subdivision.prune());
|
||||||
|
|
||||||
// Remove empty splits (splits that lost all their children after pruning)
|
// Remove empty panel groups
|
||||||
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
|
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()));
|
||||||
|
|
||||||
// If a split has exactly one child, replace this subdivision with that child's subdivision
|
// Remove empty splits (splits that lost all their children after pruning)
|
||||||
if children.len() == 1 {
|
children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty()));
|
||||||
*self = children.remove(0).subdivision;
|
}
|
||||||
}
|
|
||||||
|
/// Check if this subtree contains a panel group with the given ID.
|
||||||
|
pub fn contains_group(&self, target_id: PanelGroupId) -> bool {
|
||||||
|
match self {
|
||||||
|
PanelLayoutSubdivision::PanelGroup { id, .. } => *id == target_id,
|
||||||
|
PanelLayoutSubdivision::Split { children } => children.iter().any(|child| child.subdivision.contains_group(target_id)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let PanelLayoutSubdivision::Split { children } = self else { return false };
|
||||||
|
|
||||||
|
let is_horizontal = depth.is_multiple_of(2);
|
||||||
|
let direction_matches = is_horizontal == needs_horizontal;
|
||||||
|
|
||||||
|
// Find which child subtree contains the target
|
||||||
|
let Some(containing_index) = children.iter().position(|child| child.subdivision.contains_group(target_id)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if direction_matches {
|
||||||
|
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)
|
||||||
|
else {
|
||||||
|
let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] });
|
||||||
|
let old_child = SplitChild {
|
||||||
|
subdivision: old_child_subdivision,
|
||||||
|
size: 50.,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision {
|
||||||
|
if insert_before {
|
||||||
|
sub_children.push(new_child);
|
||||||
|
sub_children.push(old_child);
|
||||||
|
} else {
|
||||||
|
sub_children.push(old_child);
|
||||||
|
sub_children.push(new_child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if this subtree contains the document panel.
|
/// Check if this subtree contains the document panel.
|
||||||
pub fn contains_document(&self) -> bool {
|
pub fn contains_document(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
||||||
|
|
@ -54,30 +54,6 @@
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 100%;
|
|
||||||
|
|
||||||
.workspace-grid-subdivision {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 28px;
|
|
||||||
|
|
||||||
&.folded {
|
|
||||||
flex-grow: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-grid-resize-gutter {
|
|
||||||
flex: 0 0 4px;
|
|
||||||
|
|
||||||
&.layout-row {
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.layout-col {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Needed for the viewport hole punch on desktop
|
// Needed for the viewport hole punch on desktop
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
import Welcome from "/src/components/panels/Welcome.svelte";
|
import Welcome from "/src/components/panels/Welcome.svelte";
|
||||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||||
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover } from "/src/stores/panel-drag";
|
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag";
|
||||||
|
import type { DockingEdge } from "/src/stores/panel-drag";
|
||||||
import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
const PANEL_COMPONENTS = {
|
const PANEL_COMPONENTS = {
|
||||||
|
|
@ -37,6 +38,8 @@
|
||||||
export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
|
export let reorderAction: ((oldIndex: number, newIndex: number) => void) | undefined = undefined;
|
||||||
export let emptySpaceAction: (() => void) | undefined = undefined;
|
export let emptySpaceAction: (() => void) | undefined = undefined;
|
||||||
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
||||||
|
export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
|
||||||
|
export let splitDropAction: ((targetPanelId: string, direction: DockingEdge, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined;
|
||||||
|
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
@ -48,7 +51,7 @@
|
||||||
let tabElements: (LayoutRow | undefined)[] = [];
|
let tabElements: (LayoutRow | undefined)[] = [];
|
||||||
|
|
||||||
// Tab drag-and-drop state
|
// Tab drag-and-drop state
|
||||||
let dragStartState: { tabIndex: number; pointerX: number; pointerY: number } | undefined = undefined;
|
let dragStartState: { tabIndex: number; pointerX: number; pointerY: number; isGroupDrag: boolean } | undefined = undefined;
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
let insertionIndex: number | undefined = undefined;
|
let insertionIndex: number | undefined = undefined;
|
||||||
let insertionMarkerLeft: number | undefined = undefined;
|
let insertionMarkerLeft: number | undefined = undefined;
|
||||||
|
|
@ -64,6 +67,20 @@
|
||||||
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
|
if (e.button === BUTTON_MIDDLE || (e.button === BUTTON_LEFT && e.detail === 2)) emptySpaceAction?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tabBarPointerDown(e: PointerEvent) {
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
dragStartState = { tabIndex: tabActiveIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: true };
|
||||||
|
dragging = false;
|
||||||
|
insertionIndex = undefined;
|
||||||
|
insertionMarkerLeft = undefined;
|
||||||
|
|
||||||
|
addDragListeners();
|
||||||
|
}
|
||||||
|
|
||||||
export async function scrollTabIntoView(newIndex: number) {
|
export async function scrollTabIntoView(newIndex: number) {
|
||||||
await tick();
|
await tick();
|
||||||
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
tabElements[newIndex]?.div?.()?.scrollIntoView();
|
||||||
|
|
@ -83,7 +100,7 @@
|
||||||
const canCrossPanelDrag = crossPanelDropAction !== undefined;
|
const canCrossPanelDrag = crossPanelDropAction !== undefined;
|
||||||
if (!canReorder && !canCrossPanelDrag) return;
|
if (!canReorder && !canCrossPanelDrag) return;
|
||||||
|
|
||||||
dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY };
|
dragStartState = { tabIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: false };
|
||||||
dragging = false;
|
dragging = false;
|
||||||
insertionIndex = undefined;
|
insertionIndex = undefined;
|
||||||
insertionMarkerLeft = undefined;
|
insertionMarkerLeft = undefined;
|
||||||
|
|
@ -103,8 +120,12 @@
|
||||||
dragging = true;
|
dragging = true;
|
||||||
|
|
||||||
if (crossPanelDropAction) {
|
if (crossPanelDropAction) {
|
||||||
// Notify the shared store that a cross-panel drag has started
|
if (dragStartState.isGroupDrag) {
|
||||||
startCrossPanelDrag(panelId, tabLabels[dragStartState.tabIndex].name, dragStartState.tabIndex);
|
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
|
||||||
|
} else {
|
||||||
|
const draggedTab = panelTypes[dragStartState.tabIndex];
|
||||||
|
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +144,7 @@
|
||||||
|
|
||||||
// Check if the pointer is over any other dockable panel's tab bar
|
// Check if the pointer is over any other dockable panel's tab bar
|
||||||
if (crossPanelDropAction) {
|
if (crossPanelDropAction) {
|
||||||
const target = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => {
|
const tabBarTarget = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => {
|
||||||
const targetPanelId = element.getAttribute("data-panel-tab-bar");
|
const targetPanelId = element.getAttribute("data-panel-tab-bar");
|
||||||
if (!targetPanelId || targetPanelId === panelId) return false;
|
if (!targetPanelId || targetPanelId === panelId) return false;
|
||||||
|
|
||||||
|
|
@ -131,12 +152,39 @@
|
||||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetPanelId = target?.getAttribute("data-panel-tab-bar");
|
const tabBarTargetId = tabBarTarget?.getAttribute("data-panel-tab-bar");
|
||||||
if (target instanceof HTMLDivElement && targetPanelId) {
|
if (tabBarTarget instanceof HTMLDivElement && tabBarTargetId) {
|
||||||
calculateForeignInsertionIndex(e.clientX, targetPanelId, target);
|
calculateForeignInsertionIndex(e.clientX, tabBarTargetId, tabBarTarget);
|
||||||
} else {
|
return;
|
||||||
updateCrossPanelHover(undefined, undefined, undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not hovering any drop target
|
||||||
|
updateCrossPanelHover(undefined, undefined, undefined);
|
||||||
|
updateDockingHover(undefined, undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,15 +192,30 @@
|
||||||
if (dragging && dragStartState) {
|
if (dragging && dragStartState) {
|
||||||
const crossPanelState = $panelDrag;
|
const crossPanelState = $panelDrag;
|
||||||
|
|
||||||
// Cross-panel drop: the pointer is over a different panel's tab bar
|
// Center drop: append tabs to the target panel group
|
||||||
if (
|
if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge === "Center") {
|
||||||
|
const dropAction = crossPanelState.draggingGroup ? groupDropAction : crossPanelDropAction;
|
||||||
|
dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER);
|
||||||
|
}
|
||||||
|
// Edge docking drop: create a new split adjacent to the target panel
|
||||||
|
else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge) {
|
||||||
|
splitDropAction?.(
|
||||||
|
crossPanelState.hoverDockingPanelId,
|
||||||
|
crossPanelState.hoverDockingEdge,
|
||||||
|
crossPanelState.draggedTabs,
|
||||||
|
crossPanelState.draggingGroup ? crossPanelState.sourceTabIndex : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Cross-panel tab bar drop: insert as a tab in the target panel group
|
||||||
|
else if (
|
||||||
crossPanelDropAction &&
|
crossPanelDropAction &&
|
||||||
crossPanelState.active &&
|
crossPanelState.active &&
|
||||||
crossPanelState.hoverTargetPanelId &&
|
crossPanelState.hoverTargetPanelId &&
|
||||||
crossPanelState.hoverTargetPanelId !== panelId &&
|
crossPanelState.hoverTargetPanelId !== panelId &&
|
||||||
crossPanelState.hoverInsertionIndex !== undefined
|
crossPanelState.hoverInsertionIndex !== undefined
|
||||||
) {
|
) {
|
||||||
crossPanelDropAction?.(panelId, crossPanelState.hoverTargetPanelId, crossPanelState.hoverInsertionIndex);
|
const dropAction = crossPanelState.draggingGroup ? groupDropAction : crossPanelDropAction;
|
||||||
|
dropAction?.(panelId, crossPanelState.hoverTargetPanelId, crossPanelState.hoverInsertionIndex);
|
||||||
}
|
}
|
||||||
// Within-panel reorder
|
// Within-panel reorder
|
||||||
else if (insertionIndex !== undefined) {
|
else if (insertionIndex !== undefined) {
|
||||||
|
|
@ -199,6 +262,27 @@
|
||||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect which zone the pointer is in: the nearest edge (by diagonal quadrant) if within the 25% border, or center if interior.
|
||||||
|
function detectDockingEdge(clientX: number, clientY: number, rect: DOMRect): DockingEdge {
|
||||||
|
const distLeft = clientX - rect.left;
|
||||||
|
const distRight = rect.right - clientX;
|
||||||
|
const distTop = clientY - rect.top;
|
||||||
|
const distBottom = rect.bottom - clientY;
|
||||||
|
const minDist = Math.min(distLeft, distRight, distTop, distBottom);
|
||||||
|
|
||||||
|
// If the nearest edge is beyond the 25% threshold, it's the center zone
|
||||||
|
const THRESHOLD = 0.25;
|
||||||
|
const edgeThresholdX = rect.width * THRESHOLD;
|
||||||
|
const edgeThresholdY = rect.height * THRESHOLD;
|
||||||
|
if (distLeft > edgeThresholdX && distRight > edgeThresholdX && distTop > edgeThresholdY && distBottom > edgeThresholdY) return "Center";
|
||||||
|
|
||||||
|
// Return whichever edge is closest (diagonal dividing lines between quadrants)
|
||||||
|
if (minDist === distLeft) return "Left";
|
||||||
|
if (minDist === distRight) return "Right";
|
||||||
|
if (minDist === distTop) return "Top";
|
||||||
|
return "Bottom";
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate the insertion position for a foreign panel's tab bar
|
// Calculate the insertion position for a foreign panel's tab bar
|
||||||
function calculateForeignInsertionIndex(pointerX: number, targetPanelId: string, tabBarDiv: HTMLDivElement) {
|
function calculateForeignInsertionIndex(pointerX: number, targetPanelId: string, tabBarDiv: HTMLDivElement) {
|
||||||
const tabBarRect = tabBarDiv.getBoundingClientRect();
|
const tabBarRect = tabBarDiv.getBoundingClientRect();
|
||||||
|
|
@ -270,12 +354,21 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LayoutCol on:pointerdown={() => panelTypes[tabActiveIndex] && editor.setActivePanel(panelTypes[tabActiveIndex])} class={`panel ${className}`.trim()} {classes} style={styleName} {styles}>
|
<LayoutCol
|
||||||
|
on:pointerdown={() => panelTypes[tabActiveIndex] && editor.setActivePanel(panelTypes[tabActiveIndex])}
|
||||||
|
class={`panel ${className}`.trim()}
|
||||||
|
{classes}
|
||||||
|
style={styleName}
|
||||||
|
{styles}
|
||||||
|
data-panel-body={panelId}
|
||||||
|
data-panel-dockable={crossPanelDropAction ? "" : undefined}
|
||||||
|
>
|
||||||
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
<LayoutRow class="tab-bar" classes={{ "min-widths": tabMinWidths }}>
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
class="tab-group"
|
class="tab-group"
|
||||||
scrollableX={true}
|
scrollableX={true}
|
||||||
data-panel-tab-bar={crossPanelDropAction ? panelId : undefined}
|
data-panel-tab-bar={crossPanelDropAction ? panelId : undefined}
|
||||||
|
on:pointerdown={tabBarPointerDown}
|
||||||
on:click={onEmptySpaceAction}
|
on:click={onEmptySpaceAction}
|
||||||
on:auxclick={onEmptySpaceAction}
|
on:auxclick={onEmptySpaceAction}
|
||||||
bind:this={tabGroupElement}
|
bind:this={tabGroupElement}
|
||||||
|
|
@ -330,6 +423,16 @@
|
||||||
<svelte:component this={PANEL_COMPONENTS[panelTypes[tabActiveIndex]]} />
|
<svelte:component this={PANEL_COMPONENTS[panelTypes[tabActiveIndex]]} />
|
||||||
{/if}
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
|
{#if $panelDrag.active && $panelDrag.hoverDockingPanelId === panelId && $panelDrag.hoverDockingEdge}
|
||||||
|
<div
|
||||||
|
class="docking-ghost"
|
||||||
|
class:left={$panelDrag.hoverDockingEdge === "Left"}
|
||||||
|
class:right={$panelDrag.hoverDockingEdge === "Right"}
|
||||||
|
class:top={$panelDrag.hoverDockingEdge === "Top"}
|
||||||
|
class:bottom={$panelDrag.hoverDockingEdge === "Bottom"}
|
||||||
|
class:center={$panelDrag.hoverDockingEdge === "Center"}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -337,6 +440,7 @@
|
||||||
background: var(--color-1-nearblack);
|
background: var(--color-1-nearblack);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -473,6 +577,59 @@
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(.docking-ghost) .tab-bar,
|
||||||
|
&:has(.docking-ghost) .panel-body {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docking-ghost {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(var(--color-f-white-rgb), 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
transition:
|
||||||
|
top 0.2s ease,
|
||||||
|
left 0.2s ease,
|
||||||
|
width 0.2s ease,
|
||||||
|
height 0.2s ease;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: calc(100% - 12px);
|
||||||
|
height: calc(100% - 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Needed for the viewport hole punch on desktop
|
// Needed for the viewport hole punch on desktop
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,14 @@
|
||||||
editor.movePanelTab(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
editor.movePanelTab(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupDrop(sourcePanelId: string, targetPanelId: string, insertIndex: number) {
|
||||||
|
editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDrop(targetPanelId: string, direction: string, tabs: string[], activeTabIndex: number) {
|
||||||
|
editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
function isDocumentGroup(state: PanelGroupState): boolean {
|
function isDocumentGroup(state: PanelGroupState): boolean {
|
||||||
return state.tabs.some((t) => t === "Document" || t === "Welcome");
|
return state.tabs.some((t) => t === "Document" || t === "Welcome");
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +174,8 @@
|
||||||
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
closeAction={(tabIndex) => editor.closeDocumentWithConfirmation($portfolio.documents[tabIndex].id)}
|
||||||
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
reorderAction={(oldIndex, newIndex) => editor.reorderDocument($portfolio.documents[oldIndex].id, newIndex)}
|
||||||
tabActiveIndex={$portfolio.activeDocumentIndex}
|
tabActiveIndex={$portfolio.activeDocumentIndex}
|
||||||
|
groupDropAction={groupDrop}
|
||||||
|
splitDropAction={splitDrop}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Panel
|
<Panel
|
||||||
|
|
@ -176,6 +186,8 @@
|
||||||
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
|
clickAction={(tabIndex) => editor.setPanelGroupActiveTab(group.id, tabIndex)}
|
||||||
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
|
reorderAction={(oldIndex, newIndex) => editor.reorderPanelGroupTab(group.id, oldIndex, newIndex)}
|
||||||
crossPanelDropAction={crossPanelDrop}
|
crossPanelDropAction={crossPanelDrop}
|
||||||
|
groupDropAction={groupDrop}
|
||||||
|
splitDropAction={splitDrop}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if "Split" in subdivision}
|
{:else if "Split" in subdivision}
|
||||||
|
|
@ -198,3 +210,35 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.workspace-grid-subdivision {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 28px;
|
||||||
|
|
||||||
|
&.folded {
|
||||||
|
flex-grow: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid-resize-gutter {
|
||||||
|
flex: 0 0 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.2s 0s;
|
||||||
|
|
||||||
|
&.layout-row {
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-col {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-5-dullgray);
|
||||||
|
transition: background 0.2s 0.1s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,36 @@
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
import type { PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
|
export type DockingEdge = "Left" | "Right" | "Top" | "Bottom" | "Center";
|
||||||
|
|
||||||
export type PanelDragState = {
|
export type PanelDragState = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
sourcePanelId: string | undefined;
|
sourcePanelId: string | undefined;
|
||||||
draggedTabLabel: string | undefined;
|
draggedTabs: PanelType[];
|
||||||
sourceTabIndex: number;
|
sourceTabIndex: number;
|
||||||
// Which panel's tab bar the pointer is currently hovering over (undefined if none)
|
// Whether we're dragging an entire tab group (via the tab bar background) vs a single tab
|
||||||
|
draggingGroup: boolean;
|
||||||
|
// Hover state for tab bar insertion (existing behavior)
|
||||||
hoverTargetPanelId: string | undefined;
|
hoverTargetPanelId: string | undefined;
|
||||||
hoverInsertionIndex: number | undefined;
|
hoverInsertionIndex: number | undefined;
|
||||||
hoverInsertionMarkerLeft: number | undefined;
|
hoverInsertionMarkerLeft: number | undefined;
|
||||||
|
// Hover state for edge docking (new split creation)
|
||||||
|
hoverDockingPanelId: string | undefined;
|
||||||
|
hoverDockingEdge: DockingEdge | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: PanelDragState = {
|
const initialState: PanelDragState = {
|
||||||
active: false,
|
active: false,
|
||||||
sourcePanelId: undefined,
|
sourcePanelId: undefined,
|
||||||
draggedTabLabel: undefined,
|
draggedTabs: [],
|
||||||
sourceTabIndex: 0,
|
sourceTabIndex: 0,
|
||||||
|
draggingGroup: false,
|
||||||
hoverTargetPanelId: undefined,
|
hoverTargetPanelId: undefined,
|
||||||
hoverInsertionIndex: undefined,
|
hoverInsertionIndex: undefined,
|
||||||
hoverInsertionMarkerLeft: undefined,
|
hoverInsertionMarkerLeft: undefined,
|
||||||
|
hoverDockingPanelId: undefined,
|
||||||
|
hoverDockingEdge: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
// Store state persisted across HMR to maintain reactive subscriptions in the component tree
|
||||||
|
|
@ -28,12 +39,13 @@ if (import.meta.hot) import.meta.hot.data.store = store;
|
||||||
|
|
||||||
export const panelDrag = store;
|
export const panelDrag = store;
|
||||||
|
|
||||||
export function startCrossPanelDrag(sourcePanelId: string, draggedTabLabel: string, sourceTabIndex: number) {
|
export function startCrossPanelDrag(sourcePanelId: string, draggedTabs: PanelType[], sourceTabIndex: number, draggingGroup: boolean) {
|
||||||
store.update((state) => {
|
store.update((state) => {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.sourcePanelId = sourcePanelId;
|
state.sourcePanelId = sourcePanelId;
|
||||||
state.draggedTabLabel = draggedTabLabel;
|
state.draggedTabs = draggedTabs;
|
||||||
state.sourceTabIndex = sourceTabIndex;
|
state.sourceTabIndex = sourceTabIndex;
|
||||||
|
state.draggingGroup = draggingGroup;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -42,11 +54,14 @@ export function endCrossPanelDrag() {
|
||||||
store.update((state) => {
|
store.update((state) => {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.sourcePanelId = undefined;
|
state.sourcePanelId = undefined;
|
||||||
state.draggedTabLabel = undefined;
|
state.draggedTabs = [];
|
||||||
state.sourceTabIndex = 0;
|
state.sourceTabIndex = 0;
|
||||||
|
state.draggingGroup = false;
|
||||||
state.hoverTargetPanelId = undefined;
|
state.hoverTargetPanelId = undefined;
|
||||||
state.hoverInsertionIndex = undefined;
|
state.hoverInsertionIndex = undefined;
|
||||||
state.hoverInsertionMarkerLeft = undefined;
|
state.hoverInsertionMarkerLeft = undefined;
|
||||||
|
state.hoverDockingPanelId = undefined;
|
||||||
|
state.hoverDockingEdge = undefined;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +71,21 @@ export function updateCrossPanelHover(hoverTargetPanelId: string | undefined, ho
|
||||||
state.hoverTargetPanelId = hoverTargetPanelId;
|
state.hoverTargetPanelId = hoverTargetPanelId;
|
||||||
state.hoverInsertionIndex = hoverInsertionIndex;
|
state.hoverInsertionIndex = hoverInsertionIndex;
|
||||||
state.hoverInsertionMarkerLeft = hoverInsertionMarkerLeft;
|
state.hoverInsertionMarkerLeft = hoverInsertionMarkerLeft;
|
||||||
|
// Clear docking state when hovering a tab bar
|
||||||
|
state.hoverDockingPanelId = undefined;
|
||||||
|
state.hoverDockingEdge = undefined;
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDockingHover(panelId: string | undefined, edge: DockingEdge | undefined) {
|
||||||
|
store.update((state) => {
|
||||||
|
state.hoverDockingPanelId = panelId;
|
||||||
|
state.hoverDockingEdge = edge;
|
||||||
|
// Clear tab bar insertion state when hovering an edge
|
||||||
|
state.hoverTargetPanelId = undefined;
|
||||||
|
state.hoverInsertionIndex = undefined;
|
||||||
|
state.hoverInsertionMarkerLeft = undefined;
|
||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
|
||||||
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
|
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
|
||||||
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||||
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
|
||||||
use editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, PanelGroupId};
|
use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType};
|
||||||
use editor::messages::prelude::*;
|
use editor::messages::prelude::*;
|
||||||
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
||||||
use graph_craft::document::NodeId;
|
use graph_craft::document::NodeId;
|
||||||
|
|
@ -444,6 +444,16 @@ impl EditorWrapper {
|
||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = moveAllPanelTabs)]
|
||||||
|
pub fn move_all_panel_tabs(&self, source_group: u64, target_group: u64, insert_index: usize) {
|
||||||
|
let message = PortfolioMessage::MoveAllPanelTabs {
|
||||||
|
source_group: PanelGroupId(source_group),
|
||||||
|
target_group: PanelGroupId(target_group),
|
||||||
|
insert_index,
|
||||||
|
};
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = movePanelTab)]
|
#[wasm_bindgen(js_name = movePanelTab)]
|
||||||
pub fn move_panel_tab(&self, source_group: u64, target_group: u64, insert_index: usize) {
|
pub fn move_panel_tab(&self, source_group: u64, target_group: u64, insert_index: usize) {
|
||||||
let message = PortfolioMessage::MovePanelTab {
|
let message = PortfolioMessage::MovePanelTab {
|
||||||
|
|
@ -463,6 +473,19 @@ impl EditorWrapper {
|
||||||
self.dispatch(message);
|
self.dispatch(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = splitPanelGroup)]
|
||||||
|
pub fn split_panel_group(&self, target_group: u64, direction: String, tabs: JsValue, active_tab_index: usize) {
|
||||||
|
let direction: DockingSplitDirection = serde_wasm_bindgen::from_value(JsValue::from_str(&direction)).unwrap();
|
||||||
|
let tabs: Vec<PanelType> = serde_wasm_bindgen::from_value(tabs).unwrap();
|
||||||
|
let message = PortfolioMessage::SplitPanelGroup {
|
||||||
|
target_group: PanelGroupId(target_group),
|
||||||
|
direction,
|
||||||
|
tabs,
|
||||||
|
active_tab_index,
|
||||||
|
};
|
||||||
|
self.dispatch(message);
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
#[wasm_bindgen(js_name = closeDocumentWithConfirmation)]
|
||||||
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
pub fn close_document_with_confirmation(&self, document_id: u64) {
|
||||||
let document_id = DocumentId(document_id);
|
let document_id = DocumentId(document_id);
|
||||||
|
|
|
||||||
|
|
@ -142,9 +142,9 @@ Marrying vector and raster under one roof enables both art forms to complement e
|
||||||
<img class="atlas" style="--atlas-index: 73" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 73" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Blend tool to morph between shapes</span>
|
<span>Blend tool to morph between shapes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-icon ongoing" title="Development Ongoing">
|
<div class="feature-icon complete" title="Development Complete">
|
||||||
<img class="atlas" style="--atlas-index: 24" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 24" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Dockable and multi-window panels</span>
|
<span>Dockable panels and tab reordering</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-icon ongoing" title="Development Ongoing">
|
<div class="feature-icon ongoing" title="Development Ongoing">
|
||||||
<img class="atlas" style="--atlas-index: 17" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 17" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
|
|
@ -290,6 +290,14 @@ Marrying vector and raster under one roof enables both art forms to complement e
|
||||||
<img class="atlas" style="--atlas-index: 27" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 27" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Automation/batch processing tools</span>
|
<span>Automation/batch processing tools</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-icon">
|
||||||
|
<img class="atlas" style="--atlas-index: 24" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
|
<span>Multiple adjacent document panels</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-icon">
|
||||||
|
<img class="atlas" style="--atlas-index: 40" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
|
<span>Tear-out panels as separate windows</span>
|
||||||
|
</div>
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<img class="atlas" style="--atlas-index: 21" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
<img class="atlas" style="--atlas-index: 21" src="https://static.graphite.art/icons/icon-atlas-roadmap__5.png" alt="" />
|
||||||
<span>Select mode (marquee masking)</span>
|
<span>Select mode (marquee masking)</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue