feat: Add smooth slide-up animation to SelectionToolbox (#4832)

This commit is contained in:
Chenlei Hu
2025-08-08 00:34:10 -04:00
committed by GitHub
parent 5d71d6f9cf
commit 95ab88693c
5 changed files with 134 additions and 4 deletions

View File

@@ -14,12 +14,13 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { provide, readonly, ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
@@ -27,6 +28,13 @@ const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
// Increment counter to notify child components of position/visibility change
// This does not include viewport changes.
const overlayUpdateCount = ref(0)
provide(SelectionOverlayInjectionKey, {
visible: readonly(visible),
updateCount: readonly(overlayUpdateCount)
})
const positionSelectionOverlay = () => {
const selectableItems = getSelectableItems()
@@ -52,6 +60,7 @@ whenever(
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
overlayUpdateCount.value++
canvasStore.getCanvas().state.selectionChanged = false
})
},
@@ -71,6 +80,7 @@ watch(
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay()
overlayUpdateCount.value++
})
} else {
// Selection change update to visible state is delayed by a frame. Here
@@ -78,6 +88,7 @@ watch(
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
overlayUpdateCount.value++
})
}
}

View File

@@ -1,6 +1,7 @@
<template>
<Panel
class="selection-toolbox absolute left-1/2 rounded-lg"
:class="{ 'animate-slide-up': shouldAnimate }"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
@@ -27,7 +28,7 @@
<script setup lang="ts">
import Panel from 'primevue/panel'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
@@ -40,16 +41,24 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
const { shouldAnimate } = useRetriggerableAnimation(
selectionOverlayState?.updateCount,
{ animateOnMount: true }
)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
@@ -71,4 +80,20 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
}
/* Slide up animation using CSS animation */
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(-120%);
opacity: 1;
}
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,80 @@
import { onMounted, ref, watch } from 'vue'
import type { Ref, WatchSource } from 'vue'
/**
* A composable that manages retriggerable CSS animations.
* Provides a boolean ref that can be toggled to restart CSS animations.
*
* @param trigger - Optional reactive source that triggers the animation when it changes
* @param options - Configuration options
* @returns An object containing the animation state ref
*
* @example
* ```vue
* <template>
* <div :class="{ 'animate-slide-up': shouldAnimate }">
* Content
* </div>
* </template>
*
* <script setup>
* const { shouldAnimate } = useRetriggerableAnimation(someReactiveTrigger)
* </script>
* ```
*/
export function useRetriggerableAnimation<T = any>(
trigger?: WatchSource<T> | Ref<T>,
options: {
animateOnMount?: boolean
animationDelay?: number
} = {}
) {
const { animateOnMount = true, animationDelay = 0 } = options
const shouldAnimate = ref(false)
/**
* Retriggers the animation by removing and re-adding the animation class
*/
const retriggerAnimation = () => {
// Remove animation class
shouldAnimate.value = false
// Force browser reflow to ensure the class removal is processed
void document.body.offsetHeight
// Re-add animation class in the next frame
requestAnimationFrame(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Trigger animation on mount if requested
if (animateOnMount) {
onMounted(() => {
if (animationDelay > 0) {
setTimeout(() => {
shouldAnimate.value = true
}, animationDelay)
} else {
shouldAnimate.value = true
}
})
}
// Watch for trigger changes to retrigger animation
if (trigger) {
watch(trigger, () => {
retriggerAnimation()
})
}
return {
shouldAnimate,
retriggerAnimation
}
}

View File

@@ -4130,6 +4130,7 @@ export class LGraphCanvas
const selected = this.selectedItems
if (!selected.size) return
const initialSelectionSize = selected.size
let wasSelected: Positionable | undefined
for (const sel of selected) {
if (sel === keepSelected) {
@@ -4170,8 +4171,12 @@ export class LGraphCanvas
}
}
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
// Only set selectionChanged if selection actually changed
const finalSelectionSize = selected.size
if (initialSelectionSize !== finalSelectionSize) {
this.state.selectionChanged = true
this.onSelectionChange?.(this.selected_nodes)
}
}
/** @deprecated See {@link LGraphCanvas.deselectAll} */

View File

@@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
export interface SelectionOverlayState {
visible: Readonly<Ref<boolean>>
updateCount: Readonly<Ref<number>>
}
export const SelectionOverlayInjectionKey: InjectionKey<SelectionOverlayState> =
Symbol('selectionOverlayState')