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 17:02:10 +00:00
committed by Timon
parent 4c1974c200
commit 83d03ad67d
14 changed files with 417 additions and 255 deletions
+4 -19
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>
+10 -62
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>
@@ -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>
+5 -19
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>
+34 -33
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();
}
@@ -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}