Compare commits
62 Commits
luke-mino-
...
cloud/1.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d6c9ff94 | ||
|
|
b3d6a49328 | ||
|
|
0ea066599d | ||
|
|
20b29f0301 | ||
|
|
ac4c553a46 | ||
|
|
6516e0bdbf | ||
|
|
fbf5e6db21 | ||
|
|
93b71b2b64 | ||
|
|
a10f9fa30b | ||
|
|
427bc312e4 | ||
|
|
b63df5efaf | ||
|
|
f599b89252 | ||
|
|
19b673cbf5 | ||
|
|
50c353ebb6 | ||
|
|
75e448085b | ||
|
|
3d2be8f259 | ||
|
|
15ab78370e | ||
|
|
00e27700e4 | ||
|
|
5d279b680e | ||
|
|
4d7369b97d | ||
|
|
66509b81c4 | ||
|
|
e4ef961876 | ||
|
|
7e7d0e84e5 | ||
|
|
cca12d8dc5 | ||
|
|
ccccd0ff0d | ||
|
|
5af9b842ac | ||
|
|
fe9c960d24 | ||
|
|
b2f2144d51 | ||
|
|
c4f7686575 | ||
|
|
14d94da52b | ||
|
|
a521066b25 | ||
|
|
ada0993572 | ||
|
|
e42715086e | ||
|
|
92968f3f9b | ||
|
|
73692464ef | ||
|
|
61b3ca046a | ||
|
|
f8912ebaf4 | ||
|
|
80b87c1277 | ||
|
|
00fa9b691b | ||
|
|
0cff8eb357 | ||
|
|
7a0302ba7a | ||
|
|
a4d979e4c9 | ||
|
|
2f81e5b30a | ||
|
|
471ccca1dd | ||
|
|
3effe714f3 | ||
|
|
c43a4990a9 | ||
|
|
8dd5a9900b | ||
|
|
7a11dc59b6 | ||
|
|
ba768c32f3 | ||
|
|
e3f19ab856 | ||
|
|
a9f416233d | ||
|
|
b23a92b442 | ||
|
|
8c3caa77d6 | ||
|
|
ad6dda4435 | ||
|
|
c43cd287bb | ||
|
|
b347dd1734 | ||
|
|
1a6913c466 | ||
|
|
f80fc4cf9a | ||
|
|
8b8f3538bf | ||
|
|
ecd87ae0f4 | ||
|
|
693d408c4a | ||
|
|
1feee48284 |
1
.github/workflows/release-version-bump.yaml
vendored
@@ -59,7 +59,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
@@ -243,7 +243,7 @@ pnpm format
|
||||
|
||||
### Styling
|
||||
- Use Tailwind CSS classes instead of custom CSS
|
||||
- Follow the existing dark theme pattern: `dark-theme:` prefix (not `dark:`)
|
||||
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
|
||||
|
||||
### Internationalization
|
||||
- All user-facing strings must use vue-i18n
|
||||
|
||||
@@ -85,11 +85,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InstallStage,
|
||||
type InstallStageInfo,
|
||||
type InstallStageName,
|
||||
ProgressStatus
|
||||
import { InstallStage, ProgressStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type {
|
||||
InstallStageInfo,
|
||||
InstallStageName
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
@@ -564,7 +564,7 @@ export class ComfyPage {
|
||||
async dragAndDrop(source: Position, target: Position) {
|
||||
await this.page.mouse.move(source.x, source.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y)
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ export class VueNodeHelpers {
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
// Add additional nodes with Ctrl+click on header
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
|
||||
.click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
response_id: string
|
||||
}
|
||||
|
||||
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
|
||||
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
|
||||
// Simulate API sending display_component message
|
||||
await page.evaluate(
|
||||
({ nodeId, history }) => {
|
||||
const event = new CustomEvent('display_component', {
|
||||
detail: {
|
||||
node_id: nodeId,
|
||||
component: 'ChatHistoryWidget',
|
||||
props: {
|
||||
history: JSON.stringify(history)
|
||||
}
|
||||
}
|
||||
})
|
||||
window['app'].api.dispatchEvent(event)
|
||||
return true
|
||||
},
|
||||
{ nodeId, history }
|
||||
)
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
test.describe('Chat History Widget', () => {
|
||||
let nodeId: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
nodeId = await renderChatHistory(comfyPage.page, [
|
||||
{ prompt: 'Hello', response: 'World', response_id: '123' }
|
||||
])
|
||||
// Wait for chat history to be rendered
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
})
|
||||
|
||||
test('displays chat history when receiving display_component message', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Verify the chat history is displayed correctly
|
||||
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('World')).toBeVisible()
|
||||
})
|
||||
|
||||
test('handles message editing interaction', async ({ comfyPage }) => {
|
||||
// Get first node's ID
|
||||
nodeId = await comfyPage.page.evaluate(() => {
|
||||
const node = window['app'].graph.nodes[0]
|
||||
|
||||
// Make sure the node has a prompt widget (for editing functionality)
|
||||
if (!node.widgets) {
|
||||
node.widgets = []
|
||||
}
|
||||
|
||||
// Add a prompt widget if it doesn't exist
|
||||
if (!node.widgets.find((w) => w.name === 'prompt')) {
|
||||
node.widgets.push({
|
||||
name: 'prompt',
|
||||
type: 'text',
|
||||
value: 'Original prompt'
|
||||
})
|
||||
}
|
||||
|
||||
return node.id
|
||||
})
|
||||
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Message 1',
|
||||
response: 'Response 1',
|
||||
response_id: '123'
|
||||
},
|
||||
{
|
||||
prompt: 'Message 2',
|
||||
response: 'Response 2',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
const originalTextAreaInput = await comfyPage.page
|
||||
.getByPlaceholder('text')
|
||||
.nth(1)
|
||||
.inputValue()
|
||||
|
||||
// Click edit button on first message
|
||||
await comfyPage.page.getByLabel('Edit').first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify cancel button appears
|
||||
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
|
||||
|
||||
// Click cancel edit
|
||||
await comfyPage.page.getByLabel('Cancel').click()
|
||||
|
||||
// Verify prompt input is restored
|
||||
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
|
||||
originalTextAreaInput
|
||||
)
|
||||
})
|
||||
|
||||
test('handles real-time updates to chat history', async ({ comfyPage }) => {
|
||||
// Send initial history
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Initial message',
|
||||
response: 'Initial response',
|
||||
response_id: '123'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Update history with additional messages
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Follow-up',
|
||||
response: 'New response',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Move mouse over the canvas to force update
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify new messages appear
|
||||
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('New response')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
@@ -516,6 +517,7 @@ This is English documentation.
|
||||
)
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select KSampler first
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('Vue Nodes Zoom', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 143 KiB |
@@ -1,48 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 81 KiB |
@@ -140,8 +140,8 @@ export default defineConfig([
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
// Prohibit dark-theme: and dark: prefixes
|
||||
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
'vue/match-component-import-name': 'error',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.5",
|
||||
"version": "1.32.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -131,6 +131,7 @@
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
|
||||
--color-interface-panel-job-progress-primary: var(--color-azure-300);
|
||||
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
@@ -160,7 +163,6 @@
|
||||
|
||||
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
|
||||
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgb(0 122 255 / 1);
|
||||
@@ -265,6 +267,14 @@
|
||||
--palette-interface-panel-selected-surface: color-mix(in srgb, var(--interface-panel-surface) 87.5%, var(--contrast-mix-color));
|
||||
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
@@ -286,8 +296,6 @@
|
||||
|
||||
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
|
||||
|
||||
--modal-card-button-surface: var(--color-charcoal-300);
|
||||
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
|
||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||
@@ -326,6 +334,12 @@
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-azure-600);
|
||||
|
||||
/* Queue progress (dark theme) */
|
||||
--color-interface-panel-job-progress-primary: var(--color-cobalt-800);
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
|
||||
@@ -362,6 +376,16 @@
|
||||
--component-node-widget-background-selected: var(--color-charcoal-100);
|
||||
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--color-ash-400);
|
||||
--modal-card-button-surface: var(--color-charcoal-300);
|
||||
--modal-card-placeholder-background: var(--secondary-background);
|
||||
--modal-card-tag-background: var(--color-ash-800);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-charcoal-600);
|
||||
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -372,7 +396,16 @@
|
||||
--color-button-surface: var(--button-surface);
|
||||
--color-button-surface-contrast: var(--button-surface-contrast);
|
||||
--color-subscription-button-gradient: var(--subscription-button-gradient);
|
||||
|
||||
--color-modal-card-background: var(--modal-card-background);
|
||||
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
|
||||
--color-modal-card-border-highlighted: var(--modal-card-border-highlighted);
|
||||
--color-modal-card-button-surface: var(--modal-card-button-surface);
|
||||
--color-modal-card-placeholder-background: var(--modal-card-placeholder-background);
|
||||
--color-modal-card-tag-background: var(--modal-card-tag-background);
|
||||
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
|
||||
--color-modal-panel-background: var(--modal-panel-background);
|
||||
|
||||
--color-dialog-surface: var(--dialog-surface);
|
||||
--color-interface-menu-component-surface-hovered: var(
|
||||
--interface-menu-component-surface-hovered
|
||||
@@ -1296,53 +1329,465 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
position: absolute;
|
||||
backgroundColor: transparent;
|
||||
z-index: 8889;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
overflow: visible;
|
||||
outline: 1px dashed black;
|
||||
box-shadow: 0 0 0 1px white;
|
||||
}
|
||||
#maskEditor_brushPreviewGradient {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
}
|
||||
.maskEditor_sidePanelTitle {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushShapeCircle {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
margin-left: 7.5px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange {
|
||||
width: 180px;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
margin-top: -8px;
|
||||
background: var(--p-surface-700);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
.maskEditor_sidePanelBrushRange::-moz-range-track {
|
||||
background: var(--p-surface-700);
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
.maskEditor_sidePanelBrushShapeSquare {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
margin: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
pointer-events: auto;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.maskEditor_brushShape_dark {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
.maskEditor_brushShape_dark:hover {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
.maskEditor_brushShape_light {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
.maskEditor_brushShape_light:hover {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
.maskEditor_sidePanelLayer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
.maskEditor_sidePanelLayerVisibilityContainer {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.maskEditor_sidePanelVisibilityToggle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer {
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
.maskEditor_sidePanelLayerIconContainer svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton {
|
||||
width: 85px;
|
||||
height: 30px;
|
||||
background: rgb(0 0 0 / 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
.maskEditor_sidePanelBigButton:hover {
|
||||
background-color: var(--p-overlaybadge-outline-color);
|
||||
border: none;
|
||||
}
|
||||
.maskEditor_toolPanelContainer {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected svg {
|
||||
fill: var(--p-button-text-primary-color) !important;
|
||||
}
|
||||
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
|
||||
display: block;
|
||||
}
|
||||
.maskEditor_toolPanelContainer svg {
|
||||
width: 75%;
|
||||
aspect-ratio: 1/1;
|
||||
fill: var(--p-button-text-secondary-color);
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
.maskEditor_toolPanelContainerDark:hover {
|
||||
background-color: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.maskEditor_toolPanelContainerLight:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
.maskEditor_toolPanelIndicator {
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
.maskEditor_sidePanelSeparator {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
#maskEditorCanvasContainer {
|
||||
position: absolute;
|
||||
width: 1000px;
|
||||
height: 667px;
|
||||
left: 359px;
|
||||
top: 280px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_dark svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.1s;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelIconButton_light svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
fill: var(--input-text);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark {
|
||||
height: 30px;
|
||||
background: var(--p-surface-800);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_dark:hover {
|
||||
background-color: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light {
|
||||
height: 30px;
|
||||
background: var(--comfy-menu-bg);
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--input-text);
|
||||
font-family: sans-serif;
|
||||
pointer-events: auto;
|
||||
transition: 0.1s;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.maskEditor_topPanelButton_light:hover {
|
||||
background-color: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_paintBucket_Container {
|
||||
width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_Container {
|
||||
display: flex;
|
||||
width: 180px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanel_colorSelect_tolerance_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelContainerRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_dark {
|
||||
background: var(--p-surface-800);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_dark {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_light {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_accent_bg_very_light {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
|
||||
.maskEditor_sidePanelToggleContainer {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleSwitch {
|
||||
display: inline-block;
|
||||
border-radius: 16px;
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
transition: background 0.25s;
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelToggleSwitch {
|
||||
background: var(--p-surface-700);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleSwitch::before, .maskEditor_sidePanelToggleSwitch::after {
|
||||
content: "";
|
||||
}
|
||||
.maskEditor_sidePanelToggleSwitch::before {
|
||||
display: block;
|
||||
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
transition: ease 0.2s;
|
||||
}
|
||||
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
|
||||
background: var(--p-button-text-primary-color);
|
||||
}
|
||||
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: var(--comfy-menu-bg);
|
||||
left: 20px;
|
||||
}
|
||||
.dark-theme .maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelToggleCheckbox {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown {
|
||||
border: 1px solid var(--p-form-field-border-color);
|
||||
background: var(--comfy-menu-bg);
|
||||
height: 24px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown option {
|
||||
background: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown:focus {
|
||||
outline: 1px solid var(--p-surface-300);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelDropdown option:hover {
|
||||
background: white;
|
||||
}
|
||||
.maskEditor_sidePanelDropdown option:active {
|
||||
background: var(--p-surface-300);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown option {
|
||||
background: var(--p-surface-900);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown:focus {
|
||||
outline: 1px solid var(--p-button-text-primary-color);
|
||||
}
|
||||
|
||||
.dark-theme .maskEditor_sidePanelDropdown option:active {
|
||||
background: var(--p-highlight-background);
|
||||
}
|
||||
|
||||
.maskEditor_layerRow {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerPreviewContainer > svg{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
fill: var(--p-surface-100);
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelImageLayerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelSubTitle {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.maskEditor_containerDropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.maskEditor_sidePanelLayerCheckbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
|
||||
3
packages/design-system/src/icons/file-output.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.33398 1.33337V4.00004C9.33398 4.35366 9.47446 4.6928 9.72451 4.94285C9.97456 5.1929 10.3137 5.33337 10.6673 5.33337H13.334M2.66732 4.66671V2.66671C2.66732 2.31309 2.80779 1.97395 3.05784 1.7239C3.30789 1.47385 3.64703 1.33337 4.00065 1.33337H10.0006L13.334 4.66671V13.3334C13.334 13.687 13.1935 14.0261 12.9435 14.2762C12.6934 14.5262 12.3543 14.6667 12.0006 14.6667L4.04264 14.666C3.77927 14.7004 3.51166 14.6552 3.2741 14.5365C3.03655 14.4177 2.83988 14.2307 2.70931 13.9994M3.33398 7.33337L1.33398 9.33337M1.33398 9.33337L3.33398 11.3334M1.33398 9.33337H8.00065" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 B |
3
pnpm-lock.yaml
generated
@@ -326,6 +326,9 @@ importers:
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/shared-frontend-utils
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS only (no custom CSS)
|
||||
- Dark theme: use "dark-theme:" prefix
|
||||
- Use the correct tokens from style.css in the design system package
|
||||
- For common operations, try to use existing VueUse composables that automatically handle effect scope
|
||||
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
|
||||
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
|
||||
|
||||
@@ -4,38 +4,80 @@
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:class="queueHistoryButtonBackgroundClass"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--history] block size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
isQueueOverlayExpanded.value
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
@@ -45,6 +87,10 @@ onMounted(() => {
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1000"
|
||||
class="pointer-events-auto z-1010"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -260,6 +260,7 @@ const actionbarClass = computed(() =>
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
isMouseOverDropZone.value &&
|
||||
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
|
||||
)
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: workspaceStore.shiftDown
|
||||
? $t('menu.runWorkflowFront')
|
||||
: $t('menu.runWorkflow'),
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
@@ -16,16 +14,7 @@
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i v-if="workspaceStore.shiftDown" class="icon-[lucide--list-start]" />
|
||||
<i v-else-if="queueMode === 'disabled'" class="icon-[lucide--play]" />
|
||||
<i
|
||||
v-else-if="queueMode === 'instant'"
|
||||
class="icon-[lucide--fast-forward]"
|
||||
/>
|
||||
<i
|
||||
v-else-if="queueMode === 'change'"
|
||||
class="icon-[lucide--step-forward]"
|
||||
/>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
@@ -89,12 +78,15 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
@@ -102,6 +94,11 @@ const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
const items: Record<string, MenuItem> = {
|
||||
@@ -157,6 +154,35 @@ const hasPendingTasks = computed(
|
||||
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
|
||||
)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return 'icon-[lucide--list-start]'
|
||||
}
|
||||
if (queueMode.value === 'disabled') {
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
if (queueMode.value === 'instant') {
|
||||
return 'icon-[lucide--fast-forward]'
|
||||
}
|
||||
if (queueMode.value === 'change') {
|
||||
return 'icon-[lucide--step-forward]'
|
||||
}
|
||||
return 'icon-[lucide--play]'
|
||||
})
|
||||
|
||||
const queueButtonTooltip = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return t('menu.runWorkflowFront')
|
||||
}
|
||||
return t('menu.runWorkflow')
|
||||
})
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h3
|
||||
class="subcategory-title mb-4 text-xs font-bold tracking-wide text-surface-600 uppercase dark-theme:text-surface-400"
|
||||
class="subcategory-title mb-4 text-xs font-bold tracking-wide text-text-secondary uppercase"
|
||||
>
|
||||
{{ getSubcategoryTitle(subcategory) }}
|
||||
</h3>
|
||||
@@ -16,7 +16,7 @@
|
||||
<div
|
||||
v-for="command in subcategoryCommands"
|
||||
:key="command.id"
|
||||
class="shortcut-item flex items-center justify-between rounded py-2 transition-colors duration-200 hover:bg-surface-100 dark-theme:hover:bg-surface-700"
|
||||
class="shortcut-item flex items-center justify-between rounded py-2 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
@@ -32,7 +32,7 @@
|
||||
<span
|
||||
v-for="key in command.keybinding!.combo.getKeySequences()"
|
||||
:key="key"
|
||||
class="key-badge min-w-6 rounded border bg-surface-200 px-2 py-1 text-center font-mono text-xs dark-theme:bg-surface-600"
|
||||
class="key-badge min-w-6 rounded bg-muted-background px-2 py-1 text-center font-mono text-xs"
|
||||
>
|
||||
{{ formatKey(key) }}
|
||||
</span>
|
||||
@@ -100,21 +100,3 @@ const formatKey = (key: string): string => {
|
||||
return keyMap[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subcategory-title {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.key-badge {
|
||||
background-color: var(--p-surface-200);
|
||||
border: 1px solid var(--p-surface-300);
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark-theme .key-badge {
|
||||
background-color: var(--p-surface-600);
|
||||
border-color: var(--p-surface-500);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: item.label,
|
||||
value: tooltipText,
|
||||
showDelay: 512
|
||||
}"
|
||||
draggable="false"
|
||||
@@ -16,6 +16,10 @@
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="hasMissingNodes && isRoot"
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
@@ -60,10 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -74,6 +81,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
const dialogService = useDialogService()
|
||||
@@ -115,6 +127,14 @@ const rename = async (
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -10,8 +10,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
const iconGroupClasses = cn(
|
||||
'flex justify-center items-center shrink-0',
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'bg-secondary-background shadow-sm',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ defineOptions({
|
||||
interface IconTextButtonProps extends BaseButtonProps {
|
||||
iconPosition?: 'left' | 'right'
|
||||
label: string
|
||||
onClick: () => void
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -47,7 +47,7 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
|
||||
@@ -7,13 +7,24 @@
|
||||
|
||||
<Popover
|
||||
ref="popover"
|
||||
:append-to="'body'"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
append-to="body"
|
||||
auto-z-index
|
||||
dismissable
|
||||
close-on-escape
|
||||
unstyled
|
||||
:pt="pt"
|
||||
:base-z-index="1000"
|
||||
:pt="{
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-1 rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'shadow-lg'
|
||||
)
|
||||
}
|
||||
}"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
@@ -26,7 +37,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -57,19 +68,4 @@ const toggle = (event: Event) => {
|
||||
const hide = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: cn('absolute z-50')
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-1 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
'border border-zinc-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -47,8 +47,8 @@ const containerClasses = computed(() => {
|
||||
// Variant styles
|
||||
const variantClasses = {
|
||||
default: cn(
|
||||
hasBackground && 'bg-white dark-theme:bg-zinc-800',
|
||||
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
|
||||
hasBackground && 'bg-modal-card-background',
|
||||
hasBorder && 'border border-border-default',
|
||||
hasShadow && 'shadow-sm',
|
||||
hasCursor && 'cursor-pointer'
|
||||
),
|
||||
@@ -57,9 +57,9 @@ const containerClasses = computed(() => {
|
||||
'p-2 transition-colors duration-200'
|
||||
),
|
||||
outline: cn(
|
||||
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
|
||||
hasBorder && 'border-2 border-border-subtle',
|
||||
hasCursor && 'cursor-pointer',
|
||||
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
|
||||
'hover:border-border-subtle/50 transition-colors'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div class="line-clamp-2 h-7 text-xs text-zinc-500 dark-theme:text-zinc-400">
|
||||
<div class="line-clamp-2 h-7 text-xs text-muted-foreground">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
variant?: 'dark' | 'light' | 'gray'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
@@ -19,7 +19,10 @@ const baseClasses =
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
|
||||
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground'),
|
||||
gray: cn(
|
||||
'backdrop-blur-[2px] bg-modal-card-tag-background text-base-foreground'
|
||||
)
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="preview-box flex h-16 w-16 items-center justify-center rounded border p-2"
|
||||
:class="{ 'bg-smoke-100 dark-theme:bg-smoke-800': !modelValue }"
|
||||
:class="{ 'bg-base-background': !modelValue }"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 text-muted dark-theme:bg-surface-800"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
|
||||
@@ -35,7 +35,6 @@ import { ValidationState } from '@/utils/validationUtil'
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -102,8 +101,6 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
@@ -364,10 +364,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
|
||||
>
|
||||
<div v-if="!isLoading" class="mt-6 px-6 text-sm text-muted">
|
||||
{{
|
||||
$t('templateWorkflows.resultsCount', {
|
||||
count: filteredCount,
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const { apiNodeNames, onLogin, onCancel } = defineProps<{
|
||||
apiNodeNames: string[]
|
||||
@@ -38,6 +41,9 @@ const { apiNodeNames, onLogin, onCancel } = defineProps<{
|
||||
}>()
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-2 py-2 px-4">
|
||||
<IconTextButton
|
||||
:label="$t('cloud.missingNodes.learnMore')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
icon-position="left"
|
||||
@click="handleLearnMoreClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
:label="$t('cloud.missingNodes.gotIt')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="handleGotItClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
window.open('https://www.comfy.org/cloud', '_blank')
|
||||
}
|
||||
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-cloud-missing-nodes' })
|
||||
}
|
||||
</script>
|
||||
@@ -24,16 +24,14 @@
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div
|
||||
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
|
||||
>
|
||||
<div class="text-sm font-medium text-surface-600">
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
|
||||
<div class="ml-4 text-sm text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,14 +47,14 @@ import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const props = defineProps<{
|
||||
const { missingCoreNodes } = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
return Object.keys(missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
// Use computed for reactive version tracking
|
||||
@@ -66,7 +64,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
|
||||
})
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
return Object.entries(missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compare(b, a) // Reversed for descending order
|
||||
})
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{ $t('cloud.missingNodes.description') }}
|
||||
<br /><br />
|
||||
{{ $t('cloud.missingNodes.priorityMessage') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.description')
|
||||
: $t('missingNodes.oss.description')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
|
||||
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
class="flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
@@ -24,13 +27,18 @@
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom instruction -->
|
||||
<div>
|
||||
<p class="m-0 text-sm leading-4 text-muted-foreground">
|
||||
{{ $t('cloud.missingNodes.replacementInstruction') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.replacementInstruction')
|
||||
: $t('missingNodes.oss.replacementInstruction')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +48,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
@@ -1,39 +1,42 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="$t('loadWorkflowWarning.missingNodesTitle')"
|
||||
:message="$t('loadWorkflowWarning.missingNodesDescription')"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
scroll-height="100%"
|
||||
class="comfy-missing-nodes"
|
||||
:pt="{
|
||||
list: { class: 'border-none' }
|
||||
}"
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="align-items-center flex">
|
||||
<span class="node-type">{{ slotProps.option.label }}</span>
|
||||
<span v-if="slotProps.option.hint" class="node-hint">{{
|
||||
slotProps.option.hint
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="slotProps.option.action"
|
||||
:label="slotProps.option.action.text"
|
||||
size="small"
|
||||
outlined
|
||||
@click="slotProps.option.action.callback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="showManagerButtons" class="flex justify-end py-3">
|
||||
<IconTextButton
|
||||
:label="$t('missingNodes.cloud.learnMore')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
icon-position="left"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
:label="$t('missingNodes.cloud.gotIt')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
@click="handleGotItClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<TextButton
|
||||
:label="$t('g.openManager')"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
@click="openManager"
|
||||
/>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
@@ -46,40 +49,32 @@
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('g.openManager')"
|
||||
size="small"
|
||||
outlined
|
||||
@click="openManager"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
@@ -91,27 +86,6 @@ const isInstalling = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
.filter((node) => {
|
||||
const type = typeof node === 'object' ? node.type : node
|
||||
if (seenTypes.has(type)) return false
|
||||
seenTypes.add(type)
|
||||
return true
|
||||
})
|
||||
.map((node) => {
|
||||
if (typeof node === 'object') {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
|
||||
// Show manager buttons unless manager is disabled
|
||||
const showManagerButtons = computed(() => {
|
||||
return managerState.shouldShowManagerButtons.value
|
||||
@@ -129,9 +103,6 @@ const openManager = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// Computed to check if all missing nodes have been installed
|
||||
const allMissingNodesInstalled = computed(() => {
|
||||
return (
|
||||
@@ -140,13 +111,14 @@ const allMissingNodesInstalled = computed(() => {
|
||||
missingNodePacks.value?.length === 0
|
||||
)
|
||||
})
|
||||
// Watch for completion and close dialog
|
||||
|
||||
// Watch for completion and close dialog (OSS mode only)
|
||||
watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
if (allInstalled && showInstallAllButton.value) {
|
||||
if (!isCloud && allInstalled && showInstallAllButton.value) {
|
||||
// Use nextTick to ensure state updates are complete
|
||||
await nextTick()
|
||||
|
||||
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
|
||||
// Show success toast
|
||||
useToastStore().add({
|
||||
@@ -158,20 +130,3 @@ watch(allMissingNodesInstalled, async (allInstalled) => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-nodes {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.node-hint {
|
||||
margin-left: 0.5rem;
|
||||
font-style: italic;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
:deep(.p-button) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,16 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('cloud.missingNodes.title') }}
|
||||
{{
|
||||
isCloud
|
||||
? $t('missingNodes.cloud.title')
|
||||
: $t('missingNodes.oss.title')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
</script>
|
||||
@@ -123,6 +123,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -137,6 +138,7 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
@@ -183,12 +185,17 @@ const handleMessageSupport = async () => {
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
|
||||
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
|
||||
includeLocale: true
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,18 +84,6 @@ describe('BypassButton', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show normal styling when node is not bypassed', () => {
|
||||
const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
|
||||
canvasStore.selectedItems = [normalNode] as any
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
expect(button.classes()).not.toContain(
|
||||
'dark-theme:[&:not(:active)]:!bg-charcoal-600'
|
||||
)
|
||||
})
|
||||
|
||||
it('should show bypassed styling when node is bypassed', async () => {
|
||||
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
|
||||
canvasStore.selectedItems = [bypassedNode] as any
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
value: t('selectionToolbox.executeButton.tooltip'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
class="size-8 bg-azure-400 !p-0 dark-theme:bg-azure-600"
|
||||
class="size-8 bg-primary-background text-white p-0"
|
||||
text
|
||||
@mouseenter="() => handleMouseEnter()"
|
||||
@mouseleave="() => handleMouseLeave()"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i class="icon-[lucide--play] size-4 text-white" />
|
||||
<i class="icon-[lucide--play] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="option.type === 'divider'"
|
||||
class="my-1 h-px bg-smoke-200 dark-theme:bg-zinc-700"
|
||||
/>
|
||||
<div v-if="option.type === 'divider'" class="my-1 h-px bg-border-default" />
|
||||
<div
|
||||
v-else
|
||||
role="button"
|
||||
@@ -26,18 +23,21 @@
|
||||
v-if="option.badge"
|
||||
:severity="option.badge === 'new' ? 'info' : 'secondary'"
|
||||
:value="t(option.badge)"
|
||||
:class="{
|
||||
'rounded-4xl bg-azure-400 dark-theme:bg-azure-600':
|
||||
option.badge === 'new',
|
||||
'rounded-4xl bg-slate-100 dark-theme:bg-black':
|
||||
option.badge === 'deprecated',
|
||||
'h-4 gap-2.5 px-1 text-[9px] text-white uppercase': true
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'h-4 gap-2.5 px-1 text-[9px] text-base-foreground uppercase rounded-4xl',
|
||||
{
|
||||
'bg-primary-background': option.badge === 'new',
|
||||
'bg-secondary-background': option.badge === 'deprecated'
|
||||
}
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Badge from 'primevue/badge'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -8,7 +8,18 @@
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-50 w-[300px]'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'mt-2 text-base-foreground rounded-lg',
|
||||
'shadow-lg border border-border-default',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
@@ -36,7 +47,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
forceCloseMoreOptionsSignal,
|
||||
@@ -253,19 +264,6 @@ const setSubmenuRef = (key: string, el: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50 w-[300px] px-[12]'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'mt-2 text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
|
||||
const onPopoverShow = () => {
|
||||
overlayElCache = resolveOverlayEl()
|
||||
|
||||
@@ -6,7 +6,18 @@
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="submenuPt"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'absolute z-[60]'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'text-base-foreground rounded-lg',
|
||||
'shadow-lg border border-base-background',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
@@ -19,22 +30,25 @@
|
||||
v-for="subOption in option.submenu"
|
||||
:key="subOption.label"
|
||||
:class="
|
||||
isColorSubmenu
|
||||
? 'w-7 h-7 flex items-center justify-center hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
|
||||
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
|
||||
cn(
|
||||
'hover:bg-secondary-background-hover rounded cursor-pointer',
|
||||
isColorSubmenu
|
||||
? 'w-7 h-7 flex items-center justify-center'
|
||||
: 'flex items-center gap-2 px-3 py-1.5 text-sm'
|
||||
)
|
||||
"
|
||||
:title="subOption.label"
|
||||
@click="handleSubmenuClick(subOption)"
|
||||
>
|
||||
<div
|
||||
v-if="subOption.color"
|
||||
class="h-5 w-5 rounded-full border border-smoke-300 dark-theme:border-zinc-600"
|
||||
class="size-5 rounded-full border border-border-default"
|
||||
:style="{ backgroundColor: subOption.color }"
|
||||
/>
|
||||
<template v-else-if="!subOption.color">
|
||||
<i
|
||||
v-if="isShapeSelected(subOption)"
|
||||
class="icon-[lucide--check] h-4 w-4 flex-shrink-0"
|
||||
class="icon-[lucide--check] size-4 flex-shrink-0"
|
||||
/>
|
||||
<div v-else class="w-4 flex-shrink-0" />
|
||||
<span>{{ subOption.label }}</span>
|
||||
@@ -45,6 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -102,17 +117,4 @@ const isColorSubmenu = computed(() => {
|
||||
props.option.submenu.every((item) => item.color && !item.icon)
|
||||
)
|
||||
})
|
||||
|
||||
const submenuPt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-[60]'
|
||||
},
|
||||
content: {
|
||||
class: [
|
||||
'text-neutral dark-theme:text-white rounded-lg',
|
||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
|
||||
'bg-interface-panel-surface'
|
||||
]
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-6 w-px self-center bg-smoke-300/10 dark-theme:bg-smoke-600/10"
|
||||
/>
|
||||
<div class="h-6 w-px self-center bg-border-default" />
|
||||
</template>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { edit: 'Edit' },
|
||||
chatHistory: {
|
||||
cancelEdit: 'Cancel edit',
|
||||
cancelEditTooltip: 'Cancel edit'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/graph/widgets/chatHistory/CopyButton.vue', () => ({
|
||||
default: {
|
||||
name: 'CopyButton',
|
||||
template: '<div class="mock-copy-button"></div>',
|
||||
props: ['text']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/graph/widgets/chatHistory/ResponseBlurb.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseBlurb',
|
||||
template: '<div class="mock-response-blurb"><slot /></div>',
|
||||
props: ['text']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ChatHistoryWidget.vue', () => {
|
||||
const mockHistory = JSON.stringify([
|
||||
{ prompt: 'Test prompt', response: 'Test response', response_id: '123' }
|
||||
])
|
||||
|
||||
const mountWidget = (props: { history: string; widget?: any }) => {
|
||||
return mount(ChatHistoryWidget, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['icon', 'aria-label']
|
||||
},
|
||||
ScrollPanel: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders chat history correctly', () => {
|
||||
const wrapper = mountWidget({ history: mockHistory })
|
||||
expect(wrapper.text()).toContain('Test prompt')
|
||||
expect(wrapper.text()).toContain('Test response')
|
||||
})
|
||||
|
||||
it('handles empty history', () => {
|
||||
const wrapper = mountWidget({ history: '[]' })
|
||||
expect(wrapper.find('.mb-4').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('edits previous prompts', () => {
|
||||
const mockWidget = {
|
||||
node: { widgets: [{ name: 'prompt', value: '' }] }
|
||||
}
|
||||
|
||||
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
|
||||
const vm = wrapper.vm as any
|
||||
vm.handleEdit(0)
|
||||
|
||||
expect(mockWidget.node.widgets[0].value).toContain('Test prompt')
|
||||
expect(mockWidget.node.widgets[0].value).toContain('starting_point_id')
|
||||
})
|
||||
|
||||
it('cancels editing correctly', () => {
|
||||
const mockWidget = {
|
||||
node: { widgets: [{ name: 'prompt', value: 'Original value' }] }
|
||||
}
|
||||
|
||||
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
|
||||
const vm = wrapper.vm as any
|
||||
|
||||
vm.handleEdit(0)
|
||||
vm.handleCancelEdit()
|
||||
|
||||
expect(mockWidget.node.widgets[0].value).toBe('Original value')
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<ScrollPanel
|
||||
ref="scrollPanelRef"
|
||||
class="min-h-[400px] w-full rounded-lg px-2 py-2 text-xs"
|
||||
:pt="{ content: { id: 'chat-scroll-content' } }"
|
||||
>
|
||||
<div v-for="(item, i) in parsedHistory" :key="i" class="mb-4">
|
||||
<!-- Prompt (user, right) -->
|
||||
<span
|
||||
:class="{
|
||||
'pointer-events-none opacity-40': editIndex !== null && i > editIndex
|
||||
}"
|
||||
>
|
||||
<div class="mb-1 flex justify-end">
|
||||
<div
|
||||
class="max-w-[80%] rounded-xl bg-smoke-300 px-4 py-1 text-right dark-theme:bg-smoke-800"
|
||||
>
|
||||
<div class="text-[12px] break-words">{{ item.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-1 mb-2 flex justify-end">
|
||||
<CopyButton :text="item.prompt" />
|
||||
<Button
|
||||
v-tooltip="
|
||||
editIndex === i ? $t('chatHistory.cancelEditTooltip') : null
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="h-4! w-4! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
|
||||
pt:icon:class="text-xs!"
|
||||
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
|
||||
:aria-label="
|
||||
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
|
||||
"
|
||||
@click="editIndex === i ? handleCancelEdit() : handleEdit(i)"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<!-- Response (LLM, left) -->
|
||||
<ResponseBlurb
|
||||
:text="item.response"
|
||||
:class="{
|
||||
'pointer-events-none opacity-25': editIndex !== null && i >= editIndex
|
||||
}"
|
||||
>
|
||||
<div v-html="nl2br(linkifyHtml(item.response))" />
|
||||
</ResponseBlurb>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
|
||||
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const { widget, history } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
history: string
|
||||
}>()
|
||||
|
||||
const editIndex = ref<number | null>(null)
|
||||
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
|
||||
|
||||
const parsedHistory = computed(() => JSON.parse(history || '[]'))
|
||||
|
||||
const findPromptInput = () =>
|
||||
widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
let promptInput = findPromptInput()
|
||||
const previousPromptInput = ref<string | null>(null)
|
||||
|
||||
const getPreviousResponseId = (index: number) =>
|
||||
index > 0 ? (parsedHistory.value[index - 1]?.response_id ?? '') : ''
|
||||
|
||||
const storePromptInput = () => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
if (!promptInput) return
|
||||
|
||||
previousPromptInput.value = String(promptInput.value)
|
||||
}
|
||||
|
||||
const setPromptInput = (text: string, previousResponseId?: string | null) => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
if (!promptInput) return
|
||||
|
||||
if (previousResponseId !== null) {
|
||||
promptInput.value = `<starting_point_id:${previousResponseId}>\n\n${text}`
|
||||
} else {
|
||||
promptInput.value = text
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (index: number) => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
editIndex.value = index
|
||||
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
|
||||
const promptText = parsedHistory.value[index]?.prompt ?? ''
|
||||
|
||||
storePromptInput()
|
||||
setPromptInput(promptText, prevResponseId)
|
||||
}
|
||||
|
||||
const resetEditingState = () => {
|
||||
editIndex.value = null
|
||||
}
|
||||
const handleCancelEdit = () => {
|
||||
resetEditingState()
|
||||
if (promptInput) {
|
||||
promptInput.value = previousPromptInput.value ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const scrollChatToBottom = () => {
|
||||
const content = document.getElementById('chat-scroll-content')
|
||||
if (content) {
|
||||
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const onHistoryChanged = () => {
|
||||
resetEditingState()
|
||||
void nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
watch(() => parsedHistory.value, onHistoryChanged, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
})
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="h-4! w-6! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
|
||||
pt:icon:class="text-xs!"
|
||||
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
|
||||
:aria-label="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { text } = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1024)
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<span>
|
||||
<div class="mb-1 flex justify-start">
|
||||
<div class="max-w-[80%] rounded-xl px-4 py-1">
|
||||
<div class="text-[12px] break-words">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-1 flex justify-start">
|
||||
<CopyButton :text="text" />
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -143,6 +143,7 @@ import type { CSSProperties, Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -168,15 +169,6 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
// Constants
|
||||
const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
const TIME_UNITS = {
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
@@ -193,7 +185,8 @@ const SUBMENU_CONFIG = {
|
||||
} as const
|
||||
|
||||
// Composables
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -230,11 +223,12 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
|
||||
openExternalLink(docsUrl)
|
||||
openExternalLink(
|
||||
buildDocsUrl('/installation/desktop', {
|
||||
includeLocale: true,
|
||||
platform: true
|
||||
})
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -286,7 +280,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -297,7 +291,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: 'Discord',
|
||||
action: () => {
|
||||
trackResourceClick('discord', true)
|
||||
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
openExternalLink(staticUrls.discord)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -308,7 +302,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.github'),
|
||||
action: () => {
|
||||
trackResourceClick('github', true)
|
||||
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||
openExternalLink(staticUrls.github)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -533,25 +527,19 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
||||
trackResourceClick('release_notes', true)
|
||||
void releaseStore.handleShowChangelog(release.version)
|
||||
const versionAnchor = formatVersionAnchor(release.version)
|
||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||
const changelogUrl = `${buildDocsUrl('/changelog', { includeLocale: true })}#${versionAnchor}`
|
||||
openExternalLink(changelogUrl)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||
openExternalLink(
|
||||
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Generate language-aware changelog URL
|
||||
const getChangelogUrl = (): string => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
return isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: SelectOption[]
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
title: 'Components/Input/MultiSelect/Accessibility',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
# MultiSelect Accessibility Guide
|
||||
|
||||
This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
- **Tab** - Focus the trigger button
|
||||
- **Enter/Space** - Open/close dropdown when focused
|
||||
- **Arrow Up/Down** - Navigate through options when dropdown is open
|
||||
- **Enter/Space** - Select/deselect options when navigating
|
||||
- **Escape** - Close dropdown
|
||||
|
||||
## Screen Reader Support
|
||||
|
||||
- Uses \`role="combobox"\` to identify as dropdown
|
||||
- \`aria-haspopup="listbox"\` indicates popup contains list
|
||||
- \`aria-expanded\` shows dropdown state
|
||||
- \`aria-label\` provides accessible name with i18n fallback
|
||||
- Selected count announced to screen readers
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
1. **Tab Navigation**: Use Tab key to focus the component
|
||||
2. **Keyboard Opening**: Press Enter or Space to open dropdown
|
||||
3. **Option Navigation**: Use Arrow keys to navigate options
|
||||
4. **Selection**: Press Enter/Space to select options
|
||||
5. **Closing**: Press Escape to close dropdown
|
||||
6. **Screen Reader**: Test with screen reader software
|
||||
|
||||
Try these stories with keyboard-only navigation!
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'Label for the trigger button'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Show search box in dropdown header'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Show selected count in dropdown header'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Show clear all button in dropdown header'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const frameworkOptions = [
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' },
|
||||
{ name: 'TypeScript', value: 'typescript' },
|
||||
{ name: 'JavaScript', value: 'javascript' }
|
||||
]
|
||||
|
||||
export const KeyboardNavigationDemo: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selectedFrameworks = ref<SelectOption[]>([])
|
||||
const searchQuery = ref('')
|
||||
|
||||
return {
|
||||
args: {
|
||||
...args,
|
||||
options: frameworkOptions,
|
||||
modelValue: selectedFrameworks,
|
||||
'onUpdate:modelValue': (value: SelectOption[]) => {
|
||||
selectedFrameworks.value = value
|
||||
},
|
||||
'onUpdate:searchQuery': (value: string) => {
|
||||
searchQuery.value = value
|
||||
}
|
||||
},
|
||||
selectedFrameworks,
|
||||
searchQuery
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
|
||||
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
|
||||
Use your keyboard to navigate this MultiSelect:
|
||||
</p>
|
||||
<ol class="text-sm text-smoke-600 list-decimal list-inside space-y-1">
|
||||
<li><strong>Tab</strong> to focus the dropdown</li>
|
||||
<li><strong>Enter/Space</strong> to open dropdown</li>
|
||||
<li><strong>Arrow Up/Down</strong> to navigate options</li>
|
||||
<li><strong>Enter/Space</strong> to select options</li>
|
||||
<li><strong>Escape</strong> to close dropdown</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700">
|
||||
Select Frameworks (Keyboard Navigation Test)
|
||||
</label>
|
||||
<MultiSelect v-bind="args" class="w-80" />
|
||||
<p class="text-xs text-smoke-500">
|
||||
Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Choose Frameworks',
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const ScreenReaderFriendly: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selectedColors = ref<SelectOption[]>([])
|
||||
const selectedSizes = ref<SelectOption[]>([])
|
||||
|
||||
const colorOptions = [
|
||||
{ name: 'Red', value: 'red' },
|
||||
{ name: 'Blue', value: 'blue' },
|
||||
{ name: 'Green', value: 'green' },
|
||||
{ name: 'Yellow', value: 'yellow' }
|
||||
]
|
||||
|
||||
const sizeOptions = [
|
||||
{ name: 'Small', value: 'sm' },
|
||||
{ name: 'Medium', value: 'md' },
|
||||
{ name: 'Large', value: 'lg' },
|
||||
{ name: 'Extra Large', value: 'xl' }
|
||||
]
|
||||
|
||||
return {
|
||||
selectedColors,
|
||||
selectedSizes,
|
||||
colorOptions,
|
||||
sizeOptions,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-6 p-4">
|
||||
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
|
||||
<p class="text-sm text-smoke-600 mb-2">
|
||||
These dropdowns have proper ARIA attributes and labels for screen readers:
|
||||
</p>
|
||||
<ul class="text-sm text-smoke-600 list-disc list-inside space-y-1">
|
||||
<li><code>role="combobox"</code> identifies as dropdown</li>
|
||||
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
|
||||
<li><code>aria-expanded</code> shows open/closed state</li>
|
||||
<li><code>aria-label</code> provides accessible name</li>
|
||||
<li>Selection count announced to assistive technology</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700">
|
||||
Color Preferences
|
||||
</label>
|
||||
<MultiSelect
|
||||
v-model="selectedColors"
|
||||
:options="colorOptions"
|
||||
label="Select colors"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-smoke-500" aria-live="polite">
|
||||
{{ selectedColors.length }} color(s) selected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700">
|
||||
Size Preferences
|
||||
</label>
|
||||
<MultiSelect
|
||||
v-model="selectedSizes"
|
||||
:options="sizeOptions"
|
||||
label="Select sizes"
|
||||
:show-selected-count="true"
|
||||
:show-search-box="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-smoke-500" aria-live="polite">
|
||||
{{ selectedSizes.length }} size(s) selected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const FocusManagement: Story = {
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selectedItems = ref<SelectOption[]>([])
|
||||
const focusTestOptions = [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' },
|
||||
{ name: 'Option C', value: 'c' }
|
||||
]
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
focusTestOptions,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">🎯 Focus Management Test</h3>
|
||||
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
|
||||
Test focus behavior with multiple form elements:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 mb-1">
|
||||
Before MultiSelect
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Previous field"
|
||||
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 mb-1">
|
||||
MultiSelect (Test Focus Ring)
|
||||
</label>
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
:options="focusTestOptions"
|
||||
label="Focus test dropdown"
|
||||
:show-selected-count="true"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 mb-1">
|
||||
After MultiSelect
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Next field"
|
||||
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Submit Button
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-smoke-600 mt-4">
|
||||
<strong>Test:</strong> Tab through all elements and verify focus rings are visible and logical.
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AccessibilityChecklist: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold mb-4">♿ MultiSelect Accessibility Checklist</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Color Contrast:</strong> Meets WCAG AA requirements</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
|
||||
<ol class="space-y-2 text-sm list-decimal list-inside">
|
||||
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
|
||||
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
|
||||
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
|
||||
<li><strong>Tab Order:</strong> Verify logical progression</li>
|
||||
<li><strong>Announcements:</strong> Check state changes are announced</li>
|
||||
<li><strong>Escape Behavior:</strong> Escape always closes dropdown</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
|
||||
<p class="text-sm text-smoke-700 dark-theme:text-smoke-300">
|
||||
Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above.
|
||||
If you can successfully navigate and make selections, the accessibility implementation is working!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export const Default: Story = {
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export const WithPreselectedValues: Story = {
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ export const MultipleSelectors: Story = {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<div class="p-4 bg-base-background rounded">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
|
||||
@@ -13,12 +13,77 @@
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
:pt="{
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'h-10 relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
? 'border-node-component-border'
|
||||
: 'border-transparent',
|
||||
'focus-within:border-node-component-border',
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class:
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
},
|
||||
dropdown: {
|
||||
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
showSearchBox || showSelectedCount || showClearButton
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg py-2 px-2',
|
||||
'bg-base-background',
|
||||
'text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'flex gap-2 items-center h-10 px-2 rounded-lg',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
context?.focused &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
pcOptionCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.multiSelectDropdown')"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
:tabindex="0"
|
||||
tabindex="0"
|
||||
>
|
||||
<template
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
@@ -39,7 +104,7 @@
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="px-1 text-sm text-neutral-400 dark-theme:text-zinc-500"
|
||||
class="px-1 text-sm text-base-foreground"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
@@ -52,22 +117,22 @@
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
size="fit-content"
|
||||
class="text-sm text-blue-500 dark-theme:text-blue-600"
|
||||
class="text-sm text-text-primary"
|
||||
@click.stop="selectedItems = []"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
<div class="my-4 h-px bg-border-default"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-smoke-200">
|
||||
<span class="text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 text-xs font-semibold text-white dark-theme:bg-blue-500"
|
||||
class="pointer-events-none absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
@@ -75,7 +140,7 @@
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
@@ -85,8 +150,8 @@
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
? 'bg-primary-background'
|
||||
: 'bg-secondary-background'
|
||||
"
|
||||
>
|
||||
<i
|
||||
@@ -203,70 +268,4 @@ const filteredOptions = computed(() => {
|
||||
|
||||
return [...selectedButNotInResults, ...searchResults]
|
||||
})
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'h-10 relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount.value > 0
|
||||
? 'border-blue-400 dark-theme:border-blue-500'
|
||||
: 'border-transparent',
|
||||
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
)
|
||||
}),
|
||||
labelContainer: {
|
||||
class:
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
|
||||
},
|
||||
label: {
|
||||
class: 'p-0'
|
||||
},
|
||||
dropdown: {
|
||||
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg py-2 px-2',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'flex gap-2 items-center h-10 px-2 rounded-lg',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
{
|
||||
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
},
|
||||
pcOptionCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
style: { display: 'none' }
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :class="wrapperStyle" @click="focusInput">
|
||||
<i class="icon-[lucide--search]" :class="iconColorStyle" />
|
||||
<i class="icon-[lucide--search] text-muted-foreground" />
|
||||
<InputText
|
||||
ref="input"
|
||||
v-model="internalSearchQuery"
|
||||
@@ -12,7 +12,7 @@
|
||||
"
|
||||
type="text"
|
||||
unstyled
|
||||
:class="inputStyle"
|
||||
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,18 +72,13 @@ const focusInput = () => {
|
||||
onMounted(() => autofocus && focusInput())
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const baseClasses = [
|
||||
'relative flex w-full items-center gap-2',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'cursor-text'
|
||||
]
|
||||
const baseClasses =
|
||||
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
|
||||
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
...baseClasses,
|
||||
'rounded p-2',
|
||||
'border border-solid',
|
||||
'border-zinc-200 dark-theme:border-zinc-700'
|
||||
baseClasses,
|
||||
'rounded p-2 border border-solid border-border-default'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,20 +88,6 @@ const wrapperStyle = computed(() => {
|
||||
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||
}[size]
|
||||
|
||||
return cn(...baseClasses, 'rounded-lg', sizeClasses)
|
||||
})
|
||||
|
||||
const inputStyle = computed(() => {
|
||||
return cn(
|
||||
'absolute inset-0 w-full h-full pl-11',
|
||||
'border-none outline-none bg-transparent',
|
||||
'text-sm text-neutral dark-theme:text-white'
|
||||
)
|
||||
})
|
||||
|
||||
const iconColorStyle = computed(() => {
|
||||
return cn(
|
||||
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
|
||||
)
|
||||
return cn(baseClasses, 'rounded-lg', sizeClasses)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
interface SingleSelectProps {
|
||||
label?: string
|
||||
options?: Array<{ name: string; value: string }>
|
||||
listMaxHeight?: string
|
||||
popoverMinWidth?: string
|
||||
popoverMaxWidth?: string
|
||||
modelValue?: string | null
|
||||
}
|
||||
|
||||
const meta: Meta<SingleSelectProps> = {
|
||||
title: 'Components/Input/SingleSelect/Accessibility',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
# SingleSelect Accessibility Guide
|
||||
|
||||
This SingleSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
- **Tab** - Focus the trigger button
|
||||
- **Enter/Space** - Open/close dropdown when focused
|
||||
- **Arrow Up/Down** - Navigate through options when dropdown is open
|
||||
- **Enter/Space** - Select option when navigating
|
||||
- **Escape** - Close dropdown
|
||||
|
||||
## Screen Reader Support
|
||||
|
||||
- Uses \`role="combobox"\` to identify as dropdown
|
||||
- \`aria-haspopup="listbox"\` indicates popup contains list
|
||||
- \`aria-expanded\` shows dropdown state
|
||||
- \`aria-label\` provides accessible name with i18n fallback
|
||||
- Selected option announced to screen readers
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
1. **Tab Navigation**: Use Tab key to focus the component
|
||||
2. **Keyboard Opening**: Press Enter or Space to open dropdown
|
||||
3. **Option Navigation**: Use Arrow keys to navigate options
|
||||
4. **Selection**: Press Enter/Space to select an option
|
||||
5. **Closing**: Press Escape to close dropdown
|
||||
6. **Screen Reader**: Test with screen reader software
|
||||
|
||||
Try these stories with keyboard-only navigation!
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'Label for the trigger button'
|
||||
},
|
||||
listMaxHeight: {
|
||||
control: 'text',
|
||||
description: 'Maximum height of dropdown list'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sortOptions = [
|
||||
{ name: 'Name A → Z', value: 'name-asc' },
|
||||
{ name: 'Name Z → A', value: 'name-desc' },
|
||||
{ name: 'Most Popular', value: 'popular' },
|
||||
{ name: 'Most Recent', value: 'recent' },
|
||||
{ name: 'File Size', value: 'size' }
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ name: 'High Priority', value: 'high' },
|
||||
{ name: 'Medium Priority', value: 'medium' },
|
||||
{ name: 'Low Priority', value: 'low' },
|
||||
{ name: 'No Priority', value: 'none' }
|
||||
]
|
||||
|
||||
export const KeyboardNavigationDemo: Story = {
|
||||
render: (args) => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selectedSort = ref<string | null>(null)
|
||||
const selectedPriority = ref<string | null>('medium')
|
||||
|
||||
return {
|
||||
args,
|
||||
selectedSort,
|
||||
selectedPriority,
|
||||
sortOptions,
|
||||
priorityOptions
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-6 p-4">
|
||||
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
|
||||
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
|
||||
Use your keyboard to navigate these SingleSelect dropdowns:
|
||||
</p>
|
||||
<ol class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-decimal list-inside space-y-1">
|
||||
<li><strong>Tab</strong> to focus the dropdown</li>
|
||||
<li><strong>Enter/Space</strong> to open dropdown</li>
|
||||
<li><strong>Arrow Up/Down</strong> to navigate options</li>
|
||||
<li><strong>Enter/Space</strong> to select option</li>
|
||||
<li><strong>Escape</strong> to close dropdown</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
|
||||
Sort Order
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedSort"
|
||||
:options="sortOptions"
|
||||
label="Choose sort order"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-smoke-500">
|
||||
Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
|
||||
Task Priority (With Icon)
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedPriority"
|
||||
:options="priorityOptions"
|
||||
label="Set priority level"
|
||||
class="w-full"
|
||||
>
|
||||
<template #icon>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<p class="text-xs text-smoke-500">
|
||||
Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const ScreenReaderFriendly: Story = {
|
||||
render: (args) => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selectedLanguage = ref<string | null>('en')
|
||||
const selectedTheme = ref<string | null>(null)
|
||||
|
||||
const languageOptions = [
|
||||
{ name: 'English', value: 'en' },
|
||||
{ name: 'Spanish', value: 'es' },
|
||||
{ name: 'French', value: 'fr' },
|
||||
{ name: 'German', value: 'de' },
|
||||
{ name: 'Japanese', value: 'ja' }
|
||||
]
|
||||
|
||||
const themeOptions = [
|
||||
{ name: 'Light Theme', value: 'light' },
|
||||
{ name: 'Dark Theme', value: 'dark' },
|
||||
{ name: 'Auto (System)', value: 'auto' },
|
||||
{ name: 'High Contrast', value: 'contrast' }
|
||||
]
|
||||
|
||||
return {
|
||||
selectedLanguage,
|
||||
selectedTheme,
|
||||
languageOptions,
|
||||
themeOptions,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-6 p-4">
|
||||
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
|
||||
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-2">
|
||||
These dropdowns have proper ARIA attributes and labels for screen readers:
|
||||
</p>
|
||||
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-disc list-inside space-y-1">
|
||||
<li><code>role="combobox"</code> identifies as dropdown</li>
|
||||
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
|
||||
<li><code>aria-expanded</code> shows open/closed state</li>
|
||||
<li><code>aria-label</code> provides accessible name</li>
|
||||
<li>Selected option value announced to assistive technology</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="language-label">
|
||||
Preferred Language
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedLanguage"
|
||||
:options="languageOptions"
|
||||
label="Select language"
|
||||
class="w-full"
|
||||
aria-labelledby="language-label"
|
||||
/>
|
||||
<p class="text-xs text-smoke-500" aria-live="polite">
|
||||
Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="theme-label">
|
||||
Interface Theme
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedTheme"
|
||||
:options="themeOptions"
|
||||
label="Select theme"
|
||||
class="w-full"
|
||||
aria-labelledby="theme-label"
|
||||
/>
|
||||
<p class="text-xs text-smoke-500" aria-live="polite">
|
||||
Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-2">🎧 Screen Reader Testing Tips</h4>
|
||||
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 space-y-1">
|
||||
<li>• Listen for role announcements when focusing</li>
|
||||
<li>• Verify dropdown state changes are announced</li>
|
||||
<li>• Check that selected values are spoken clearly</li>
|
||||
<li>• Ensure option navigation is announced</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const FormIntegration: Story = {
|
||||
render: (args) => ({
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const formData = ref({
|
||||
category: null as string | null,
|
||||
status: 'draft' as string | null,
|
||||
assignee: null as string | null
|
||||
})
|
||||
|
||||
const categoryOptions = [
|
||||
{ name: 'Bug Report', value: 'bug' },
|
||||
{ name: 'Feature Request', value: 'feature' },
|
||||
{ name: 'Documentation', value: 'docs' },
|
||||
{ name: 'Question', value: 'question' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ name: 'Draft', value: 'draft' },
|
||||
{ name: 'Review', value: 'review' },
|
||||
{ name: 'Approved', value: 'approved' },
|
||||
{ name: 'Published', value: 'published' }
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ name: 'Alice Johnson', value: 'alice' },
|
||||
{ name: 'Bob Smith', value: 'bob' },
|
||||
{ name: 'Carol Davis', value: 'carol' },
|
||||
{ name: 'David Wilson', value: 'david' }
|
||||
]
|
||||
|
||||
const handleSubmit = () => {
|
||||
alert('Form submitted with: ' + JSON.stringify(formData.value, null, 2))
|
||||
}
|
||||
|
||||
return {
|
||||
formData,
|
||||
categoryOptions,
|
||||
statusOptions,
|
||||
assigneeOptions,
|
||||
handleSubmit,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="max-w-2xl mx-auto p-6">
|
||||
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-2">📝 Form Integration Test</h3>
|
||||
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300">
|
||||
Test keyboard navigation through a complete form with SingleSelect components.
|
||||
Tab order should be logical and all elements should be accessible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter a title"
|
||||
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
|
||||
Category *
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="formData.category"
|
||||
:options="categoryOptions"
|
||||
label="Select category"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="formData.status"
|
||||
:options="statusOptions"
|
||||
label="Select status"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
|
||||
Assignee
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="formData.assignee"
|
||||
:options="assigneeOptions"
|
||||
label="Select assignee"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows="4"
|
||||
placeholder="Enter description"
|
||||
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-smoke-300 dark-theme:bg-smoke-600 text-smoke-700 dark-theme:text-smoke-200 rounded-md hover:bg-smoke-400 dark-theme:hover:bg-smoke-500 focus:ring-2 focus:ring-smoke-500 focus:ring-offset-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Current Form Data:</h4>
|
||||
<pre class="text-xs text-smoke-600 dark-theme:text-smoke-300">{{ JSON.stringify(formData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AccessibilityChecklist: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold mb-4">♿ SingleSelect Accessibility Checklist</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2">✓</span>
|
||||
<span><strong>Form Integration:</strong> Works properly in forms with other elements</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
|
||||
<ol class="space-y-2 text-sm list-decimal list-inside">
|
||||
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
|
||||
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
|
||||
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
|
||||
<li><strong>Tab Order:</strong> Verify logical progression in forms</li>
|
||||
<li><strong>Announcements:</strong> Check state changes are announced</li>
|
||||
<li><strong>Selection:</strong> Verify selected value is announced</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
|
||||
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
|
||||
Close your eyes, use only the keyboard, and try to select different options from any dropdown above.
|
||||
If you can successfully navigate and make selections, the accessibility implementation is working!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">⚡ Performance Note</h4>
|
||||
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
|
||||
These accessibility features are built into the component with minimal performance impact.
|
||||
The ARIA attributes and keyboard handlers add less than 1KB to the bundle size.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export const Default: Story = {
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@ export const WithIcon: Story = {
|
||||
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<div class="mt-4 p-3 bg-base-background rounded">
|
||||
<p class="text-sm">Selected: {{ selected }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,69 @@
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
:pt="{
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
// disabled
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
]
|
||||
}),
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
// Right chevron touch area
|
||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||
},
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 p-2 rounded-lg',
|
||||
'bg-base-background text-base-foreground',
|
||||
'border border-solid border-border-default'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: `max-height: min(${listMaxHeight}, 50vh)`,
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class:
|
||||
// Same list tone/size as MultiSelect
|
||||
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: cn(
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 px-2 py-3 rounded',
|
||||
'hover:bg-secondary-background-hover',
|
||||
// Add focus state for keyboard navigation
|
||||
context.focused && 'bg-secondary-background-hover',
|
||||
// Selected state + check icon
|
||||
context.selected &&
|
||||
'bg-secondary-background-selected hover:bg-secondary-background-selected'
|
||||
)
|
||||
}),
|
||||
optionLabel: {
|
||||
class: 'truncate'
|
||||
},
|
||||
optionGroupLabel: {
|
||||
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 py-2 text-sm text-muted-foreground'
|
||||
}
|
||||
}"
|
||||
:aria-label="label || t('g.singleSelectDropdown')"
|
||||
role="combobox"
|
||||
:aria-expanded="false"
|
||||
@@ -22,15 +84,15 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-smoke-200"
|
||||
class="text-base-foreground"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-smoke-200">
|
||||
<span v-else class="text-base-foreground">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -38,7 +100,7 @@
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
@@ -48,10 +110,7 @@
|
||||
:style="optionStyle"
|
||||
>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i
|
||||
v-if="selected"
|
||||
class="icon-[lucide--check] text-neutral-600 dark-theme:text-white"
|
||||
/>
|
||||
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
@@ -119,73 +178,4 @@ const optionStyle = computed(() => {
|
||||
|
||||
return styles.join('; ')
|
||||
})
|
||||
|
||||
/**
|
||||
* Unstyled + PT API only
|
||||
* - No background/border (same as page background)
|
||||
* - Text/icon scale: compact size matching MultiSelect
|
||||
*/
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// container
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
|
||||
// disabled
|
||||
{ 'opacity-60 cursor-default': props.disabled }
|
||||
]
|
||||
}),
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
// Right chevron touch area
|
||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||
},
|
||||
overlay: {
|
||||
class: cn(
|
||||
'mt-2 p-2 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
|
||||
)
|
||||
},
|
||||
listContainer: () => ({
|
||||
style: `max-height: min(${listMaxHeight}, 50vh)`,
|
||||
class: 'scrollbar-custom'
|
||||
}),
|
||||
list: {
|
||||
class:
|
||||
// Same list tone/size as MultiSelect
|
||||
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||
},
|
||||
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
|
||||
class: [
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 px-2 py-3 rounded',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Selected state + check icon
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
||||
// Add focus state for keyboard navigation
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
|
||||
]
|
||||
}),
|
||||
optionLabel: {
|
||||
class: 'truncate'
|
||||
},
|
||||
optionGroupLabel: {
|
||||
class:
|
||||
'px-3 py-2 text-xs uppercase tracking-wide text-zinc-500 dark-theme:text-zinc-400'
|
||||
},
|
||||
emptyMessage: {
|
||||
class: 'px-3 py-2 text-sm text-zinc-500 dark-theme:text-zinc-400'
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
v-model:background-render-mode="viewer.backgroundRenderMode.value"
|
||||
v-model:fov="viewer.fov.value"
|
||||
:has-background-image="viewer.hasBackgroundImage.value"
|
||||
:disable-background-upload="viewer.isStandaloneMode.value"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,13 +92,15 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
node: LGraphNode
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
}>()
|
||||
|
||||
const viewerContentRef = ref<HTMLDivElement>()
|
||||
@@ -106,20 +109,30 @@ const mainContentRef = ref<HTMLDivElement>()
|
||||
const maximized = ref(false)
|
||||
const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
const isStandaloneMode = !props.node && props.modelUrl
|
||||
|
||||
const viewer = props.node
|
||||
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
: useLoad3dViewer()
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
await viewer.handleModelDrop(file)
|
||||
},
|
||||
disabled: viewer.isPreview
|
||||
disabled: viewer.isPreview.value || isStandaloneMode
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source && containerRef.value) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
if (!containerRef.value) return
|
||||
|
||||
if (isStandaloneMode && props.modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||
} else if (props.node) {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewerContentRef.value) {
|
||||
@@ -150,7 +163,9 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
viewer.restoreInitialState()
|
||||
if (!isStandaloneMode) {
|
||||
viewer.restoreInitialState()
|
||||
}
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<div v-if="!hasBackgroundImage && !disableBackgroundUpload">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="$t('load3d.uploadBackgroundImage')"
|
||||
@@ -74,6 +74,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
|
||||
|
||||
defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
disableBackgroundUpload?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
97
src/components/maskeditor/BrushCursor.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
width: `${brushSize}px`,
|
||||
height: `${brushSize}px`,
|
||||
left: `${brushLeft}px`,
|
||||
top: `${brushTop}px`,
|
||||
borderRadius: borderRadius,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}"
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { containerRef } = defineProps<{
|
||||
containerRef?: HTMLElement
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const brushOpacity = computed(() => {
|
||||
return store.brushVisible ? '1' : '0'
|
||||
})
|
||||
|
||||
const brushRadius = computed(() => {
|
||||
return store.brushSettings.size * store.zoomRatio
|
||||
})
|
||||
|
||||
const brushSize = computed(() => {
|
||||
return brushRadius.value * 2
|
||||
})
|
||||
|
||||
const brushLeft = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetLeft = dialogRect?.left || 0
|
||||
return (
|
||||
store.cursorPoint.x +
|
||||
store.panOffset.x -
|
||||
brushRadius.value -
|
||||
dialogOffsetLeft
|
||||
)
|
||||
})
|
||||
|
||||
const brushTop = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetTop = dialogRect?.top || 0
|
||||
return (
|
||||
store.cursorPoint.y +
|
||||
store.panOffset.y -
|
||||
brushRadius.value -
|
||||
dialogOffsetTop
|
||||
)
|
||||
})
|
||||
|
||||
const borderRadius = computed(() => {
|
||||
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
|
||||
})
|
||||
|
||||
const gradientVisible = computed(() => {
|
||||
return store.brushPreviewGradientVisible
|
||||
})
|
||||
|
||||
const gradientBackground = computed(() => {
|
||||
const hardness = store.brushSettings.hardness
|
||||
|
||||
if (hardness === 1) {
|
||||
return 'rgba(255, 0, 0, 0.5)'
|
||||
}
|
||||
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
|
||||
return `radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)`
|
||||
})
|
||||
</script>
|
||||
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Rect)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.smoothingPrecision')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.smoothingPrecision"
|
||||
@update:model-value="onSmoothingPrecisionChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
const onColorChange = (event: Event) => {
|
||||
store.rgbColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onThicknessChange = (value: number) => {
|
||||
store.setBrushSize(value)
|
||||
}
|
||||
|
||||
const onOpacityChange = (value: number) => {
|
||||
store.setBrushOpacity(value)
|
||||
}
|
||||
|
||||
const onHardnessChange = (value: number) => {
|
||||
store.setBrushHardness(value)
|
||||
}
|
||||
|
||||
const onSmoothingPrecisionChange = (value: number) => {
|
||||
store.setBrushSmoothingPrecision(value)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
</script>
|
||||
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.colorSelectSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.colorSelectTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.selectionOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.selectionOpacity"
|
||||
@update:model-value="onSelectionOpacityChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.livePreview')"
|
||||
:model-value="store.colorSelectLivePreview"
|
||||
@update:model-value="onLivePreviewChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.applyToWholeImage')"
|
||||
:model-value="store.applyWholeImage"
|
||||
@update:model-value="onWholeImageChange"
|
||||
/>
|
||||
|
||||
<DropdownControl
|
||||
:label="t('maskEditor.method')"
|
||||
:options="methodOptions"
|
||||
:model-value="store.colorComparisonMethod"
|
||||
@update:model-value="onMethodChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.stopAtMask')"
|
||||
:model-value="store.maskBoundary"
|
||||
@update:model-value="onMaskBoundaryChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskTolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.maskTolerance"
|
||||
@update:model-value="onMaskToleranceChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setColorSelectTolerance(value)
|
||||
}
|
||||
|
||||
const onSelectionOpacityChange = (value: number) => {
|
||||
store.setSelectionOpacity(value)
|
||||
}
|
||||
|
||||
const onLivePreviewChange = (value: boolean) => {
|
||||
store.colorSelectLivePreview = value
|
||||
}
|
||||
|
||||
const onWholeImageChange = (value: boolean) => {
|
||||
store.applyWholeImage = value
|
||||
}
|
||||
|
||||
const onMethodChange = (value: string | number) => {
|
||||
store.colorComparisonMethod = value as ColorComparisonMethod
|
||||
}
|
||||
|
||||
const onMaskBoundaryChange = (value: boolean) => {
|
||||
store.maskBoundary = value
|
||||
}
|
||||
|
||||
const onMaskToleranceChange = (value: number) => {
|
||||
store.setMaskTolerance(value)
|
||||
}
|
||||
</script>
|
||||
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.layers') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskOpacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.maskOpacity"
|
||||
@update:model-value="onMaskOpacityChange"
|
||||
/>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskBlendingOptions')
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
|
||||
>
|
||||
<select
|
||||
class="maskEditor_sidePanelDropdown"
|
||||
:value="store.maskBlendMode"
|
||||
@change="onBlendModeChange"
|
||||
>
|
||||
<option value="black">{{ t('maskEditor.black') }}</option>
|
||||
<option value="white">{{ t('maskEditor.white') }}</option>
|
||||
<option value="negative">{{ t('maskEditor.negative') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
:style="{
|
||||
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="maskLayerVisible"
|
||||
@change="onMaskLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20" style="">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
|
||||
:disabled="store.activeLayer === 'mask'"
|
||||
@click="setActiveLayer('mask')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.paintLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
:style="{
|
||||
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="paintLayerVisible"
|
||||
@change="onPaintLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
|
||||
/>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{
|
||||
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
|
||||
display: showLayerButtons ? 'block' : 'none'
|
||||
}"
|
||||
:disabled="store.activeLayer === 'rgb'"
|
||||
@click="setActiveLayer('rgb')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.baseImageLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="baseImageLayerVisible"
|
||||
@change="onBaseImageLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<img
|
||||
class="maskEditor_sidePanelImageLayerImage"
|
||||
:src="baseImageSrc"
|
||||
:alt="t('maskEditor.baseLayerPreview')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
const maskLayerVisible = ref(true)
|
||||
const paintLayerVisible = ref(true)
|
||||
const baseImageLayerVisible = ref(true)
|
||||
|
||||
const baseImageSrc = computed(() => {
|
||||
return store.image?.src ?? ''
|
||||
})
|
||||
|
||||
const showLayerButtons = computed(() => {
|
||||
return store.currentTool === Tools.Eraser
|
||||
})
|
||||
|
||||
const onMaskLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
maskLayerVisible.value = checked
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onPaintLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
paintLayerVisible.value = checked
|
||||
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
if (rgbCanvas) {
|
||||
rgbCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onBaseImageLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
baseImageLayerVisible.value = checked
|
||||
|
||||
const imgCanvas = store.imgCanvas
|
||||
if (imgCanvas) {
|
||||
imgCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onMaskOpacityChange = (value: number) => {
|
||||
store.setMaskOpacity(value)
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = String(value)
|
||||
}
|
||||
|
||||
maskLayerVisible.value = value !== 0
|
||||
}
|
||||
|
||||
const onBlendModeChange = async (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
let blendMode: MaskBlendMode
|
||||
|
||||
switch (value) {
|
||||
case 'white':
|
||||
blendMode = MaskBlendMode.White
|
||||
break
|
||||
case 'negative':
|
||||
blendMode = MaskBlendMode.Negative
|
||||
break
|
||||
default:
|
||||
blendMode = MaskBlendMode.Black
|
||||
}
|
||||
|
||||
store.maskBlendMode = blendMode
|
||||
|
||||
await canvasManager.updateMaskColor()
|
||||
}
|
||||
|
||||
const setActiveLayer = (layer: ImageLayer) => {
|
||||
toolManager?.setActiveLayer(layer)
|
||||
}
|
||||
</script>
|
||||
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
ref="canvasContainerRef"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<canvas
|
||||
ref="imgCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="rgbCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="maskCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
||||
</div>
|
||||
|
||||
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ToolPanel
|
||||
v-if="initialized"
|
||||
ref="toolPanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
|
||||
<PointerZone
|
||||
v-if="initialized"
|
||||
:tool-manager="toolManager!"
|
||||
:pan-zoom="panZoom!"
|
||||
/>
|
||||
|
||||
<SidePanel
|
||||
v-if="initialized"
|
||||
ref="sidePanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrushCursor v-if="initialized" :container-ref="containerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushCursor from './BrushCursor.vue'
|
||||
import PointerZone from './PointerZone.vue'
|
||||
import SidePanel from './SidePanel.vue'
|
||||
import ToolPanel from './ToolPanel.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const loader = useMaskEditorLoader()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
const imgCanvasRef = ref<HTMLCanvasElement>()
|
||||
const maskCanvasRef = ref<HTMLCanvasElement>()
|
||||
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
||||
const canvasBackgroundRef = ref<HTMLDivElement>()
|
||||
|
||||
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
||||
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
const keyboard = useKeyboard()
|
||||
const panZoom = usePanAndZoom()
|
||||
|
||||
let toolManager: ReturnType<typeof useToolManager> | null = null
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const initUI = async () => {
|
||||
if (!containerRef.value) {
|
||||
console.error(
|
||||
'[MaskEditorContent] Cannot initialize - missing required refs'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!imgCanvasRef.value ||
|
||||
!maskCanvasRef.value ||
|
||||
!rgbCanvasRef.value ||
|
||||
!canvasContainerRef.value ||
|
||||
!canvasBackgroundRef.value
|
||||
) {
|
||||
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
|
||||
return
|
||||
}
|
||||
|
||||
store.maskCanvas = maskCanvasRef.value
|
||||
store.rgbCanvas = rgbCanvasRef.value
|
||||
store.imgCanvas = imgCanvasRef.value
|
||||
store.canvasContainer = canvasContainerRef.value
|
||||
store.canvasBackground = canvasBackgroundRef.value
|
||||
|
||||
try {
|
||||
await loader.loadFromNode(node)
|
||||
|
||||
toolManager = useToolManager(keyboard, panZoom)
|
||||
|
||||
const imageLoader = useImageLoader()
|
||||
const image = await imageLoader.loadImages()
|
||||
|
||||
await panZoom.initializeCanvasPanZoom(
|
||||
image,
|
||||
containerRef.value,
|
||||
toolPanelRef.value?.$el as HTMLElement | undefined,
|
||||
sidePanelRef.value?.$el as HTMLElement | undefined
|
||||
)
|
||||
|
||||
store.canvasHistory.saveInitialState()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('[MaskEditorContent] Initialization failed:', error)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyboard.addListeners()
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(async () => {
|
||||
if (panZoom) {
|
||||
await panZoom.invalidatePanZoom()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
void initUI()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
toolManager?.brushDrawing.saveBrushSettings()
|
||||
|
||||
keyboard?.removeListeners()
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maskEditor-dialog-root {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maskEditor-ui-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(#maskEditorCanvasContainer) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.paintBucketSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.paintBucketTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.fillOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.fillOpacity"
|
||||
@update:model-value="onFillOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setPaintBucketTolerance(value)
|
||||
}
|
||||
|
||||
const onFillOpacityChange = (value: number) => {
|
||||
store.setFillOpacity(value)
|
||||
}
|
||||
</script>
|
||||
95
src/components/maskeditor/PointerZone.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
class="w-[calc(100%-4rem-220px)] h-full"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager, panZoom } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
panZoom: ReturnType<typeof usePanAndZoom>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const pointerZoneRef = ref<HTMLDivElement>()
|
||||
|
||||
onMounted(() => {
|
||||
if (!pointerZoneRef.value) {
|
||||
console.error('[PointerZone] Pointer zone ref not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
store.pointerZone = pointerZoneRef.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.isPanning,
|
||||
(isPanning) => {
|
||||
if (!pointerZoneRef.value) return
|
||||
|
||||
if (isPanning) {
|
||||
pointerZoneRef.value.style.cursor = 'grabbing'
|
||||
} else {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerDown(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerMove(event)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
void toolManager.handlePointerUp(event)
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
store.brushVisible = false
|
||||
if (pointerZoneRef.value) {
|
||||
pointerZoneRef.value.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
panZoom.handleTouchStart(event)
|
||||
}
|
||||
|
||||
const handleTouchMove = async (event: TouchEvent) => {
|
||||
await panZoom.handleTouchMove(event)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
panZoom.handleTouchEnd(event)
|
||||
}
|
||||
|
||||
const handleWheel = async (event: WheelEvent) => {
|
||||
await panZoom.zoom(event)
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
}
|
||||
</script>
|
||||
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="maskEditor_sidePanel">
|
||||
<div class="maskEditor_sidePanelContainer">
|
||||
<component :is="currentPanelComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushSettingsPanel from './BrushSettingsPanel.vue'
|
||||
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
|
||||
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
|
||||
|
||||
const currentPanelComponent = computed<Component>(() => {
|
||||
const tool = useMaskEditorStore().currentTool
|
||||
|
||||
if (tool === Tools.MaskBucket) {
|
||||
return PaintBucketSettingsPanel
|
||||
} else if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
} else {
|
||||
return BrushSettingsPanel
|
||||
}
|
||||
})
|
||||
</script>
|
||||
24
src/components/maskeditor/SidePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
|
||||
>
|
||||
<div class="w-full min-h-full">
|
||||
<SettingsPanelContainer />
|
||||
|
||||
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
|
||||
|
||||
<ImageLayerSettingsPanel :tool-manager="toolManager" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
|
||||
import SettingsPanelContainer from './SettingsPanelContainer.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
</script>
|
||||
63
src/components/maskeditor/ToolPanel.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="h-full z-[8888] flex flex-col justify-between bg-comfy-menu-bg">
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
]"
|
||||
@click="onToolSelect(tool)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
v-html="iconsHtml[tool]"
|
||||
></div>
|
||||
<div class="maskEditor_toolPanelIndicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-secondary-background-hover"
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
|
||||
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
toolManager.switchTool(tool)
|
||||
}
|
||||
|
||||
const currentTool = computed(() => store.currentTool)
|
||||
|
||||
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
|
||||
const dimensionsText = computed(() => {
|
||||
const img = store.image
|
||||
return img ? `${img.width}x${img.height}` : ' '
|
||||
})
|
||||
|
||||
const onResetZoom = () => {
|
||||
store.resetZoom()
|
||||
}
|
||||
</script>
|
||||
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<select
|
||||
class="absolute right-0 h-6 px-1.5 rounded-md border border-border-default transition-colors duration-100 bg-secondary-background focus:outline focus:outline-node-component-border"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface DropdownOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
options: string[] | DropdownOption[]
|
||||
modelValue: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed((): DropdownOption[] => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option }
|
||||
}
|
||||
return option
|
||||
})
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="maskEditor_sidePanelBrushRange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<label class="maskEditor_sidePanelToggleContainer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelToggleCheckbox"
|
||||
:checked="modelValue"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelToggleSwitch"></div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
emit('update:modelValue', checked)
|
||||
}
|
||||
</script>
|
||||
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.undo')"
|
||||
@click="onUndo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.redo')"
|
||||
@click="onRedo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onClear">
|
||||
{{ t('maskEditor.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
:label="saveButtonText"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:disabled="!saveEnabled"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
|
||||
const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
canvasTools.clearMask()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saveButtonText.value = t('g.saving')
|
||||
saveEnabled.value = false
|
||||
|
||||
try {
|
||||
store.brushVisible = false
|
||||
await saver.save()
|
||||
dialogStore.closeDialog()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Save failed:', error)
|
||||
store.brushVisible = true
|
||||
saveButtonText.value = t('g.save')
|
||||
saveEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogStore.closeDialog({ key: 'global-mask-editor' })
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||