mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-03 04:00:31 +00:00
[backport 1.25] Add preview to workflow tabs (#4882)
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
This commit is contained in:
@@ -767,8 +767,8 @@ export class ComfyPage {
|
|||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
async rightClickCanvas() {
|
async rightClickCanvas(x: number = 10, y: number = 10) {
|
||||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
await this.page.mouse.click(x, y, { button: 'right' })
|
||||||
await this.nextFrame()
|
await this.nextFrame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { Locator, expect } from '@playwright/test'
|
||||||
import { Position } from '@vueuse/core'
|
import { Position } from '@vueuse/core'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -767,6 +767,17 @@ test.describe('Viewport settings', () => {
|
|||||||
comfyPage,
|
comfyPage,
|
||||||
comfyMouse
|
comfyMouse
|
||||||
}) => {
|
}) => {
|
||||||
|
const changeTab = async (tab: Locator) => {
|
||||||
|
await tab.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyMouse.move(comfyPage.emptySpace)
|
||||||
|
|
||||||
|
// If tooltip is visible, wait for it to hide
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.locator('.workflow-popover-fade')
|
||||||
|
).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
// Screenshot the canvas element
|
// Screenshot the canvas element
|
||||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||||
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
|
|||||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||||
|
|
||||||
// Go back to Workflow A
|
// Go back to Workflow A
|
||||||
await tabA.click()
|
await changeTab(tabA)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||||
screenshotA
|
screenshotA
|
||||||
)
|
)
|
||||||
|
|
||||||
// And back to Workflow B
|
// And back to Workflow B
|
||||||
await tabB.click()
|
await changeTab(tabB)
|
||||||
await comfyPage.nextFrame()
|
|
||||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||||
screenshotB
|
screenshotB
|
||||||
)
|
)
|
||||||
|
|||||||
155
browser_tests/tests/workflowTabThumbnail.spec.ts
Normal file
155
browser_tests/tests/workflowTabThumbnail.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Workflow Tab Thumbnails', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||||
|
await comfyPage.setup()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getTab(comfyPage: ComfyPage, index: number) {
|
||||||
|
const tab = comfyPage.page
|
||||||
|
.locator(`.workflow-tabs .p-togglebutton`)
|
||||||
|
.nth(index)
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTabPopover(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
index: number,
|
||||||
|
name?: string
|
||||||
|
) {
|
||||||
|
const tab = await getTab(comfyPage, index)
|
||||||
|
await tab.hover()
|
||||||
|
|
||||||
|
const popover = comfyPage.page.locator('.workflow-popover-fade')
|
||||||
|
await expect(popover).toHaveCount(1)
|
||||||
|
await expect(popover).toBeVisible({ timeout: 500 })
|
||||||
|
if (name) {
|
||||||
|
await expect(popover).toContainText(name)
|
||||||
|
}
|
||||||
|
return popover
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTabThumbnailImage(
|
||||||
|
comfyPage: ComfyPage,
|
||||||
|
index: number,
|
||||||
|
name?: string
|
||||||
|
) {
|
||||||
|
const popover = await getTabPopover(comfyPage, index, name)
|
||||||
|
const thumbnailImg = popover.locator('.workflow-preview-thumbnail img')
|
||||||
|
return thumbnailImg
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) {
|
||||||
|
const thumbnailImg = await getTabThumbnailImage(comfyPage, index)
|
||||||
|
const src = (await thumbnailImg.getAttribute('src'))!
|
||||||
|
|
||||||
|
// Convert blob to base64, need to execute a script to get the base64
|
||||||
|
const base64 = await comfyPage.page.evaluate(async (src: string) => {
|
||||||
|
const blob = await fetch(src).then((res) => res.blob())
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => resolve(reader.result)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}, src)
|
||||||
|
return base64
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Should show thumbnail when hovering over a non-active tab', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||||
|
const thumbnailImg = await getTabThumbnailImage(
|
||||||
|
comfyPage,
|
||||||
|
0,
|
||||||
|
'Unsaved Workflow'
|
||||||
|
)
|
||||||
|
await expect(thumbnailImg).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||||
|
const thumbnailImg = await getTabThumbnailImage(
|
||||||
|
comfyPage,
|
||||||
|
1,
|
||||||
|
'Unsaved Workflow (2)'
|
||||||
|
)
|
||||||
|
await expect(thumbnailImg).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addNode(comfyPage: ComfyPage, category: string, node: string) {
|
||||||
|
const canvasArea = await comfyPage.canvas.boundingBox()
|
||||||
|
|
||||||
|
await comfyPage.page.mouse.move(
|
||||||
|
canvasArea!.x + canvasArea!.width - 100,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
await comfyPage.delay(300) // Wait for the popover to hide
|
||||||
|
|
||||||
|
await comfyPage.rightClickCanvas(200, 200)
|
||||||
|
await comfyPage.page.getByText('Add Node').click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.getByText(category).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.getByText(node).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Thumbnail should update when switching tabs', async ({ comfyPage }) => {
|
||||||
|
// Wait for initial workflow to load
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Create a new workflow (tab 1) which will be empty
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
|
||||||
|
// Tab 1 is currently active, so we can only get thumbnail for tab 0
|
||||||
|
|
||||||
|
// Step 1: Different tabs should show different previews
|
||||||
|
const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0)
|
||||||
|
|
||||||
|
// Add a node to tab 1 (current active tab)
|
||||||
|
await addNode(comfyPage, 'loaders', 'Load Checkpoint')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Switch to tab 0 so we can get tab 1's thumbnail
|
||||||
|
await (await getTab(comfyPage, 0)).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1)
|
||||||
|
|
||||||
|
// The thumbnails should be different
|
||||||
|
expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode)
|
||||||
|
|
||||||
|
// Step 2: Switching without changes shouldn't update thumbnail
|
||||||
|
const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1)
|
||||||
|
|
||||||
|
// Switch to tab 1 and back to tab 0 without making changes
|
||||||
|
await (await getTab(comfyPage, 1)).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await (await getTab(comfyPage, 0)).click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1)
|
||||||
|
expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter)
|
||||||
|
|
||||||
|
// Step 3: Adding another node should cause thumbnail to change
|
||||||
|
// We're on tab 0, add a node
|
||||||
|
await addNode(comfyPage, 'loaders', 'Load VAE')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
// Switch to tab 1 and back to update tab 0's thumbnail
|
||||||
|
await (await getTab(comfyPage, 1)).click()
|
||||||
|
|
||||||
|
const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0)
|
||||||
|
|
||||||
|
// The thumbnail should have changed after adding a node
|
||||||
|
expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
<div
|
||||||
<span
|
ref="workflowTabRef"
|
||||||
v-tooltip.bottom="{
|
class="flex p-2 gap-2 workflow-tab"
|
||||||
value: workflowOption.workflow.key,
|
v-bind="$attrs"
|
||||||
class: 'workflow-tab-tooltip',
|
@mouseenter="handleMouseEnter"
|
||||||
showDelay: 512
|
@mouseleave="handleMouseLeave"
|
||||||
}"
|
@click="handleClick"
|
||||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
>
|
||||||
>
|
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
|
||||||
{{ workflowOption.workflow.filename }}
|
{{ workflowOption.workflow.filename }}
|
||||||
</span>
|
</span>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -22,23 +22,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WorkflowTabPopover
|
||||||
|
ref="popoverRef"
|
||||||
|
:workflow-filename="workflowOption.workflow.filename"
|
||||||
|
:thumbnail-url="thumbnailUrl"
|
||||||
|
:is-active-tab="isActiveTab"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
usePragmaticDraggable,
|
usePragmaticDraggable,
|
||||||
usePragmaticDroppable
|
usePragmaticDroppable
|
||||||
} from '@/composables/usePragmaticDragAndDrop'
|
} from '@/composables/usePragmaticDragAndDrop'
|
||||||
|
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||||
import { useWorkflowService } from '@/services/workflowService'
|
import { useWorkflowService } from '@/services/workflowService'
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
|
|
||||||
|
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||||
|
|
||||||
interface WorkflowOption {
|
interface WorkflowOption {
|
||||||
value: string
|
value: string
|
||||||
workflow: ComfyWorkflow
|
workflow: ComfyWorkflow
|
||||||
@@ -55,6 +65,8 @@ const workspaceStore = useWorkspaceStore()
|
|||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||||
|
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||||
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
|
|
||||||
// Use computed refs to cache autosave settings
|
// Use computed refs to cache autosave settings
|
||||||
const autoSaveSetting = computed(() =>
|
const autoSaveSetting = computed(() =>
|
||||||
@@ -90,6 +102,27 @@ const shouldShowStatusIndicator = computed(() => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isActiveTab = computed(() => {
|
||||||
|
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||||
|
})
|
||||||
|
|
||||||
|
const thumbnailUrl = computed(() => {
|
||||||
|
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event handlers that delegate to the popover component
|
||||||
|
const handleMouseEnter = (event: Event) => {
|
||||||
|
popoverRef.value?.showPopover(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
popoverRef.value?.hidePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (event: Event) => {
|
||||||
|
popoverRef.value?.togglePopover(event)
|
||||||
|
}
|
||||||
|
|
||||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
if (
|
if (
|
||||||
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
popoverRef.value?.hidePopover()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
229
src/components/topbar/WorkflowTabPopover.vue
Normal file
229
src/components/topbar/WorkflowTabPopover.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="positionRef"
|
||||||
|
class="absolute left-1/2 -translate-x-1/2"
|
||||||
|
:class="positions.positioner"
|
||||||
|
></div>
|
||||||
|
<Popover
|
||||||
|
ref="popoverRef"
|
||||||
|
append-to="body"
|
||||||
|
:pt="{
|
||||||
|
root: {
|
||||||
|
class: 'workflow-popover-fade fit-content ' + positions.root,
|
||||||
|
'data-popover-id': id,
|
||||||
|
style: {
|
||||||
|
transform: positions.active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@mouseenter="cancelHidePopover"
|
||||||
|
@mouseleave="hidePopover"
|
||||||
|
>
|
||||||
|
<div class="workflow-preview-content">
|
||||||
|
<div
|
||||||
|
v-if="thumbnailUrl && !isActiveTab"
|
||||||
|
class="workflow-preview-thumbnail relative"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="thumbnailUrl"
|
||||||
|
class="block h-[200px] object-cover rounded-lg p-2"
|
||||||
|
:style="{ width: `${POPOVER_WIDTH}px` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-preview-footer">
|
||||||
|
<span class="workflow-preview-name">{{ workflowFilename }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import { computed, nextTick, ref, toRefs, useId } from 'vue'
|
||||||
|
|
||||||
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
|
||||||
|
const POPOVER_WIDTH = 250
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
workflowFilename: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
isActiveTab: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { thumbnailUrl, isActiveTab } = toRefs(props)
|
||||||
|
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const positions = computed<{
|
||||||
|
positioner: string
|
||||||
|
root?: string
|
||||||
|
active?: string
|
||||||
|
}>(() => {
|
||||||
|
if (
|
||||||
|
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar' &&
|
||||||
|
settingStore.get('Comfy.UseNewMenu') === 'Bottom'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
positioner: 'top-0',
|
||||||
|
root: 'p-popover-flipped',
|
||||||
|
active: isActiveTab.value ? 'translateY(-100%)' : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positioner: 'bottom-0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
const positionRef = ref<HTMLElement | null>(null)
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
const showPopover = (event: Event) => {
|
||||||
|
// Clear any existing timeouts
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
if (showTimeout) {
|
||||||
|
clearTimeout(showTimeout)
|
||||||
|
showTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show popover after a short delay
|
||||||
|
showTimeout = setTimeout(async () => {
|
||||||
|
if (popoverRef.value && positionRef.value) {
|
||||||
|
popoverRef.value.show(event, positionRef.value)
|
||||||
|
await nextTick()
|
||||||
|
// PrimeVue has a bug where when the tabs are scrolled, it positions the element incorrectly
|
||||||
|
// Manually set the position to the middle of the tab and prevent it from going off the left/right edge
|
||||||
|
const el = document.querySelector(
|
||||||
|
`.workflow-popover-fade[data-popover-id="${id}"]`
|
||||||
|
) as HTMLElement
|
||||||
|
if (el) {
|
||||||
|
const middle = positionRef.value!.getBoundingClientRect().left
|
||||||
|
const popoverWidth = el.getBoundingClientRect().width
|
||||||
|
const halfWidth = popoverWidth / 2
|
||||||
|
let pos = middle - halfWidth
|
||||||
|
let shift = 0
|
||||||
|
|
||||||
|
// Calculate shift when clamping is needed
|
||||||
|
if (pos < 0) {
|
||||||
|
shift = pos - 8 // Negative shift to move arrow left
|
||||||
|
pos = 8
|
||||||
|
} else if (pos + popoverWidth > window.innerWidth) {
|
||||||
|
const newPos = window.innerWidth - popoverWidth - 16
|
||||||
|
shift = pos - newPos // Positive shift to move arrow right
|
||||||
|
pos = newPos
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shift + halfWidth < 0) {
|
||||||
|
shift = -halfWidth + 24
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.left = `${pos}px`
|
||||||
|
el.style.setProperty('--shift', `${shift}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 200) // 200ms delay before showing
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelHidePopover = () => {
|
||||||
|
// Temporarily disable this functionality until we need the popover to be interactive:
|
||||||
|
/*
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidePopover = () => {
|
||||||
|
// Clear show timeout if mouse leaves before popover appears
|
||||||
|
if (showTimeout) {
|
||||||
|
clearTimeout(showTimeout)
|
||||||
|
showTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
if (popoverRef.value) {
|
||||||
|
popoverRef.value.hide()
|
||||||
|
}
|
||||||
|
}, 100) // Minimal delay to allow moving to popover
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePopover = (event: Event) => {
|
||||||
|
if (popoverRef.value) {
|
||||||
|
popoverRef.value.toggle(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
showPopover,
|
||||||
|
hidePopover,
|
||||||
|
togglePopover
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-preview-content {
|
||||||
|
@apply flex flex-col rounded-xl overflow-hidden;
|
||||||
|
max-width: var(--popover-width);
|
||||||
|
background-color: var(--comfy-menu-secondary-bg);
|
||||||
|
color: var(--fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-preview-thumbnail {
|
||||||
|
@apply relative p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-preview-thumbnail img {
|
||||||
|
@apply shadow-md;
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--comfy-menu-secondary-bg) 70%,
|
||||||
|
black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .workflow-preview-thumbnail img {
|
||||||
|
@apply shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-preview-footer {
|
||||||
|
@apply pt-1 pb-2 px-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-preview-name {
|
||||||
|
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
|
color: var(--fg-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.workflow-popover-fade {
|
||||||
|
--p-popover-background: transparent;
|
||||||
|
--p-popover-content-padding: 0;
|
||||||
|
@apply bg-transparent rounded-xl shadow-lg;
|
||||||
|
transition: opacity 0.15s ease-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-popover-fade.p-popover-flipped {
|
||||||
|
@apply -translate-y-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .workflow-popover-fade {
|
||||||
|
@apply shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-popover-fade.p-popover:after,
|
||||||
|
.workflow-popover-fade.p-popover:before {
|
||||||
|
--p-popover-border-color: var(--comfy-menu-secondary-bg);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(calc(-50% + var(--shift)));
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -696,6 +696,7 @@ export function useMinimap() {
|
|||||||
init,
|
init,
|
||||||
destroy,
|
destroy,
|
||||||
toggle,
|
toggle,
|
||||||
|
renderMinimap,
|
||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp,
|
||||||
|
|||||||
108
src/composables/useWorkflowThumbnail.ts
Normal file
108
src/composables/useWorkflowThumbnail.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||||
|
|
||||||
|
import { useMinimap } from './useMinimap'
|
||||||
|
|
||||||
|
// Store thumbnails for each workflow
|
||||||
|
const workflowThumbnails = ref<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Shared minimap instance
|
||||||
|
let minimap: ReturnType<typeof useMinimap> | null = null
|
||||||
|
|
||||||
|
export const useWorkflowThumbnail = () => {
|
||||||
|
/**
|
||||||
|
* Capture a thumbnail of the canvas
|
||||||
|
*/
|
||||||
|
const createMinimapPreview = (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
if (!minimap) {
|
||||||
|
minimap = useMinimap()
|
||||||
|
minimap.canvasRef.value = document.createElement('canvas')
|
||||||
|
minimap.canvasRef.value.width = minimap.width
|
||||||
|
minimap.canvasRef.value.height = minimap.height
|
||||||
|
}
|
||||||
|
minimap.renderMinimap()
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
minimap!.canvasRef.value!.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(URL.createObjectURL(blob))
|
||||||
|
} else {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to capture canvas thumbnail:', error)
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a thumbnail for a workflow
|
||||||
|
*/
|
||||||
|
const storeThumbnail = async (workflow: ComfyWorkflow) => {
|
||||||
|
const thumbnail = await createMinimapPreview()
|
||||||
|
if (thumbnail) {
|
||||||
|
// Clean up existing thumbnail if it exists
|
||||||
|
const existingThumbnail = workflowThumbnails.value.get(workflow.key)
|
||||||
|
if (existingThumbnail) {
|
||||||
|
URL.revokeObjectURL(existingThumbnail)
|
||||||
|
}
|
||||||
|
workflowThumbnails.value.set(workflow.key, thumbnail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a thumbnail for a workflow
|
||||||
|
*/
|
||||||
|
const getThumbnail = (workflowKey: string): string | undefined => {
|
||||||
|
return workflowThumbnails.value.get(workflowKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a thumbnail for a workflow
|
||||||
|
*/
|
||||||
|
const clearThumbnail = (workflowKey: string) => {
|
||||||
|
const thumbnail = workflowThumbnails.value.get(workflowKey)
|
||||||
|
if (thumbnail) {
|
||||||
|
URL.revokeObjectURL(thumbnail)
|
||||||
|
}
|
||||||
|
workflowThumbnails.value.delete(workflowKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all thumbnails
|
||||||
|
*/
|
||||||
|
const clearAllThumbnails = () => {
|
||||||
|
for (const thumbnail of workflowThumbnails.value.values()) {
|
||||||
|
URL.revokeObjectURL(thumbnail)
|
||||||
|
}
|
||||||
|
workflowThumbnails.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a thumbnail from one workflow key to another (useful for workflow renaming)
|
||||||
|
*/
|
||||||
|
const moveWorkflowThumbnail = (oldKey: string, newKey: string) => {
|
||||||
|
// Don't do anything if moving to the same key
|
||||||
|
if (oldKey === newKey) return
|
||||||
|
|
||||||
|
const thumbnail = workflowThumbnails.value.get(oldKey)
|
||||||
|
if (thumbnail) {
|
||||||
|
workflowThumbnails.value.set(newKey, thumbnail)
|
||||||
|
workflowThumbnails.value.delete(oldKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createMinimapPreview,
|
||||||
|
storeThumbnail,
|
||||||
|
getThumbnail,
|
||||||
|
clearThumbnail,
|
||||||
|
clearAllThumbnails,
|
||||||
|
moveWorkflowThumbnail,
|
||||||
|
workflowThumbnails
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { toRaw } from 'vue'
|
import { toRaw } from 'vue'
|
||||||
|
|
||||||
|
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||||
@@ -21,6 +22,7 @@ export const useWorkflowService = () => {
|
|||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
const domWidgetStore = useDomWidgetStore()
|
const domWidgetStore = useDomWidgetStore()
|
||||||
|
|
||||||
async function getFilename(defaultName: string): Promise<string | null> {
|
async function getFilename(defaultName: string): Promise<string | null> {
|
||||||
@@ -287,8 +289,14 @@ export const useWorkflowService = () => {
|
|||||||
*/
|
*/
|
||||||
const beforeLoadNewGraph = () => {
|
const beforeLoadNewGraph = () => {
|
||||||
// Use workspaceStore here as it is patched in unit tests.
|
// Use workspaceStore here as it is patched in unit tests.
|
||||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
const workflowStore = useWorkspaceStore().workflow
|
||||||
domWidgetStore.clear()
|
const activeWorkflow = workflowStore.activeWorkflow
|
||||||
|
if (activeWorkflow) {
|
||||||
|
activeWorkflow.changeTracker.store()
|
||||||
|
// Capture thumbnail before loading new graph
|
||||||
|
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||||
|
domWidgetStore.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import _ from 'lodash'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||||
@@ -327,6 +328,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
(path) => path !== workflow.path
|
(path) => path !== workflow.path
|
||||||
)
|
)
|
||||||
if (workflow.isTemporary) {
|
if (workflow.isTemporary) {
|
||||||
|
// Clear thumbnail when temporary workflow is closed
|
||||||
|
clearThumbnail(workflow.key)
|
||||||
delete workflowLookup.value[workflow.path]
|
delete workflowLookup.value[workflow.path]
|
||||||
} else {
|
} else {
|
||||||
workflow.unload()
|
workflow.unload()
|
||||||
@@ -387,12 +390,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
|
|
||||||
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
||||||
const isBusy = ref<boolean>(false)
|
const isBusy = ref<boolean>(false)
|
||||||
|
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
|
||||||
|
|
||||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||||
isBusy.value = true
|
isBusy.value = true
|
||||||
try {
|
try {
|
||||||
// Capture all needed values upfront
|
// Capture all needed values upfront
|
||||||
const oldPath = workflow.path
|
const oldPath = workflow.path
|
||||||
|
const oldKey = workflow.key
|
||||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||||
|
|
||||||
const openIndex = detachWorkflow(workflow)
|
const openIndex = detachWorkflow(workflow)
|
||||||
@@ -403,6 +408,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
attachWorkflow(workflow, openIndex)
|
attachWorkflow(workflow, openIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||||
|
const newKey = workflow.key
|
||||||
|
moveWorkflowThumbnail(oldKey, newKey)
|
||||||
// Update bookmarks
|
// Update bookmarks
|
||||||
if (wasBookmarked) {
|
if (wasBookmarked) {
|
||||||
await bookmarkStore.setBookmarked(oldPath, false)
|
await bookmarkStore.setBookmarked(oldPath, false)
|
||||||
@@ -420,6 +428,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||||
}
|
}
|
||||||
|
// Clear thumbnail when workflow is deleted
|
||||||
|
clearThumbnail(workflow.key)
|
||||||
delete workflowLookup.value[workflow.path]
|
delete workflowLookup.value[workflow.path]
|
||||||
} finally {
|
} finally {
|
||||||
isBusy.value = false
|
isBusy.value = false
|
||||||
|
|||||||
282
tests-ui/tests/composables/useWorkflowThumbnail.spec.ts
Normal file
282
tests-ui/tests/composables/useWorkflowThumbnail.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useMinimap', () => ({
|
||||||
|
useMinimap: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
moveUserData: vi.fn(),
|
||||||
|
listUserDataFullInfo: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getUserData: vi.fn(),
|
||||||
|
storeUserData: vi.fn(),
|
||||||
|
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useWorkflowThumbnail } = await import(
|
||||||
|
'@/composables/useWorkflowThumbnail'
|
||||||
|
)
|
||||||
|
const { useMinimap } = await import('@/composables/useMinimap')
|
||||||
|
const { api } = await import('@/scripts/api')
|
||||||
|
|
||||||
|
describe('useWorkflowThumbnail', () => {
|
||||||
|
let mockMinimapInstance: any
|
||||||
|
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
|
// Clear any existing thumbnails from previous tests BEFORE mocking
|
||||||
|
const { clearAllThumbnails } = useWorkflowThumbnail()
|
||||||
|
clearAllThumbnails()
|
||||||
|
|
||||||
|
// Now set up mocks
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
const blob = new Blob()
|
||||||
|
|
||||||
|
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
|
||||||
|
global.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Mock API responses
|
||||||
|
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
|
||||||
|
|
||||||
|
mockMinimapInstance = {
|
||||||
|
renderMinimap: vi.fn(),
|
||||||
|
canvasRef: {
|
||||||
|
value: {
|
||||||
|
toBlob: vi.fn((cb) => cb(blob))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
width: 250,
|
||||||
|
height: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should capture minimap thumbnail', async () => {
|
||||||
|
const { createMinimapPreview } = useWorkflowThumbnail()
|
||||||
|
const thumbnail = await createMinimapPreview()
|
||||||
|
|
||||||
|
expect(useMinimap).toHaveBeenCalledOnce()
|
||||||
|
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store and retrieve thumbnails', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||||
|
|
||||||
|
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||||
|
|
||||||
|
await storeThumbnail(mockWorkflow)
|
||||||
|
|
||||||
|
const thumbnail = getThumbnail('test-workflow-key')
|
||||||
|
expect(thumbnail).toBe('data:image/png;base64,test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear thumbnail', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, clearThumbnail } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
const mockWorkflow = { key: 'test-workflow-key' } as ComfyWorkflow
|
||||||
|
|
||||||
|
await storeThumbnail(mockWorkflow)
|
||||||
|
|
||||||
|
expect(getThumbnail('test-workflow-key')).toBeDefined()
|
||||||
|
|
||||||
|
clearThumbnail('test-workflow-key')
|
||||||
|
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||||
|
'data:image/png;base64,test'
|
||||||
|
)
|
||||||
|
expect(getThumbnail('test-workflow-key')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear all thumbnails', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, clearAllThumbnails } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
const mockWorkflow1 = { key: 'workflow-1' } as ComfyWorkflow
|
||||||
|
const mockWorkflow2 = { key: 'workflow-2' } as ComfyWorkflow
|
||||||
|
|
||||||
|
await storeThumbnail(mockWorkflow1)
|
||||||
|
await storeThumbnail(mockWorkflow2)
|
||||||
|
|
||||||
|
expect(getThumbnail('workflow-1')).toBeDefined()
|
||||||
|
expect(getThumbnail('workflow-2')).toBeDefined()
|
||||||
|
|
||||||
|
clearAllThumbnails()
|
||||||
|
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getThumbnail('workflow-1')).toBeUndefined()
|
||||||
|
expect(getThumbnail('workflow-2')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should automatically handle thumbnail cleanup when workflow is renamed', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
// Create a temporary workflow
|
||||||
|
const workflow = workflowStore.createTemporary('test-workflow.json')
|
||||||
|
const originalKey = workflow.key
|
||||||
|
|
||||||
|
// Store thumbnail for the workflow
|
||||||
|
await storeThumbnail(workflow)
|
||||||
|
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// Rename the workflow - this should automatically handle thumbnail cleanup
|
||||||
|
const newPath = 'workflows/renamed-workflow.json'
|
||||||
|
await workflowStore.renameWorkflow(workflow, newPath)
|
||||||
|
|
||||||
|
const newKey = workflow.key // The workflow's key should now be the new path
|
||||||
|
|
||||||
|
// The thumbnail should be moved from old key to new key
|
||||||
|
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||||
|
expect(getThumbnail(newKey)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// No URL should be revoked since we're moving the thumbnail, not deleting it
|
||||||
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly revoke old URL when storing thumbnail over existing one', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail } = useWorkflowThumbnail()
|
||||||
|
|
||||||
|
const mockWorkflow = { key: 'test-workflow' } as ComfyWorkflow
|
||||||
|
|
||||||
|
// Store first thumbnail
|
||||||
|
await storeThumbnail(mockWorkflow)
|
||||||
|
const firstThumbnail = getThumbnail('test-workflow')
|
||||||
|
expect(firstThumbnail).toBe('data:image/png;base64,test')
|
||||||
|
|
||||||
|
// Reset the mock to track new calls and create different URL
|
||||||
|
vi.clearAllMocks()
|
||||||
|
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
|
||||||
|
|
||||||
|
// Store second thumbnail for same workflow - should revoke the first URL
|
||||||
|
await storeThumbnail(mockWorkflow)
|
||||||
|
const secondThumbnail = getThumbnail('test-workflow')
|
||||||
|
expect(secondThumbnail).toBe('data:image/png;base64,test2')
|
||||||
|
|
||||||
|
// URL.revokeObjectURL should have been called for the first thumbnail
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||||
|
'data:image/png;base64,test'
|
||||||
|
)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear thumbnail when workflow is deleted', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
// Create a workflow and store thumbnail
|
||||||
|
const workflow = workflowStore.createTemporary('test-delete.json')
|
||||||
|
await storeThumbnail(workflow)
|
||||||
|
|
||||||
|
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// Delete the workflow - this should clear the thumbnail
|
||||||
|
await workflowStore.deleteWorkflow(workflow)
|
||||||
|
|
||||||
|
// Thumbnail should be cleared and URL revoked
|
||||||
|
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||||
|
expect(workflowThumbnails.value.size).toBe(0)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||||
|
'data:image/png;base64,test'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear thumbnail when temporary workflow is closed', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
// Create a temporary workflow and store thumbnail
|
||||||
|
const workflow = workflowStore.createTemporary('temp-workflow.json')
|
||||||
|
await storeThumbnail(workflow)
|
||||||
|
|
||||||
|
expect(getThumbnail(workflow.key)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// Close the workflow - this should clear the thumbnail for temporary workflows
|
||||||
|
await workflowStore.closeWorkflow(workflow)
|
||||||
|
|
||||||
|
// Thumbnail should be cleared and URL revoked
|
||||||
|
expect(getThumbnail(workflow.key)).toBeUndefined()
|
||||||
|
expect(workflowThumbnails.value.size).toBe(0)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
|
||||||
|
'data:image/png;base64,test'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple renames without leaking', async () => {
|
||||||
|
const { storeThumbnail, getThumbnail, workflowThumbnails } =
|
||||||
|
useWorkflowThumbnail()
|
||||||
|
|
||||||
|
// Create workflow and store thumbnail
|
||||||
|
const workflow = workflowStore.createTemporary('original.json')
|
||||||
|
await storeThumbnail(workflow)
|
||||||
|
const originalKey = workflow.key
|
||||||
|
|
||||||
|
expect(getThumbnail(originalKey)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// Rename multiple times
|
||||||
|
await workflowStore.renameWorkflow(workflow, 'workflows/renamed1.json')
|
||||||
|
const firstRenameKey = workflow.key
|
||||||
|
|
||||||
|
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||||
|
expect(getThumbnail(firstRenameKey)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
await workflowStore.renameWorkflow(workflow, 'workflows/renamed2.json')
|
||||||
|
const secondRenameKey = workflow.key
|
||||||
|
|
||||||
|
expect(getThumbnail(originalKey)).toBeUndefined()
|
||||||
|
expect(getThumbnail(firstRenameKey)).toBeUndefined()
|
||||||
|
expect(getThumbnail(secondRenameKey)).toBe('data:image/png;base64,test')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
|
||||||
|
// No URLs should be revoked since we're just moving thumbnails
|
||||||
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle edge cases like empty keys or invalid operations', async () => {
|
||||||
|
const {
|
||||||
|
getThumbnail,
|
||||||
|
clearThumbnail,
|
||||||
|
moveWorkflowThumbnail,
|
||||||
|
workflowThumbnails
|
||||||
|
} = useWorkflowThumbnail()
|
||||||
|
|
||||||
|
// Test getting non-existent thumbnail
|
||||||
|
expect(getThumbnail('non-existent')).toBeUndefined()
|
||||||
|
|
||||||
|
// Test clearing non-existent thumbnail (should not throw)
|
||||||
|
expect(() => clearThumbnail('non-existent')).not.toThrow()
|
||||||
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Test moving non-existent thumbnail (should not throw)
|
||||||
|
expect(() => moveWorkflowThumbnail('non-existent', 'target')).not.toThrow()
|
||||||
|
expect(workflowThumbnails.value.size).toBe(0)
|
||||||
|
|
||||||
|
// Test moving to same key (should not cause issues)
|
||||||
|
const { storeThumbnail } = useWorkflowThumbnail()
|
||||||
|
const mockWorkflow = { key: 'test-key' } as ComfyWorkflow
|
||||||
|
await storeThumbnail(mockWorkflow)
|
||||||
|
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
moveWorkflowThumbnail('test-key', 'test-key')
|
||||||
|
expect(workflowThumbnails.value.size).toBe(1)
|
||||||
|
expect(getThumbnail('test-key')).toBe('data:image/png;base64,test')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user