-
-
- {{ $t('assetBrowser.uploadModelDescription1') }}
-
-
+
+
+
+
+ {{ $t('assetBrowser.uploadModelDescription1Generic') }}
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+ {{
+ $t('assetBrowser.maxFileSizeValue')
+ }}
+
+
+
+
-
-
-
-
- {{ error }}
-
-
+
+ {{ $t('assetBrowser.uploadModelHelpFooterText') }}
@@ -44,4 +90,9 @@ const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
+
+const civitaiIcon = '/assets/images/civitai.svg'
+const civitaiUrl = 'https://civitai.com/models'
+const huggingFaceIcon = '/assets/images/hf-logo.svg'
+const huggingFaceUrl = 'https://huggingface.co'
diff --git a/src/platform/assets/components/UploadModelUrlInputCivitai.vue b/src/platform/assets/components/UploadModelUrlInputCivitai.vue
new file mode 100644
index 000000000..b89ee5329
--- /dev/null
+++ b/src/platform/assets/components/UploadModelUrlInputCivitai.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ {{ $t('assetBrowser.uploadModelDescription1') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/assets/composables/useModelTypes.ts b/src/platform/assets/composables/useModelTypes.ts
index 12aa8d419..8f578c926 100644
--- a/src/platform/assets/composables/useModelTypes.ts
+++ b/src/platform/assets/composables/useModelTypes.ts
@@ -50,10 +50,12 @@ export const useModelTypes = createSharedComposable(() => {
} = useAsyncState(
async (): Promise
=> {
const response = await api.getModelFolders()
- return response.map((folder) => ({
- name: formatDisplayName(folder.name),
- value: folder.name
- }))
+ return response
+ .map((folder) => ({
+ name: formatDisplayName(folder.name),
+ value: folder.name
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
},
[] as ModelTypeOption[],
{
diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts
index 73341c0f2..2d97efd9c 100644
--- a/src/platform/assets/composables/useUploadModelWizard.ts
+++ b/src/platform/assets/composables/useUploadModelWizard.ts
@@ -1,9 +1,15 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
+import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
+import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
+import type { ImportSource } from '@/platform/assets/types/importSource'
+import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -21,8 +27,10 @@ interface ModelTypeOption {
}
export function useUploadModelWizard(modelTypes: Ref) {
+ const { t } = useI18n()
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
+ const { flags } = useFeatureFlags()
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
@@ -37,6 +45,20 @@ export function useUploadModelWizard(modelTypes: Ref) {
const selectedModelType = ref()
+ // Available import sources
+ const importSources: ImportSource[] = flags.huggingfaceModelImportEnabled
+ ? [civitaiImportSource, huggingfaceImportSource]
+ : [civitaiImportSource]
+
+ // Detected import source based on URL
+ const detectedSource = computed(() => {
+ const url = wizardData.value.url.trim()
+ if (!url) return null
+ return (
+ importSources.find((source) => validateSourceUrl(url, source)) ?? null
+ )
+ })
+
// Clear error when URL changes
watch(
() => wizardData.value.url,
@@ -54,15 +76,6 @@ export function useUploadModelWizard(modelTypes: Ref) {
return !!selectedModelType.value
})
- function isCivitaiUrl(url: string): boolean {
- try {
- const hostname = new URL(url).hostname.toLowerCase()
- return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
- } catch {
- return false
- }
- }
-
async function fetchMetadata() {
if (!canFetchMetadata.value) return
@@ -75,17 +88,36 @@ export function useUploadModelWizard(modelTypes: Ref) {
}
wizardData.value.url = cleanedUrl
- if (!isCivitaiUrl(wizardData.value.url)) {
- uploadError.value = st(
- 'assetBrowser.onlyCivitaiUrlsSupported',
- 'Only Civitai URLs are supported'
- )
+ // Validate URL belongs to a supported import source
+ const source = detectedSource.value
+ if (!source) {
+ const supportedSources = importSources.map((s) => s.name).join(', ')
+ uploadError.value = t('assetBrowser.unsupportedUrlSource', {
+ sources: supportedSources
+ })
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
+
+ // Decode URL-encoded filenames (e.g., Chinese characters)
+ if (metadata.filename) {
+ try {
+ metadata.filename = decodeURIComponent(metadata.filename)
+ } catch {
+ // Keep original if decoding fails
+ }
+ }
+ if (metadata.name) {
+ try {
+ metadata.name = decodeURIComponent(metadata.name)
+ } catch {
+ // Keep original if decoding fails
+ }
+ }
+
wizardData.value.metadata = metadata
// Pre-fill name from metadata
@@ -125,6 +157,14 @@ export function useUploadModelWizard(modelTypes: Ref) {
async function uploadModel() {
if (!canUploadModel.value) return
+ // Defensive check: detectedSource should be valid after fetchMetadata validation,
+ // but guard against edge cases (e.g., URL modified between steps)
+ const source = detectedSource.value
+ if (!source) {
+ uploadError.value = t('assetBrowser.noValidSourceDetected')
+ return false
+ }
+
isUploading.value = true
uploadStatus.value = 'uploading'
@@ -170,7 +210,7 @@ export function useUploadModelWizard(modelTypes: Ref) {
name: filename,
tags,
user_metadata: {
- source: 'civitai',
+ source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
@@ -224,6 +264,7 @@ export function useUploadModelWizard(modelTypes: Ref) {
// Computed
canFetchMetadata,
canUploadModel,
+ detectedSource,
// Actions
fetchMetadata,
diff --git a/src/platform/assets/importSources/civitaiImportSource.ts b/src/platform/assets/importSources/civitaiImportSource.ts
new file mode 100644
index 000000000..5ff324d00
--- /dev/null
+++ b/src/platform/assets/importSources/civitaiImportSource.ts
@@ -0,0 +1,10 @@
+import type { ImportSource } from '@/platform/assets/types/importSource'
+
+/**
+ * Civitai model import source configuration
+ */
+export const civitaiImportSource: ImportSource = {
+ type: 'civitai',
+ name: 'Civitai',
+ hostnames: ['civitai.com']
+}
diff --git a/src/platform/assets/importSources/huggingfaceImportSource.ts b/src/platform/assets/importSources/huggingfaceImportSource.ts
new file mode 100644
index 000000000..310e170af
--- /dev/null
+++ b/src/platform/assets/importSources/huggingfaceImportSource.ts
@@ -0,0 +1,10 @@
+import type { ImportSource } from '@/platform/assets/types/importSource'
+
+/**
+ * Hugging Face model import source configuration
+ */
+export const huggingfaceImportSource: ImportSource = {
+ type: 'huggingface',
+ name: 'Hugging Face',
+ hostnames: ['huggingface.co']
+}
diff --git a/src/platform/assets/types/importSource.ts b/src/platform/assets/types/importSource.ts
new file mode 100644
index 000000000..12aa5e3db
--- /dev/null
+++ b/src/platform/assets/types/importSource.ts
@@ -0,0 +1,24 @@
+/**
+ * Supported model import sources
+ */
+type ImportSourceType = 'civitai' | 'huggingface'
+
+/**
+ * Configuration for a model import source
+ */
+export interface ImportSource {
+ /**
+ * Unique identifier for this import source
+ */
+ readonly type: ImportSourceType
+
+ /**
+ * Display name for the source
+ */
+ readonly name: string
+
+ /**
+ * Hostname(s) that identify this source
+ */
+ readonly hostnames: readonly string[]
+}
diff --git a/src/platform/assets/utils/importSourceUtil.ts b/src/platform/assets/utils/importSourceUtil.ts
new file mode 100644
index 000000000..2628593cc
--- /dev/null
+++ b/src/platform/assets/utils/importSourceUtil.ts
@@ -0,0 +1,15 @@
+import type { ImportSource } from '@/platform/assets/types/importSource'
+
+/**
+ * Check if a URL belongs to a specific import source
+ */
+export function validateSourceUrl(url: string, source: ImportSource): boolean {
+ try {
+ const hostname = new URL(url).hostname.toLowerCase()
+ return source.hostnames.some(
+ (h) => hostname === h || hostname.endsWith(`.${h}`)
+ )
+ } catch {
+ return false
+ }
+}
diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue
index ea9266304..82ec83c97 100644
--- a/src/platform/cloud/subscription/components/PricingTable.vue
+++ b/src/platform/cloud/subscription/components/PricingTable.vue
@@ -333,7 +333,7 @@ const { n } = useI18n()
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
-const { reportError } = useFirebaseAuthActions()
+const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
@@ -443,9 +443,15 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
loadingTier.value = tierKey
try {
- const response = await initiateCheckout(tierKey)
- if (response.checkout_url) {
- window.open(response.checkout_url, '_blank')
+ if (isActiveSubscription.value) {
+ // Pass the target tier to create a deep link to subscription update confirmation
+ const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
+ await accessBillingPortal(checkoutTier)
+ } else {
+ const response = await initiateCheckout(tierKey)
+ if (response.checkout_url) {
+ window.open(response.checkout_url, '_blank')
+ }
}
} finally {
isLoading.value = false
diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts
index 1a4ef1261..cbca526bf 100644
--- a/src/platform/remoteConfig/types.ts
+++ b/src/platform/remoteConfig/types.ts
@@ -38,4 +38,5 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
+ huggingface_model_import_enabled?: boolean
}
diff --git a/src/renderer/core/layout/sync/useLayoutSync.ts b/src/renderer/core/layout/sync/useLayoutSync.ts
index b51aeafee..221fe64eb 100644
--- a/src/renderer/core/layout/sync/useLayoutSync.ts
+++ b/src/renderer/core/layout/sync/useLayoutSync.ts
@@ -8,7 +8,6 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
-import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -44,13 +43,15 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
- const targetHeight = addNodeTitleHeight(layout.size.height)
+ // Note: layout.size.height is the content height without title.
+ // LiteGraph's measure() will add titleHeight to get boundingRect.
+ // Do NOT use addNodeTitleHeight here - that would double-count the title.
if (
liteNode.size[0] !== layout.size.width ||
- liteNode.size[1] !== targetHeight
+ liteNode.size[1] !== layout.size.height
) {
// Use setSize() to trigger onResize callback
- liteNode.setSize([layout.size.width, targetHeight])
+ liteNode.setSize([layout.size.width, layout.size.height])
}
}
diff --git a/src/renderer/core/layout/utils/nodeSizeUtil.ts b/src/renderer/core/layout/utils/nodeSizeUtil.ts
index 811240cd0..ce2691a92 100644
--- a/src/renderer/core/layout/utils/nodeSizeUtil.ts
+++ b/src/renderer/core/layout/utils/nodeSizeUtil.ts
@@ -2,6 +2,3 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
-
-export const addNodeTitleHeight = (height: number) =>
- height + LiteGraph.NODE_TITLE_HEIGHT
diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue
index 9205768e6..43aa36ad6 100644
--- a/src/renderer/extensions/vueNodes/components/InputSlot.vue
+++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue
@@ -43,7 +43,12 @@
)
"
>
- {{ slotData.localized_name || slotData.name || `Input ${index}` }}
+ {{
+ slotData.label ||
+ slotData.localized_name ||
+ slotData.name ||
+ `Input ${index}`
+ }}
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts
index 847ffc657..e1eb34e1b 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts
@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
-import PrimeVue from 'primevue/config'
-import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
) => {
return mount(WidgetImageCompare, {
global: {
- plugins: [PrimeVue],
- components: { ImageCompare }
+ mocks: {
+ $t: (key: string) => key
+ }
},
props: {
widget,
@@ -36,7 +35,7 @@ describe('WidgetImageCompare Display', () => {
}
describe('Component Rendering', () => {
- it('renders imagecompare component with proper structure and styling', () => {
+ it('renders with proper structure and styling when images are provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -44,21 +43,15 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
- // Component exists
- const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
- expect(imageCompare.exists()).toBe(true)
-
- // Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
- expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
- expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
- // Images have proper styling classes
+ // In the new implementation: after image is first (background), before image is second (overlay)
+ expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
+ expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
+
images.forEach((img) => {
- expect(img.classes()).toContain('object-cover')
- expect(img.classes()).toContain('w-full')
- expect(img.classes()).toContain('h-full')
+ expect(img.classes()).toContain('object-contain')
})
})
})
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
- expect(customImages[0].attributes('alt')).toBe('Original design')
- expect(customImages[1].attributes('alt')).toBe('Updated design')
+ // DOM order: [after, before]
+ expect(customImages[0].attributes('alt')).toBe('Updated design')
+ expect(customImages[1].attributes('alt')).toBe('Original design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
- expect(defaultImages[0].attributes('alt')).toBe('Before image')
- expect(defaultImages[1].attributes('alt')).toBe('After image')
+ expect(defaultImages[0].attributes('alt')).toBe('After image')
+ expect(defaultImages[1].attributes('alt')).toBe('Before image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
- expect(emptyImages[0].attributes('alt')).toBe('Before image')
- expect(emptyImages[1].attributes('alt')).toBe('After image')
+ expect(emptyImages[0].attributes('alt')).toBe('After image')
+ expect(emptyImages[1].attributes('alt')).toBe('Before image')
})
- it('handles missing and partial image URLs gracefully', () => {
- // Missing URLs
- const missingValue: ImageCompareValue = { before: '', after: '' }
- const missingWrapper = mountComponent(createMockWidget(missingValue))
- const missingImages = missingWrapper.findAll('img')
- expect(missingImages[0].attributes('src')).toBe('')
- expect(missingImages[1].attributes('src')).toBe('')
-
- // Partial URLs
- const partialValue: ImageCompareValue = {
+ it('handles partial image URLs gracefully', () => {
+ // Only before image provided
+ const beforeOnlyValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
- const partialWrapper = mountComponent(createMockWidget(partialValue))
- const partialImages = partialWrapper.findAll('img')
- expect(partialImages[0].attributes('src')).toBe(
+ const beforeOnlyWrapper = mountComponent(
+ createMockWidget(beforeOnlyValue)
+ )
+ const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
+ expect(beforeOnlyImages).toHaveLength(1)
+ expect(beforeOnlyImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
- expect(partialImages[1].attributes('src')).toBe('')
+
+ // Only after image provided
+ const afterOnlyValue: ImageCompareValue = {
+ before: '',
+ after: 'https://example.com/after.jpg'
+ }
+ const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
+ const afterOnlyImages = afterOnlyWrapper.findAll('img')
+ expect(afterOnlyImages).toHaveLength(1)
+ expect(afterOnlyImages[0].attributes('src')).toBe(
+ 'https://example.com/after.jpg'
+ )
})
})
@@ -129,75 +130,14 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
+ expect(images).toHaveLength(1)
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
- expect(images[1].attributes('src')).toBe('')
- })
-
- it('uses default alt text for string values', () => {
- const value = 'https://example.com/single.jpg'
- const widget = createMockWidget(value)
- const wrapper = mountComponent(widget)
-
- const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
- expect(images[1].attributes('alt')).toBe('After image')
- })
- })
-
- describe('Widget Options Handling', () => {
- it('passes through accessibility options', () => {
- const value: ImageCompareValue = {
- before: 'https://example.com/before.jpg',
- after: 'https://example.com/after.jpg'
- }
- const widget = createMockWidget(value, {
- tabindex: 1,
- ariaLabel: 'Compare images',
- ariaLabelledby: 'compare-label'
- })
- const wrapper = mountComponent(widget)
-
- const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
- expect(imageCompare.props('tabindex')).toBe(1)
- expect(imageCompare.props('ariaLabel')).toBe('Compare images')
- expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
- })
-
- it('uses default tabindex when not provided', () => {
- const value: ImageCompareValue = {
- before: 'https://example.com/before.jpg',
- after: 'https://example.com/after.jpg'
- }
- const widget = createMockWidget(value)
- const wrapper = mountComponent(widget)
-
- const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
- expect(imageCompare.props('tabindex')).toBe(0)
- })
-
- it('passes through PrimeVue specific options', () => {
- const value: ImageCompareValue = {
- before: 'https://example.com/before.jpg',
- after: 'https://example.com/after.jpg'
- }
- const widget = createMockWidget(value, {
- unstyled: true,
- pt: { root: { class: 'custom-class' } },
- ptOptions: { mergeSections: true }
- })
- const wrapper = mountComponent(widget)
-
- const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
- expect(imageCompare.props('unstyled')).toBe(true)
- expect(imageCompare.props('pt')).toEqual({
- root: { class: 'custom-class' }
- })
- expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
- it('renders normally in readonly mode (no interaction restrictions)', () => {
+ it('renders normally in readonly mode', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -205,45 +145,39 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
- // ImageCompare is display-only, readonly doesn't affect rendering
- const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
- expect(imageCompare.exists()).toBe(true)
-
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
- it('handles null or undefined widget value', () => {
+ it('shows no images message when widget value is empty string', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
- expect(images[0].attributes('src')).toBe('')
- expect(images[1].attributes('src')).toBe('')
- expect(images[0].attributes('alt')).toBe('Before image')
- expect(images[1].attributes('alt')).toBe('After image')
+ expect(images).toHaveLength(0)
+ expect(wrapper.text()).toContain('imageCompare.noImages')
})
- it('handles empty object value', () => {
+ it('shows no images message when both URLs are empty', () => {
+ const value: ImageCompareValue = { before: '', after: '' }
+ const widget = createMockWidget(value)
+ const wrapper = mountComponent(widget)
+
+ const images = wrapper.findAll('img')
+ expect(images).toHaveLength(0)
+ expect(wrapper.text()).toContain('imageCompare.noImages')
+ })
+
+ it('shows no images message for empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
- expect(images[0].attributes('src')).toBe('')
- expect(images[1].attributes('src')).toBe('')
- })
-
- it('handles malformed object value', () => {
- const value = { randomProp: 'test', before: '', after: '' }
- const widget = createMockWidget(value)
- const wrapper = mountComponent(widget)
-
- const images = wrapper.findAll('img')
- expect(images[0].attributes('src')).toBe('')
- expect(images[1].attributes('src')).toBe('')
+ expect(images).toHaveLength(0)
+ expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
})
describe('Template Structure', () => {
- it('correctly assigns images to left and right template slots', () => {
+ it('correctly renders after image as background and before image as overlay', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
- // First image (before) should be in left template slot
- expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
- // Second image (after) should be in right template slot
- expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
+ // After image is rendered first as background
+ expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
+ // Before image is rendered second as overlay with clipPath
+ expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
+ expect(images[1].classes()).toContain('absolute')
})
})
@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
+
+ describe('Slider Element', () => {
+ it('renders slider divider when images are present', () => {
+ const value: ImageCompareValue = {
+ before: 'https://example.com/before.jpg',
+ after: 'https://example.com/after.jpg'
+ }
+ const widget = createMockWidget(value)
+ const wrapper = mountComponent(widget)
+
+ const slider = wrapper.find('[role="presentation"]')
+ expect(slider.exists()).toBe(true)
+ expect(slider.classes()).toContain('bg-white')
+ })
+
+ it('does not render slider when no images', () => {
+ const widget = createMockWidget('')
+ const wrapper = mountComponent(widget)
+
+ const slider = wrapper.find('[role="presentation"]')
+ expect(slider.exists()).toBe(false)
+ })
+ })
})
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue
index 34516a120..8cb80341a 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue
@@ -1,32 +1,39 @@
-
-
-
-
-
+
+
![]()
-
-
+
+
![]()
+
+
+
+
+
+ {{ $t('imageCompare.noImages') }}
+
+