Merge branch 'main' of github.com:Comfy-Org/ComfyUI_frontend into sno-deploy-ghpage
8
.github/workflows/update-locales.yaml
vendored
@@ -18,14 +18,14 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
@@ -130,7 +129,8 @@ export class ComfyPage {
|
||||
|
||||
// Buttons
|
||||
public readonly resetViewButton: Locator
|
||||
public readonly queueButton: Locator
|
||||
public readonly queueButton: Locator // Run button in Legacy UI
|
||||
public readonly runButton: Locator // Run button (renamed "Queue" -> "Run")
|
||||
|
||||
// Inputs
|
||||
public readonly workflowUploadInput: Locator
|
||||
@@ -165,6 +165,9 @@ export class ComfyPage {
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.runButton = page
|
||||
.getByTestId('queue-button')
|
||||
.getByRole('button', { name: 'Run' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
@@ -1086,12 +1089,6 @@ export class ComfyPage {
|
||||
|
||||
const targetPosition = await targetSlot.getPosition()
|
||||
|
||||
// Debug: Log the positions we're trying to use
|
||||
console.log('Drag positions:', {
|
||||
source: sourcePosition,
|
||||
target: targetPosition
|
||||
})
|
||||
|
||||
await this.dragAndDrop(sourcePosition, targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { VueNodeFixture } from './utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
@@ -106,6 +108,24 @@ export class VueNodeHelpers {
|
||||
await this.page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
const nodeId = await node.evaluate((el) => el.getAttribute('data-node-id'))
|
||||
if (!nodeId) {
|
||||
throw new Error(
|
||||
`Vue node titled "${title}" is missing its data-node-id attribute`
|
||||
)
|
||||
}
|
||||
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Vue nodes to be rendered
|
||||
*/
|
||||
|
||||
@@ -1,131 +1,66 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { NodeReference } from './litegraphUtils'
|
||||
|
||||
/**
|
||||
* VueNodeFixture provides Vue-specific testing utilities for interacting with
|
||||
* Vue node components. It bridges the gap between litegraph node references
|
||||
* and Vue UI components.
|
||||
*/
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(
|
||||
private readonly nodeRef: NodeReference,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
constructor(private readonly locator: Locator) {}
|
||||
|
||||
/**
|
||||
* Get the node's header element using data-testid
|
||||
*/
|
||||
async getHeader(): Promise<Locator> {
|
||||
const nodeId = this.nodeRef.id
|
||||
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
|
||||
get header(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-header-"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's title element
|
||||
*/
|
||||
async getTitleElement(): Promise<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-title"]')
|
||||
get title(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title-input"]')
|
||||
}
|
||||
|
||||
get body(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
get collapseIcon(): Locator {
|
||||
return this.collapseButton.locator('i')
|
||||
}
|
||||
|
||||
get root(): Locator {
|
||||
return this.locator
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current title text
|
||||
*/
|
||||
async getTitle(): Promise<string> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
return (await titleElement.textContent()) || ''
|
||||
return (await this.title.textContent()) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new title by double-clicking and entering text
|
||||
*/
|
||||
async setTitle(newTitle: string): Promise<void> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
|
||||
const input = (await this.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill(newTitle)
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel title editing
|
||||
*/
|
||||
async cancelTitleEdit(): Promise<void> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
|
||||
const input = (await this.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.press('Escape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the title is currently being edited
|
||||
*/
|
||||
async isEditingTitle(): Promise<boolean> {
|
||||
const header = await this.getHeader()
|
||||
const input = header.locator('[data-testid="node-title-input"]')
|
||||
return await input.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse/expand button
|
||||
*/
|
||||
async getCollapseButton(): Promise<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the node's collapsed state
|
||||
*/
|
||||
async toggleCollapse(): Promise<void> {
|
||||
const button = await this.getCollapseButton()
|
||||
await button.click()
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon element
|
||||
*/
|
||||
async getCollapseIcon(): Promise<Locator> {
|
||||
const button = await this.getCollapseButton()
|
||||
return button.locator('i')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon's CSS classes
|
||||
*/
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
const icon = await this.getCollapseIcon()
|
||||
return (await icon.getAttribute('class')) || ''
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the collapse button is visible
|
||||
*/
|
||||
async isCollapseButtonVisible(): Promise<boolean> {
|
||||
const button = await this.getCollapseButton()
|
||||
return await button.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's body/content element
|
||||
*/
|
||||
async getBody(): Promise<Locator> {
|
||||
const nodeId = this.nodeRef.id
|
||||
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the node body is visible (not collapsed)
|
||||
*/
|
||||
async isBodyVisible(): Promise<boolean> {
|
||||
const body = await this.getBody()
|
||||
return await body.isVisible()
|
||||
boundingBox(): ReturnType<Locator['boundingBox']> {
|
||||
return this.locator.boundingBox()
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -39,15 +39,15 @@ test.describe('Graph Canvas Menu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Focus mode button is clickable and has correct test id', async ({
|
||||
test('Toggle minimap button is clickable and has correct test id', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
|
||||
await expect(focusButton).toBeVisible()
|
||||
await expect(focusButton).toBeEnabled()
|
||||
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
await expect(minimapButton).toBeVisible()
|
||||
await expect(minimapButton).toBeEnabled()
|
||||
|
||||
// Test that the button can be clicked without error
|
||||
await focusButton.click()
|
||||
await minimapButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
@@ -3,10 +3,10 @@ import { expect } from '@playwright/test'
|
||||
import type { Position } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
testComfySnapToGridGridSize
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -786,16 +786,8 @@ test.describe('Viewport settings', () => {
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
await toggleButton.click()
|
||||
// close zoom menu
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
|
||||
@@ -35,12 +35,6 @@ test.describe('Minimap', () => {
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
@@ -51,13 +45,6 @@ test.describe('Minimap', () => {
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
// Open zoom controls dropdown first
|
||||
const zoomControlsButton = comfyPage.page.getByTestId(
|
||||
'zoom-controls-button'
|
||||
)
|
||||
await zoomControlsButton.click()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
@@ -67,22 +54,10 @@ test.describe('Minimap', () => {
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
|
||||
// Open zoom controls dropdown again
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(toggleButton).toContainText('Show Minimap')
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
// Open zoom controls dropdown again to verify button text
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(toggleButton).toContainText('Hide Minimap')
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -60,7 +60,6 @@ async function getInputLinkDetails(
|
||||
)
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition across cases
|
||||
function slotLocator(
|
||||
page: Page,
|
||||
nodeId: NodeId,
|
||||
@@ -789,6 +788,45 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('should batch disconnect all links with ctrl+alt+click on slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 1 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
await connectSlots(
|
||||
comfyPage.page,
|
||||
{ nodeId: clipNode.id, index: 0 },
|
||||
{ nodeId: samplerNode.id, index: 2 },
|
||||
() => comfyPage.nextFrame()
|
||||
)
|
||||
|
||||
const clipOutput = await clipNode.getOutput(0)
|
||||
expect(await clipOutput.getLinkCount()).toBe(2)
|
||||
|
||||
const clipOutputSlot = slotLocator(comfyPage.page, clipNode.id, 0, false)
|
||||
|
||||
await clipOutputSlot.dispatchEvent('pointerdown', {
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
ctrlKey: true,
|
||||
altKey: true,
|
||||
shiftKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await clipOutput.getLinkCount()).toBe(0)
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -2,70 +2,46 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Nodes Renaming', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should display node title', async ({ comfyPage }) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const title = await vueNode.getTitle()
|
||||
expect(title).toBe('KSampler')
|
||||
|
||||
// Verify title is visible in the header
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('KSampler')
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.header).toContainText('KSampler')
|
||||
})
|
||||
|
||||
test('should allow title renaming by double clicking on the node header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// Test renaming with Enter
|
||||
await vueNode.setTitle('My Custom Sampler')
|
||||
const newTitle = await vueNode.getTitle()
|
||||
expect(newTitle).toBe('My Custom Sampler')
|
||||
|
||||
// Verify the title is displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('My Custom Sampler')
|
||||
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
|
||||
await expect(vueNode.header).toContainText('My Custom Sampler')
|
||||
|
||||
// Test cancel with Escape
|
||||
const titleElement = await vueNode.getTitleElement()
|
||||
await titleElement.dblclick()
|
||||
await vueNode.title.dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Type a different value but cancel
|
||||
const input = (await vueNode.getHeader()).locator(
|
||||
'[data-testid="node-title-input"]'
|
||||
)
|
||||
await input.fill('This Should Be Cancelled')
|
||||
await input.press('Escape')
|
||||
await vueNode.titleInput.fill('This Should Be Cancelled')
|
||||
await vueNode.titleInput.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Title should remain as the previously saved value
|
||||
const titleAfterCancel = await vueNode.getTitle()
|
||||
expect(titleAfterCancel).toBe('My Custom Sampler')
|
||||
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
|
||||
})
|
||||
|
||||
test('Double click node body does not trigger edit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const loadCheckpointNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Checkpoint')
|
||||
.first()
|
||||
const nodeBbox = await loadCheckpointNode.boundingBox()
|
||||
if (!nodeBbox) throw new Error('Node not found')
|
||||
await loadCheckpointNode.dblclick()
|
||||
|
||||
@@ -50,15 +50,23 @@ test.describe('Vue Node Selection', () => {
|
||||
})
|
||||
}
|
||||
|
||||
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
|
||||
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
|
||||
expect(selectedCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('should select pinned node without dragging', async ({ comfyPage }) => {
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
// Select a node by clicking its title
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
|
||||
// Pin it using the hotkey (as a user would)
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
|
||||
@@ -20,6 +20,9 @@ test.describe('Vue Node Bypass', () => {
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Node Collapse', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -10,43 +9,50 @@ test.describe('Vue Node Collapse', () => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow collapsing node with collapse icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get the KSampler node from the default workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Initially should not be collapsed
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
const body = await vueNode.getBody()
|
||||
const body = vueNode.body
|
||||
await expect(body).toBeVisible()
|
||||
const expandedBoundingBox = await vueNode.boundingBox()
|
||||
if (!expandedBoundingBox)
|
||||
throw new Error('Failed to get node bounding box before collapse')
|
||||
|
||||
// Collapse the node
|
||||
await vueNode.toggleCollapse()
|
||||
expect(await node.isCollapsed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify node content is hidden
|
||||
const collapsedSize = await node.getSize()
|
||||
await expect(body).not.toBeVisible()
|
||||
const collapsedBoundingBox = await vueNode.boundingBox()
|
||||
if (!collapsedBoundingBox)
|
||||
throw new Error('Failed to get node bounding box after collapse')
|
||||
expect(collapsedBoundingBox.height).toBeLessThan(expandedBoundingBox.height)
|
||||
|
||||
// Expand again
|
||||
await vueNode.toggleCollapse()
|
||||
expect(await node.isCollapsed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(body).toBeVisible()
|
||||
|
||||
// Size should be restored
|
||||
const expandedSize = await node.getSize()
|
||||
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
|
||||
const expandedBoundingBoxAfter = await vueNode.boundingBox()
|
||||
if (!expandedBoundingBoxAfter)
|
||||
throw new Error('Failed to get node bounding box after expand')
|
||||
expect(expandedBoundingBoxAfter.height).toBeGreaterThanOrEqual(
|
||||
collapsedBoundingBox.height
|
||||
)
|
||||
})
|
||||
|
||||
test('should show collapse/expand icon state', async ({ comfyPage }) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Check initial expanded state icon
|
||||
let iconClass = await vueNode.getCollapseIconClass()
|
||||
@@ -66,9 +72,8 @@ test.describe('Vue Node Collapse', () => {
|
||||
test('should preserve title when collapsing/expanding', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(vueNode.root).toBeVisible()
|
||||
|
||||
// Set custom title
|
||||
await vueNode.setTitle('Test Sampler')
|
||||
@@ -83,7 +88,6 @@ test.describe('Vue Node Collapse', () => {
|
||||
expect(await vueNode.getTitle()).toBe('Test Sampler')
|
||||
|
||||
// Verify title is still displayed
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('Test Sampler')
|
||||
await expect(vueNode.header).toContainText('Test Sampler')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-error/
|
||||
const ERROR_CLASS = /border-node-stroke-error/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -17,16 +17,21 @@ test.describe('Vue Node Error', () => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Close missing nodes warning dialog
|
||||
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
|
||||
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_CLASS = /opacity-50/
|
||||
const MUTE_OPACITY = '0.5'
|
||||
|
||||
test.describe('Vue Node Mute', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +19,11 @@ test.describe('Vue Node Mute', () => {
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
@@ -35,11 +36,11 @@ test.describe('Vue Node Mute', () => {
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Widget Reactivity', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
test('Should display added widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.push(node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets[2] = node.widgets[0]
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.splice(0, 0, node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
test('Should hide removed widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.pop()
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(5)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.length--
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.splice(0, 1)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.29.1",
|
||||
"version": "1.29.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,6 +11,7 @@
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
@@ -90,6 +91,7 @@
|
||||
"nx": "catalog:",
|
||||
"postcss-html": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"rollup-plugin-visualizer": "catalog:",
|
||||
"storybook": "catalog:",
|
||||
"stylelint": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -24,11 +24,14 @@
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 8px;
|
||||
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* Palette Colors */
|
||||
--color-charcoal-100: #55565e;
|
||||
--color-charcoal-100: #171718;
|
||||
--color-charcoal-200: #494a50;
|
||||
--color-charcoal-300: #3c3d42;
|
||||
--color-charcoal-400: #313235;
|
||||
@@ -85,10 +88,29 @@
|
||||
--color-bypass: #6a246a;
|
||||
--color-error: #962a2a;
|
||||
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
--text-xxxs: 0.5625rem;
|
||||
--text-xxxs--line-height: calc(1 / 0.5625);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
|
||||
--color-alpha-charcoal-600-30: color-mix(
|
||||
in srgb,
|
||||
var(--color-charcoal-600) 30%,
|
||||
transparent
|
||||
);
|
||||
--color-alpha-stone-100-20: color-mix(
|
||||
in srgb,
|
||||
var(--color-stone-100) 20%,
|
||||
transparent
|
||||
);
|
||||
--color-alpha-gray-500-50: color-mix(
|
||||
in srgb,
|
||||
var(--color-gray-500) 50%,
|
||||
transparent
|
||||
);
|
||||
|
||||
/* PrimeVue pulled colors */
|
||||
--color-muted: var(--p-text-muted-color);
|
||||
@@ -129,9 +151,20 @@
|
||||
|
||||
/* --- */
|
||||
|
||||
--accent-primary: var(--color-charcoal-700);
|
||||
--backdrop: var(--color-white);
|
||||
--button-hover-surface: var(--color-gray-200);
|
||||
--button-active-surface: var(--color-gray-400);
|
||||
--dialog-surface: var(--color-neutral-200);
|
||||
--interface-menu-component-surface-hovered: var(--color-gray-200);
|
||||
--interface-menu-component-surface-selected: var(--color-gray-400);
|
||||
--interface-menu-keybind-surface-default: var(--color-gray-500);
|
||||
--interface-panel-surface: var(--color-pure-white);
|
||||
--interface-stroke: var(--color-gray-300);
|
||||
--nav-background: var(--color-pure-white);
|
||||
--node-border: var(--color-gray-300);
|
||||
--node-component-border: var(--color-gray-400);
|
||||
--node-component-disabled: var(--color-alpha-stone-100-20);
|
||||
--node-component-executing: var(--color-blue-500);
|
||||
--node-component-header: var(--fg-color);
|
||||
--node-component-header-icon: var(--color-stone-200);
|
||||
@@ -143,7 +176,7 @@
|
||||
--node-component-slot-dot-outline: var(--color-black);
|
||||
--node-component-slot-text: var(--color-stone-200);
|
||||
--node-component-surface-highlight: var(--color-stone-100);
|
||||
--node-component-surface-hovered: var(--color-charcoal-400);
|
||||
--node-component-surface-hovered: var(--color-gray-200);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-white);
|
||||
--node-component-tooltip: var(--color-charcoal-700);
|
||||
@@ -154,13 +187,34 @@
|
||||
from var(--color-zinc-500) r g b / 10%
|
||||
);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-300);
|
||||
--node-stroke: var(--color-stone-100);
|
||||
--node-divider: var(--color-sand-100);
|
||||
--node-icon-disabled: var(--color-alpha-gray-500-50);
|
||||
--node-stroke: var(--color-gray-400);
|
||||
--node-stroke-selected: var(--color-accent-primary);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
--text-secondary: var(--color-stone-100);
|
||||
--text-primary: var(--color-charcoal-700);
|
||||
--input-surface: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
--accent-primary: var(--color-pure-white);
|
||||
--backdrop: var(--color-neutral-900);
|
||||
--button-hover-surface: var(--color-charcoal-600);
|
||||
--button-active-surface: var(--color-charcoal-600);
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||
--interface-menu-component-surface-selected: var(--color-charcoal-300);
|
||||
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
|
||||
--interface-panel-surface: var(--color-charcoal-100);
|
||||
--interface-stroke: var(--color-charcoal-400);
|
||||
--nav-background: var(--color-charcoal-100);
|
||||
--node-border: var(--color-charcoal-500);
|
||||
--node-component-border: var(--color-stone-200);
|
||||
--node-component-border-error: var(--color-danger-100);
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-slate-300);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
@@ -169,19 +223,37 @@
|
||||
--node-component-slot-dot-outline: var(--color-white);
|
||||
--node-component-slot-text: var(--color-slate-200);
|
||||
--node-component-surface-highlight: var(--color-slate-100);
|
||||
--node-component-surface-hovered: var(--color-charcoal-400);
|
||||
--node-component-surface-hovered: var(--color-charcoal-600);
|
||||
--node-component-surface-selected: var(--color-charcoal-200);
|
||||
--node-component-surface: var(--color-charcoal-800);
|
||||
--node-component-tooltip: var(--color-white);
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-800);
|
||||
--node-stroke: var(--color-slate-100);
|
||||
--node-component-disabled: var(--color-alpha-charcoal-600-30);
|
||||
--node-divider: var(--color-charcoal-500);
|
||||
--node-icon-disabled: var(--color-alpha-stone-100-20);
|
||||
--node-stroke: var(--color-stone-200);
|
||||
--node-stroke-selected: var(--color-pure-white);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-pure-white);
|
||||
--input-surface: rgba(130, 130, 130, 0.1);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-backdrop: var(--backdrop);
|
||||
--color-button-hover-surface: var(--button-hover-surface);
|
||||
--color-button-active-surface: var(--button-active-surface);
|
||||
--color-dialog-surface: var(--dialog-surface);
|
||||
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
|
||||
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);
|
||||
--color-interface-menu-keybind-surface-default: var(--interface-menu-keybind-surface-default);
|
||||
--color-interface-panel-surface: var(--interface-panel-surface);
|
||||
--color-interface-stroke: var(--interface-stroke);
|
||||
--color-nav-background: var(--nav-background);
|
||||
--color-node-border: var(--node-border);
|
||||
--color-node-component-border: var(--node-component-border);
|
||||
--color-node-component-executing: var(--node-component-executing);
|
||||
--color-node-component-header: var(--node-component-header);
|
||||
@@ -213,7 +285,16 @@
|
||||
--color-node-component-widget-skeleton-surface: var(
|
||||
--node-component-widget-skeleton-surface
|
||||
);
|
||||
--color-node-component-disabled: var(--node-component-disabled);
|
||||
--color-node-divider: var(--node-divider);
|
||||
--color-node-icon-disabled: var(--node-icon-disabled);
|
||||
--color-node-stroke: var(--node-stroke);
|
||||
--color-node-stroke-selected: var(--node-stroke-selected);
|
||||
--color-node-stroke-error: var(--node-stroke-error);
|
||||
--color-node-stroke-executing: var(--node-stroke-executing);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-input-surface: var(--input-surface);
|
||||
}
|
||||
|
||||
@custom-variant dark-theme {
|
||||
|
||||
1
packages/design-system/src/icons/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
function getFilenameDetails(fullFilename: string) {
|
||||
export function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
@@ -451,3 +451,26 @@ export function stringToLocale(locale: string): SupportedLocale {
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds < 0) return '0s'
|
||||
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (remainingSeconds > 0 || parts.length === 0) {
|
||||
parts.push(`${remainingSeconds}s`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
34
pnpm-lock.yaml
generated
@@ -201,6 +201,9 @@ catalogs:
|
||||
primevue:
|
||||
specifier: ^4.2.5
|
||||
version: 4.2.5
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.4
|
||||
version: 6.0.4
|
||||
storybook:
|
||||
specifier: ^9.1.6
|
||||
version: 9.1.6
|
||||
@@ -591,6 +594,9 @@ importers:
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.2
|
||||
rollup-plugin-visualizer:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.4(rollup@4.22.4)
|
||||
storybook:
|
||||
specifier: 'catalog:'
|
||||
version: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
@@ -6668,6 +6674,19 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rollup-plugin-visualizer@6.0.4:
|
||||
resolution: {integrity: sha512-q8Q7J/6YofkmaGW1sH/fPRAz37x/+pd7VBuaUU7lwvOS/YikuiiEU9jeb9PH8XHiq50XFrUsBbOxeAMYQ7KZkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
rolldown: 1.x || ^1.0.0-beta
|
||||
rollup: 2.x || 3.x || 4.x
|
||||
peerDependenciesMeta:
|
||||
rolldown:
|
||||
optional: true
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
rollup@4.22.4:
|
||||
resolution: {integrity: sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -6822,6 +6841,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -14733,6 +14756,15 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup-plugin-visualizer@6.0.4(rollup@4.22.4):
|
||||
dependencies:
|
||||
open: 8.4.2
|
||||
picomatch: 4.0.3
|
||||
source-map: 0.7.6
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
rollup: 4.22.4
|
||||
|
||||
rollup@4.22.4:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
@@ -14924,6 +14956,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
@@ -68,6 +68,7 @@ catalog:
|
||||
prettier: ^3.3.2
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^9.1.6
|
||||
stylelint: ^16.24.0
|
||||
tailwindcss: ^4.1.12
|
||||
|
||||
@@ -12,6 +12,7 @@ const iconGroupClasses = cn(
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton @click="toggle">
|
||||
<i class="icon-[lucide--more-vertical] text-sm" />
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
@@ -13,6 +14,8 @@
|
||||
: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" />
|
||||
@@ -25,12 +28,28 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
isVertical = false
|
||||
} = defineProps<MoreButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
@@ -45,7 +64,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'mt-1 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
|
||||
@@ -11,7 +11,15 @@ import CardTop from './CardTop.vue'
|
||||
|
||||
interface CardStoryArgs {
|
||||
// CardContainer props
|
||||
containerRatio: 'square' | 'portrait' | 'tallPortrait'
|
||||
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant: 'default' | 'ghost' | 'outline'
|
||||
rounded: 'none' | 'sm' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder: boolean
|
||||
hasBackground: boolean
|
||||
hasShadow: boolean
|
||||
hasCursor: boolean
|
||||
customClass: string
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
|
||||
@@ -44,10 +52,44 @@ interface CardStoryArgs {
|
||||
const meta: Meta<CardStoryArgs> = {
|
||||
title: 'Components/Card/Card',
|
||||
argTypes: {
|
||||
containerRatio: {
|
||||
containerSize: {
|
||||
control: 'select',
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
|
||||
description: 'Card container size preset'
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost', 'outline'],
|
||||
description: 'Card visual variant'
|
||||
},
|
||||
rounded: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'lg', 'xl'],
|
||||
description: 'Border radius size'
|
||||
},
|
||||
customAspectRatio: {
|
||||
control: 'text',
|
||||
description: 'Custom aspect ratio (e.g., "16/9")'
|
||||
},
|
||||
hasBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Add border styling'
|
||||
},
|
||||
hasBackground: {
|
||||
control: 'boolean',
|
||||
description: 'Add background styling'
|
||||
},
|
||||
hasShadow: {
|
||||
control: 'boolean',
|
||||
description: 'Add shadow styling'
|
||||
},
|
||||
hasCursor: {
|
||||
control: 'boolean',
|
||||
description: 'Add cursor pointer'
|
||||
},
|
||||
customClass: {
|
||||
control: 'text',
|
||||
description: 'Additional custom CSS classes'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
@@ -149,8 +191,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
template: `
|
||||
<div class="min-h-screen">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
class="max-w-[320px] mx-auto"
|
||||
:size="args.containerSize"
|
||||
:variant="args.variant"
|
||||
:rounded="args.rounded"
|
||||
:custom-aspect-ratio="args.customAspectRatio"
|
||||
:has-border="args.hasBorder"
|
||||
:has-background="args.hasBackground"
|
||||
:has-shadow="args.hasShadow"
|
||||
:has-cursor="args.hasCursor"
|
||||
:class="args.customClass || 'max-w-[320px] mx-auto'"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -205,7 +254,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardBottom>
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -218,7 +267,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -243,7 +300,15 @@ export const Default: Story = {
|
||||
export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -268,7 +333,15 @@ export const SquareCard: Story = {
|
||||
export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -293,7 +366,15 @@ export const TallPortraitCard: Story = {
|
||||
export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -314,10 +395,50 @@ export const ImageCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const MiniCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'mini',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Mini Asset',
|
||||
description: '',
|
||||
backgroundColor: '#06b6d4',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Asset'],
|
||||
showFileSize: true,
|
||||
fileSize: '124 KB',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -338,10 +459,209 @@ export const MinimalCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const GhostVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'compact',
|
||||
variant: 'ghost',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Workflow Template',
|
||||
description: 'Ghost variant for workflow templates',
|
||||
backgroundColor: '#10b981',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Template'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const OutlineVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'outline',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Outline Card',
|
||||
description: 'Card with outline variant styling',
|
||||
backgroundColor: '#f59e0b',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomAspectRatio: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
customAspectRatio: '16/9',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Wide Format Card',
|
||||
description: '',
|
||||
backgroundColor: '#8b5cf6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Wide'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedNone: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'none',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Sharp Corners',
|
||||
description: 'Card with no border radius',
|
||||
backgroundColor: '#dc2626',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedXL: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'xl',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Extra Rounded',
|
||||
description: 'Card with extra large border radius',
|
||||
backgroundColor: '#059669',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const NoStylesCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: false,
|
||||
hasBackground: false,
|
||||
hasShadow: false,
|
||||
hasCursor: true,
|
||||
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Custom Styled Card',
|
||||
description: 'Card with all default styles removed and custom gradient',
|
||||
backgroundColor: 'transparent',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
|
||||
@@ -8,26 +8,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
size = 'regular',
|
||||
variant = 'default',
|
||||
rounded = 'md',
|
||||
customAspectRatio,
|
||||
hasBorder = true,
|
||||
hasBackground = true,
|
||||
hasShadow = true,
|
||||
hasCursor = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant?: 'default' | 'ghost' | 'outline'
|
||||
rounded?: 'none' | 'md' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder?: boolean
|
||||
hasBackground?: boolean
|
||||
hasShadow?: boolean
|
||||
hasCursor?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
// Base structure classes
|
||||
const structureClasses = 'flex flex-col overflow-hidden'
|
||||
|
||||
// Rounded corners
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl'
|
||||
} as const
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
// Variant styles
|
||||
const variantClasses = {
|
||||
default: cn(
|
||||
hasBackground && 'bg-white dark-theme:bg-zinc-800',
|
||||
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
|
||||
hasShadow && 'shadow-sm',
|
||||
hasCursor && 'cursor-pointer'
|
||||
),
|
||||
ghost: cn(
|
||||
hasCursor && 'cursor-pointer',
|
||||
'p-2 transition-colors duration-200'
|
||||
),
|
||||
outline: cn(
|
||||
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
|
||||
hasCursor && 'cursor-pointer',
|
||||
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
|
||||
)
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
// Size/aspect ratio
|
||||
const aspectRatio = customAspectRatio
|
||||
? `aspect-[${customAspectRatio}]`
|
||||
: {
|
||||
mini: 'aspect-100/120',
|
||||
compact: 'aspect-240/311',
|
||||
regular: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tall: 'aspect-256/353'
|
||||
}[size]
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
return cn(
|
||||
structureClasses,
|
||||
roundedClasses[rounded],
|
||||
variantClasses[variant],
|
||||
aspectRatio,
|
||||
customClass
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,31 +2,27 @@
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 h-full w-full"></slot>
|
||||
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
|
||||
<slot name="center-left"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
|
||||
<slot name="center-right"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-2"
|
||||
>
|
||||
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,10 +31,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
const {
|
||||
ratio = 'square',
|
||||
topLeftClass,
|
||||
topRightClass,
|
||||
centerLeftClass,
|
||||
centerRightClass,
|
||||
bottomLeftClass,
|
||||
bottomRightClass
|
||||
} = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
topLeftClass?: string
|
||||
topRightClass?: string
|
||||
centerLeftClass?: string
|
||||
centerRightClass?: string
|
||||
bottomLeftClass?: string
|
||||
bottomRightClass?: string
|
||||
}>()
|
||||
|
||||
const topStyle = computed(() => {
|
||||
@@ -51,4 +63,26 @@ const topStyle = computed(() => {
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
// Get default classes for each slot position
|
||||
const defaultSlotClasses = {
|
||||
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
|
||||
'center-left':
|
||||
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
|
||||
'center-right':
|
||||
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
|
||||
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
|
||||
}
|
||||
|
||||
// Compute all slot classes once and cache them
|
||||
const slotClasses = computed(() => ({
|
||||
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
|
||||
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
|
||||
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
|
||||
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
|
||||
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
|
||||
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex shrink-0 items-center justify-center gap-1 rounded bg-[#D9D9D966]/40 px-2 py-1 text-xs font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<div :class="chipClasses">
|
||||
<slot name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const { label } = defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
return cn(baseClasses, variantStyles[variant])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden"
|
||||
:class="containerClass"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
@@ -41,17 +42,20 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
containerClass = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
@@ -141,8 +141,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
@@ -172,9 +174,11 @@
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
@@ -316,8 +320,10 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
|
||||
126
src/components/graph/CanvasModeSelector.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
severity="secondary"
|
||||
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
|
||||
:style="buttonStyles"
|
||||
@click="toggle"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex items-center gap-1 pr-0.5">
|
||||
<div
|
||||
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
|
||||
>
|
||||
<i :class="currentModeIcon" class="block h-4 w-4" />
|
||||
</div>
|
||||
<i class="icon-[lucide--chevron-down] block h-4 w-4 pr-1.5" />
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@click="setMode('select')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--mouse-pointer-2] h-4 w-4" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@click="setMode('hand')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--hand] h-4 w-4" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
interface Props {
|
||||
buttonStyles?: Record<string, string>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const buttonRef = ref<InstanceType<typeof Button>>()
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
|
||||
|
||||
const currentModeIcon = computed(() =>
|
||||
isCanvasReadOnly.value
|
||||
? 'icon-[lucide--hand]'
|
||||
: 'icon-[lucide--mouse-pointer-2]'
|
||||
)
|
||||
|
||||
const unlockCommandText = computed(() =>
|
||||
commandStore
|
||||
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Unlock'))
|
||||
.toUpperCase()
|
||||
)
|
||||
|
||||
const lockCommandText = computed(() =>
|
||||
commandStore
|
||||
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock'))
|
||||
.toUpperCase()
|
||||
)
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
const el = (buttonRef.value as any)?.$el || buttonRef.value
|
||||
popover.value?.toggle(event, el)
|
||||
}
|
||||
|
||||
const setMode = (mode: 'select' | 'hand') => {
|
||||
if (mode === 'select' && isCanvasReadOnly.value) {
|
||||
void commandStore.execute('Comfy.Canvas.Unlock')
|
||||
} else if (mode === 'hand' && !isCanvasReadOnly.value) {
|
||||
void commandStore.execute('Comfy.Canvas.Lock')
|
||||
}
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const popoverPt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50 -translate-y-2'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'mb-2 text-text-primary',
|
||||
'shadow-lg border border-node-border',
|
||||
'bg-nav-background',
|
||||
'rounded-lg',
|
||||
'p-2 px-3',
|
||||
'min-w-39',
|
||||
'select-none'
|
||||
]
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
@@ -10,41 +10,15 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
class="p-buttongroup-vertical absolute right-2 bottom-4 p-1 md:right-4"
|
||||
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
|
||||
:style="stringifiedMinimapStyles.buttonGroupStyles"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="selectTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
severity="secondary"
|
||||
:aria-label="selectTooltip"
|
||||
:pressed="isCanvasReadOnly"
|
||||
icon="i-material-symbols:pan-tool-outline"
|
||||
:class="selectButtonClass"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.Unlock')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--mouse-pointer-2]" />
|
||||
</template>
|
||||
</Button>
|
||||
<CanvasModeSelector
|
||||
:button-styles="stringifiedMinimapStyles.buttonStyles"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="handTooltip"
|
||||
severity="secondary"
|
||||
:aria-label="handTooltip"
|
||||
:pressed="isCanvasUnlocked"
|
||||
:class="handButtonClass"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.Lock')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--hand]" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<!-- vertical line with bg E1DED5 -->
|
||||
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
|
||||
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
|
||||
|
||||
<Button
|
||||
v-tooltip.top="fitViewTooltip"
|
||||
@@ -52,11 +26,11 @@
|
||||
icon="pi pi-expand"
|
||||
:aria-label="fitViewTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
class="hover:bg-[#E7E6E6]! dark-theme:hover:bg-[#444444]!"
|
||||
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--focus]" />
|
||||
<i class="icon-[lucide--focus] h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
@@ -71,26 +45,26 @@
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="toggleModal"
|
||||
>
|
||||
<span class="inline-flex text-xs">
|
||||
<span class="inline-flex items-center gap-1 px-2 text-xs">
|
||||
<span>{{ canvasStore.appScalePercentage }}%</span>
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
<i class="icon-[lucide--chevron-down] h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
|
||||
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
|
||||
|
||||
<Button
|
||||
ref="focusButton"
|
||||
v-tooltip.top="focusModeTooltip"
|
||||
ref="minimapButton"
|
||||
v-tooltip.top="minimapTooltip"
|
||||
severity="secondary"
|
||||
:aria-label="focusModeTooltip"
|
||||
data-testid="focus-mode-button"
|
||||
:aria-label="minimapTooltip"
|
||||
data-testid="toggle-minimap-button"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
:class="focusButtonClass"
|
||||
@click="() => commandStore.execute('Workspace.ToggleFocusMode')"
|
||||
:class="minimapButtonClass"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--lightbulb]" />
|
||||
<i class="icon-[lucide--map] h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
@@ -111,7 +85,7 @@
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--route-off]" />
|
||||
<i class="icon-[lucide--route-off] h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
@@ -131,8 +105,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import CanvasModeSelector from './CanvasModeSelector.vue'
|
||||
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -141,21 +115,16 @@ const { formatKeySequence } = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const minimap = useMinimap()
|
||||
|
||||
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
|
||||
useZoomControls()
|
||||
|
||||
const stringifiedMinimapStyles = computed(() => {
|
||||
const buttonGroupKeys = ['backgroundColor', 'borderRadius', '']
|
||||
const buttonKeys = ['backgroundColor', 'borderRadius']
|
||||
const buttonGroupKeys = ['borderRadius']
|
||||
const buttonKeys = ['borderRadius']
|
||||
const additionalButtonStyles = {
|
||||
border: 'none',
|
||||
width: '35px',
|
||||
height: '35px',
|
||||
'margin-right': '2px',
|
||||
'margin-left': '2px'
|
||||
border: 'none'
|
||||
}
|
||||
|
||||
const containerStyles = minimap.containerStyles.value
|
||||
@@ -176,72 +145,56 @@ const stringifiedMinimapStyles = computed(() => {
|
||||
})
|
||||
|
||||
// Computed properties for reactive states
|
||||
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
|
||||
const isCanvasUnlocked = computed(() => !isCanvasReadOnly.value)
|
||||
const linkHidden = computed(
|
||||
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
|
||||
// Computed properties for command text
|
||||
const unlockCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Comfy.Canvas.Unlock')
|
||||
).toUpperCase()
|
||||
)
|
||||
const lockCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock')).toUpperCase()
|
||||
)
|
||||
const fitViewCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Comfy.Canvas.FitView')
|
||||
).toUpperCase()
|
||||
)
|
||||
const focusCommandText = computed(() =>
|
||||
const minimapCommandText = computed(() =>
|
||||
formatKeySequence(
|
||||
commandStore.getCommand('Workspace.ToggleFocusMode')
|
||||
commandStore.getCommand('Comfy.Canvas.ToggleMinimap')
|
||||
).toUpperCase()
|
||||
)
|
||||
|
||||
// Computed properties for button classes and states
|
||||
const selectButtonClass = computed(() =>
|
||||
isCanvasUnlocked.value
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: ''
|
||||
)
|
||||
|
||||
const handButtonClass = computed(() =>
|
||||
isCanvasReadOnly.value
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: ''
|
||||
)
|
||||
|
||||
const zoomButtonClass = computed(() => [
|
||||
'w-16!',
|
||||
isModalVisible.value
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: '',
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
|
||||
'bg-interface-panel-surface',
|
||||
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
|
||||
'hover:bg-button-hover-surface!',
|
||||
'p-0',
|
||||
'h-8',
|
||||
'w-15'
|
||||
])
|
||||
|
||||
const focusButtonClass = computed(() => ({
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!': true,
|
||||
'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!':
|
||||
workspaceStore.focusMode
|
||||
const minimapButtonClass = computed(() => ({
|
||||
'bg-interface-panel-surface': true,
|
||||
'hover:bg-button-hover-surface!': true,
|
||||
'not-active:bg-button-active-surface!': settingStore.get(
|
||||
'Comfy.Minimap.Visible'
|
||||
),
|
||||
'p-0': true,
|
||||
'w-8': true,
|
||||
'h-8': true
|
||||
}))
|
||||
|
||||
// Computed properties for tooltip and aria-label texts
|
||||
const selectTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.select')} (${unlockCommandText.value})`
|
||||
)
|
||||
const handTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.hand')} (${lockCommandText.value})`
|
||||
)
|
||||
const fitViewTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.fitView')} (${fitViewCommandText.value})`
|
||||
)
|
||||
const focusModeTooltip = computed(
|
||||
() => `${t('graphCanvasMenu.focusMode')} (${focusCommandText.value})`
|
||||
)
|
||||
const fitViewTooltip = computed(() => {
|
||||
const label = t('graphCanvasMenu.fitView')
|
||||
const shortcut = fitViewCommandText.value
|
||||
return shortcut ? `${label} (${shortcut})` : label
|
||||
})
|
||||
const minimapTooltip = computed(() => {
|
||||
const label = settingStore.get('Comfy.Minimap.Visible')
|
||||
? t('zoomControls.hideMinimap')
|
||||
: t('zoomControls.showMinimap')
|
||||
const shortcut = minimapCommandText.value
|
||||
return shortcut ? `${label} (${shortcut})` : label
|
||||
})
|
||||
const linkVisibilityTooltip = computed(() =>
|
||||
linkHidden.value
|
||||
? t('graphCanvasMenu.showLinks')
|
||||
@@ -253,10 +206,12 @@ const linkVisibilityAriaLabel = computed(() =>
|
||||
: t('graphCanvasMenu.hideLinks')
|
||||
)
|
||||
const linkVisibleClass = computed(() => [
|
||||
linkHidden.value
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: '',
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
|
||||
'bg-interface-panel-surface',
|
||||
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
|
||||
'hover:bg-button-hover-surface!',
|
||||
'p-0',
|
||||
'w-8',
|
||||
'h-8'
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
@@ -267,19 +222,3 @@ onBeforeUnmount(() => {
|
||||
canvasStore.cleanupScaleSync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-buttongroup-vertical {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1200;
|
||||
border-radius: var(--p-button-border-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--p-panel-border-color);
|
||||
}
|
||||
|
||||
.p-buttongroup-vertical .p-button {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
<Transition name="slide-up">
|
||||
<Panel
|
||||
v-if="visible"
|
||||
class="selection-toolbox pointer-events-auto rounded-lg"
|
||||
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-1 h-10 flex flex-row gap-1'
|
||||
content: 'p-2 h-12 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
@@ -65,7 +64,6 @@ import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionTo
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
@@ -78,8 +76,6 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const minimap = useMinimap()
|
||||
const containerStyles = minimap.containerStyles
|
||||
|
||||
const toolboxRef = ref<HTMLElement | undefined>()
|
||||
const { visible } = useSelectionToolboxPosition(toolboxRef)
|
||||
|
||||
@@ -1,118 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit! md:right-11"
|
||||
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
|
||||
>
|
||||
<div
|
||||
class="w-4/5 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark-theme:border-gray-700 dark-theme:bg-[#2b2b2b]"
|
||||
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
|
||||
:style="filteredMinimapStyles"
|
||||
@click.stop
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
>
|
||||
<template #default>
|
||||
<span class="block text-sm font-medium">{{
|
||||
$t('graphCanvasMenu.zoomIn')
|
||||
}}</span>
|
||||
<span class="block text-sm text-gray-500">{{
|
||||
zoomInCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<span class="font-medium">{{ $t('graphCanvasMenu.zoomIn') }}</span>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
zoomInCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
|
||||
@mouseup="stopRepeat"
|
||||
@mouseleave="stopRepeat"
|
||||
>
|
||||
<template #default>
|
||||
<span class="block text-sm font-medium">{{
|
||||
$t('graphCanvasMenu.zoomOut')
|
||||
}}</span>
|
||||
<span class="block text-sm text-gray-500">{{
|
||||
zoomOutCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<span class="font-medium">{{ $t('graphCanvasMenu.zoomOut') }}</span>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
zoomOutCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@click="executeCommand('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #default>
|
||||
<span class="block text-sm font-medium">{{
|
||||
$t('zoomControls.zoomToFit')
|
||||
}}</span>
|
||||
<span class="block text-sm text-gray-500">{{
|
||||
zoomToFitCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<hr class="mb-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
|
||||
<Button
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="toggle-minimap-button"
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
}
|
||||
}"
|
||||
@click="executeCommand('Comfy.Canvas.ToggleMinimap')"
|
||||
>
|
||||
<template #default>
|
||||
<span class="block text-sm font-medium">{{
|
||||
minimapToggleText
|
||||
}}</span>
|
||||
<span class="block text-sm text-gray-500">{{
|
||||
showMinimapCommandText
|
||||
}}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<hr class="mt-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
|
||||
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
zoomToFitCommandText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="zoomInputContainer"
|
||||
class="zoomInputContainer flex items-center rounded bg-[#E7E6E6] p-2 px-2 focus-within:bg-[#F3F3F3] dark-theme:bg-[#8282821A]"
|
||||
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
|
||||
>
|
||||
<InputNumber
|
||||
ref="zoomInput"
|
||||
@@ -122,12 +55,12 @@
|
||||
:show-buttons="false"
|
||||
:use-grouping="false"
|
||||
:unstyled="true"
|
||||
input-class="flex-1 bg-transparent border-none outline-hidden text-sm shadow-none my-0 "
|
||||
input-class="bg-transparent border-none outline-hidden text-sm shadow-none my-0 w-full"
|
||||
fluid
|
||||
@input="applyZoom"
|
||||
@keyup.enter="applyZoom"
|
||||
/>
|
||||
<span class="-ml-4 text-sm text-gray-500">%</span>
|
||||
<span class="flex-shrink-0 text-sm text-text-primary">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,18 +69,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { InputNumberInputEvent } from 'primevue'
|
||||
import { Button, InputNumber } from 'primevue'
|
||||
import { InputNumber } from 'primevue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const minimap = useMinimap()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { formatKeySequence } = useCommandStore()
|
||||
@@ -158,19 +87,8 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const interval = ref<number | null>(null)
|
||||
|
||||
// Computed properties for reactive states
|
||||
const minimapToggleText = computed(() =>
|
||||
settingStore.get('Comfy.Minimap.Visible')
|
||||
? t('zoomControls.hideMinimap')
|
||||
: t('zoomControls.showMinimap')
|
||||
)
|
||||
|
||||
const applyZoom = (val: InputNumberInputEvent) => {
|
||||
const inputValue = val.value as number
|
||||
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
|
||||
@@ -181,9 +99,6 @@ const applyZoom = (val: InputNumberInputEvent) => {
|
||||
|
||||
const executeCommand = (command: string) => {
|
||||
void commandStore.execute(command)
|
||||
if (command === 'Comfy.Canvas.ToggleMinimap') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const startRepeat = (command: string) => {
|
||||
@@ -215,9 +130,6 @@ const zoomOutCommandText = computed(() =>
|
||||
const zoomToFitCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
|
||||
)
|
||||
const showMinimapCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ToggleMinimap'))
|
||||
)
|
||||
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
|
||||
const zoomInputContainer = ref<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -236,10 +148,6 @@ watch(
|
||||
</script>
|
||||
<style>
|
||||
.zoomInputContainer:focus-within {
|
||||
border: 1px solid rgb(204 204 204);
|
||||
}
|
||||
|
||||
.dark-theme .zoomInputContainer:focus-within {
|
||||
border: 1px solid rgb(204 204 204);
|
||||
border: 1px solid var(--color-pure-white);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
<div
|
||||
v-else
|
||||
role="button"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
|
||||
<span class="flex-1">{{ option.label }}</span>
|
||||
<span v-if="option.shortcut" class="text-xs opacity-60">
|
||||
<span
|
||||
v-if="option.shortcut"
|
||||
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
|
||||
>
|
||||
{{ option.shortcut }}
|
||||
</span>
|
||||
<i
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
:key="`submenu-${option.label}`"
|
||||
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
|
||||
:option="option"
|
||||
:container-styles="containerStyles"
|
||||
@submenu-click="handleSubmenuClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,7 +54,6 @@ import type {
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
||||
|
||||
import MenuOptionItem from './MenuOptionItem.vue'
|
||||
import SubmenuPopover from './SubmenuPopover.vue'
|
||||
@@ -75,8 +73,6 @@ const currentSubmenu = ref<string | null>(null)
|
||||
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
|
||||
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const minimap = useMinimap()
|
||||
const containerStyles = minimap.containerStyles
|
||||
|
||||
let lastLogTs = 0
|
||||
const LOG_INTERVAL = 120 // ms
|
||||
@@ -264,11 +260,9 @@ const pt = computed(() => ({
|
||||
content: {
|
||||
class: [
|
||||
'mt-2 text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||
],
|
||||
style: {
|
||||
backgroundColor: containerStyles.value.backgroundColor
|
||||
}
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -56,13 +56,6 @@ import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||
|
||||
interface Props {
|
||||
option: MenuOption
|
||||
containerStyles: {
|
||||
width: string
|
||||
height: string
|
||||
backgroundColor: string
|
||||
border: string
|
||||
borderRadius: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -117,11 +110,9 @@ const submenuPt = computed(() => ({
|
||||
content: {
|
||||
class: [
|
||||
'text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
||||
],
|
||||
style: {
|
||||
backgroundColor: props.containerStyles.backgroundColor
|
||||
}
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'docs',
|
||||
type: 'item',
|
||||
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
// Extension manager - only in non-cloud distributions
|
||||
if (!isCloud) {
|
||||
items.push({
|
||||
key: 'manager',
|
||||
type: 'item',
|
||||
icon: PuzzleIcon,
|
||||
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'more',
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems.value
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Utility Functions
|
||||
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
|
||||
}
|
||||
|
||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||
// Hide update buttons in cloud distribution
|
||||
if (isCloud) return false
|
||||
|
||||
return (
|
||||
releaseStore.shouldShowUpdateButton &&
|
||||
release === releaseStore.recentReleases[0]
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
<div>
|
||||
<div
|
||||
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
||||
class="z-1001 flex h-[38px] w-full content-end"
|
||||
class="z-1001 flex h-9.5 w-full content-end"
|
||||
style="background: var(--border-color)"
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
class="comfyui-menu flex items-center bg-gray-100"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<CommandMenubar />
|
||||
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
import TopbarBadges from './TopbarBadges.vue'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||
const showTopMenu = computed(
|
||||
|
||||
20
src/components/topbar/TopbarBadge.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
|
||||
<div
|
||||
v-if="badge.label"
|
||||
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</div>
|
||||
<div class="font-inter text-sm font-extrabold text-slate-100">
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
defineProps<{
|
||||
badge: TopbarBadge
|
||||
}>()
|
||||
</script>
|
||||
17
src/components/topbar/TopbarBadges.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<TopbarBadge
|
||||
v-for="badge in topbarBadgeStore.badges"
|
||||
:key="badge.text"
|
||||
:badge
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
|
||||
const topbarBadgeStore = useTopbarBadgeStore()
|
||||
</script>
|
||||
@@ -88,7 +88,7 @@
|
||||
<template #content>
|
||||
<!-- Card Examples -->
|
||||
<div :style="gridStyle">
|
||||
<CardContainer v-for="i in 100" :key="i" ratio="square">
|
||||
<CardContainer v-for="i in 100" :key="i" size="regular">
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
|
||||
@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
|
||||
import type { Ref } from 'vue'
|
||||
import { markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
|
||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
theme: isDesktop ? { background: '#171717' } : undefined
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -132,44 +134,57 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
})
|
||||
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(
|
||||
() =>
|
||||
node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
|
||||
@@ -1141,9 +1141,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (model.includes('veo-3.0-fast-generate-001')) {
|
||||
if (
|
||||
model.includes('veo-3.0-fast-generate-001') ||
|
||||
model.includes('veo-3.1-fast-generate')
|
||||
) {
|
||||
return generateAudio ? '$1.20/Run' : '$0.80/Run'
|
||||
} else if (model.includes('veo-3.0-generate-001')) {
|
||||
} else if (
|
||||
model.includes('veo-3.0-generate-001') ||
|
||||
model.includes('veo-3.1-generate')
|
||||
) {
|
||||
return generateAudio ? '$3.20/Run' : '$1.60/Run'
|
||||
}
|
||||
|
||||
|
||||
@@ -162,12 +162,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e',
|
||||
|
||||
@@ -115,11 +115,14 @@ const onConfigure = function (
|
||||
this.arrange()
|
||||
}
|
||||
})
|
||||
if (serialisedNode.properties?.proxyWidgets)
|
||||
if (serialisedNode.properties?.proxyWidgets) {
|
||||
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
if (v !== null) this.widgets[index].value = v
|
||||
})
|
||||
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
const widget = this.widgets.find((w) => w.name == parsed[index][1])
|
||||
if (v !== null && widget) widget.value = v
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function newProxyWidget(
|
||||
|
||||
16
src/extensions/core/cloudBadge.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CloudBadge',
|
||||
// Only show badge when running in cloud environment
|
||||
topbarBadges: isCloud
|
||||
? [
|
||||
{
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
@@ -21,3 +23,7 @@ import './uploadAudio'
|
||||
import './uploadImage'
|
||||
import './webcamCapture'
|
||||
import './widgetInputs'
|
||||
|
||||
if (isCloud) {
|
||||
import('./cloudBadge')
|
||||
}
|
||||
|
||||
@@ -178,8 +178,9 @@ app.registerExtension({
|
||||
audioUIWidget.options.canvasOnly = true
|
||||
|
||||
const onAudioWidgetUpdate = () => {
|
||||
if (typeof audioWidget.value !== 'string') return
|
||||
audioUIWidget.element.src = api.apiURL(
|
||||
getResourceURL(...splitFilePath(audioWidget.value as string))
|
||||
getResourceURL(...splitFilePath(audioWidget.value))
|
||||
)
|
||||
}
|
||||
// Initially load default audio file to audioUIWidget.
|
||||
|
||||
@@ -9,8 +9,19 @@
|
||||
"downloadImage": "Download image",
|
||||
"downloadVideo": "Download video",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"editImage": "Edit image",
|
||||
"deleteImage": "Delete image",
|
||||
"deleteAudioFile": "Delete audio file",
|
||||
"removeImage": "Remove image",
|
||||
"removeVideo": "Remove video",
|
||||
"chart": "Chart",
|
||||
"chartLowercase": "chart",
|
||||
"file": "file",
|
||||
"selectedFile": "Selected file",
|
||||
"none": "None",
|
||||
"markdown": "markdown",
|
||||
"content": "content",
|
||||
"audioProgress": "Audio progress",
|
||||
"viewImageOfTotal": "View image {index} of {total}",
|
||||
"viewVideoOfTotal": "View video {index} of {total}",
|
||||
"imagePreview": "Image preview - Use arrow keys to navigate between images",
|
||||
@@ -193,7 +204,8 @@
|
||||
"volume": "Volume",
|
||||
"halfSpeed": "0.5x",
|
||||
"1x": "1x",
|
||||
"2x": "2x"
|
||||
"2x": "2x",
|
||||
"beta": "BETA"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -832,6 +832,11 @@
|
||||
"guidance": {
|
||||
"name": "guidance"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CLIPTextEncodeHiDream": {
|
||||
@@ -871,6 +876,11 @@
|
||||
"mt5xl": {
|
||||
"name": "mt5xl"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CLIPTextEncodeLumina2": {
|
||||
@@ -937,6 +947,11 @@
|
||||
"empty_padding": {
|
||||
"name": "empty_padding"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CLIPTextEncodeSDXL": {
|
||||
@@ -1475,10 +1490,12 @@
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive"
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative"
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1974,6 +1991,11 @@
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"EmptyHunyuanLatentVideo": {
|
||||
@@ -1991,6 +2013,11 @@
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"EmptyImage": {
|
||||
@@ -2113,6 +2140,11 @@
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Epsilon Scaling": {
|
||||
@@ -2200,6 +2232,11 @@
|
||||
"conditioning": {
|
||||
"name": "conditioning"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxGuidance": {
|
||||
@@ -2211,6 +2248,11 @@
|
||||
"guidance": {
|
||||
"name": "guidance"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxKontextImageScale": {
|
||||
@@ -2220,6 +2262,11 @@
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxKontextMaxImageNode": {
|
||||
@@ -2272,6 +2319,11 @@
|
||||
"reference_latents_method": {
|
||||
"name": "reference_latents_method"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxKontextProImageNode": {
|
||||
@@ -2629,6 +2681,10 @@
|
||||
"name": "files",
|
||||
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio",
|
||||
"tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
@@ -2870,10 +2926,12 @@
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive"
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "latent"
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2895,13 +2953,16 @@
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive"
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative"
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent"
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3478,6 +3539,11 @@
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageYUVToRGB": {
|
||||
@@ -3582,6 +3648,11 @@
|
||||
"alpha": {
|
||||
"name": "alpha"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"KarrasScheduler": {
|
||||
@@ -4209,6 +4280,11 @@
|
||||
"samples2": {
|
||||
"name": "samples2"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentApplyOperation": {
|
||||
@@ -4220,6 +4296,11 @@
|
||||
"operation": {
|
||||
"name": "operation"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentApplyOperationCFG": {
|
||||
@@ -4231,6 +4312,11 @@
|
||||
"operation": {
|
||||
"name": "operation"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentBatch": {
|
||||
@@ -4242,6 +4328,11 @@
|
||||
"samples2": {
|
||||
"name": "samples2"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentBatchSeedBehavior": {
|
||||
@@ -4253,6 +4344,11 @@
|
||||
"seed_behavior": {
|
||||
"name": "seed_behavior"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentBlend": {
|
||||
@@ -4324,6 +4420,11 @@
|
||||
"dim": {
|
||||
"name": "dim"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentCrop": {
|
||||
@@ -4361,6 +4462,11 @@
|
||||
"amount": {
|
||||
"name": "amount"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentFlip": {
|
||||
@@ -4400,6 +4506,11 @@
|
||||
"ratio": {
|
||||
"name": "ratio"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentMultiply": {
|
||||
@@ -4411,6 +4522,11 @@
|
||||
"multiplier": {
|
||||
"name": "multiplier"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentOperationSharpen": {
|
||||
@@ -4425,6 +4541,11 @@
|
||||
"alpha": {
|
||||
"name": "alpha"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentOperationTonemapReinhard": {
|
||||
@@ -4433,6 +4554,11 @@
|
||||
"multiplier": {
|
||||
"name": "multiplier"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentRotate": {
|
||||
@@ -4455,6 +4581,11 @@
|
||||
"samples2": {
|
||||
"name": "samples2"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LatentUpscale": {
|
||||
@@ -7701,6 +7832,10 @@
|
||||
"name": "video",
|
||||
"tooltip": "The reference video used to generate the output video. Must be at least 5 seconds long. Videos longer than 5s will be automatically trimmed. Only MP4 format supported."
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"tooltip": "Number of inference steps"
|
||||
},
|
||||
"control_type": {
|
||||
"name": "control_type"
|
||||
},
|
||||
@@ -8106,6 +8241,11 @@
|
||||
"upscale_method": {
|
||||
"name": "upscale_method"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PerpNeg": {
|
||||
@@ -8407,7 +8547,7 @@
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "Use the mask to define areas in the video to replace"
|
||||
"tooltip": "Use the mask to define areas in the video to replace."
|
||||
},
|
||||
"prompt_text": {
|
||||
"name": "prompt_text"
|
||||
@@ -8418,6 +8558,10 @@
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"region_to_modify": {
|
||||
"name": "region_to_modify",
|
||||
"tooltip": "Plaintext description of the object / region to modify."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
@@ -8635,6 +8779,14 @@
|
||||
"mode": {
|
||||
"name": "mode"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preview3D": {
|
||||
@@ -10248,6 +10400,11 @@
|
||||
"rescaling_scale": {
|
||||
"name": "rescaling_scale"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkipLayerGuidanceDiTSimple": {
|
||||
@@ -10269,6 +10426,11 @@
|
||||
"end_percent": {
|
||||
"name": "end_percent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkipLayerGuidanceSD3": {
|
||||
@@ -10290,6 +10452,11 @@
|
||||
"end_percent": {
|
||||
"name": "end_percent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SolidMask": {
|
||||
@@ -10329,6 +10496,14 @@
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitSigmas": {
|
||||
@@ -11152,6 +11327,11 @@
|
||||
"name": "image_interleave",
|
||||
"tooltip": "How much the image influences things vs the text prompt. Higher number means more influence from the text prompt."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TextEncodeQwenImageEdit": {
|
||||
@@ -11379,6 +11559,11 @@
|
||||
"clip_name3": {
|
||||
"name": "clip_name3"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoConversionNode": {
|
||||
@@ -11760,6 +11945,11 @@
|
||||
"model_name": {
|
||||
"name": "model_name"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"USOStyleReference": {
|
||||
|
||||
@@ -171,10 +171,10 @@
|
||||
"label": "自定义节点(测试版)"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
|
||||
"label": "自訂節點(舊版)"
|
||||
"label": "自定义节点(旧版)"
|
||||
},
|
||||
"Comfy_Manager_ShowLegacyManagerMenu": {
|
||||
"label": "管理員選單(舊版)"
|
||||
"label": "管理员菜单(旧版)"
|
||||
},
|
||||
"Comfy_Manager_ShowMissingPacks": {
|
||||
"label": "安装缺失的包"
|
||||
@@ -279,13 +279,13 @@
|
||||
"label": "切换日志底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "切換基本下方面板"
|
||||
"label": "切换基本下方面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "切換檢視控制底部面板"
|
||||
"label": "切换检视控制底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "顯示快捷鍵對話框"
|
||||
"label": "显示快捷键对话框"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "切换焦点模式"
|
||||
|
||||
29
src/platform/assets/components/Media3DBottom.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
16
src/platform/assets/components/Media3DTop.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('3D Model')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
50
src/platform/assets/components/MediaAssetActions.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<IconGroup>
|
||||
<IconButton size="sm" @click="handleDelete">
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</IconButton>
|
||||
<IconButton size="sm" @click="handleDownload">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</IconButton>
|
||||
<MoreButton
|
||||
size="sm"
|
||||
@menu-opened="emit('menuStateChanged', true)"
|
||||
@menu-closed="emit('menuStateChanged', false)"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<MediaAssetMoreMenu :close="close" />
|
||||
</template>
|
||||
</MoreButton>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuStateChanged: [isOpen: boolean]
|
||||
}>()
|
||||
|
||||
const { asset } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
339
src/platform/assets/components/MediaAssetCard.stories.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetCard from './MediaAssetCard.vue'
|
||||
|
||||
const meta: Meta<typeof MediaAssetCard> = {
|
||||
title: 'Platform/Assets/MediaAssetCard',
|
||||
component: MediaAssetCard,
|
||||
decorators: [
|
||||
() => ({
|
||||
components: { ResultGallery },
|
||||
setup() {
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
return { galleryStore }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<story />
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryStore.activeIndex"
|
||||
:all-gallery-items="galleryStore.items"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
context: {
|
||||
control: 'select',
|
||||
options: ['input', 'output']
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Public sample media URLs
|
||||
const SAMPLE_MEDIA = {
|
||||
image1: 'https://i.imgur.com/OB0y6MR.jpg',
|
||||
image2: 'https://i.imgur.com/CzXTtJV.jpg',
|
||||
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
|
||||
video:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
videoThumbnail:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
|
||||
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
}
|
||||
|
||||
const sampleAsset: AssetMeta = {
|
||||
id: 'asset-1',
|
||||
name: 'sample-image.png',
|
||||
kind: 'image',
|
||||
duration: 3345,
|
||||
size: 2048576,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
|
||||
export const ImageAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 3 },
|
||||
asset: sampleAsset,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-2',
|
||||
name: 'Big_Buck_Bunny.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video file
|
||||
dimensions: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Model3DAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-3',
|
||||
name: 'SoundHelix-Song.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
dimensions: undefined,
|
||||
duration: 23180
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: sampleAsset,
|
||||
loading: true
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SelectedState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'output', outputCount: 2 },
|
||||
asset: sampleAsset,
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WebMVideo: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-webm',
|
||||
name: 'animated-clip.webm',
|
||||
kind: 'video',
|
||||
size: 3145728,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.image1, // Poster image
|
||||
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
|
||||
duration: 620,
|
||||
dimensions: {
|
||||
width: 640,
|
||||
height: 360
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GifAnimation: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
context: { type: 'input' },
|
||||
asset: {
|
||||
id: 'asset-gif',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 1572864,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
|
||||
dimensions: {
|
||||
width: 480,
|
||||
height: 270
|
||||
},
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GridLayout: Story = {
|
||||
render: () => ({
|
||||
components: { MediaAssetCard },
|
||||
setup() {
|
||||
const assets: AssetMeta[] = [
|
||||
{
|
||||
id: 'grid-1',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image1,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-2',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image2,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-3',
|
||||
name: 'video-file.mp4',
|
||||
kind: 'video',
|
||||
size: 10485760,
|
||||
duration: 13425,
|
||||
created_at: Date.now().toString(),
|
||||
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
|
||||
src: SAMPLE_MEDIA.video, // Actual video
|
||||
dimensions: { width: 1280, height: 720 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-4',
|
||||
name: 'audio-file.mp3',
|
||||
kind: 'audio',
|
||||
size: 5242880,
|
||||
duration: 180,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.audio,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-5',
|
||||
name: 'animation.gif',
|
||||
kind: 'image',
|
||||
size: 3145728,
|
||||
duration: 1345,
|
||||
created_at: Date.now().toString(),
|
||||
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
|
||||
dimensions: { width: 480, height: 360 },
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-6',
|
||||
name: 'Asset-3d-model.glb',
|
||||
kind: '3D',
|
||||
size: 7340032,
|
||||
src: '',
|
||||
dimensions: undefined,
|
||||
duration: 18023,
|
||||
created_at: Date.now().toString(),
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'grid-7',
|
||||
name: 'image-file.jpg',
|
||||
kind: 'image',
|
||||
size: 2097152,
|
||||
duration: 4500,
|
||||
created_at: Date.now().toString(),
|
||||
src: SAMPLE_MEDIA.image3,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
return { assets }
|
||||
},
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 16px;">
|
||||
<MediaAssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
|
||||
:asset="asset"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
278
src/platform/assets/components/MediaAssetCard.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<CardContainer
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
@click="handleCardClick"
|
||||
@keydown.enter="handleCardClick"
|
||||
@keydown.space.prevent="handleCardClick"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getTopComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset(asset!.id)"
|
||||
@play="actions.playAsset(asset!.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
|
||||
<template v-if="showActionsOverlay" #top-left>
|
||||
<MediaAssetActions
|
||||
@menu-state-changed="isMenuOpen = $event"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Zoom button (top-right) - show on hover for all media types -->
|
||||
<template v-if="showZoomOverlay" #top-right>
|
||||
<IconButton
|
||||
size="sm"
|
||||
@click.stop="handleZoomClick"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
|
||||
<template v-if="showDurationChips" #bottom-left>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<SquareChip variant="light" :label="formattedDuration" />
|
||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Output count (bottom-right) - show on hover even when playing -->
|
||||
<template v-if="showOutputCount" #bottom-right>
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:label="context?.outputCount?.toString() ?? '0'"
|
||||
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||
@mouseenter="handleOverlayMouseEnter"
|
||||
@mouseleave="handleOverlayMouseLeave"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset">
|
||||
<component
|
||||
:is="getBottomComponent(asset.kind)"
|
||||
:asset="asset"
|
||||
:context="context"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import type {
|
||||
AssetContext,
|
||||
AssetMeta,
|
||||
MediaKind
|
||||
} from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetActions from './MediaAssetActions.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const { context, asset, loading, selected } = defineProps<{
|
||||
context: AssetContext
|
||||
asset?: AssetMeta
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isOverlayHovered = ref(false)
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
provide(MediaAssetKey, {
|
||||
asset: toRef(() => asset),
|
||||
context: toRef(() => context),
|
||||
isVideoPlaying,
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn(
|
||||
'gap-1',
|
||||
selected
|
||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
||||
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||
)
|
||||
)
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
if (!asset?.duration) return ''
|
||||
return formatDuration(asset.duration)
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
if (!asset?.name) return ''
|
||||
const parts = asset.name.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (asset?.kind === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset?.kind === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isCardOrOverlayHovered = computed(
|
||||
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||
)
|
||||
|
||||
const showHoverActions = computed(
|
||||
() => !loading && !!asset && isCardOrOverlayHovered.value
|
||||
)
|
||||
|
||||
const showActionsOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showZoomOverlay = computed(
|
||||
() =>
|
||||
showHoverActions.value &&
|
||||
asset?.kind !== '3D' &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showDurationChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
asset?.duration &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const showOutputCount = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
context?.outputCount &&
|
||||
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||
)
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (asset) {
|
||||
actions.selectAsset(asset)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOverlayMouseEnter = () => {
|
||||
isOverlayHovered.value = true
|
||||
}
|
||||
|
||||
const handleOverlayMouseLeave = () => {
|
||||
isOverlayHovered.value = false
|
||||
}
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
galleryStore.openSingle(asset)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
159
src/platform/assets/components/MediaAssetMoreMenu.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Inspect asset"
|
||||
@click="handleInspect"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Add to current workflow"
|
||||
@click="handleAddToWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--node] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Open as workflow in new tab"
|
||||
@click="handleOpenWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[comfy--workflow] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Export workflow"
|
||||
@click="handleExportWorkflow"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--file-output] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Copy job ID"
|
||||
@click="handleCopyJobId"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider />
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
class="dark-theme:text-white"
|
||||
label="Delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||
|
||||
const { close } = defineProps<{
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
const { asset, context } = inject(MediaAssetKey)!
|
||||
const actions = useMediaAssetActions()
|
||||
const galleryStore = useMediaAssetGalleryStore()
|
||||
|
||||
const showWorkflowOptions = computed(() => context.value.type)
|
||||
|
||||
const handleInspect = () => {
|
||||
if (asset.value) {
|
||||
galleryStore.openSingle(asset.value)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleAddToWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.addWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (asset.value) {
|
||||
actions.downloadAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleOpenWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.openWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleExportWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.exportWorkflow(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleCopyJobId = () => {
|
||||
if (asset.value) {
|
||||
actions.copyAssetUrl(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (asset.value) {
|
||||
actions.deleteAsset(asset.value.id)
|
||||
}
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaAudioBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
28
src/platform/assets/components/MediaAudioTop.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
|
||||
/>
|
||||
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
|
||||
$t('Audio')
|
||||
}}</span>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
class="absolute bottom-0 left-0 w-full p-2"
|
||||
:src="asset.src"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaImageBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
27
src/platform/assets/components/MediaImageTop.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-hidden rounded">
|
||||
<LazyImage
|
||||
v-if="asset.src"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:container-class="'aspect-square'"
|
||||
:image-class="'w-full h-full object-cover'"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
|
||||
>
|
||||
<i class="pi pi-image text-3xl text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
</script>
|
||||
30
src/platform/assets/components/MediaVideoBottom.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
|
||||
:title="asset.name"
|
||||
>
|
||||
{{ fileName }}
|
||||
</h3>
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
68
src/platform/assets/components/MediaVideoTop.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:controls="shouldShowControls"
|
||||
preload="none"
|
||||
:poster="asset.preview_url"
|
||||
class="relative h-full w-full object-contain"
|
||||
@click.stop
|
||||
@play="onVideoPlay"
|
||||
@pause="onVideoPause"
|
||||
@ended="onVideoEnded"
|
||||
>
|
||||
<source :src="asset.src || ''" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
context: AssetContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [assetId: string]
|
||||
videoPlayingStateChanged: [isPlaying: boolean]
|
||||
videoControlsChanged: [showControls: boolean]
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
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)
|
||||
|
||||
watch(shouldShowControls, (controlsVisible) => {
|
||||
emit('videoControlsChanged', controlsVisible)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
emit('videoControlsChanged', shouldShowControls.value)
|
||||
})
|
||||
|
||||
const onVideoPlay = () => {
|
||||
isPlaying.value = true
|
||||
emit('videoPlayingStateChanged', true)
|
||||
}
|
||||
|
||||
const onVideoPause = () => {
|
||||
isPlaying.value = false
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
|
||||
const onVideoEnded = () => {
|
||||
isPlaying.value = false
|
||||
emit('videoPlayingStateChanged', false)
|
||||
}
|
||||
</script>
|
||||
57
src/platform/assets/composables/useMediaAssetActions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const selectAsset = (asset: AssetMeta) => {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: string) => {
|
||||
console.log('Deleting asset:', assetId)
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyAssetUrl = (assetId: string) => {
|
||||
console.log('Copy asset URL:', assetId)
|
||||
}
|
||||
|
||||
const copyJobId = (jobId: string) => {
|
||||
console.log('Copy job ID:', jobId)
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
console.log('Adding asset to workflow:', assetId)
|
||||
}
|
||||
|
||||
const openWorkflow = (assetId: string) => {
|
||||
console.log('Opening workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const exportWorkflow = (assetId: string) => {
|
||||
console.log('Exporting workflow for asset:', assetId)
|
||||
}
|
||||
|
||||
const openMoreOutputs = (assetId: string) => {
|
||||
console.log('Opening more outputs for asset:', assetId)
|
||||
}
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
copyAssetUrl,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
exportWorkflow,
|
||||
openMoreOutputs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||
...data,
|
||||
url: ''
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('openSingle', () => {
|
||||
it('should convert AssetMeta to ResultItemImpl format', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-1',
|
||||
name: 'test-image.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/image.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith({
|
||||
filename: 'test-image.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: 'images'
|
||||
})
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for video assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockVideoAsset: AssetMeta = {
|
||||
id: 'test-2',
|
||||
name: 'test-video.mp4',
|
||||
kind: 'video',
|
||||
src: 'https://example.com/video.mp4',
|
||||
size: 2048,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockVideoAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-video.mp4',
|
||||
mediaType: 'video'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for audio assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAudioAsset: AssetMeta = {
|
||||
id: 'test-3',
|
||||
name: 'test-audio.mp3',
|
||||
kind: 'audio',
|
||||
src: 'https://example.com/audio.mp3',
|
||||
size: 512,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAudioAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-audio.mp3',
|
||||
mediaType: 'audio'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should override url getter with asset.src', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-4',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/custom-url.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('https://example.com/custom-url.png')
|
||||
})
|
||||
|
||||
it('should handle assets without src gracefully', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-5',
|
||||
name: 'no-src.png',
|
||||
kind: 'image',
|
||||
src: '',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('')
|
||||
})
|
||||
|
||||
it('should update activeIndex and items when called multiple times', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset1: AssetMeta = {
|
||||
id: '1',
|
||||
name: 'first.png',
|
||||
kind: 'image',
|
||||
src: 'url1',
|
||||
size: 100,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
const asset2: AssetMeta = {
|
||||
id: '2',
|
||||
name: 'second.png',
|
||||
kind: 'image',
|
||||
src: 'url2',
|
||||
size: 200,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset1)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('first.png')
|
||||
|
||||
store.openSingle(asset2)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('second.png')
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close', () => {
|
||||
it('should reset activeIndex to -1', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'test-url',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
|
||||
store.close()
|
||||
expect(store.activeIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export const useMediaAssetGalleryStore = defineStore(
|
||||
'mediaAssetGallery',
|
||||
() => {
|
||||
const activeIndex = ref(-1)
|
||||
const items = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const close = () => {
|
||||
activeIndex.value = -1
|
||||
}
|
||||
|
||||
const openSingle = (asset: AssetMeta) => {
|
||||
// Convert AssetMeta to ResultItemImpl format
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: asset.kind === 'image' ? 'images' : asset.kind
|
||||
})
|
||||
|
||||
// Override the url getter to use asset.src
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.src || ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
items.value = [resultItem]
|
||||
activeIndex.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
items,
|
||||
close,
|
||||
openSingle
|
||||
}
|
||||
}
|
||||
)
|
||||
46
src/platform/assets/schemas/mediaAssetSchema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { assetItemSchema } from './assetSchema'
|
||||
|
||||
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
|
||||
export type MediaKind = z.infer<typeof zMediaKindSchema>
|
||||
|
||||
const zDimensionsSchema = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive()
|
||||
})
|
||||
|
||||
// Extend the base asset schema with media-specific fields
|
||||
const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
|
||||
// New required fields
|
||||
kind: zMediaKindSchema,
|
||||
src: z.string().url(),
|
||||
|
||||
// New optional fields
|
||||
duration: z.number().nonnegative().optional(),
|
||||
dimensions: zDimensionsSchema.optional(),
|
||||
jobId: z.string().optional(),
|
||||
isMulti: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Asset context schema
|
||||
const zAssetContextSchema = z.object({
|
||||
type: z.enum(['input', 'output']),
|
||||
outputCount: z.number().positive().optional() // Only for output context
|
||||
})
|
||||
|
||||
// Export the inferred types
|
||||
export type AssetMeta = z.infer<typeof zMediaAssetDisplayItemSchema>
|
||||
export type AssetContext = z.infer<typeof zAssetContextSchema>
|
||||
|
||||
// Injection key for MediaAsset provide/inject pattern
|
||||
interface MediaAssetProviderValue {
|
||||
asset: Ref<AssetMeta | undefined>
|
||||
context: Ref<AssetContext>
|
||||
isVideoPlaying: Ref<boolean>
|
||||
showVideoControls: Ref<boolean>
|
||||
}
|
||||
|
||||
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
|
||||
Symbol('mediaAsset')
|
||||
20
src/platform/distribution/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
/**
|
||||
* Distribution types and compile-time constants for managing
|
||||
* multi-distribution builds (Desktop, Localhost, Cloud)
|
||||
*/
|
||||
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
declare global {
|
||||
const __DISTRIBUTION__: Distribution
|
||||
}
|
||||
|
||||
/** Current distribution - replaced at compile time */
|
||||
const DISTRIBUTION: Distribution = __DISTRIBUTION__
|
||||
|
||||
/** Distribution type checks */
|
||||
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
|
||||
export const isCloud = DISTRIBUTION === 'cloud'
|
||||
// export const isLocalhost = !isDesktop && !isCloud
|
||||