From 06e2a049deea98ee8c614aa004208a91420ef444 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 15 May 2026 15:39:01 -0700 Subject: [PATCH] Implement dragging layers into the group/new/delete buttons in the Layers panel (#4153) --- editor/src/messages/layout/layout_message.rs | 4 ++ .../messages/layout/layout_message_handler.rs | 13 +++++ .../utility_types/widgets/button_widgets.rs | 3 ++ .../document/document_message_handler.rs | 6 +++ frontend/src/components/panels/Layers.svelte | 48 +++++++++++++++++-- .../src/components/widgets/WidgetSpan.svelte | 5 ++ .../widgets/buttons/IconButton.svelte | 15 ++++++ frontend/wrapper/src/editor_wrapper.rs | 7 +++ 8 files changed, 98 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/layout/layout_message.rs b/editor/src/messages/layout/layout_message.rs index 6376f628..61d2d545 100644 --- a/editor/src/messages/layout/layout_message.rs +++ b/editor/src/messages/layout/layout_message.rs @@ -26,4 +26,8 @@ pub enum LayoutMessage { widget_id: WidgetId, value: serde_json::Value, }, + WidgetValueDragDrop { + layout_target: LayoutTarget, + widget_id: WidgetId, + }, } diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index c4657655..40dd1168 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -62,6 +62,19 @@ impl MessageHandler> for LayoutMessageHa LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => { self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Update, responses); } + LayoutMessage::WidgetValueDragDrop { layout_target, widget_id } => { + let Some(layout) = self.layouts.get_mut(layout_target as usize) else { + warn!("WidgetValueDragDrop referenced an invalid layout. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`"); + return; + }; + let Some(widget_instance) = layout.iter_mut().find(|widget| widget.widget_id == widget_id) else { + warn!("WidgetValueDragDrop referenced an invalid widget ID. `widget_id: {widget_id}`, `layout_target: {layout_target:?}`"); + return; + }; + if let Widget::IconButton(icon_button) = &mut *widget_instance.widget { + responses.add((icon_button.on_drag_drop.callback)(icon_button)); + } + } } } diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index 18937a5a..82ac8b8e 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -39,6 +39,9 @@ pub struct IconButton { #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub on_commit: WidgetCallback<()>, + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_drag_drop: WidgetCallback, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index ec556c86..109e04c3 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -3288,17 +3288,23 @@ impl DocumentMessageHandler { let group_folder_type = GroupFolderType::Layer; DocumentMessage::GroupSelectedLayers { group_folder_type }.into() }) + .on_drag_drop(|_| { + let group_folder_type = GroupFolderType::Layer; + DocumentMessage::GroupSelectedLayers { group_folder_type }.into() + }) .disabled(!has_selection) .widget_instance(), IconButton::new("NewLayer", 24) .tooltip_label("New Layer") .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::CreateEmptyFolder)) .on_update(|_| DocumentMessage::CreateEmptyFolder.into()) + .on_drag_drop(|_| DocumentMessage::DuplicateSelectedLayers.into()) .widget_instance(), IconButton::new("Trash", 24) .tooltip_label("Delete Selected") .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DeleteSelectedLayers)) .on_update(|_| DocumentMessage::DeleteSelectedLayers.into()) + .on_drag_drop(|_| DocumentMessage::DeleteSelectedLayers.into()) .disabled(!has_selection) .widget_instance(), ]; diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 1e9c164f..30c396a8 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -64,6 +64,7 @@ let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined; let justFinishedDrag = false; // Used to prevent click events after a drag let dragInPanel = false; + let dragDropTarget: HTMLElement | undefined = undefined; // Interactive clipping let layerToClipUponClick: LayerListingInfo | undefined = undefined; @@ -258,7 +259,7 @@ let markerHeight = 0; const layerPanel = document.querySelector("[data-layer-panel]"); // Selects the element with the data-layer-panel attribute - if (layerPanel !== null && treeChildren !== undefined && treeOffset !== undefined) { + if (layerPanel && treeChildren && treeOffset !== undefined) { let layerPanelTop = layerPanel.getBoundingClientRect().top; Array.from(treeChildren).forEach((treeChild) => { const indexAttribute = treeChild.getAttribute("data-index"); @@ -358,6 +359,18 @@ // Perform drag calculations if a drag is occurring if (internalDragState.active) { + // Check if the cursor is over any element flagged as a drag drop target + // (e.g. a bottom-bar action button whose backend widget has an `on_drag_drop` callback set) + const droppable = (e.target instanceof Element && e.target.closest("[data-drag-droppable]")) || undefined; + dragDropTarget = droppable instanceof HTMLElement ? droppable : undefined; + + // Hide the move-in-tree insert indicator whenever the cursor enters the bottom bar + const overBottomBar = ((e.target instanceof Element && e.target.closest("[data-layer-bottom-bar]")) || undefined) !== undefined; + if (dragDropTarget || overBottomBar) { + draggingData = undefined; + return; + } + const select = () => { if (internalDragState && !$nodeGraph.selected.includes(internalDragState.layerId)) { selectLayer(internalDragState.listing, false, false); @@ -369,7 +382,15 @@ } function draggingPointerUp() { - if (internalDragState?.active && draggingData) { + if (internalDragState?.active && dragDropTarget) { + // Ensure the dragged layer is part of the selection, matching the move-in-tree behavior + if (!$nodeGraph.selected.includes(internalDragState.layerId)) selectLayer(internalDragState.listing, false, false); + + // Hand off to the button's backend `on_drag_drop` callback via the custom event + dragDropTarget.dispatchEvent(new CustomEvent("dragdrop")); + + justFinishedDrag = true; + } else if (internalDragState?.active && draggingData) { const { select, insertParentId, insertIndex } = draggingData; // Commit the move @@ -394,6 +415,7 @@ draggingData = undefined; fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; dragInPanel = false; + dragDropTarget = undefined; } function draggingMouseDown(e: MouseEvent) { @@ -658,7 +680,7 @@
{/if} - + @@ -698,6 +720,26 @@ &:not(:has(*)) { display: none; } + + &.layer-drag-active .icon-button, + &.layer-drag-active .popover-button { + &.drag-droppable:hover { + background: var(--color-e-nearwhite); + + svg { + fill: var(--color-2-mildblack); + } + } + + &:not(.drag-droppable) { + pointer-events: none; + background: none; + + svg { + fill: var(--color-8-uppergray); + } + } + } } // Layer hierarchy diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 8f74aede..301fcf67 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -65,6 +65,10 @@ editor.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); } + function widgetValueDragDrop(widgetIndex: number) { + editor.widgetValueDragDrop(layoutTarget, widgets[widgetIndex].widgetId); + } + // Extracts the kind and props from a Widget tagged enum, validated against the widget registry. // The overload declares the precise correlated return type while the implementation uses broader types. function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined; @@ -175,6 +179,7 @@ getProps: (props, index) => ({ ...props, action: () => widgetValueCommitAndUpdate(index, undefined, true), + actionDragDrop: () => widgetValueDragDrop(index), }), }, IconLabel: { diff --git a/frontend/src/components/widgets/buttons/IconButton.svelte b/frontend/src/components/widgets/buttons/IconButton.svelte index c7610cce..75ce14bc 100644 --- a/frontend/src/components/widgets/buttons/IconButton.svelte +++ b/frontend/src/components/widgets/buttons/IconButton.svelte @@ -1,4 +1,5 @@