mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat: Add smooth slide-up animation to SelectionToolbox (#4832)
This commit is contained in:
@@ -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++
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
src/composables/element/useRetriggerableAnimation.ts
Normal file
80
src/composables/element/useRetriggerableAnimation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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} */
|
||||
|
||||
9
src/types/selectionOverlayTypes.ts
Normal file
9
src/types/selectionOverlayTypes.ts
Normal 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')
|
||||
Reference in New Issue
Block a user