Add support for RMB/Escape canceling layer drag reordering in the Layers panel (#3426)

* Add support for RMB/Escape canceling layer drag reordering in the Layers panel

* Disable hover effects on layers during drag; fix insertion line getting cut off at top of stack
This commit is contained in:
Keavon Chambers 2025-11-27 03:10:33 -08:00 committed by GitHub
parent ab5c87f017
commit 8cebde76e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 157 additions and 99 deletions

View File

@ -3,7 +3,6 @@
import { type Editor } from "@graphite/editor"; import { type Editor } from "@graphite/editor";
import { createClipboardManager } from "@graphite/io-managers/clipboard"; import { createClipboardManager } from "@graphite/io-managers/clipboard";
import { createDragManager } from "@graphite/io-managers/drag";
import { createHyperlinkManager } from "@graphite/io-managers/hyperlinks"; import { createHyperlinkManager } from "@graphite/io-managers/hyperlinks";
import { createInputManager } from "@graphite/io-managers/input"; import { createInputManager } from "@graphite/io-managers/input";
import { createLocalizationManager } from "@graphite/io-managers/localization"; import { createLocalizationManager } from "@graphite/io-managers/localization";
@ -46,7 +45,6 @@
createLocalizationManager(editor); createLocalizationManager(editor);
createPanicManager(editor, dialog); createPanicManager(editor, dialog);
createPersistenceManager(editor, portfolio); createPersistenceManager(editor, portfolio);
let dragManagerDestructor = createDragManager();
let inputManagerDestructor = createInputManager(editor, dialog, portfolio, document, fullscreen); let inputManagerDestructor = createInputManager(editor, dialog, portfolio, document, fullscreen);
onMount(() => { onMount(() => {
@ -56,7 +54,6 @@
onDestroy(() => { onDestroy(() => {
// Call the destructor for each manager // Call the destructor for each manager
dragManagerDestructor();
inputManagerDestructor(); inputManagerDestructor();
}); });
</script> </script>

View File

@ -2,7 +2,6 @@
import { getContext, onMount, onDestroy, tick } from "svelte"; import { getContext, onMount, onDestroy, tick } from "svelte";
import type { Editor } from "@graphite/editor"; import type { Editor } from "@graphite/editor";
import { beginDraggingElement } from "@graphite/io-managers/drag";
import { import {
defaultWidgetLayout, defaultWidgetLayout,
patchWidgetLayout, patchWidgetLayout,
@ -40,6 +39,14 @@
markerHeight: number; markerHeight: number;
}; };
type InternalDragState = {
active: boolean;
layerId: bigint;
listing: LayerListingInfo;
startX: number;
startY: number;
};
const editor = getContext<Editor>("editor"); const editor = getContext<Editor>("editor");
const nodeGraph = getContext<NodeGraphState>("nodeGraph"); const nodeGraph = getContext<NodeGraphState>("nodeGraph");
@ -52,7 +59,9 @@
// Interactive dragging // Interactive dragging
let draggable = true; let draggable = true;
let draggingData: undefined | DraggingData = undefined; let draggingData: undefined | DraggingData = undefined;
let internalDragState: InternalDragState | undefined = undefined;
let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined; let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined;
let justFinishedDrag = false; // Used to prevent click events after a drag
let dragInPanel = false; let dragInPanel = false;
// Interactive clipping // Interactive clipping
@ -92,6 +101,11 @@
updateLayerInTree(targetId, targetLayer); updateLayerInTree(targetId, targetLayer);
}); });
addEventListener("pointerup", draggingPointerUp);
addEventListener("pointermove", draggingPointerMove);
addEventListener("mousedown", draggingMouseDown);
addEventListener("keydown", draggingKeyDown);
addEventListener("pointermove", clippingHover); addEventListener("pointermove", clippingHover);
addEventListener("keydown", clippingKeyPress); addEventListener("keydown", clippingKeyPress);
addEventListener("keyup", clippingKeyPress); addEventListener("keyup", clippingKeyPress);
@ -104,6 +118,11 @@
editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerStructureJs); editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerStructureJs);
editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerDetails); editor.subscriptions.unsubscribeJsMessage(UpdateDocumentLayerDetails);
removeEventListener("pointerup", draggingPointerUp);
removeEventListener("pointermove", draggingPointerMove);
removeEventListener("mousedown", draggingMouseDown);
removeEventListener("keydown", draggingKeyDown);
removeEventListener("pointermove", clippingHover); removeEventListener("pointermove", clippingHover);
removeEventListener("keydown", clippingKeyPress); removeEventListener("keydown", clippingKeyPress);
removeEventListener("keyup", clippingKeyPress); removeEventListener("keyup", clippingKeyPress);
@ -223,6 +242,13 @@
} }
function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) { function selectLayerWithModifiers(e: MouseEvent, listing: LayerListingInfo) {
if (justFinishedDrag) {
justFinishedDrag = false;
// Prevent bubbling to deselectAllLayers
e.stopPropagation();
return;
}
// Get the pressed state of the modifier keys // Get the pressed state of the modifier keys
const [ctrl, meta, shift, alt] = [e.ctrlKey, e.metaKey, e.shiftKey, e.altKey]; const [ctrl, meta, shift, alt] = [e.ctrlKey, e.metaKey, e.shiftKey, e.altKey];
// Get the state of the platform's accel key and its opposite platform's accel key // Get the state of the platform's accel key and its opposite platform's accel key
@ -255,7 +281,7 @@
return; return;
} }
// Check if the cursor is near the border btween two layers // Check if the cursor is near the border between two layers
const DISTANCE = 6; const DISTANCE = 6;
const distanceFromTop = e.clientY - target.getBoundingClientRect().top; const distanceFromTop = e.clientY - target.getBoundingClientRect().top;
const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY; const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY;
@ -288,6 +314,11 @@
} }
async function deselectAllLayers() { async function deselectAllLayers() {
if (justFinishedDrag) {
justFinishedDrag = false;
return;
}
editor.handle.deselectAllLayers(); editor.handle.deselectAllLayers();
} }
@ -371,83 +402,135 @@
}; };
} }
async function dragStart(event: DragEvent, listing: LayerListingInfo) { function layerPointerDown(e: PointerEvent, listing: LayerListingInfo) {
const layer = listing.entry; // Only left click drags
dragInPanel = true; if (e.button !== 0 || !draggable) return;
if (!$nodeGraph.selected.includes(layer.id)) {
fakeHighlightOfNotYetSelectedLayerBeingDragged = layer.id; internalDragState = {
} active: false,
const select = () => { layerId: listing.entry.id,
if (!$nodeGraph.selected.includes(layer.id)) selectLayer(listing, false, false); listing: listing,
startX: e.clientX,
startY: e.clientY,
}; };
const target = (event.target instanceof HTMLElement && event.target) || undefined;
const closest = target?.closest("[data-layer]") || undefined;
const draggingELement = (closest instanceof HTMLElement && closest) || undefined;
if (draggingELement) beginDraggingElement(draggingELement);
// Set style of cursor for drag
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
event.dataTransfer.effectAllowed = "move";
}
if (list) draggingData = calculateDragIndex(list, event.clientY, select);
} }
function updateInsertLine(event: DragEvent) { function draggingPointerMove(e: PointerEvent) {
if (!draggable) return; if (!internalDragState || !list) return;
// Calculate distance moved
if (!internalDragState.active) {
const distance = Math.hypot(e.clientX - internalDragState.startX, e.clientY - internalDragState.startY);
const DRAG_THRESHOLD = 5;
if (distance > DRAG_THRESHOLD) {
internalDragState.active = true;
dragInPanel = true;
const layer = internalDragState.listing.entry;
if (!$nodeGraph.selected.includes(layer.id)) {
fakeHighlightOfNotYetSelectedLayerBeingDragged = layer.id;
}
}
}
// Perform drag calculations if a drag is occurring
if (internalDragState.active) {
const select = () => {
if (internalDragState && !$nodeGraph.selected.includes(internalDragState.layerId)) {
selectLayer(internalDragState.listing, false, false);
}
};
draggingData = calculateDragIndex(list, e.clientY, select);
}
}
function draggingPointerUp() {
if (internalDragState?.active && draggingData) {
const { select, insertParentId, insertIndex } = draggingData;
// Commit the move
select?.();
editor.handle.moveLayerInTree(insertParentId, insertIndex);
// Prevent the subsequent click event from processing
justFinishedDrag = true;
} else if (justFinishedDrag) {
// Avoid right-click abort getting stuck with `justFinishedDrag` set and blocking the first subsequent click to select a layer
setTimeout(() => {
justFinishedDrag = false;
}, 0);
}
// Reset state
abortDrag();
}
function abortDrag() {
internalDragState = undefined;
draggingData = undefined;
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
dragInPanel = false;
}
function draggingMouseDown(e: MouseEvent) {
// Abort if a drag is active and the user presses the right mouse button (button 2)
if (e.button === 2 && internalDragState?.active) {
justFinishedDrag = true;
abortDrag();
}
}
function draggingKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && internalDragState?.active) {
justFinishedDrag = true;
abortDrag();
}
}
function fileDragOver(e: DragEvent) {
if (!draggable || !e.dataTransfer || !e.dataTransfer.types.includes("Files")) return;
// Stop the drag from being shown as cancelled // Stop the drag from being shown as cancelled
event.preventDefault(); e.preventDefault();
dragInPanel = true; dragInPanel = true;
if (list) draggingData = calculateDragIndex(list, event.clientY, draggingData?.select); if (list) draggingData = calculateDragIndex(list, e.clientY);
} }
function drop(e: DragEvent) { function fileDrop(e: DragEvent) {
if (!draggingData) return; if (!draggingData || !e.dataTransfer || !e.dataTransfer.types.includes("Files")) return;
const { select, insertParentId, insertIndex } = draggingData;
const { insertParentId, insertIndex } = draggingData;
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) { Array.from(e.dataTransfer.items).forEach(async (item) => {
// Moving layers const file = item.getAsFile();
if (e.dataTransfer.items.length === 0) { if (!file) return;
if (draggable && dragInPanel) {
select?.(); if (file.type.includes("svg")) {
editor.handle.moveLayerInTree(insertParentId, insertIndex); const svgData = await file.text();
} editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex);
return;
} }
// Importing files
else {
Array.from(e.dataTransfer.items).forEach(async (item) => {
const file = item.getAsFile();
if (!file) return;
if (file.type.includes("svg")) { if (file.type.startsWith("image")) {
const svgData = await file.text(); const imageData = await extractPixelData(file);
editor.handle.pasteSvg(file.name, svgData, undefined, undefined, insertParentId, insertIndex); editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex);
return; return;
}
if (file.type.startsWith("image")) {
const imageData = await extractPixelData(file);
editor.handle.pasteImage(file.name, new Uint8Array(imageData.data), imageData.width, imageData.height, undefined, undefined, insertParentId, insertIndex);
return;
}
// When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab
const graphiteFileSuffix = "." + editor.handle.fileExtension();
if (file.name.endsWith(graphiteFileSuffix)) {
const content = await file.text();
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
editor.handle.openDocumentFile(documentName, content);
return;
}
});
} }
}
// When we eventually have sub-documents, this should be changed to import the document instead of opening it in a separate tab
const graphiteFileSuffix = "." + editor.handle.fileExtension();
if (file.name.endsWith(graphiteFileSuffix)) {
const content = await file.text();
const documentName = file.name.slice(0, -graphiteFileSuffix.length);
editor.handle.openDocumentFile(documentName, content);
return;
}
});
draggingData = undefined; draggingData = undefined;
fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined; fakeHighlightOfNotYetSelectedLayerBeingDragged = undefined;
@ -502,16 +585,15 @@
{/if} {/if}
<WidgetLayout layout={layersPanelControlBarRightLayout} /> <WidgetLayout layout={layersPanelControlBarRightLayout} />
</LayoutRow> </LayoutRow>
<LayoutRow class="list-area" scrollableY={true}> <LayoutRow class="list-area" classes={{ "drag-ongoing": Boolean(internalDragState?.active && draggingData) }} scrollableY={true}>
<LayoutCol <LayoutCol
class="list" class="list"
styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }} styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }}
data-layer-panel data-layer-panel
bind:this={list} bind:this={list}
on:click={() => deselectAllLayers()} on:click={() => deselectAllLayers()}
on:dragover={updateInsertLine} on:dragover={fileDragOver}
on:dragend={drop} on:drop={fileDrop}
on:drop={drop}
> >
{#each layers as listing, index} {#each layers as listing, index}
{@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected} {@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected}
@ -528,8 +610,7 @@
data-layer data-layer
data-index={index} data-index={index}
tooltip={listing.entry.tooltip} tooltip={listing.entry.tooltip}
{draggable} on:pointerdown={(e) => layerPointerDown(e, listing)}
on:dragstart={(e) => draggable && dragStart(e, listing)}
on:click={(e) => selectLayerWithModifiers(e, listing)} on:click={(e) => selectLayerWithModifiers(e, listing)}
> >
{#if listing.entry.childrenAllowed} {#if listing.entry.childrenAllowed}
@ -642,10 +723,14 @@
// Layer hierarchy // Layer hierarchy
.list-area { .list-area {
position: relative; position: relative;
margin-top: 4px; padding-top: 4px;
// Combine with the bottom bar to avoid a double border // Combine with the bottom bar to avoid a double border
margin-bottom: -1px; margin-bottom: -1px;
&.drag-ongoing .layer {
pointer-events: none;
}
.layer { .layer {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
@ -813,7 +898,7 @@
left: 4px; left: 4px;
right: 4px; right: 4px;
background: var(--color-e-nearwhite); background: var(--color-e-nearwhite);
margin-top: -3px; margin-top: 1px;
height: 5px; height: 5px;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;

View File

@ -1,24 +0,0 @@
let draggingElement: HTMLElement | undefined;
export function createDragManager(): () => void {
const clearDraggingElement = () => {
draggingElement = undefined;
};
// Add the event listener
document.addEventListener("drop", clearDraggingElement);
// Return the destructor
return () => {
// We use setTimeout to sequence this drop after any potential users in the current call stack progression, since this will begin in an entirely new call stack later
setTimeout(() => document.removeEventListener("drop", clearDraggingElement), 0);
};
}
export function beginDraggingElement(element: HTMLElement) {
draggingElement = element;
}
export function currentDraggingElement(): HTMLElement | undefined {
return draggingElement;
}