Implement dragging the visibility/lock icons in the Layers panel to toggle each (#4152)
* Implement dragging the visibility/lock icons in the Layers panel to toggle each * Code review fixes
This commit is contained in:
parent
16c7544d96
commit
79df7cfa87
|
|
@ -6,6 +6,7 @@
|
||||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||||
|
import { createDragToggleManager, destroyDragToggleManager } from "/src/managers/drag-toggle";
|
||||||
import type { NodeGraphStore } from "/src/stores/node-graph";
|
import type { NodeGraphStore } from "/src/stores/node-graph";
|
||||||
import { layersPanelControlBarLeftLayout, layersPanelControlBarRightLayout, layersPanelBottomBarLayout } from "/src/stores/portfolio";
|
import { layersPanelControlBarLeftLayout, layersPanelControlBarRightLayout, layersPanelBottomBarLayout } from "/src/stores/portfolio";
|
||||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||||
|
|
@ -68,9 +69,14 @@
|
||||||
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
|
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
|
||||||
let layerToClipAltKeyPressed = false;
|
let layerToClipAltKeyPressed = false;
|
||||||
|
|
||||||
|
// Drag-toggle: tracked here so the template can render the invisible lock placeholder during a `layer-lock` gesture
|
||||||
|
let activeDragToggleGroup: string | undefined = undefined;
|
||||||
|
|
||||||
$: rebuildLayerHierarchy($portfolio.layerStructure, $portfolio.layerCache);
|
$: rebuildLayerHierarchy($portfolio.layerStructure, $portfolio.layerCache);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
createDragToggleManager(dragToggleListener);
|
||||||
|
|
||||||
addEventListener("pointerup", draggingPointerUp);
|
addEventListener("pointerup", draggingPointerUp);
|
||||||
addEventListener("pointermove", draggingPointerMove);
|
addEventListener("pointermove", draggingPointerMove);
|
||||||
addEventListener("mousedown", draggingMouseDown);
|
addEventListener("mousedown", draggingMouseDown);
|
||||||
|
|
@ -83,6 +89,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
destroyDragToggleManager(dragToggleListener);
|
||||||
|
|
||||||
removeEventListener("pointerup", draggingPointerUp);
|
removeEventListener("pointerup", draggingPointerUp);
|
||||||
removeEventListener("pointermove", draggingPointerMove);
|
removeEventListener("pointermove", draggingPointerMove);
|
||||||
removeEventListener("mousedown", draggingMouseDown);
|
removeEventListener("mousedown", draggingMouseDown);
|
||||||
|
|
@ -94,6 +102,10 @@
|
||||||
removeEventListener("keyup", clippingKeyPress);
|
removeEventListener("keyup", clippingKeyPress);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function dragToggleListener(group: string | undefined) {
|
||||||
|
activeDragToggleGroup = group;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleNodeVisibilityLayerPanel(id: bigint) {
|
function toggleNodeVisibilityLayerPanel(id: bigint) {
|
||||||
editor.toggleNodeVisibilityLayerPanel(id);
|
editor.toggleNodeVisibilityLayerPanel(id);
|
||||||
}
|
}
|
||||||
|
|
@ -614,6 +626,17 @@
|
||||||
hoverIcon={listing.entry.unlocked ? "PadlockLocked" : "PadlockUnlocked"}
|
hoverIcon={listing.entry.unlocked ? "PadlockLocked" : "PadlockUnlocked"}
|
||||||
tooltipLabel={listing.entry.unlocked ? "Lock" : "Unlock"}
|
tooltipLabel={listing.entry.unlocked ? "Lock" : "Unlock"}
|
||||||
tooltipDescription={!listing.parentsUnlocked ? "A parent of this layer is locked and that status is being inherited." : ""}
|
tooltipDescription={!listing.parentsUnlocked ? "A parent of this layer is locked and that status is being inherited." : ""}
|
||||||
|
data-drag-toggle-group="layer-lock"
|
||||||
|
data-drag-toggle-state={listing.entry.unlocked ? "unlocked" : "locked"}
|
||||||
|
/>
|
||||||
|
{:else if activeDragToggleGroup === "layer-lock"}
|
||||||
|
<IconButton
|
||||||
|
class="status-toggle drag-toggle-placeholder"
|
||||||
|
action={(e) => (toggleLayerLock(listing.entry.id), e?.stopPropagation())}
|
||||||
|
size={24}
|
||||||
|
icon="PadlockUnlocked"
|
||||||
|
data-drag-toggle-group="layer-lock"
|
||||||
|
data-drag-toggle-state="unlocked"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -625,6 +648,8 @@
|
||||||
hoverIcon={listing.entry.visible ? "EyeHide" : "EyeShow"}
|
hoverIcon={listing.entry.visible ? "EyeHide" : "EyeShow"}
|
||||||
tooltipLabel={listing.entry.visible ? "Hide" : "Show"}
|
tooltipLabel={listing.entry.visible ? "Hide" : "Show"}
|
||||||
tooltipDescription={!listing.parentsVisible ? "A parent of this layer is hidden and that status is being inherited." : ""}
|
tooltipDescription={!listing.parentsVisible ? "A parent of this layer is hidden and that status is being inherited." : ""}
|
||||||
|
data-drag-toggle-group="layer-visibility"
|
||||||
|
data-drag-toggle-state={listing.entry.visible ? "visible" : "hidden"}
|
||||||
/>
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -844,6 +869,17 @@
|
||||||
background-image: var(--inheritance-stripes-background);
|
background-image: var(--inheritance-stripes-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invisible placeholder rendered only during a lock drag-toggle gesture, so the drag can still land on rows whose lock icon is normally omitted.
|
||||||
|
// Overlaid with absolute positioning so it doesn't shift the layer name's width.
|
||||||
|
&.drag-toggle-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
right: 24px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
opacity: 0; // Not `visibility: hidden`, which would exclude it from hit-testing
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: calc(24px + 2 * 4px);
|
width: calc(24px + 2 * 4px);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// Drag-toggle: the user presses one toggleable button and drags across its siblings to flip them all
|
||||||
|
// to the opposite of the source's starting state.
|
||||||
|
//
|
||||||
|
// Peers declare themselves with two data attributes on the clickable element:
|
||||||
|
// - data-drag-toggle-group="<group-name>": siblings share this name to form a drag-toggle group
|
||||||
|
// - data-drag-toggle-state="<current-state>": the current toggle state (e.g. "visible" / "hidden")
|
||||||
|
//
|
||||||
|
// The gesture only engages once the pointer crosses from the source into a different sibling, so plain
|
||||||
|
// clicks still toggle as usual. When engaged, the source is clicked once (toggling it) and any sibling
|
||||||
|
// the pointer enters whose state still matches the source's recorded starting state is also clicked.
|
||||||
|
|
||||||
|
type ActiveGroupListener = (activeGroup: string | undefined) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<ActiveGroupListener>();
|
||||||
|
let activeGroup: string | undefined = undefined;
|
||||||
|
let source: HTMLElement | undefined = undefined;
|
||||||
|
let startingState: string | undefined = undefined;
|
||||||
|
let visited = new WeakSet<Element>();
|
||||||
|
let engaged = false;
|
||||||
|
let suppressNextClickFromSource: HTMLElement | undefined = undefined;
|
||||||
|
|
||||||
|
export function createDragToggleManager(activeGroupListener?: ActiveGroupListener) {
|
||||||
|
if (activeGroupListener) listeners.add(activeGroupListener);
|
||||||
|
|
||||||
|
// Install the window event listeners only when the first consumer subscribes
|
||||||
|
if (listeners.size === 1) {
|
||||||
|
// Capture phase on pointerdown preempts sibling drag handlers on ancestors so they don't also engage
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
// Capture phase on click suppresses the natural source-click before the button's handler runs
|
||||||
|
window.addEventListener("click", onClickCapture, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyDragToggleManager(activeGroupListener?: ActiveGroupListener) {
|
||||||
|
if (activeGroupListener) listeners.delete(activeGroupListener);
|
||||||
|
|
||||||
|
// Uninstall the window event listeners only once the last consumer leaves
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
|
window.removeEventListener("click", onClickCapture, true);
|
||||||
|
|
||||||
|
activeGroup = undefined;
|
||||||
|
source = undefined;
|
||||||
|
startingState = undefined;
|
||||||
|
visited = new WeakSet();
|
||||||
|
engaged = false;
|
||||||
|
suppressNextClickFromSource = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyActiveGroupChange(group: string | undefined) {
|
||||||
|
activeGroup = group;
|
||||||
|
listeners.forEach((listener) => listener(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMember(target: EventTarget | undefined): HTMLElement | undefined {
|
||||||
|
if (!(target instanceof Element)) return undefined;
|
||||||
|
const found = target.closest("[data-drag-toggle-group]");
|
||||||
|
return found instanceof HTMLElement ? found : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
suppressNextClickFromSource = undefined;
|
||||||
|
|
||||||
|
const found = findMember(e.target || undefined);
|
||||||
|
if (!found) return;
|
||||||
|
|
||||||
|
// Stop the event so sibling drag/select handlers on ancestors don't also engage
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
source = found;
|
||||||
|
startingState = found.getAttribute("data-drag-toggle-state") || undefined;
|
||||||
|
visited = new WeakSet();
|
||||||
|
engaged = false;
|
||||||
|
|
||||||
|
notifyActiveGroupChange(found.getAttribute("data-drag-toggle-group") || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!activeGroup || !source) return;
|
||||||
|
|
||||||
|
const member = findMember(e.target || undefined);
|
||||||
|
if (!member || member.getAttribute("data-drag-toggle-group") !== activeGroup) return;
|
||||||
|
|
||||||
|
// Engages only when the cursor crosses from the source to a different peer, so tiny wobbles over the source don't trigger
|
||||||
|
if (member === source || visited.has(member)) return;
|
||||||
|
|
||||||
|
// First crossing engages the drag and toggles the source as part of the operation
|
||||||
|
if (!engaged) {
|
||||||
|
engaged = true;
|
||||||
|
visited.add(source);
|
||||||
|
if ((source.getAttribute("data-drag-toggle-state") || undefined) === startingState) source.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle only peers still in the starting state, so we don't flip ones already at the target state
|
||||||
|
if ((member.getAttribute("data-drag-toggle-state") || undefined) !== startingState) return;
|
||||||
|
|
||||||
|
visited.add(member);
|
||||||
|
member.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!activeGroup) return;
|
||||||
|
|
||||||
|
// If a drag engaged, the source was already clicked programmatically; suppress its natural click so it isn't re-toggled
|
||||||
|
if (engaged && source) suppressNextClickFromSource = source;
|
||||||
|
|
||||||
|
source = undefined;
|
||||||
|
startingState = undefined;
|
||||||
|
visited = new WeakSet();
|
||||||
|
engaged = false;
|
||||||
|
|
||||||
|
notifyActiveGroupChange(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickCapture(e: Event) {
|
||||||
|
if (suppressNextClickFromSource && e.target instanceof Node && suppressNextClickFromSource.contains(e.target)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
suppressNextClickFromSource = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-accepting HMR: tear down the old instance and re-create with the new module's code
|
||||||
|
import.meta.hot?.accept((newModule) => {
|
||||||
|
const carried = Array.from(listeners);
|
||||||
|
carried.forEach((listener) => destroyDragToggleManager(listener));
|
||||||
|
carried.forEach((listener) => newModule?.createDragToggleManager(listener));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue