Implement dragging layers into the group/new/delete buttons in the Layers panel (#4153)

This commit is contained in:
Keavon Chambers 2026-05-15 15:39:01 -07:00 committed by GitHub
parent 79df7cfa87
commit 06e2a049de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 98 additions and 3 deletions

View File

@ -26,4 +26,8 @@ pub enum LayoutMessage {
widget_id: WidgetId, widget_id: WidgetId,
value: serde_json::Value, value: serde_json::Value,
}, },
WidgetValueDragDrop {
layout_target: LayoutTarget,
widget_id: WidgetId,
},
} }

View File

@ -62,6 +62,19 @@ impl MessageHandler<LayoutMessage, LayoutMessageContext<'_>> for LayoutMessageHa
LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => { LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value } => {
self.handle_widget_callback(layout_target, widget_id, value, WidgetValueAction::Update, responses); 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));
}
}
} }
} }

View File

@ -39,6 +39,9 @@ pub struct IconButton {
#[serde(skip)] #[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>, pub on_commit: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_drag_drop: WidgetCallback<IconButton>,
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]

View File

@ -3288,17 +3288,23 @@ impl DocumentMessageHandler {
let group_folder_type = GroupFolderType::Layer; let group_folder_type = GroupFolderType::Layer;
DocumentMessage::GroupSelectedLayers { group_folder_type }.into() DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
}) })
.on_drag_drop(|_| {
let group_folder_type = GroupFolderType::Layer;
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(!has_selection) .disabled(!has_selection)
.widget_instance(), .widget_instance(),
IconButton::new("NewLayer", 24) IconButton::new("NewLayer", 24)
.tooltip_label("New Layer") .tooltip_label("New Layer")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::CreateEmptyFolder)) .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_update(|_| DocumentMessage::CreateEmptyFolder.into()) .on_update(|_| DocumentMessage::CreateEmptyFolder.into())
.on_drag_drop(|_| DocumentMessage::DuplicateSelectedLayers.into())
.widget_instance(), .widget_instance(),
IconButton::new("Trash", 24) IconButton::new("Trash", 24)
.tooltip_label("Delete Selected") .tooltip_label("Delete Selected")
.tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DeleteSelectedLayers)) .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::DeleteSelectedLayers))
.on_update(|_| DocumentMessage::DeleteSelectedLayers.into()) .on_update(|_| DocumentMessage::DeleteSelectedLayers.into())
.on_drag_drop(|_| DocumentMessage::DeleteSelectedLayers.into())
.disabled(!has_selection) .disabled(!has_selection)
.widget_instance(), .widget_instance(),
]; ];

View File

