mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 11:17:35 +00:00
Compare commits
28 Commits
v1.40.9
...
parallel-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7da951b90 | ||
|
|
16ddcfdbaf | ||
|
|
ef5198be25 | ||
|
|
38675e658f | ||
|
|
bd95150f82 | ||
|
|
f9317e7078 | ||
|
|
79e71a5761 | ||
|
|
3e4d273832 | ||
|
|
8aa4e36fd5 | ||
|
|
d9fdb01d9b | ||
|
|
8f48b11f6a | ||
|
|
bb40ffae3c | ||
|
|
de131133bd | ||
|
|
17f34788dc | ||
|
|
9184f9bce4 | ||
|
|
ea7bbb744f | ||
|
|
25880aa024 | ||
|
|
40aa7c5974 | ||
|
|
3d3a4dd1a2 | ||
|
|
39af93ae3e | ||
|
|
4849d4a6c9 | ||
|
|
55ee6e7e63 | ||
|
|
74d285bda9 | ||
|
|
1bcebd8293 | ||
|
|
dac58ad811 | ||
|
|
6ee3803770 | ||
|
|
fd2ffb7100 | ||
|
|
c05644045f |
@@ -90,7 +90,6 @@ const preview: Preview = {
|
||||
{ value: 'light', icon: 'sun', title: 'Light' },
|
||||
{ value: 'dark', icon: 'moon', title: 'Dark' }
|
||||
],
|
||||
showName: true,
|
||||
dynamicTitle: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DebugHelper } from './helpers/DebugHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
@@ -174,7 +173,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly debug: DebugHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -219,7 +217,6 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.debug = new DebugHelper(page, this.canvas)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
|
||||
export interface DebugScreenshotOptions {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
|
||||
export class DebugHelper {
|
||||
constructor(
|
||||
private page: Page,
|
||||
private canvas: Locator
|
||||
) {}
|
||||
|
||||
async addMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
async attachScreenshot(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
options?: DebugScreenshotOptions
|
||||
): Promise<void> {
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.addMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
if (options?.markers) {
|
||||
await this.removeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async saveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
}
|
||||
|
||||
async getCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async showCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
async hideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -244,21 +244,9 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
const parsed = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.serialize()
|
||||
})
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
|
||||
@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
|
||||
// Monitor for server feature flags
|
||||
const checkInterval = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags &&
|
||||
Object.keys(window.app.api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window.app.api.serverFeatureFlags
|
||||
const flags = window.app?.api?.serverFeatureFlags?.value
|
||||
if (flags && Object.keys(flags).length > 0) {
|
||||
window.__capturedMessages!.serverFeatureFlags = flags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 100)
|
||||
@@ -96,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverFeatureFlags
|
||||
return window.app!.api.serverFeatureFlags.value
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -129,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window.app!.api.serverFeatureFlags
|
||||
window.app!.api.serverFeatureFlags = {
|
||||
const original = window.app!.api.serverFeatureFlags.value
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window.app!.api.serverFeatureFlags = original
|
||||
window.app!.api.serverFeatureFlags.value = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Monitor when feature flags arrive by checking periodically
|
||||
const checkFeatureFlags = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined
|
||||
) {
|
||||
window.__appReadiness!.featureFlagsReceived = true
|
||||
clearInterval(checkFeatureFlags)
|
||||
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Wait for feature flags to be received
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined,
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined,
|
||||
{
|
||||
timeout: 10000
|
||||
}
|
||||
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...window.__appReadiness,
|
||||
currentFlags: window.app!.api.serverFeatureFlags
|
||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
})
|
||||
|
||||
|
||||
@@ -736,6 +736,25 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
)
|
||||
// Wait for V2 persistence debounce to save the modified workflow
|
||||
const start = Date.now()
|
||||
await comfyPage.page.waitForFunction((since) => {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (!json) continue
|
||||
try {
|
||||
const index = JSON.parse(json)
|
||||
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, start)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'single_ksampler_modified.png'
|
||||
@@ -758,10 +777,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage to persist the workflow paths before reloading
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
|
||||
)
|
||||
// Wait for sessionStorage to persist the workflow paths before reloading
|
||||
// V2 persistence uses sessionStorage with client-scoped keys
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||
const key = window.sessionStorage.key(i)
|
||||
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Properties panel position', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
// Open a sidebar tab to ensure sidebar is visible
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
@@ -53,6 +53,7 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
@@ -5,6 +5,7 @@ import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
test.describe('Node library sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeLibrary.BookmarksCustomization',
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
@@ -29,6 +28,7 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -40,6 +40,7 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.40.9",
|
||||
"version": "1.41.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -70,13 +70,14 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
"@tiptap/extension-table-cell": "^2.10.4",
|
||||
"@tiptap/extension-table-header": "^2.10.4",
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
"@tiptap/extension-table-cell": "catalog:",
|
||||
"@tiptap/extension-table-header": "catalog:",
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -93,9 +94,9 @@
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
@@ -1192,7 +1192,7 @@ button.comfy-queue-btn {
|
||||
.graphdialog {
|
||||
min-height: 1em;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.graphdialog .name {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getMediaTypeFromFilename,
|
||||
highlightQuery,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
|
||||
{ filename: 'image.jpeg', expected: 'image' },
|
||||
{ filename: 'animation.gif', expected: 'image' },
|
||||
{ filename: 'web.webp', expected: 'image' },
|
||||
{ filename: 'bitmap.bmp', expected: 'image' }
|
||||
{ filename: 'bitmap.bmp', expected: 'image' },
|
||||
{ filename: 'modern.avif', expected: 'image' }
|
||||
]
|
||||
|
||||
it.for(imageTestCases)(
|
||||
@@ -96,26 +98,37 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text files', () => {
|
||||
it('should identify text file extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(getMediaTypeFromFilename('')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
expect(getMediaTypeFromFilename('README')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('README')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle unknown extensions', () => {
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle paths with directories', () => {
|
||||
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(null)).toBe('other')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('other')
|
||||
})
|
||||
|
||||
it('should handle special characters in filenames', () => {
|
||||
@@ -184,4 +197,18 @@ describe('formatUtil', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'avif',
|
||||
'tif',
|
||||
'tiff'
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'csv',
|
||||
'yaml',
|
||||
'yml',
|
||||
'xml',
|
||||
'log'
|
||||
] as const
|
||||
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
|
||||
type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
|
||||
export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
|
||||
// Type guard helper for checking array membership
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
* Truncates a filename while preserving the extension
|
||||
@@ -543,20 +565,30 @@ export function truncateFilename(
|
||||
/**
|
||||
* Determines the media type from a filename's extension (singular form)
|
||||
* @param filename The filename to analyze
|
||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
|
||||
*/
|
||||
export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'image'
|
||||
if (!filename) return 'other'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'image'
|
||||
if (!ext) return 'other'
|
||||
|
||||
// Type-safe array includes check using type assertion
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||
|
||||
return 'image'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
5063
pnpm-lock.yaml
generated
5063
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,12 @@ catalog:
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.2.6
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -27,11 +27,19 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
'@tiptap/extension-table-cell': ^2.27.2
|
||||
'@tiptap/extension-table-header': ^2.27.2
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -45,7 +53,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.8.2
|
||||
axios: ^1.13.5
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.3.1
|
||||
@@ -55,24 +63,26 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.25.0
|
||||
eslint-plugin-storybook: ^10.1.9
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
nx: 22.5.2
|
||||
oxfmt: ^0.34.0
|
||||
oxlint: ^1.49.0
|
||||
oxlint-tsgolint: ^0.14.2
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
@@ -81,9 +91,9 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: ^2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.1.9
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.1.12
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -100,10 +110,10 @@ catalog:
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.1
|
||||
vue-tsc: ^3.2.5
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
@@ -130,4 +140,5 @@ onlyBuiltDependencies:
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
@@ -169,6 +169,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -189,6 +190,7 @@ const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
@@ -262,7 +264,7 @@ const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
const { hasAnyError } = storeToRefs(executionErrorStore)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
22
src/components/common/MarqueeLine.test.ts
Normal file
22
src/components/common/MarqueeLine.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
|
||||
describe(MarqueeLine, () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('renders content inside a span within the container', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Test Text' }
|
||||
})
|
||||
const span = wrapper.find('span')
|
||||
expect(span.exists()).toBe(true)
|
||||
expect(span.text()).toBe('Test Text')
|
||||
})
|
||||
})
|
||||
24
src/components/common/MarqueeLine.vue
Normal file
24
src/components/common/MarqueeLine.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: translateX(var(--_marquee-end));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
@@ -51,7 +51,7 @@
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div class="font-medium">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>{{ getDisplayValue(col) }}</div>
|
||||
<div :class="cn(isOutdated(col) && 'text-danger-100')">
|
||||
{{ getDisplayValue(col) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,6 +49,7 @@ import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -76,7 +79,8 @@ const localColumns: ColumnDef[] = [
|
||||
{ field: 'pytorch_version', header: 'Pytorch Version' },
|
||||
{ field: 'argv', header: 'Arguments' },
|
||||
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
|
||||
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
|
||||
{ field: 'installed_templates_version', header: 'Templates Version' }
|
||||
]
|
||||
|
||||
/** Columns for cloud distribution */
|
||||
@@ -97,6 +101,13 @@ const cloudColumns: ColumnDef[] = [
|
||||
|
||||
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
|
||||
|
||||
function isOutdated(column: ColumnDef): boolean {
|
||||
if (column.field !== 'installed_templates_version') return false
|
||||
const installed = props.stats.system.installed_templates_version
|
||||
const required = props.stats.system.required_templates_version
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
|
||||
105
src/components/common/TextTickerMultiLine.test.ts
Normal file
105
src/components/common/TextTickerMultiLine.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import TextTickerMultiLine from './TextTickerMultiLine.vue'
|
||||
|
||||
type Callback = () => void
|
||||
|
||||
const resizeCallbacks: Callback[] = []
|
||||
const mutationCallbacks: Callback[] = []
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useResizeObserver: (_target: unknown, cb: Callback) => {
|
||||
resizeCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
},
|
||||
useMutationObserver: (_target: unknown, cb: Callback) => {
|
||||
mutationCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mockElementSize(
|
||||
el: HTMLElement,
|
||||
clientWidth: number,
|
||||
scrollWidth: number
|
||||
) {
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'scrollWidth', {
|
||||
value: scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe(TextTickerMultiLine, () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
resizeCallbacks.length = 0
|
||||
mutationCallbacks.length = 0
|
||||
})
|
||||
|
||||
function mountComponent(text: string) {
|
||||
wrapper = mount(TextTickerMultiLine, {
|
||||
slots: { default: text }
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function getMeasureEl(): HTMLElement {
|
||||
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
|
||||
}
|
||||
|
||||
async function triggerSplitLines() {
|
||||
resizeCallbacks.forEach((cb) => cb())
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
mountComponent('Load Checkpoint')
|
||||
expect(wrapper.text()).toContain('Load Checkpoint')
|
||||
})
|
||||
|
||||
it('renders a single MarqueeLine when text fits', async () => {
|
||||
mountComponent('Short')
|
||||
mockElementSize(getMeasureEl(), 200, 100)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders two MarqueeLines when text overflows', async () => {
|
||||
mountComponent('Load Checkpoint Loader Simple')
|
||||
mockElementSize(getMeasureEl(), 100, 300)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('splits text at word boundary when overflowing', async () => {
|
||||
mountComponent('Load Checkpoint Loader')
|
||||
mockElementSize(getMeasureEl(), 100, 200)
|
||||
await triggerSplitLines()
|
||||
|
||||
const lines = wrapper.findAllComponents(MarqueeLine)
|
||||
expect(lines[0].text()).toBe('Load')
|
||||
expect(lines[1].text()).toBe('Checkpoint Loader')
|
||||
})
|
||||
|
||||
it('has hidden measurement element with aria-hidden', () => {
|
||||
mountComponent('Test')
|
||||
const measureEl = wrapper.find('[aria-hidden="true"]')
|
||||
expect(measureEl.exists()).toBe(true)
|
||||
expect(measureEl.classes()).toContain('invisible')
|
||||
})
|
||||
})
|
||||
66
src/components/common/TextTickerMultiLine.vue
Normal file
66
src/components/common/TextTickerMultiLine.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hidden single-line measurement element for overflow detection -->
|
||||
<div
|
||||
ref="measureRef"
|
||||
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<MarqueeLine v-if="!secondLine">
|
||||
<slot />
|
||||
</MarqueeLine>
|
||||
|
||||
<div v-else class="flex flex-col w-full">
|
||||
<MarqueeLine>{{ firstLine }}</MarqueeLine>
|
||||
<MarqueeLine>{{ secondLine }}</MarqueeLine>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
|
||||
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const firstLine = ref('')
|
||||
const secondLine = ref('')
|
||||
|
||||
function splitLines() {
|
||||
const el = measureRef.value
|
||||
const text = el?.textContent?.trim()
|
||||
if (!el || !text) {
|
||||
firstLine.value = ''
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = el.clientWidth
|
||||
const textWidth = el.scrollWidth
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const [first, second] = splitTextAtWordBoundary(
|
||||
text,
|
||||
containerWidth / textWidth
|
||||
)
|
||||
firstLine.value = first
|
||||
secondLine.value = second
|
||||
}
|
||||
|
||||
useResizeObserver(measureRef, splitLines)
|
||||
useMutationObserver(measureRef, splitLines, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
</script>
|
||||
@@ -1,37 +1,41 @@
|
||||
<template>
|
||||
<ContextMenuRoot>
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
<ContextMenuTrigger :disabled="!showContextMenu" as-child>
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
>
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
|
||||
emit('nodeClick', node, e)
|
||||
"
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
e: MouseEvent
|
||||
) => emit('nodeClick', node, e)
|
||||
"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuContent
|
||||
@@ -49,7 +53,11 @@
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
|
||||
{{
|
||||
isCurrentNodeBookmarked
|
||||
? $t('sideToolbar.nodeLibraryTab.sections.unfavoriteNode')
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
@@ -63,6 +71,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuTrigger,
|
||||
TreeRoot,
|
||||
TreeVirtualizer
|
||||
} from 'reka-ui'
|
||||
|
||||
@@ -80,10 +80,6 @@ describe('TreeExplorerV2Node', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
ContextMenuTrigger: {
|
||||
name: 'ContextMenuTrigger',
|
||||
template: '<div data-testid="context-menu-trigger"><slot /></div>'
|
||||
},
|
||||
Teleport: { template: '<div />' }
|
||||
},
|
||||
provide: {
|
||||
@@ -145,36 +141,12 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
|
||||
describe('context menu', () => {
|
||||
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node'),
|
||||
showContextMenu: true
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render ContextMenuTrigger for folder items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('sets contextMenuNode when contextmenu event is triggered', async () => {
|
||||
it('sets contextMenuNode when contextmenu event is triggered on node', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const nodeItem = createMockItem('node')
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{
|
||||
item: nodeItem,
|
||||
showContextMenu: true
|
||||
},
|
||||
{ item: nodeItem },
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
@@ -187,6 +159,24 @@ describe('TreeExplorerV2Node', () => {
|
||||
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('contextmenu')
|
||||
|
||||
expect(contextMenuNode.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
||||
@@ -5,27 +5,26 @@
|
||||
:level="item.level"
|
||||
as-child
|
||||
>
|
||||
<!-- Node with context menu -->
|
||||
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
|
||||
<div
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
<div
|
||||
@@ -69,7 +68,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
|
||||
import { TreeItem } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
class="about-badge inline-flex items-center no-underline"
|
||||
:title="badge.url"
|
||||
>
|
||||
<Tag class="mr-2">
|
||||
<Tag class="mr-2" :severity="badge.severity">
|
||||
<template #icon>
|
||||
<i :class="[badge.icon, 'mr-2 text-xl']" />
|
||||
</template>
|
||||
|
||||
@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
@@ -90,7 +90,7 @@ const isVisible = computed(
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
@@ -100,6 +100,6 @@ function seeErrors() {
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
executionErrorStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
@@ -152,7 +152,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
@@ -170,6 +170,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -196,6 +197,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const toastStore = useToastStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
@@ -376,7 +378,7 @@ watch(
|
||||
// Update node slot errors for LiteGraph nodes
|
||||
// (Vue nodes read from store directly)
|
||||
watch(
|
||||
() => executionStore.lastNodeErrors,
|
||||
() => executionErrorStore.lastNodeErrors,
|
||||
(lastNodeErrors) => {
|
||||
if (!comfyApp.graph) return
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
|
||||
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionStore.hasInternalErrorForNode(node.id)
|
||||
return executionErrorStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
@@ -192,7 +193,7 @@ export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
@@ -223,7 +224,7 @@ export function useErrorGroups(
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
for (const execId of executionErrorStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
@@ -262,10 +263,10 @@ export function useErrorGroups(
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const error = executionErrorStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
@@ -293,10 +294,10 @@ export function useErrorGroups(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
@@ -316,9 +317,9 @@ export function useErrorGroups(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
return executionErrorStore.activeGraphErrorNodeIds.has(
|
||||
String(targetNode.value.id)
|
||||
)
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
t(label)
|
||||
st(label, label)
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -50,12 +50,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
icon = '',
|
||||
selected = false,
|
||||
@@ -83,7 +81,7 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -112,6 +112,22 @@ const sampleAssets: AssetItem[] = [
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-text-1',
|
||||
name: 'generation-notes.txt',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 2048,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-other-1',
|
||||
name: 'workflow-payload.bin',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 4096,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -134,6 +150,16 @@ export const RunningAndGenerated: Story = {
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
export const TextAndMiscGeneratedAssets: Story = {
|
||||
args: {
|
||||
assets: sampleAssets.filter((asset) =>
|
||||
['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix))
|
||||
),
|
||||
jobs: []
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
@@ -72,4 +72,38 @@ describe('AssetsSidebarListView', () => {
|
||||
|
||||
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
|
||||
})
|
||||
|
||||
it('marks mp4 assets as video previews', () => {
|
||||
const videoAsset = {
|
||||
...buildAsset('video-asset', 'clip.mp4'),
|
||||
preview_url: '/api/view/clip.mp4',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(videoAsset)])
|
||||
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses icon fallback for text assets even when preview_url exists', () => {
|
||||
const textAsset = {
|
||||
...buildAsset('text-asset', 'notes.txt'),
|
||||
preview_url: '/api/view/notes.txt',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(textAsset)])
|
||||
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
:class="
|
||||
@@ -43,11 +43,10 @@
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
||||
"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
@@ -135,6 +134,22 @@ function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
return getMediaTypeFromFilename(asset.name)
|
||||
}
|
||||
|
||||
function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
|
||||
function getAssetPreviewUrl(asset: AssetItem): string {
|
||||
const mediaType = getAssetMediaType(asset)
|
||||
if (mediaType === 'image' || mediaType === 'video') {
|
||||
return asset.preview_url || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
|
||||
@@ -199,17 +199,27 @@ import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Load3dViewerContent = () =>
|
||||
import('@/components/load3d/Load3dViewerContent.vue')
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
)
|
||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
@@ -234,7 +244,11 @@ import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -395,6 +409,12 @@ const visibleAssets = computed(() => {
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
)
|
||||
)
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
@@ -420,12 +440,10 @@ watch(visibleAssets, (newAssets) => {
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
const newIndex = previewableVisibleAssets.value.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
)
|
||||
if (newIndex !== -1) {
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
})
|
||||
|
||||
@@ -436,7 +454,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
return previewableVisibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -483,7 +501,16 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
|
||||
() => {
|
||||
contextMenuAsset.value = null
|
||||
},
|
||||
0,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
cancelCleanup()
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
@@ -491,10 +518,7 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
// Delay clearing to allow command callbacks to emit before component unmounts
|
||||
requestAnimationFrame(() => {
|
||||
contextMenuAsset.value = null
|
||||
})
|
||||
scheduleCleanup()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
@@ -536,6 +560,9 @@ const handleDeleteSelected = async () => {
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
if (!isPreviewableMediaType(mediaType)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mediaType === '3D') {
|
||||
const dialogStore = useDialogStore()
|
||||
@@ -555,7 +582,9 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
const index = previewableVisibleAssets.value.findIndex(
|
||||
(a) => a.id === asset.id
|
||||
)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = wrapper.findAllComponents(TabsTrigger)
|
||||
expect(triggers.length).toBe(3)
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render search box', () => {
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
@@ -114,6 +114,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -148,15 +149,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||
custom: 'alphabetical'
|
||||
}
|
||||
)
|
||||
const sortOrder = computed({
|
||||
get: () => sortOrderByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
sortOrderByTab.value = {
|
||||
...sortOrderByTab.value,
|
||||
[selectedTab.value]: value
|
||||
}
|
||||
}
|
||||
})
|
||||
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
|
||||
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
@@ -174,12 +167,7 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
all: [],
|
||||
custom: []
|
||||
})
|
||||
const expandedKeys = computed({
|
||||
get: () => expandedKeysByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
expandedKeysByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="favoritesRoot"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
@@ -26,6 +27,7 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="section.root"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
|
||||
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
|
||||
'aspect-square'
|
||||
)
|
||||
"
|
||||
:data-node-name="nodeDef?.display_name"
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.data?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -18,11 +12,12 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
|
||||
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
{{ node.data?.display_name }}
|
||||
</TextTickerMultiLine>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="showPreview" to="body">
|
||||
@@ -30,7 +25,10 @@
|
||||
:ref="(el) => (previewRef = el as HTMLElement)"
|
||||
:style="nodePreviewStyle"
|
||||
>
|
||||
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
|
||||
<NodePreviewCard
|
||||
:node-def="node.data!"
|
||||
:show-inputs-and-outputs="false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -39,6 +37,7 @@
|
||||
import { kebabCase } from 'es-toolkit/string'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
@@ -54,9 +53,8 @@ const emit = defineEmits<{
|
||||
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
@@ -69,13 +67,13 @@ const {
|
||||
} = useNodePreviewAndDrag(nodeDef, { panelRef })
|
||||
|
||||
const nodeIcon = computed(() => {
|
||||
const nodeName = nodeDef.value?.name
|
||||
const nodeName = node.data?.name
|
||||
const iconName = nodeName ? kebabCase(nodeName) : 'node'
|
||||
return `icon-[comfy--${iconName}]`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (!nodeDef.value) return
|
||||
if (!node.data) return
|
||||
emit('click', node)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
|
||||
@@ -51,6 +52,17 @@ export const useFirebaseAuthActions = () => {
|
||||
}
|
||||
|
||||
const logout = wrapWithErrorHandlingAsync(async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
if (workflowStore.modifiedWorkflows.length > 0) {
|
||||
const dialogService = useDialogService()
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.signOut.unsavedChangesTitle'),
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
it('includes convert and unpack but hides add to library when multiple items with subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
@@ -109,16 +109,11 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
const labels = options.map((option) => option.label)
|
||||
|
||||
expect(labels).toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).not.toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).toContain('contextMenu.Unpack Subgraph')
|
||||
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
it('shows add to library and unpack when a single subgraph is selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
@@ -131,5 +126,10 @@ describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
'contextMenu.Add Subgraph to Library',
|
||||
'contextMenu.Unpack Subgraph'
|
||||
])
|
||||
|
||||
const addToLibraryOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Add Subgraph to Library'
|
||||
)
|
||||
expect(addToLibraryOption?.action).toBe(mocks.addSubgraphToLibrary)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,18 +85,18 @@ export function useSelectionMenuOptions() {
|
||||
}
|
||||
|
||||
if (hasSubgraphs) {
|
||||
options.push(
|
||||
{
|
||||
if (!hasMultipleSelection) {
|
||||
options.push({
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
action: addSubgraphToLibrary
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
90
src/composables/graph/useSubgraphOperations.test.ts
Normal file
90
src/composables/graph/useSubgraphOperations.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
selectedItems: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
getSelectedNodes: vi.fn(() => [])
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: vi.fn(),
|
||||
get selectedItems() {
|
||||
return mocks.selectedItems
|
||||
},
|
||||
updateSelectedItems: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokeSubgraphPreviews: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
publishSubgraph: mocks.publishSubgraph
|
||||
})
|
||||
}))
|
||||
|
||||
function createSubgraphNode(): SubgraphNode {
|
||||
const node = Object.create(SubgraphNode.prototype)
|
||||
return node
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
|
||||
mocks.selectedItems = [createSubgraphNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when no items selected', async () => {
|
||||
mocks.selectedItems = []
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary does not call publishSubgraph when multiple items selected', async () => {
|
||||
mocks.selectedItems = [createSubgraphNode(), createSubgraphNode()]
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { addSubgraphToLibrary } = useSubgraphOperations()
|
||||
|
||||
await addSubgraphToLibrary()
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
|
||||
/**
|
||||
* Composable for handling subgraph-related operations
|
||||
@@ -15,8 +13,7 @@ export function useSubgraphOperations() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const convertToSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
@@ -73,48 +70,13 @@ export function useSubgraphOperations() {
|
||||
|
||||
const addSubgraphToLibrary = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
|
||||
// Handle single node selection like BookmarkButton.vue
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
if (isLGraphNode(item)) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(item)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple nodes - convert to subgraph first then bookmark
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if selection contains subgraph nodes
|
||||
const hasSubgraphs = selectedNodes.some(
|
||||
(node) => node instanceof SubgraphNode
|
||||
const subgraphNodes = selectedItems.filter(
|
||||
(item): item is SubgraphNode => item instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (!hasSubgraphs) {
|
||||
// Convert regular nodes to subgraph first
|
||||
convertToSubgraph()
|
||||
if (subgraphNodes.length !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// For subgraph nodes, bookmark them
|
||||
let bookmarkedCount = 0
|
||||
for (const node of selectedNodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
bookmarkedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
await subgraphStore.publishSubgraph()
|
||||
}
|
||||
|
||||
const isSubgraphSelected = (): boolean => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isReactive, isReadonly } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.linearToggleEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
localStorage.setItem('ff:model_upload_button_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.modelUploadButtonEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveFlag falls through to server when no override is set', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.assetRenameEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('direct server flags delegate override to api.getServerFeature', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation((path) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return 'overridden'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsPreviewMetadata).toBe('overridden')
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
/**
|
||||
* Known server feature flags (top-level, not extensions)
|
||||
@@ -24,6 +25,19 @@ export enum ServerFeatureFlag {
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
|
||||
*/
|
||||
function resolveFlag<T>(
|
||||
flagKey: string,
|
||||
remoteConfigValue: T | undefined,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const override = getDevOverride<T>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -39,38 +53,40 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
remoteConfig.value.model_upload_button_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ASSET_RENAME_ENABLED,
|
||||
remoteConfig.value.asset_rename_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
|
||||
remoteConfig.value.private_models_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
|
||||
remoteConfig.value.onboarding_survey_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
if (isNightly) return true
|
||||
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
|
||||
remoteConfig.value.linear_toggle_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
@@ -80,11 +96,12 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
// Only return true if authenticated config has been loaded.
|
||||
// This prevents race conditions where code checks this flag before
|
||||
// WorkspaceAuthGate has refreshed the config with auth.
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
@@ -93,9 +110,10 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
remoteConfig.value.user_secrets_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
|
||||
73
src/composables/usePerTabState.test.ts
Normal file
73
src/composables/usePerTabState.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { usePerTabState } from './usePerTabState'
|
||||
|
||||
type TabId = 'a' | 'b' | 'c'
|
||||
|
||||
describe('usePerTabState', () => {
|
||||
function setup(initialTab: TabId = 'a') {
|
||||
const selectedTab = ref<TabId>(initialTab)
|
||||
const stateByTab = ref<Record<TabId, string[]>>({
|
||||
a: [],
|
||||
b: [],
|
||||
c: []
|
||||
})
|
||||
const state = usePerTabState(selectedTab, stateByTab)
|
||||
return { selectedTab, stateByTab, state }
|
||||
}
|
||||
|
||||
it('should return state for the current tab', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
stateByTab.value.a = ['key1', 'key2']
|
||||
stateByTab.value.b = ['key3']
|
||||
|
||||
expect(state.value).toEqual(['key1', 'key2'])
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual(['key3'])
|
||||
})
|
||||
|
||||
it('should set state only for the current tab', () => {
|
||||
const { stateByTab, state } = setup()
|
||||
|
||||
state.value = ['new-key1', 'new-key2']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
|
||||
expect(stateByTab.value.b).toEqual([])
|
||||
expect(stateByTab.value.c).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve state when switching tabs', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
state.value = ['a-key']
|
||||
selectedTab.value = 'b'
|
||||
state.value = ['b-key']
|
||||
selectedTab.value = 'c'
|
||||
state.value = ['c-key']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['a-key'])
|
||||
expect(stateByTab.value.b).toEqual(['b-key'])
|
||||
expect(stateByTab.value.c).toEqual(['c-key'])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['a-key'])
|
||||
})
|
||||
|
||||
it('should not share state between tabs', () => {
|
||||
const { selectedTab, state } = setup()
|
||||
|
||||
state.value = ['only-a']
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'c'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['only-a'])
|
||||
})
|
||||
})
|
||||
14
src/composables/usePerTabState.ts
Normal file
14
src/composables/usePerTabState.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function usePerTabState<K extends string, V>(
|
||||
selectedTab: Ref<K>,
|
||||
stateByTab: Ref<Record<K, V>>
|
||||
) {
|
||||
return computed({
|
||||
get: () => stateByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
stateByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
38
src/constants/toolkitNodes.ts
Normal file
38
src/constants/toolkitNodes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Toolkit (Essentials) node detection constants.
|
||||
*
|
||||
* Used by telemetry to track toolkit node adoption and popularity.
|
||||
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
|
||||
*
|
||||
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical node type names for individual toolkit nodes.
|
||||
*/
|
||||
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
|
||||
// Image Tools
|
||||
'ImageCrop',
|
||||
'ImageRotate',
|
||||
'ImageBlur',
|
||||
'ImageInvert',
|
||||
'ImageCompare',
|
||||
'Canny',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice',
|
||||
|
||||
// API Nodes
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
])
|
||||
|
||||
/**
|
||||
* python_module values that identify toolkit blueprint nodes.
|
||||
* Essentials blueprints are registered with node_pack 'comfy_essentials',
|
||||
* which maps to python_module on the node def.
|
||||
*/
|
||||
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
@@ -137,6 +137,8 @@ export interface ISerialisedGraph extends BaseExportedGraph {
|
||||
export interface ExportedSubgraph extends SerialisableGraph {
|
||||
/** The display name of the subgraph. */
|
||||
name: string
|
||||
/** Optional category for organizing subgraph blueprints in the node library. */
|
||||
category?: string
|
||||
inputNode: ExportedSubgraphIONode
|
||||
outputNode: ExportedSubgraphIONode
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "تسجيل الخروج",
|
||||
"success": "تم تسجيل الخروج بنجاح",
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك."
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
|
||||
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
|
||||
"unsavedChangesTitle": "تغييرات غير محفوظة"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "القائمة",
|
||||
"ModelLibrary": "مكتبة النماذج",
|
||||
"Node": "العقدة",
|
||||
"Node Library": "مكتبة العقد",
|
||||
"Node Search Box": "مربع بحث العقد",
|
||||
"Node Widget": "أداة العقدة",
|
||||
"NodeLibrary": "مكتبة العقد",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "إعادة تعيين العرض إلى الافتراضي",
|
||||
"sections": {
|
||||
"favorites": "المفضلة"
|
||||
"favoriteNode": "العقدة المفضلة",
|
||||
"favorites": "المفضلة",
|
||||
"unfavoriteNode": "إزالة العقدة من المفضلة"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "أبجدي",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "عرض شارة تسعير عقدة API"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "تصميم مكتبة العقد الجديد",
|
||||
"tooltip": "تفعيل الشريط الجانبي المعاد تصميمه لمكتبة العقد مع علامات تبويب (الأساسية، الكل، المخصصة)، بحث محسّن، ومعاينات عند التمرير."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "تفعيل الاستبدال التلقائي للعقد",
|
||||
"tooltip": "عند التفعيل، يمكن استبدال العقد المفقودة تلقائيًا بنظيراتها الأحدث إذا كان هناك مخطط استبدال متوفر."
|
||||
|
||||
@@ -751,7 +751,8 @@
|
||||
"filterVideo": "Video",
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text"
|
||||
"filterText": "Text",
|
||||
"viewSettings": "View settings"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"folderView": {
|
||||
@@ -803,7 +804,9 @@
|
||||
"alphabeticalDesc": "Sort alphabetically within groups"
|
||||
},
|
||||
"sections": {
|
||||
"favorites": "Favorites"
|
||||
"favorites": "Favorites",
|
||||
"favoriteNode": "Favorite Node",
|
||||
"unfavoriteNode": "Unfavorite Node"
|
||||
}
|
||||
},
|
||||
"modelLibrary": "Model Library",
|
||||
@@ -1389,7 +1392,8 @@
|
||||
"Workspace": "Workspace",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets"
|
||||
"Secrets": "Secrets",
|
||||
"Node Library": "Node Library"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -2008,7 +2012,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
"success": "Signed out successfully",
|
||||
"successDetail": "You have been signed out of your account."
|
||||
"successDetail": "You have been signed out of your account.",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
|
||||
},
|
||||
"passwordUpdate": {
|
||||
"success": "Password Updated",
|
||||
|
||||
@@ -4512,7 +4512,7 @@
|
||||
}
|
||||
},
|
||||
"ImageBlend": {
|
||||
"display_name": "ImageBlend",
|
||||
"display_name": "Image Blend",
|
||||
"inputs": {
|
||||
"image1": {
|
||||
"name": "image1"
|
||||
@@ -4534,7 +4534,7 @@
|
||||
}
|
||||
},
|
||||
"ImageBlur": {
|
||||
"display_name": "ImageBlur",
|
||||
"display_name": "Image Blur",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
@@ -4839,7 +4839,7 @@
|
||||
}
|
||||
},
|
||||
"ImageRotate": {
|
||||
"display_name": "ImageRotate",
|
||||
"display_name": "Image Rotate",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
|
||||
@@ -284,6 +284,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "Show API node pricing badge"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "New Node Library Design",
|
||||
"tooltip": "Enable the redesigned node library sidebar with tabs (Essential, All, Custom), improved search, and hover previews."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Enable node replacement suggestions",
|
||||
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Cerrar sesión",
|
||||
"success": "Sesión cerrada correctamente",
|
||||
"successDetail": "Has cerrado sesión en tu cuenta."
|
||||
"successDetail": "Has cerrado sesión en tu cuenta.",
|
||||
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
|
||||
"unsavedChangesTitle": "Cambios no guardados"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "Menú",
|
||||
"ModelLibrary": "Biblioteca de Modelos",
|
||||
"Node": "Nodo",
|
||||
"Node Library": "Biblioteca de nodos",
|
||||
"Node Search Box": "Caja de Búsqueda de Nodo",
|
||||
"Node Widget": "Widget de Nodo",
|
||||
"NodeLibrary": "Biblioteca de Nodos",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "Restablecer vista a la predeterminada",
|
||||
"sections": {
|
||||
"favorites": "Favoritos"
|
||||
"favoriteNode": "Nodo favorito",
|
||||
"favorites": "Favoritos",
|
||||
"unfavoriteNode": "Quitar de favoritos"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "Alfabético",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "Mostrar insignia de precios de nodo API"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "Nuevo diseño de la biblioteca de nodos",
|
||||
"tooltip": "Activa la barra lateral rediseñada de la biblioteca de nodos con pestañas (Esencial, Todo, Personalizado), búsqueda mejorada y vistas previas al pasar el cursor."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Habilitar reemplazo automático de nodos",
|
||||
"tooltip": "Cuando está habilitado, los nodos faltantes pueden ser reemplazados automáticamente por sus equivalentes más nuevos si existe un mapeo de reemplazo."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "خروج",
|
||||
"success": "خروج با موفقیت انجام شد",
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید."
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
|
||||
"unsavedChangesMessage": "شما تغییرات ذخیرهنشدهای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
|
||||
"unsavedChangesTitle": "تغییرات ذخیرهنشده"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "منو",
|
||||
"ModelLibrary": "کتابخانه مدل",
|
||||
"Node": "Node",
|
||||
"Node Library": "کتابخانه گره",
|
||||
"Node Search Box": "جعبه جستجوی Node",
|
||||
"Node Widget": "ویجت Node",
|
||||
"NodeLibrary": "کتابخانه Node",
|
||||
@@ -2451,7 +2454,8 @@
|
||||
"sortLongestFirst": "زمان تولید (بیشترین ابتدا)",
|
||||
"sortNewestFirst": "جدیدترین ابتدا",
|
||||
"sortOldestFirst": "قدیمیترین ابتدا",
|
||||
"title": "داراییهای رسانهای"
|
||||
"title": "داراییهای رسانهای",
|
||||
"viewSettings": "نمایش تنظیمات"
|
||||
},
|
||||
"modelLibrary": "کتابخانه مدل",
|
||||
"newBlankWorkflow": "ایجاد Workflow جدید خالی",
|
||||
@@ -2475,7 +2479,9 @@
|
||||
},
|
||||
"resetView": "بازنشانی نمای پیشفرض",
|
||||
"sections": {
|
||||
"favorites": "علاقهمندیها"
|
||||
"favoriteNode": "گره مورد علاقه",
|
||||
"favorites": "علاقهمندیها",
|
||||
"unfavoriteNode": "حذف از علاقهمندیها"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "حروف الفبا",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "نمایش نشان قیمتگذاری API نود"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "طراحی جدید کتابخانه Node",
|
||||
"tooltip": "فعالسازی نوار کناری بازطراحیشده کتابخانه node با تبهای (ضروری، همه، سفارشی)، جستجوی بهبود یافته و پیشنمایش هنگام قرار گرفتن نشانگر ماوس."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "فعالسازی جایگزینی خودکار node",
|
||||
"tooltip": "در صورت فعال بودن، nodeهای مفقود میتوانند بهطور خودکار با معادلهای جدیدتر خود جایگزین شوند اگر نگاشت جایگزینی وجود داشته باشد."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Se déconnecter",
|
||||
"success": "Déconnexion réussie",
|
||||
"successDetail": "Vous avez été déconnecté de votre compte."
|
||||
"successDetail": "Vous avez été déconnecté de votre compte.",
|
||||
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous vous déconnectez. Voulez-vous continuer ?",
|
||||
"unsavedChangesTitle": "Modifications non enregistrées"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "Menu",
|
||||
"ModelLibrary": "Bibliothèque de Modèles",
|
||||
"Node": "Nœud",
|
||||
"Node Library": "Bibliothèque de nœuds",
|
||||
"Node Search Box": "Boîte de Recherche de Nœud",
|
||||
"Node Widget": "Widget de Nœud",
|
||||
"NodeLibrary": "Bibliothèque de Nœuds",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "Réinitialiser la vue par défaut",
|
||||
"sections": {
|
||||
"favorites": "Favoris"
|
||||
"favoriteNode": "Nœud favori",
|
||||
"favorites": "Favoris",
|
||||
"unfavoriteNode": "Retirer des favoris"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "Alphabétique",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "Afficher l’insigne de tarification API du nœud"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "Nouveau design de la bibliothèque de nœuds",
|
||||
"tooltip": "Activez la nouvelle barre latérale de la bibliothèque de nœuds avec des onglets (Essentiel, Tous, Personnalisé), une recherche améliorée et des aperçus au survol."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Activer le remplacement automatique des nœuds",
|
||||
"tooltip": "Lorsqu'activé, les nœuds manquants peuvent être automatiquement remplacés par leurs équivalents plus récents si une correspondance de remplacement existe."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "ログアウト",
|
||||
"success": "正常にサインアウトしました",
|
||||
"successDetail": "アカウントからサインアウトしました。"
|
||||
"successDetail": "アカウントからサインアウトしました。",
|
||||
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
|
||||
"unsavedChangesTitle": "未保存の変更"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "メニュー",
|
||||
"ModelLibrary": "モデルライブラリ",
|
||||
"Node": "ノード",
|
||||
"Node Library": "ノードライブラリ",
|
||||
"Node Search Box": "ノード検索ボックス",
|
||||
"Node Widget": "ノードウィジェット",
|
||||
"NodeLibrary": "ノードライブラリ",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "ビューをデフォルトにリセット",
|
||||
"sections": {
|
||||
"favorites": "お気に入り"
|
||||
"favoriteNode": "お気に入りノード",
|
||||
"favorites": "お気に入り",
|
||||
"unfavoriteNode": "お気に入りノードから削除"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "アルファベット順",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "APIノードの料金バッジを表示"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "新しいノードライブラリデザイン",
|
||||
"tooltip": "タブ(Essential、All、Custom)、強化された検索機能、ホバープレビューを備えた再設計されたノードライブラリサイドバーを有効にします。"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "自動ノード置換を有効にする",
|
||||
"tooltip": "有効にすると、置換マッピングが存在する場合、欠落しているノードが自動的に新しい同等ノードに置き換えられます。"
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "로그아웃",
|
||||
"success": "성공적으로 로그아웃되었습니다",
|
||||
"successDetail": "계정에서 로그아웃되었습니다."
|
||||
"successDetail": "계정에서 로그아웃되었습니다.",
|
||||
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
|
||||
"unsavedChangesTitle": "저장되지 않은 변경 사항"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "메뉴",
|
||||
"ModelLibrary": "모델 라이브러리",
|
||||
"Node": "노드",
|
||||
"Node Library": "노드 라이브러리",
|
||||
"Node Search Box": "노드 검색 상자",
|
||||
"Node Widget": "노드 위젯",
|
||||
"NodeLibrary": "노드 라이브러리",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "기본 보기로 재설정",
|
||||
"sections": {
|
||||
"favorites": "즐겨찾기"
|
||||
"favoriteNode": "즐겨찾는 노드",
|
||||
"favorites": "즐겨찾기",
|
||||
"unfavoriteNode": "즐겨찾기 해제 노드"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "알파벳순",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "API 노드 가격 배지 표시"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "새로운 노드 라이브러리 디자인",
|
||||
"tooltip": "탭(필수, 전체, 사용자 정의), 향상된 검색, 그리고 미리보기 기능이 추가된 새롭게 디자인된 노드 라이브러리 사이드바를 활성화합니다."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "노드 교체 제안 활성화",
|
||||
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Sair",
|
||||
"success": "Logout realizado com sucesso",
|
||||
"successDetail": "Você saiu da sua conta."
|
||||
"successDetail": "Você saiu da sua conta.",
|
||||
"unsavedChangesMessage": "Você tem alterações não salvas que serão perdidas ao sair. Deseja continuar?",
|
||||
"unsavedChangesTitle": "Alterações não salvas"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Já tem uma conta?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "Menu",
|
||||
"ModelLibrary": "Biblioteca de Modelos",
|
||||
"Node": "Nó",
|
||||
"Node Library": "Biblioteca de nós",
|
||||
"Node Search Box": "Caixa de Pesquisa de Nós",
|
||||
"Node Widget": "Widget de Nó",
|
||||
"NodeLibrary": "Biblioteca de Nós",
|
||||
@@ -2451,7 +2454,8 @@
|
||||
"sortLongestFirst": "Tempo de geração (maior primeiro)",
|
||||
"sortNewestFirst": "Mais recentes primeiro",
|
||||
"sortOldestFirst": "Mais antigos primeiro",
|
||||
"title": "Ativos de Mídia"
|
||||
"title": "Ativos de Mídia",
|
||||
"viewSettings": "Ver configurações"
|
||||
},
|
||||
"modelLibrary": "Biblioteca de modelos",
|
||||
"newBlankWorkflow": "Criar um novo workflow em branco",
|
||||
@@ -2475,7 +2479,9 @@
|
||||
},
|
||||
"resetView": "Restaurar visualização padrão",
|
||||
"sections": {
|
||||
"favorites": "Favoritos"
|
||||
"favoriteNode": "Favoritar nó",
|
||||
"favorites": "Favoritos",
|
||||
"unfavoriteNode": "Desfavoritar nó"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "Alfabética",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "Mostrar selo de preço do nó de API"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "Novo Design da Biblioteca de Nós",
|
||||
"tooltip": "Ative a barra lateral redesenhada da biblioteca de nós com abas (Essencial, Todos, Personalizado), busca aprimorada e pré-visualizações ao passar o mouse."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Ativar substituição automática de nós",
|
||||
"tooltip": "Quando ativado, nós ausentes podem ser substituídos automaticamente por seus equivalentes mais recentes, se existir um mapeamento de substituição."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Выйти",
|
||||
"success": "Вы успешно вышли из системы",
|
||||
"successDetail": "Вы вышли из своей учетной записи."
|
||||
"successDetail": "Вы вышли из своей учетной записи.",
|
||||
"unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны при выходе из системы. Вы хотите продолжить?",
|
||||
"unsavedChangesTitle": "Несохранённые изменения"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "Меню",
|
||||
"ModelLibrary": "Библиотека моделей",
|
||||
"Node": "Нода",
|
||||
"Node Library": "Библиотека узлов",
|
||||
"Node Search Box": "Поисковая строка нод",
|
||||
"Node Widget": "Виджет ноды",
|
||||
"NodeLibrary": "Библиотека нод",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "Сбросить вид по умолчанию",
|
||||
"sections": {
|
||||
"favorites": "Избранное"
|
||||
"favoriteNode": "Избранный узел",
|
||||
"favorites": "Избранное",
|
||||
"unfavoriteNode": "Удалить из избранного"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "По алфавиту",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "Показать значок стоимости узла API"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "Новый дизайн библиотеки узлов",
|
||||
"tooltip": "Включить обновлённую боковую панель библиотеки узлов с вкладками (Основные, Все, Пользовательские), улучшенным поиском и предварительным просмотром при наведении."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Включить автоматическую замену узлов",
|
||||
"tooltip": "Если включено, отсутствующие узлы могут быть автоматически заменены их новыми эквивалентами, если существует соответствие для замены."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "Çıkış Yap",
|
||||
"success": "Başarıyla çıkış yapıldı",
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız."
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız.",
|
||||
"unsavedChangesMessage": "Oturumu kapattığınızda kaydedilmemiş değişiklikleriniz kaybolacak. Devam etmek istiyor musunuz?",
|
||||
"unsavedChangesTitle": "Kaydedilmemiş Değişiklikler"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "Menü",
|
||||
"ModelLibrary": "Model Kütüphanesi",
|
||||
"Node": "Düğüm",
|
||||
"Node Library": "Düğüm Kütüphanesi",
|
||||
"Node Search Box": "Düğüm Arama Kutusu",
|
||||
"Node Widget": "Düğüm Widget'ı",
|
||||
"NodeLibrary": "Düğüm Kütüphanesi",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "Görünümü Varsayılana Sıfırla",
|
||||
"sections": {
|
||||
"favorites": "Favoriler"
|
||||
"favoriteNode": "Favori Düğüm",
|
||||
"favorites": "Favoriler",
|
||||
"unfavoriteNode": "Favori Düğümü Kaldır"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "Alfabetik",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "API düğüm fiyatlandırma rozetini göster"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "Yeni Düğüm Kütüphanesi Tasarımı",
|
||||
"tooltip": "Sekmeli (Temel, Tümü, Özel), geliştirilmiş arama ve üzerine gelindiğinde önizleme özelliklerine sahip yeniden tasarlanmış düğüm kütüphanesi kenar çubuğunu etkinleştir."
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "Otomatik düğüm değiştirmeyi etkinleştir",
|
||||
"tooltip": "Etkinleştirildiğinde, eksik düğümler için bir değiştirme eşlemesi varsa otomatik olarak daha yeni karşılıklarıyla değiştirilebilir."
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "登出",
|
||||
"success": "成功登出",
|
||||
"successDetail": "您已成功登出您的帳戶。"
|
||||
"successDetail": "您已成功登出您的帳戶。",
|
||||
"unsavedChangesMessage": "您有尚未儲存的變更,登出後這些變更將會遺失。您確定要繼續嗎?",
|
||||
"unsavedChangesTitle": "未儲存的變更"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已經有帳戶?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "選單",
|
||||
"ModelLibrary": "模型庫",
|
||||
"Node": "節點",
|
||||
"Node Library": "節點庫",
|
||||
"Node Search Box": "節點搜尋框",
|
||||
"Node Widget": "節點元件",
|
||||
"NodeLibrary": "節點庫",
|
||||
@@ -2464,7 +2467,9 @@
|
||||
},
|
||||
"resetView": "重設檢視為預設值",
|
||||
"sections": {
|
||||
"favorites": "我的最愛"
|
||||
"favoriteNode": "收藏節點",
|
||||
"favorites": "我的最愛",
|
||||
"unfavoriteNode": "取消收藏節點"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "字母順序",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "顯示 API 節點價格標籤"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "全新節點庫設計",
|
||||
"tooltip": "啟用重新設計的節點庫側邊欄,包含分頁(精選、全部、自訂)、強化搜尋功能及滑鼠懸停預覽。"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "啟用自動節點替換",
|
||||
"tooltip": "啟用後,若有替換對應關係,遺失的節點可自動以其較新的對應節點替換。"
|
||||
|
||||
@@ -275,7 +275,9 @@
|
||||
"signOut": {
|
||||
"signOut": "退出登录",
|
||||
"success": "成功退出登录",
|
||||
"successDetail": "您已成功退出账户。"
|
||||
"successDetail": "您已成功退出账户。",
|
||||
"unsavedChangesMessage": "您有未保存的更改,注销后这些更改将会丢失。是否继续?",
|
||||
"unsavedChangesTitle": "未保存的更改"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已经有账户了?",
|
||||
@@ -2363,6 +2365,7 @@
|
||||
"Menu": "菜单",
|
||||
"ModelLibrary": "模型库",
|
||||
"Node": "节点",
|
||||
"Node Library": "节点库",
|
||||
"Node Search Box": "节点搜索框",
|
||||
"Node Widget": "节点组件",
|
||||
"NodeLibrary": "节点库",
|
||||
@@ -2451,7 +2454,8 @@
|
||||
"sortLongestFirst": "生成时间(最慢)",
|
||||
"sortNewestFirst": "最新",
|
||||
"sortOldestFirst": "最旧",
|
||||
"title": "媒体资产"
|
||||
"title": "媒体资产",
|
||||
"viewSettings": "查看设置"
|
||||
},
|
||||
"modelLibrary": "模型库",
|
||||
"newBlankWorkflow": "创建空白工作流",
|
||||
@@ -2475,7 +2479,9 @@
|
||||
},
|
||||
"resetView": "重置视图为默认",
|
||||
"sections": {
|
||||
"favorites": "收藏夹"
|
||||
"favoriteNode": "收藏节点",
|
||||
"favorites": "收藏夹",
|
||||
"unfavoriteNode": "取消收藏节点"
|
||||
},
|
||||
"sortBy": {
|
||||
"alphabetical": "字母顺序",
|
||||
|
||||
@@ -248,6 +248,10 @@
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
"name": "显示 API 节点定价徽章"
|
||||
},
|
||||
"Comfy_NodeLibrary_NewDesign": {
|
||||
"name": "全新节点库设计",
|
||||
"tooltip": "启用带有标签页(基础、全部、自定义)、改进搜索和悬停预览功能的全新节点库侧边栏。"
|
||||
},
|
||||
"Comfy_NodeReplacement_Enabled": {
|
||||
"name": "启用自动节点替换",
|
||||
"tooltip": "启用后,如果存在替换映射,缺失的节点可以自动替换为其较新的等效节点。"
|
||||
|
||||
@@ -82,6 +82,7 @@ export const GeneratedVideo: Story = {
|
||||
args: {
|
||||
previewUrl: VIDEO_PREVIEW,
|
||||
previewAlt: 'clip-01.mp4',
|
||||
isVideoPreview: true,
|
||||
primaryText: 'clip-01.mp4',
|
||||
secondaryText: '2m 12s'
|
||||
}
|
||||
|
||||
38
src/platform/assets/components/AssetsListItem.test.ts
Normal file
38
src/platform/assets/components/AssetsListItem.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import AssetsListItem from './AssetsListItem.vue'
|
||||
|
||||
describe('AssetsListItem', () => {
|
||||
it('renders video element with play overlay for video previews', () => {
|
||||
const wrapper = mount(AssetsListItem, {
|
||||
props: {
|
||||
previewUrl: 'https://example.com/preview.mp4',
|
||||
previewAlt: 'clip.mp4',
|
||||
isVideoPreview: true
|
||||
}
|
||||
})
|
||||
|
||||
const video = wrapper.find('video')
|
||||
expect(video.exists()).toBe(true)
|
||||
expect(video.attributes('src')).toBe('https://example.com/preview.mp4')
|
||||
expect(video.attributes('preload')).toBe('metadata')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('.bg-black\\/15').exists()).toBe(true)
|
||||
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show play overlay for non-video previews', () => {
|
||||
const wrapper = mount(AssetsListItem, {
|
||||
props: {
|
||||
previewUrl: 'https://example.com/preview.jpg',
|
||||
previewAlt: 'image.png',
|
||||
isVideoPreview: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('video').exists()).toBe(false)
|
||||
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -35,12 +35,24 @@
|
||||
:icon-class="iconClass"
|
||||
:icon-aria-label="iconAriaLabel"
|
||||
>
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="previewAlt"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-if="previewUrl" class="relative size-full">
|
||||
<template v-if="isVideoPreview">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="pointer-events-none size-full object-cover"
|
||||
/>
|
||||
<VideoPlayOverlay size="sm" />
|
||||
</template>
|
||||
<img
|
||||
v-else
|
||||
:src="previewUrl"
|
||||
:alt="previewAlt"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
@@ -119,6 +131,8 @@ import { useProgressBarBackground } from '@/composables/useProgressBarBackground
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import VideoPlayOverlay from './VideoPlayOverlay.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'stack-toggle': []
|
||||
}>()
|
||||
@@ -130,6 +144,7 @@ const {
|
||||
iconAriaLabel,
|
||||
iconClass,
|
||||
iconWrapperClass,
|
||||
isVideoPreview = false,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
stackCount,
|
||||
@@ -144,6 +159,7 @@ const {
|
||||
iconAriaLabel?: string
|
||||
iconClass?: string
|
||||
iconWrapperClass?: string
|
||||
isVideoPreview?: boolean
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
stackCount?: number
|
||||
|
||||
@@ -141,6 +141,40 @@ export const AudioAsset: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-5',
|
||||
name: 'generation-notes.txt',
|
||||
size: 2048,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OtherAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-6',
|
||||
name: 'workflow-payload.bin',
|
||||
size: 8192,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
:is="getTopComponent(previewKind)"
|
||||
v-else-if="asset && adaptedAsset"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@@ -59,6 +59,7 @@
|
||||
>
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
v-if="canInspect"
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@@ -141,7 +142,8 @@ import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -152,17 +154,21 @@ import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue')),
|
||||
text: defineAsyncComponent(() => import('./MediaTextTop.vue')),
|
||||
other: defineAsyncComponent(() => import('./MediaOtherTop.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
function getTopComponent(kind: PreviewKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.other
|
||||
}
|
||||
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
@@ -206,9 +212,15 @@ const assetType = computed(() => {
|
||||
|
||||
// Determine file type from extension
|
||||
const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const previewKind = computed((): PreviewKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
@@ -270,7 +282,7 @@ const showActionsOverlay = computed(() => {
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
if (asset && canInspect.value) {
|
||||
emit('zoom', asset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@hide="emit('hide')"
|
||||
@hide="onMenuHide"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<Button
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
@@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -76,19 +77,32 @@ const emit = defineEmits<{
|
||||
type ContextMenuInstance = ComponentPublicInstance & {
|
||||
show: (event: MouseEvent) => void
|
||||
hide: () => void
|
||||
container?: HTMLElement
|
||||
$el?: HTMLElement
|
||||
}
|
||||
|
||||
const contextMenu = ref<ContextMenuInstance | null>(null)
|
||||
const isVisible = ref(false)
|
||||
const actions = useMediaAssetActions()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Close context menu when clicking outside
|
||||
onClickOutside(
|
||||
computed(() => contextMenu.value?.$el),
|
||||
() => {
|
||||
hide()
|
||||
}
|
||||
)
|
||||
function getOverlayEl(): HTMLElement | null {
|
||||
return contextMenu.value?.container ?? contextMenu.value?.$el ?? null
|
||||
}
|
||||
|
||||
function dismissIfOutside(event: Event) {
|
||||
if (!isVisible.value) return
|
||||
const overlay = getOverlayEl()
|
||||
if (!overlay) return
|
||||
if (overlay.contains(event.target as Node)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
|
||||
useEventListener(window, 'scroll', dismissIfOutside, {
|
||||
capture: true,
|
||||
passive: true
|
||||
})
|
||||
|
||||
const showAddToWorkflow = computed(() => {
|
||||
// Output assets can always be added
|
||||
@@ -193,8 +207,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
|
||||
// Individual mode: Show all menu options
|
||||
|
||||
// Inspect (if not 3D)
|
||||
if (fileKind !== '3D') {
|
||||
// Inspect
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
@@ -265,11 +279,18 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const show = (event: MouseEvent) => {
|
||||
function onMenuHide() {
|
||||
isVisible.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
isVisible.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
function hide() {
|
||||
isVisible.value = false
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<MediaAssetFilterButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
|
||||
size="md"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetFilterMenu
|
||||
@@ -24,7 +23,6 @@
|
||||
<AssetSortButton
|
||||
v-if="isCloud"
|
||||
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
|
||||
size="md"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetSortMenu
|
||||
@@ -34,7 +32,13 @@
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
<MediaAssetViewModeToggle v-model:view-mode="viewMode" />
|
||||
<MediaAssetSettingsButton
|
||||
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetSettingsMenu v-model:view-mode="viewMode" :close />
|
||||
</template>
|
||||
</MediaAssetSettingsButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,7 +52,8 @@ import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSortMenu.vue'
|
||||
import MediaAssetViewModeToggle from './MediaAssetViewModeToggle.vue'
|
||||
import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
|
||||
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
|
||||
|
||||
const { showGenerationTimeSort = false } = defineProps<{
|
||||
searchQuery: string
|
||||
|
||||
@@ -1,58 +1,19 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Button variant="secondary" size="icon" @click="toggle">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="#vue-app"
|
||||
auto-z-index
|
||||
:base-z-index="1000"
|
||||
dismissable
|
||||
close-on-escape
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
<div class="inline-flex items-center">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="secondary" size="icon">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<slot :close />
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-1 rounded-lg bg-base-background text-base-foreground border border-border-default shadow-lg'
|
||||
)
|
||||
}
|
||||
}))
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@ TODO: Extract checkbox pattern into reusable Checkbox component
|
||||
<div
|
||||
v-for="filter in filters"
|
||||
:key="filter.type"
|
||||
class="flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
|
||||
class="flex h-10 min-w-32 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
|
||||
tabindex="0"
|
||||
role="checkbox"
|
||||
:aria-checked="mediaTypeFilters.includes(filter.type)"
|
||||
|
||||
23
src/platform/assets/components/MediaAssetSettingsButton.vue
Normal file
23
src/platform/assets/components/MediaAssetSettingsButton.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('sideToolbar.mediaAssets.viewSettings')"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<slot :close />
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
</script>
|
||||
48
src/platform/assets/components/MediaAssetSettingsMenu.vue
Normal file
48
src/platform/assets/components/MediaAssetSettingsMenu.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleViewModeChange('list')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--table-of-contents] size-4" />
|
||||
<span>{{ $t('sideToolbar.queueProgressOverlay.viewList') }}</span>
|
||||
</span>
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="viewMode !== 'list' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="w-full"
|
||||
@click="handleViewModeChange('grid')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
<span>{{ $t('sideToolbar.queueProgressOverlay.viewGrid') }}</span>
|
||||
</span>
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="viewMode !== 'grid' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
function handleViewModeChange(value: 'list' | 'grid') {
|
||||
viewMode.value = value
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -1,60 +1,19 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Button variant="secondary" size="icon" @click="toggle">
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
<div class="inline-flex items-center">
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="secondary" size="icon">
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<slot :close />
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-1 rounded-lg',
|
||||
'bg-base-background text-base-foreground border border-border-default',
|
||||
'shadow-lg'
|
||||
)
|
||||
}
|
||||
}))
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
</script>
|
||||
|
||||
@@ -2,39 +2,51 @@
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="justify-start"
|
||||
class="w-full"
|
||||
@click="handleSortChange('newest')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortNewestFirst') }}</span>
|
||||
<i v-if="sortBy === 'newest'" class="icon-[lucide--check] size-4" />
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="sortBy !== 'newest' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="justify-start"
|
||||
class="w-full"
|
||||
@click="handleSortChange('oldest')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortOldestFirst') }}</span>
|
||||
<i v-if="sortBy === 'oldest'" class="icon-[lucide--check] size-4" />
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="sortBy !== 'oldest' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<template v-if="showGenerationTimeSort">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="justify-start"
|
||||
class="w-full"
|
||||
@click="handleSortChange('longest')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortLongestFirst') }}</span>
|
||||
<i v-if="sortBy === 'longest'" class="icon-[lucide--check] size-4" />
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="sortBy !== 'longest' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="justify-start"
|
||||
class="w-full"
|
||||
@click="handleSortChange('fastest')"
|
||||
>
|
||||
<span>{{ $t('sideToolbar.mediaAssets.sortFastestFirst') }}</span>
|
||||
<i v-if="sortBy === 'fastest'" class="icon-[lucide--check] size-4" />
|
||||
<i
|
||||
class="icon-[lucide--check] ml-auto size-4"
|
||||
:class="sortBy !== 'fastest' && 'opacity-0'"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-secondary-background p-1"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewList')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'list'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="icon-[lucide--table-of-contents] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewGrid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'grid'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
9
src/platform/assets/components/MediaOtherTop.vue
Normal file
9
src/platform/assets/components/MediaOtherTop.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--check-check] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
9
src/platform/assets/components/MediaTextTop.vue
Normal file
9
src/platform/assets/components/MediaTextTop.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
102
src/platform/assets/components/MediaVideoTop.test.ts
Normal file
102
src/platform/assets/components/MediaVideoTop.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaVideoTop from './MediaVideoTop.vue'
|
||||
|
||||
function createVideoAsset(
|
||||
src: string,
|
||||
mimeType: AssetMeta['mime_type'] = 'video/mp4'
|
||||
): AssetMeta {
|
||||
return {
|
||||
id: 'video-1',
|
||||
name: 'clip.mp4',
|
||||
asset_hash: null,
|
||||
mime_type: mimeType,
|
||||
tags: [],
|
||||
kind: 'video',
|
||||
src
|
||||
}
|
||||
}
|
||||
|
||||
describe('MediaVideoTop', () => {
|
||||
it('renders playable video with darkened paused overlay and play icon', () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
asset: createVideoAsset('https://example.com/thumb.jpg')
|
||||
}
|
||||
})
|
||||
|
||||
const video = wrapper.find('video')
|
||||
const videoElement = video.element as HTMLVideoElement
|
||||
expect(video.exists()).toBe(true)
|
||||
expect(videoElement.controls).toBe(false)
|
||||
expect(wrapper.find('source').attributes('src')).toBe(
|
||||
'https://example.com/thumb.jpg'
|
||||
)
|
||||
expect(wrapper.find('source').attributes('type')).toBe('video/mp4')
|
||||
expect(wrapper.find('.bg-black\\/15').exists()).toBe(true)
|
||||
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render source element when src is empty', () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
asset: createVideoAsset('')
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.find('source').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits playback events and hides paused overlay while playing', async () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
asset: createVideoAsset('https://example.com/thumb.jpg')
|
||||
}
|
||||
})
|
||||
|
||||
const video = wrapper.find('video')
|
||||
const videoElement = video.element as HTMLVideoElement
|
||||
expect(video.exists()).toBe(true)
|
||||
|
||||
await video.trigger('play')
|
||||
expect(wrapper.emitted('videoPlayingStateChanged')?.at(-1)).toEqual([true])
|
||||
expect(wrapper.find('.bg-black\\/15').exists()).toBe(false)
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
expect(videoElement.controls).toBe(true)
|
||||
|
||||
await wrapper.trigger('mouseleave')
|
||||
expect(videoElement.controls).toBe(false)
|
||||
|
||||
await video.trigger('pause')
|
||||
expect(wrapper.emitted('videoPlayingStateChanged')?.at(-1)).toEqual([false])
|
||||
expect(wrapper.find('.bg-black\\/15').exists()).toBe(true)
|
||||
expect(videoElement.controls).toBe(false)
|
||||
})
|
||||
|
||||
it('starts playback from click when controls are hidden', async () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
asset: createVideoAsset('https://example.com/thumb.jpg')
|
||||
}
|
||||
})
|
||||
|
||||
const video = wrapper.find('video')
|
||||
const videoElement = video.element as HTMLVideoElement
|
||||
const playSpy = vi
|
||||
.spyOn(videoElement, 'play')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
|
||||
Object.defineProperty(videoElement, 'paused', {
|
||||
value: true,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
await video.trigger('click')
|
||||
|
||||
expect(playSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -5,20 +5,24 @@
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<video
|
||||
ref="videoElement"
|
||||
:controls="shouldShowControls"
|
||||
preload="metadata"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="asset.preview_url"
|
||||
class="relative size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
@click.stop
|
||||
@click.stop="onVideoClick"
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
@ended="onVideoEnded"
|
||||
>
|
||||
<source :src="asset.src || ''" />
|
||||
<source
|
||||
v-if="asset.src"
|
||||
:src="asset.src"
|
||||
:type="asset.mime_type ?? undefined"
|
||||
/>
|
||||
</video>
|
||||
<VideoPlayOverlay :visible="!isPlaying" size="md" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,6 +31,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
import VideoPlayOverlay from './VideoPlayOverlay.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
@@ -36,11 +42,12 @@ const emit = defineEmits<{
|
||||
videoControlsChanged: [showControls: boolean]
|
||||
}>()
|
||||
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
// Always show controls when not playing, hide/show based on hover when playing
|
||||
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
|
||||
// Show native controls only while actively playing and hovered.
|
||||
const shouldShowControls = computed(() => isPlaying.value && isHovered.value)
|
||||
|
||||
watch(shouldShowControls, (controlsVisible) => {
|
||||
emit('videoControlsChanged', controlsVisible)
|
||||
@@ -60,8 +67,17 @@ const onVideoPause = () => {
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
|
||||
const onVideoEnded = () => {
|
||||
isPlaying.value = false
|
||||
emit('videoPlayingStateChanged', false)
|
||||
const onVideoClick = async () => {
|
||||
if (shouldShowControls.value) return
|
||||
|
||||
const video = videoElement.value
|
||||
if (!video) return
|
||||
|
||||
if (video.paused || video.ended) {
|
||||
await video.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
video.pause()
|
||||
}
|
||||
</script>
|
||||
|
||||
31
src/platform/assets/components/VideoPlayOverlay.vue
Normal file
31
src/platform/assets/components/VideoPlayOverlay.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<template v-if="visible">
|
||||
<div :class="cn('pointer-events-none absolute inset-0', overlayClass)" />
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--play] text-white', iconSizeClass)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
visible = true,
|
||||
size = 'md',
|
||||
overlayClass = 'bg-black/15'
|
||||
} = defineProps<{
|
||||
visible?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
overlayClass?: string
|
||||
}>()
|
||||
|
||||
const iconSizeClass = computed(() => (size === 'sm' ? 'size-3' : 'size-6'))
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user