mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
Fix: Search Box Implementation for keyboard shortcut (#5140)
* refactor: Move searchbox preference to the searchboxstore * fix: Ensure that the search box uses the preferred implementation. * polish: Open at current mouse location. * [test] add basic unit tests for searchBoxStore * types/testing: Tweak the types and setup for the searchBoxStore tests --------- Co-authored-by: Arjan Singh <arjan@comfy.org>
This commit is contained in:
@@ -32,7 +32,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NodeTooltip v-if="tooltipEnabled" />
|
<NodeTooltip v-if="tooltipEnabled" />
|
||||||
<NodeSearchboxPopover />
|
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||||
|
|
||||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||||
canvasStore.canvas to be initialized. -->
|
canvasStore.canvas to be initialized. -->
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener, whenever } from '@vueuse/core'
|
import { useEventListener, whenever } from '@vueuse/core'
|
||||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||||
@@ -89,12 +89,16 @@ import { useSettingStore } from '@/stores/settingStore'
|
|||||||
import { useToastStore } from '@/stores/toastStore'
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
|
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
ready: []
|
ready: []
|
||||||
}>()
|
}>()
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||||
|
typeof NodeSearchboxPopover
|
||||||
|
> | null>(null)
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
@@ -318,6 +322,7 @@ onMounted(async () => {
|
|||||||
canvasStore.canvas = comfyApp.canvas
|
canvasStore.canvas = comfyApp.canvas
|
||||||
canvasStore.canvas.render_canvas_border = false
|
canvasStore.canvas.render_canvas_border = false
|
||||||
workspaceStore.spinner = false
|
workspaceStore.spinner = false
|
||||||
|
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
|
||||||
|
|
||||||
window.app = comfyApp
|
window.app = comfyApp
|
||||||
window.graph = comfyApp.graph
|
window.graph = comfyApp.graph
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ let listenerController: AbortController | null = null
|
|||||||
let disconnectOnReset = false
|
let disconnectOnReset = false
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
const searchBoxStore = useSearchBoxStore()
|
||||||
const litegraphService = useLitegraphService()
|
const litegraphService = useLitegraphService()
|
||||||
|
|
||||||
const { visible } = storeToRefs(useSearchBoxStore())
|
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
|
||||||
const dismissable = ref(true)
|
const dismissable = ref(true)
|
||||||
const getNewNodeLocation = (): Point => {
|
const getNewNodeLocation = (): Point => {
|
||||||
return triggerEvent
|
return triggerEvent
|
||||||
@@ -107,12 +108,9 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
|||||||
window.requestAnimationFrame(closeDialog)
|
window.requestAnimationFrame(closeDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSearchBoxEnabled = computed(
|
const showSearchBox = (e: CanvasPointerEvent | null) => {
|
||||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
|
||||||
)
|
|
||||||
const showSearchBox = (e: CanvasPointerEvent) => {
|
|
||||||
if (newSearchBoxEnabled.value) {
|
if (newSearchBoxEnabled.value) {
|
||||||
if (e.pointerType === 'touch') {
|
if (e?.pointerType === 'touch') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showNewSearchBox(e)
|
showNewSearchBox(e)
|
||||||
}, 128)
|
}, 128)
|
||||||
@@ -128,7 +126,7 @@ const getFirstLink = () =>
|
|||||||
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||||
|
|
||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
const showNewSearchBox = (e: CanvasPointerEvent) => {
|
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
|
||||||
const firstLink = getFirstLink()
|
const firstLink = getFirstLink()
|
||||||
if (firstLink) {
|
if (firstLink) {
|
||||||
const filter =
|
const filter =
|
||||||
@@ -304,6 +302,7 @@ watch(visible, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
|
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
|
||||||
|
defineExpose({ showSearchBox })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
|
|||||||
interface HasShowSearchCallback {
|
interface HasShowSearchCallback {
|
||||||
/** See {@link LGraphCanvas.showSearchBox} */
|
/** See {@link LGraphCanvas.showSearchBox} */
|
||||||
showSearchBox: (
|
showSearchBox: (
|
||||||
event: MouseEvent,
|
event: MouseEvent | null,
|
||||||
options?: IShowSearchOptions
|
options?: IShowSearchOptions
|
||||||
) => HTMLDivElement | void
|
) => HTMLDivElement | void
|
||||||
}
|
}
|
||||||
@@ -6870,7 +6870,7 @@ export class LGraphCanvas
|
|||||||
}
|
}
|
||||||
|
|
||||||
showSearchBox(
|
showSearchBox(
|
||||||
event: MouseEvent,
|
event: MouseEvent | null,
|
||||||
searchOptions?: IShowSearchOptions
|
searchOptions?: IShowSearchOptions
|
||||||
): HTMLDivElement {
|
): HTMLDivElement {
|
||||||
// proposed defaults
|
// proposed defaults
|
||||||
@@ -7105,14 +7105,25 @@ export class LGraphCanvas
|
|||||||
// compute best position
|
// compute best position
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
|
||||||
const left = (event ? event.clientX : rect.left + rect.width * 0.5) - 80
|
// Handles cases where the searchbox is initiated by
|
||||||
const top = (event ? event.clientY : rect.top + rect.height * 0.5) - 20
|
// non-click events. e.g. Keyboard shortcuts
|
||||||
|
const safeEvent =
|
||||||
|
event ??
|
||||||
|
new MouseEvent('click', {
|
||||||
|
clientX: rect.left + rect.width * 0.5,
|
||||||
|
clientY: rect.top + rect.height * 0.5,
|
||||||
|
// @ts-expect-error layerY is a nonstandard property
|
||||||
|
layerY: rect.top + rect.height * 0.5
|
||||||
|
})
|
||||||
|
|
||||||
|
const left = safeEvent.clientX - 80
|
||||||
|
const top = safeEvent.clientY - 20
|
||||||
dialog.style.left = `${left}px`
|
dialog.style.left = `${left}px`
|
||||||
dialog.style.top = `${top}px`
|
dialog.style.top = `${top}px`
|
||||||
|
|
||||||
// To avoid out of screen problems
|
// To avoid out of screen problems
|
||||||
if (event.layerY > rect.height - 200) {
|
if (safeEvent.layerY > rect.height - 200) {
|
||||||
helper.style.maxHeight = `${rect.height - event.layerY - 20}px`
|
helper.style.maxHeight = `${rect.height - safeEvent.layerY - 20}px`
|
||||||
}
|
}
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
input.focus()
|
input.focus()
|
||||||
@@ -7122,14 +7133,14 @@ export class LGraphCanvas
|
|||||||
function select(name: string) {
|
function select(name: string) {
|
||||||
if (name) {
|
if (name) {
|
||||||
if (that.onSearchBoxSelection) {
|
if (that.onSearchBoxSelection) {
|
||||||
that.onSearchBoxSelection(name, event, graphcanvas)
|
that.onSearchBoxSelection(name, safeEvent, graphcanvas)
|
||||||
} else {
|
} else {
|
||||||
if (!graphcanvas.graph) throw new NullGraphError()
|
if (!graphcanvas.graph) throw new NullGraphError()
|
||||||
|
|
||||||
graphcanvas.graph.beforeChange()
|
graphcanvas.graph.beforeChange()
|
||||||
const node = LiteGraph.createNode(name)
|
const node = LiteGraph.createNode(name)
|
||||||
if (node) {
|
if (node) {
|
||||||
node.pos = graphcanvas.convertEventToCanvasOffset(event)
|
node.pos = graphcanvas.convertEventToCanvasOffset(safeEvent)
|
||||||
graphcanvas.graph.add(node, false)
|
graphcanvas.graph.add(node, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
|
import { useMouse } from '@vueuse/core'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||||
|
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
|
||||||
export const useSearchBoxStore = defineStore('searchBox', () => {
|
export const useSearchBoxStore = defineStore('searchBox', () => {
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const { x, y } = useMouse()
|
||||||
|
|
||||||
|
const newSearchBoxEnabled = computed(
|
||||||
|
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||||
|
)
|
||||||
|
|
||||||
|
const popoverRef = shallowRef<InstanceType<
|
||||||
|
typeof NodeSearchBoxPopover
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
|
function setPopoverRef(
|
||||||
|
popover: InstanceType<typeof NodeSearchBoxPopover> | null
|
||||||
|
) {
|
||||||
|
popoverRef.value = popover
|
||||||
|
}
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
function toggleVisible() {
|
function toggleVisible() {
|
||||||
visible.value = !visible.value
|
if (newSearchBoxEnabled.value) {
|
||||||
|
visible.value = !visible.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!popoverRef.value) return
|
||||||
|
popoverRef.value.showSearchBox(
|
||||||
|
new MouseEvent('click', {
|
||||||
|
clientX: x.value,
|
||||||
|
clientY: y.value,
|
||||||
|
// @ts-expect-error layerY is a nonstandard property
|
||||||
|
layerY: y.value
|
||||||
|
}) as unknown as CanvasPointerEvent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visible,
|
newSearchBoxEnabled,
|
||||||
toggleVisible
|
setPopoverRef,
|
||||||
|
toggleVisible,
|
||||||
|
visible
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
137
tests-ui/tests/store/searchBoxStore.test.ts
Normal file
137
tests-ui/tests/store/searchBoxStore.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||||
|
import type { useSettingStore } from '@/stores/settingStore'
|
||||||
|
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@vueuse/core', () => ({
|
||||||
|
useMouse: vi.fn(() => ({
|
||||||
|
x: { value: 100 },
|
||||||
|
y: { value: 200 }
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockSettingStore = createMockSettingStore()
|
||||||
|
vi.mock('@/stores/settingStore', () => ({
|
||||||
|
useSettingStore: vi.fn(() => mockSettingStore)
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createMockPopover(): InstanceType<typeof NodeSearchBoxPopover> {
|
||||||
|
return { showSearchBox: vi.fn() } satisfies Partial<
|
||||||
|
InstanceType<typeof NodeSearchBoxPopover>
|
||||||
|
> as unknown as InstanceType<typeof NodeSearchBoxPopover>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSettingStore(): ReturnType<typeof useSettingStore> {
|
||||||
|
return {
|
||||||
|
get: vi.fn()
|
||||||
|
} satisfies Partial<
|
||||||
|
ReturnType<typeof useSettingStore>
|
||||||
|
> as unknown as ReturnType<typeof useSettingStore>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSearchBoxStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when user has new search box enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(mockSettingStore.get).mockReturnValue('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show new search box is enabled', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
expect(store.newSearchBoxEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle search box visibility when user presses shortcut', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
|
||||||
|
expect(store.visible).toBe(false)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
expect(store.visible).toBe(true)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
expect(store.visible).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when user has legacy search box enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show new search box is disabled', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
expect(store.newSearchBoxEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open legacy search box at mouse position when user presses shortcut', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
const mockPopover = createMockPopover()
|
||||||
|
store.setPopoverRef(mockPopover)
|
||||||
|
|
||||||
|
expect(vi.mocked(store.visible)).toBe(false)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
|
||||||
|
expect(vi.mocked(store.visible)).toBe(false) // Doesn't become visible in legacy mode.
|
||||||
|
|
||||||
|
expect(vi.mocked(mockPopover.showSearchBox)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 200
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing when user presses shortcut but popover is not ready', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
store.setPopoverRef(null)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
|
||||||
|
expect(store.visible).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when user configures popover reference', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(mockSettingStore.get).mockReturnValue('legacy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable legacy search when popover is set', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
const mockPopover = createMockPopover()
|
||||||
|
store.setPopoverRef(mockPopover)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
|
||||||
|
expect(vi.mocked(mockPopover.showSearchBox)).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable legacy search when popover is cleared', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
const mockPopover = createMockPopover()
|
||||||
|
store.setPopoverRef(mockPopover)
|
||||||
|
store.setPopoverRef(null)
|
||||||
|
|
||||||
|
store.toggleVisible()
|
||||||
|
|
||||||
|
expect(vi.mocked(mockPopover.showSearchBox)).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when user first loads the application', () => {
|
||||||
|
it('should have search box hidden by default', () => {
|
||||||
|
const store = useSearchBoxStore()
|
||||||
|
expect(store.visible).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user