@ -64,6 +64,7 @@
let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined; let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined;
let justFinishedDrag = false; // Used to prevent click events after a drag let justFinishedDrag = false; // Used to prevent click events after a drag
let dragInPanel = false; let dragInPanel = false;
let dragDropTarget: HTMLElement | undefined = undefined;
// Interactive clipping // Interactive clipping
let layerToClipUponClick: LayerListingInfo | undefined = undefined; let layerToClipUponClick: LayerListingInfo | undefined = undefined;
@ -258,7 +259,7 @@
let markerHeight = 0; let markerHeight = 0;
const layerPanel = document.querySelector("[data-layer-panel]"); // Selects the element with the data-layer-panel attribute 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; let layerPanelTop = layerPanel.getBoundingClientRect().top;
Array.from(treeChildren).forEach((treeChild) => { Array.from(treeChildren).forEach((treeChild) => {
const indexAttribute = treeChild.getAttribute("data-index"); const indexAttribute = treeChild.getAttribute("data-index");
@ -358,6 +359,18 @@
// Perform drag calculations if a drag is occurring // Perform drag calculations if a drag is occurring
if (internalDragState.active) { 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 = () => { const select = () => {
if (internalDragState && !$nodeGraph.selected.includes(internalDragState.layerId)) { if (internalDragState && !$nodeGraph.selected.includes(internalDragState.layerId)) {
selectLayer(internalDragState.listing, false, false); selectLayer(internalDragState.listing, false, false);
@ -369,7 +382,15 @@
} }
function draggingPointerUp() { 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; const { select, insertParentId, insertIndex } = draggingData;
// Commit the move // Commit the move
@ -394,6 +415,7 @@
draggingData = undefined; draggingData = undefined;
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
dragInPanel = false; dragInPanel = false;
dragDropTarget = undefined;
} }
function draggingMouseDown(e: MouseEvent) { function draggingMouseDown(e: MouseEvent) {
@ -658,7 +680,7 @@
<div class="insert-mark" style:left={`${4 + draggingData.insertDepth * 16}px`} style:top={`${draggingData.markerHeight}px`}></div> <div class="insert-mark" style:left={`${4 + draggingData.insertDepth * 16}px`} style:top={`${draggingData.markerHeight}px`}></div>
{/if} {/if}
</LayoutRow> </LayoutRow>
<LayoutRow class="bottom-bar" scrollableX={true}> <LayoutRow class="bottom-bar" classes={{ "layer-drag-active": Boolean(internalDragState?.active) }} scrollableX={true} data-layer-bottom-bar>
<WidgetLayout layout={$layersPanelBottomBarLayout} layoutTarget="LayersPanelBottomBar" /> <WidgetLayout layout={$layersPanelBottomBarLayout} layoutTarget="LayersPanelBottomBar" />
</LayoutRow> </LayoutRow>
</LayoutCol> </LayoutCol>
@ -698,6 +720,26 @@
&:not(:has(*)) { &:not(:has(*)) {
display: none; 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 // Layer hierarchy

View File

@ -65,6 +65,10 @@
editor.widgetValueCommitAndUpdate(layoutTarget, widgets[widgetIndex].widgetId, value, resendWidget); 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. // 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. // The overload declares the precise correlated return type while the implementation uses broader types.
function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined; function unwrapWidget(widgetInstance: WidgetInstance): UnwrappedWidget | undefined;
@ -175,6 +179,7 @@
getProps: (props, index) => ({ getProps: (props, index) => ({
...props, ...props,
action: () => widgetValueCommitAndUpdate(index, undefined, true), action: () => widgetValueCommitAndUpdate(index, undefined, true),
actionDragDrop: () => widgetValueDragDrop(index),
}), }),
}, },
IconLabel: { IconLabel: {

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte";
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte"; import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
import type { IconName, IconSize } from "/src/icons"; import type { IconName, IconSize } from "/src/icons";
import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
@ -16,6 +17,9 @@
export let tooltipShortcut: ActionShortcut | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined;
// Callbacks // Callbacks
export let action: (e?: MouseEvent) => void; export let action: (e?: MouseEvent) => void;
// Fired when a draggable item is dropped onto this button. Setting this also flags the button as a valid drop target
// (the consumer of the drag interaction looks for `[data-drag-droppable]` and dispatches a `dragdrop` event on it).
export let actionDragDrop: (() => void) | undefined = undefined;
let className = ""; let className = "";
export { className as class }; export { className as class };
@ -24,6 +28,14 @@
$: extraClasses = Object.entries(classes) $: extraClasses = Object.entries(classes)
.flatMap(([className, stateName]) => (stateName ? [className] : [])) .flatMap(([className, stateName]) => (stateName ? [className] : []))
.join(" "); .join(" ");
// Element-level listener for the `dragdrop` custom event that consumers dispatch when something is dropped on this button
let buttonElement: HTMLButtonElement | undefined;
function handleDragDrop() {
actionDragDrop?.();
}
onMount(() => buttonElement?.addEventListener("dragdrop", handleDragDrop));
onDestroy(() => buttonElement?.removeEventListener("dragdrop", handleDragDrop));
</script> </script>
<button <button
@ -31,11 +43,14 @@
class:hover-icon={hoverIcon && !disabled} class:hover-icon={hoverIcon && !disabled}
class:disabled class:disabled
class:emphasized class:emphasized
class:drag-droppable={Boolean(actionDragDrop)}
bind:this={buttonElement}
on:click={action} on:click={action}
{disabled} {disabled}
data-tooltip-label={tooltipLabel} data-tooltip-label={tooltipLabel}
data-tooltip-description={tooltipDescription} data-tooltip-description={tooltipDescription}
data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined} data-tooltip-shortcut={tooltipShortcut?.shortcut ? JSON.stringify(tooltipShortcut.shortcut) : undefined}
data-drag-droppable={actionDragDrop ? "" : undefined}
tabindex={emphasized ? -1 : 0} tabindex={emphasized ? -1 : 0}
{...$$restProps} {...$$restProps}
> >

View File

@ -343,6 +343,13 @@ impl EditorWrapper {
Ok(()) Ok(())
} }
/// Fire a widget's drag-drop action (e.g. when a draggable item is dropped on a button)
#[wasm_bindgen(js_name = widgetValueDragDrop)]
pub fn widget_value_drag_drop(&self, layout_target: LayoutTarget, widget_id: u64) {
let widget_id = WidgetId(widget_id);
self.dispatch(LayoutMessage::WidgetValueDragDrop { layout_target, widget_id });
}
/// Closes out the current transaction (drag-end / text-commit end), so emits during a slider drag collapse into one history step instead of N /// Closes out the current transaction (drag-end / text-commit end), so emits during a slider drag collapse into one history step instead of N
#[wasm_bindgen(js_name = endTransaction)] #[wasm_bindgen(js_name = endTransaction)]
pub fn end_transaction(&self) { pub fn end_transaction(&self) {