mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
Compare commits
5 Commits
main
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c8baf899 | ||
|
|
2ddb9c6443 | ||
|
|
841b3e89a7 | ||
|
|
0893becc6d | ||
|
|
f233dd7291 |
@@ -69,11 +69,6 @@ export const TestIds = {
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
|
||||
@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
test('Shows missing item label and locate action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
test.describe('List behavior', () => {
|
||||
test('Clicking the missing item label navigates canvas to the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -122,63 +78,25 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
await getMediaRow(comfyPage).getByRole('button').first().click()
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeHidden()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
missingMediaTitle: 'Missing Inputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one missing media item per referencing node and locates the selected node', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'3': 'First Loader',
|
||||
'4': 'Second Loader'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
|
||||
const { user } = renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'PreviewImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
}
|
||||
] satisfies MissingMediaCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
)
|
||||
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
|
||||
@@ -256,7 +256,6 @@
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -3667,21 +3667,7 @@
|
||||
"refreshFailed": "Failed to refresh missing models. Please try again."
|
||||
},
|
||||
"missingMedia": {
|
||||
"missingMediaTitle": "Missing Inputs",
|
||||
"image": "Images",
|
||||
"video": "Videos",
|
||||
"audio": "Audio",
|
||||
"locateNode": "Locate node",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"uploadFile": "Upload {type}",
|
||||
"uploading": "Uploading...",
|
||||
"uploaded": "Uploaded",
|
||||
"selectedFromLibrary": "Selected from library",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"or": "OR"
|
||||
"missingMediaTitle": "Missing Inputs"
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
@@ -3732,6 +3718,7 @@
|
||||
},
|
||||
"missing_media": {
|
||||
"displayMessage": "A required media input has no file selected.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitleOne": "Media input missing",
|
||||
"toastTitleMany": "Missing media inputs",
|
||||
"toastMessageWithNode": "{nodeName} is missing a required media file.",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveMissingErrorMessage,
|
||||
resolveMissingMediaItemLabel,
|
||||
resolveRunErrorMessage
|
||||
} from './errorMessageResolver'
|
||||
import type { NodeValidationError } from './types'
|
||||
@@ -1579,6 +1580,32 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
source: { nodeType: 'LoadImage', widgetName: 'image' },
|
||||
displayItemLabel: 'Load Image - image'
|
||||
},
|
||||
{
|
||||
source: {
|
||||
nodeDisplayName: 'Custom Loader',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
},
|
||||
displayItemLabel: 'Custom Loader - image'
|
||||
},
|
||||
{
|
||||
source: { nodeType: '', widgetName: '' },
|
||||
displayItemLabel: 'This node - unknown input'
|
||||
}
|
||||
] as const)(
|
||||
'resolves missing media item labels from $source',
|
||||
({ source, displayItemLabel }) => {
|
||||
expect(resolveMissingMediaItemLabel(source)).toEqual({
|
||||
displayItemLabel
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
[
|
||||
'image',
|
||||
|
||||
@@ -5,13 +5,15 @@ import type {
|
||||
} from './types'
|
||||
|
||||
import { resolveExecutionErrorMessage } from './executionErrorResolver'
|
||||
import { resolveMissingErrorMessage } from './missingErrorResolver'
|
||||
import { resolvePromptErrorMessage } from './promptErrorResolver'
|
||||
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
|
||||
|
||||
// Public facade for error catalog resolution. Source-specific resolver modules
|
||||
// own the actual matching/copy rules so this file stays as the routing boundary.
|
||||
export { resolveMissingErrorMessage }
|
||||
export {
|
||||
resolveMissingErrorMessage,
|
||||
resolveMissingMediaItemLabel
|
||||
} from './missingErrorResolver'
|
||||
|
||||
export function resolveRunErrorMessage(
|
||||
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
MissingErrorMessageSource,
|
||||
ResolvedMissingErrorMessage
|
||||
} from './types'
|
||||
import { translateCatalogMessage } from './catalogI18n'
|
||||
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
@@ -255,6 +255,12 @@ type MissingMediaSource = Extract<
|
||||
{ kind: 'missing_media' }
|
||||
>
|
||||
|
||||
interface MissingMediaItemLabelSource {
|
||||
nodeDisplayName?: string
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
}
|
||||
|
||||
function getMissingMediaItems(source: MissingMediaSource) {
|
||||
return source.groups.flatMap((group) => group.items)
|
||||
}
|
||||
@@ -272,6 +278,27 @@ function resolveMissingMediaDisplayMessage(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveMissingMediaItemLabel(
|
||||
source: MissingMediaItemLabelSource
|
||||
): { displayItemLabel: string } {
|
||||
const nodeName = normalizeNodeName(
|
||||
source.nodeDisplayName ||
|
||||
formatNodeTypeName(source.nodeType ?? '') ||
|
||||
undefined
|
||||
)
|
||||
const inputName =
|
||||
source.widgetName?.trim() ||
|
||||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
|
||||
|
||||
return {
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.itemLabel',
|
||||
'{nodeName} - {inputName}',
|
||||
{ nodeName, inputName }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
if (items.length !== 1) {
|
||||
|
||||
@@ -1,50 +1,60 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-for="group in missingMediaGroups"
|
||||
:key="group.mediaType"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
class="relative m-0 list-none space-y-1 p-0"
|
||||
>
|
||||
<!-- Media type header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="MEDIA_TYPE_ICONS[group.mediaType]"
|
||||
class="mr-1 size-3.5 align-text-bottom"
|
||||
/>
|
||||
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
|
||||
({{ group.items.length }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media file rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingMediaRow
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:item="item"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<li
|
||||
v-for="item in missingMediaItems"
|
||||
:key="item.key"
|
||||
data-testid="missing-media-row"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
})
|
||||
"
|
||||
@click.stop="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { st } from '@/i18n'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
|
||||
const { missingMediaGroups } = defineProps<{
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -53,9 +63,64 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
|
||||
image: 'icon-[lucide--image]',
|
||||
video: 'icon-[lucide--video]',
|
||||
audio: 'icon-[lucide--music]'
|
||||
interface MissingMediaItemEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
displayItemLabel: string
|
||||
}
|
||||
|
||||
const missingMediaItems = computed(() => {
|
||||
const items: MissingMediaItemEntry[] = []
|
||||
for (const group of missingMediaGroups) {
|
||||
for (const mediaItem of group.items) {
|
||||
for (const nodeRef of mediaItem.referencingNodes) {
|
||||
const nodeId = String(nodeRef.nodeId)
|
||||
items.push({
|
||||
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
|
||||
nodeId,
|
||||
displayItemLabel: getDisplayItemLabel(
|
||||
nodeId,
|
||||
nodeRef.nodeType ?? mediaItem.representative.nodeType,
|
||||
nodeRef.widgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return items.sort(compareMissingMediaItems)
|
||||
})
|
||||
|
||||
function getDisplayItemLabel(
|
||||
nodeId: string,
|
||||
nodeType: string,
|
||||
widgetName: string
|
||||
) {
|
||||
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
|
||||
return resolveMissingMediaItemLabel({
|
||||
nodeDisplayName,
|
||||
nodeType,
|
||||
widgetName
|
||||
}).displayItemLabel
|
||||
}
|
||||
|
||||
function compareMissingMediaItems(
|
||||
a: MissingMediaItemEntry,
|
||||
b: MissingMediaItemEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.displayItemLabel.localeCompare(b.displayItemLabel)
|
||||
)
|
||||
}
|
||||
|
||||
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, nodeId)
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
||||
<span class="text-xs font-bold text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingMedia.or') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="options.length === 0"
|
||||
@update:model-value="handleSelect"
|
||||
>
|
||||
<SelectTrigger
|
||||
size="md"
|
||||
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent class="max-h-72">
|
||||
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
|
||||
<div class="px-1 pb-1.5">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
||||
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SelectItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
alt=""
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<video
|
||||
v-else-if="mediaType === 'video'"
|
||||
aria-hidden="true"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
preload="metadata"
|
||||
muted
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ option.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResultsFound') }}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import type { MediaType } from '@/platform/missingMedia/types'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const {
|
||||
options,
|
||||
showDivider = false,
|
||||
mediaType
|
||||
} = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
mediaType: MediaType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const SEARCH_THRESHOLD = 4
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
||||
|
||||
function getPreviewUrl(filename: string): string {
|
||||
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
|
||||
}
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,318 +0,0 @@
|
||||
<template>
|
||||
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
|
||||
<!-- File header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Single node: show node display name instead of filename -->
|
||||
<template v-if="isSingleNode">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ item.referencingNodes[0].nodeId }}
|
||||
</span>
|
||||
<p
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="singleNodeLabel"
|
||||
>
|
||||
{{ singleNodeLabel }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Multiple nodes: show filename with count -->
|
||||
<p
|
||||
v-else
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="displayName"
|
||||
>
|
||||
{{ displayName }}
|
||||
({{ item.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<!-- Confirm button (visible when pending selection exists) -->
|
||||
<Button
|
||||
data-testid="missing-media-confirm-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
|
||||
:disabled="!isPending"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="confirmSelection(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="isPending ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<!-- Locate button (single node only) -->
|
||||
<Button
|
||||
v-if="isSingleNode"
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
|
||||
<!-- Expand button (multiple nodes only) -->
|
||||
<Button
|
||||
v-if="!isSingleNode"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingMedia.collapseNodes')
|
||||
: t('rightSidePanel.missingMedia.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes (expandable) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded && item.referencingNodes.length > 1"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="nodeRef in item.referencingNodes"
|
||||
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ nodeRef.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(nodeRef.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card (uploading, uploaded, or library select) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="isPending || isUploading"
|
||||
data-testid="missing-media-status-card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="currentUpload?.status === 'uploading'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span class="text-foreground truncate text-xs/tight font-medium">
|
||||
{{ pendingDisplayName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="currentUpload?.status === 'uploading'">
|
||||
{{ t('rightSidePanel.missingMedia.uploading') }}
|
||||
</template>
|
||||
<template v-else-if="currentUpload?.status === 'uploaded'">
|
||||
{{ t('rightSidePanel.missingMedia.uploaded') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-testid="missing-media-cancel-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="cancelSelection(item.name)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Upload + Library (when no pending selection) -->
|
||||
<TransitionCollapse>
|
||||
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
|
||||
<!-- Upload dropzone -->
|
||||
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
|
||||
<button
|
||||
data-testid="missing-media-upload-dropzone"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
|
||||
isOverDropZone && 'border-primary text-primary'
|
||||
)
|
||||
"
|
||||
@click="openFilePicker()"
|
||||
>
|
||||
{{
|
||||
t('rightSidePanel.missingMedia.uploadFile', {
|
||||
type: extensionHint
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OR separator + Use from Library -->
|
||||
<MissingMediaLibrarySelect
|
||||
data-testid="missing-media-library-select"
|
||||
:model-value="undefined"
|
||||
:options="libraryOptions"
|
||||
:show-divider="true"
|
||||
:media-type="item.mediaType"
|
||||
@select="handleLibrarySelect(item.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDropZone, useFileDialog } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
|
||||
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
useMissingMediaInteractions,
|
||||
getNodeDisplayLabel,
|
||||
getMediaDisplayName
|
||||
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
|
||||
|
||||
const { item, showNodeIdBadge } = defineProps<{
|
||||
item: MissingMediaViewModel
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
const { uploadState, pendingSelection } = storeToRefs(store)
|
||||
|
||||
const {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
} = useMissingMediaInteractions()
|
||||
|
||||
const displayName = getMediaDisplayName(item.name)
|
||||
const isSingleNode = item.referencingNodes.length === 1
|
||||
const singleNodeLabel = isSingleNode
|
||||
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
|
||||
: ''
|
||||
const acceptType = getAcceptType(item.mediaType)
|
||||
const extensionHint = getExtensionHint(item.mediaType)
|
||||
|
||||
const expanded = computed(() => isExpanded(item.name))
|
||||
const matchingCandidate = computed(() => {
|
||||
const candidates = store.missingMediaCandidates
|
||||
if (!candidates?.length) return null
|
||||
return candidates.find((c) => c.name === item.name) ?? null
|
||||
})
|
||||
const libraryOptions = computed(() => {
|
||||
const candidate = matchingCandidate.value
|
||||
if (!candidate) return []
|
||||
return getLibraryOptions(candidate)
|
||||
})
|
||||
|
||||
const isPending = computed(() => hasPendingSelection(item.name))
|
||||
const isUploading = computed(
|
||||
() => uploadState.value[item.name]?.status === 'uploading'
|
||||
)
|
||||
const currentUpload = computed(() => uploadState.value[item.name])
|
||||
const pendingDisplayName = computed(() => {
|
||||
if (currentUpload.value) return currentUpload.value.fileName
|
||||
const pending = pendingSelection.value[item.name]
|
||||
return pending ? getMediaDisplayName(pending) : ''
|
||||
})
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop: (_files, event) => {
|
||||
event?.stopPropagation()
|
||||
const file = _files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
|
||||
accept: acceptType,
|
||||
multiple: false
|
||||
})
|
||||
onFileSelected((files) => {
|
||||
const file = files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,224 +0,0 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type {
|
||||
MissingMediaCandidate,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_VIDEO_TYPES
|
||||
} from '@/utils/mediaUploadUtil'
|
||||
|
||||
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
|
||||
image: ACCEPTED_IMAGE_TYPES,
|
||||
video: ACCEPTED_VIDEO_TYPES,
|
||||
audio: 'audio/*'
|
||||
}
|
||||
|
||||
function getMediaComboWidget(
|
||||
candidate: MissingMediaCandidate
|
||||
): { node: LGraphNode; widget: IComboWidget } | null {
|
||||
const graph = app.rootGraph
|
||||
if (!graph || candidate.nodeId == null) return null
|
||||
|
||||
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find(
|
||||
(w) => w.name === candidate.widgetName && w.type === 'combo'
|
||||
) as IComboWidget | undefined
|
||||
if (!widget) return null
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
function resolveLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
const result = getMediaComboWidget(candidate)
|
||||
if (!result) return []
|
||||
|
||||
return resolveComboValues(result.widget)
|
||||
.filter((v) => v !== candidate.name)
|
||||
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
|
||||
}
|
||||
|
||||
function applyValueToNodes(
|
||||
candidates: MissingMediaCandidate[],
|
||||
name: string,
|
||||
newValue: string
|
||||
) {
|
||||
const matching = candidates.filter((c) => c.name === name)
|
||||
for (const c of matching) {
|
||||
const result = getMediaComboWidget(c)
|
||||
if (!result) continue
|
||||
|
||||
addToComboValues(result.widget, newValue)
|
||||
result.widget.value = newValue
|
||||
result.widget.callback?.(newValue)
|
||||
result.node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeDisplayLabel(
|
||||
nodeId: string | number,
|
||||
fallback: string
|
||||
): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, String(nodeId))
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display name for a media file.
|
||||
* Cloud widgets store asset hashes as values; this resolves them to
|
||||
* human-readable names via assetsStore.getInputName().
|
||||
*/
|
||||
export function getMediaDisplayName(name: string): string {
|
||||
if (!isCloud) return name
|
||||
return useAssetsStore().getInputName(name)
|
||||
}
|
||||
|
||||
export function useMissingMediaInteractions() {
|
||||
const store = useMissingMediaStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
function isExpanded(key: string): boolean {
|
||||
return store.expandState[key] ?? false
|
||||
}
|
||||
|
||||
function toggleExpand(key: string) {
|
||||
store.expandState[key] = !isExpanded(key)
|
||||
}
|
||||
|
||||
function getAcceptType(mediaType: MediaType): string {
|
||||
return MEDIA_ACCEPT_MAP[mediaType]
|
||||
}
|
||||
|
||||
function getExtensionHint(mediaType: MediaType): string {
|
||||
if (mediaType === 'audio') return 'audio'
|
||||
const exts = MEDIA_ACCEPT_MAP[mediaType]
|
||||
.split(',')
|
||||
.map((mime) => mime.split('/')[1])
|
||||
.join(', ')
|
||||
return `${exts}, ...`
|
||||
}
|
||||
|
||||
function getLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
return resolveLibraryOptions(candidate)
|
||||
}
|
||||
|
||||
/** Step 1: Store selection from library (does not apply yet). */
|
||||
function handleLibrarySelect(name: string, value: string) {
|
||||
store.pendingSelection[name] = value
|
||||
}
|
||||
|
||||
/** Step 1: Upload file and store result as pending (does not apply yet). */
|
||||
async function handleUpload(file: File, name: string, mediaType: MediaType) {
|
||||
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.unsupportedFileType',
|
||||
'Unsupported file type. Please select a valid file.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const uploadedPath: string = data.subfolder
|
||||
? `${data.subfolder}/${data.name}`
|
||||
: data.name
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
|
||||
store.pendingSelection[name] = uploadedPath
|
||||
|
||||
// Refresh assets store (non-critical — upload already succeeded)
|
||||
try {
|
||||
await assetsStore.updateInputs()
|
||||
} catch {
|
||||
// Asset list refresh failed but upload is valid; selection can proceed
|
||||
}
|
||||
} catch {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: Apply pending selection to widgets and remove from missing list. */
|
||||
function confirmSelection(name: string) {
|
||||
const value = store.pendingSelection[name]
|
||||
if (!value || !store.missingMediaCandidates) return
|
||||
|
||||
applyValueToNodes(store.missingMediaCandidates, name, value)
|
||||
store.removeMissingMediaByName(name)
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function cancelSelection(name: string) {
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function hasPendingSelection(name: string): boolean {
|
||||
return name in store.pendingSelection
|
||||
}
|
||||
|
||||
return {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,11 @@ describe('groupCandidatesByName', () => {
|
||||
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.referencingNodes[0]).toMatchObject({
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
})
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ export function groupCandidatesByName(
|
||||
if (existing) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
nodeType: c.nodeType,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
} else {
|
||||
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
representative: c,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
referencingNodes: [
|
||||
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('clearMissingMedia resets all state including interaction state', () => {
|
||||
it('clearMissingMedia resets candidates and aborts verification', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
const controller = store.createVerificationAbortController()
|
||||
|
||||
store.clearMissingMedia()
|
||||
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
expect(store.expandState).toEqual({})
|
||||
expect(store.uploadState).toEqual({})
|
||||
expect(store.pendingSelection).toEqual({})
|
||||
})
|
||||
|
||||
it('missingMediaNodeIds tracks unique node IDs', () => {
|
||||
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByName clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByName('photo.png')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('createVerificationAbortController aborts previous controller', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const first = store.createVerificationAbortController()
|
||||
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans interaction state for removed names', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
|
||||
})
|
||||
|
||||
it('clears interaction state for removed names not used elsewhere', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'shared.png'),
|
||||
makeCandidate('65:80:5', 'shared.png'),
|
||||
makeCandidate('65:70:64', 'only-interior.png')
|
||||
])
|
||||
store.pendingSelection['shared.png'] = 'library/shared.png'
|
||||
store.pendingSelection['only-interior.png'] = 'library/interior.png'
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Interaction state — persists across component re-mounts
|
||||
const expandState = ref<Record<string, boolean>>({})
|
||||
const uploadState = ref<
|
||||
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
|
||||
>({})
|
||||
/** Pending selection: value to apply on confirm. */
|
||||
const pendingSelection = ref<Record<string, string>>({})
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
return activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
}
|
||||
|
||||
function clearInteractionStateForName(name: string) {
|
||||
delete expandState.value[name]
|
||||
delete uploadState.value[name]
|
||||
delete pendingSelection.value[name]
|
||||
}
|
||||
|
||||
function removeMissingMediaByName(name: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => m.name !== name
|
||||
)
|
||||
clearInteractionStateForName(name)
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter(
|
||||
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
|
||||
)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByNodeId(nodeId: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter((m) => String(m.nodeId) === nodeId)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => String(m.nodeId) !== nodeId
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
*/
|
||||
function removeMissingMediaByPrefix(prefix: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set<string>()
|
||||
const remaining: MissingMediaCandidate[] = []
|
||||
for (const m of missingMediaCandidates.value) {
|
||||
// Preserve candidates without a nodeId; they cannot belong to any
|
||||
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
remaining.push(m)
|
||||
continue
|
||||
}
|
||||
if (String(m.nodeId).startsWith(prefix)) {
|
||||
removedNames.add(m.name)
|
||||
} else {
|
||||
if (!String(m.nodeId).startsWith(prefix)) {
|
||||
remaining.push(m)
|
||||
}
|
||||
}
|
||||
if (removedNames.size === 0) return
|
||||
if (remaining.length === missingMediaCandidates.value.length) return
|
||||
missingMediaCandidates.value = remaining.length ? remaining : null
|
||||
for (const name of removedNames) {
|
||||
if (!remaining.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMissingMedia(media: MissingMediaCandidate[]) {
|
||||
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingMediaCandidates.value = null
|
||||
expandState.value = {}
|
||||
uploadState.value = {}
|
||||
pendingSelection.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
|
||||
setMissingMedia,
|
||||
addMissingMedia,
|
||||
removeMissingMediaByName,
|
||||
removeMissingMediaByWidget,
|
||||
removeMissingMediaByNodeId,
|
||||
removeMissingMediaByPrefix,
|
||||
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia,
|
||||
|
||||
expandState,
|
||||
uploadState,
|
||||
pendingSelection
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
|
||||
representative: MissingMediaCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
nodeType?: string
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user