+
+ >
+
+
+
+ /
+
-
+
+
diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
new file mode 100644
index 000000000..ffadd86f8
--- /dev/null
+++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
@@ -0,0 +1,215 @@
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/ApiNodesCostBreakdown.vue b/src/components/common/ApiNodesCostBreakdown.vue
deleted file mode 100644
index 318421555..000000000
--- a/src/components/common/ApiNodesCostBreakdown.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
{{ t('apiNodesCostBreakdown.title') }}
-
{{ t('apiNodesCostBreakdown.costPerRun') }}
-
-
-
-
-
- {{
- node.name
- }}
-
-
-
-
- {{ node.cost.toFixed(costPrecision) }}
-
-
-
-
-
-
-
-
-
{{ t('apiNodesCostBreakdown.totalCost') }}
-
-
- {{ totalCost.toFixed(costPrecision) }}
-
-
-
-
-
-
-
diff --git a/src/components/common/ApiNodesList.vue b/src/components/common/ApiNodesList.vue
deleted file mode 100644
index 38d89607b..000000000
--- a/src/components/common/ApiNodesList.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
{{ t('apiNodesCostBreakdown.title') }}
-
-
-
-
-
- {{
- nodeName
- }}
-
-
-
-
-
-
-
-
diff --git a/src/components/common/LazyImage.vue b/src/components/common/LazyImage.vue
new file mode 100644
index 000000000..79c7320f6
--- /dev/null
+++ b/src/components/common/LazyImage.vue
@@ -0,0 +1,124 @@
+
+
+
+
![]()
+
+
+
+
+
+
+
diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/LoadWorkflowWarning.vue
index 201c6d242..41d422d20 100644
--- a/src/components/dialog/content/LoadWorkflowWarning.vue
+++ b/src/components/dialog/content/LoadWorkflowWarning.vue
@@ -2,7 +2,7 @@
diff --git a/src/components/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue
index 40347061e..036f088b3 100644
--- a/src/components/dialog/content/MissingCoreNodesMessage.vue
+++ b/src/components/dialog/content/MissingCoreNodesMessage.vue
@@ -42,11 +42,11 @@
diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue
index 9f7117e00..7edca3f5e 100644
--- a/src/components/graph/DomWidgets.vue
+++ b/src/components/graph/DomWidgets.vue
@@ -11,7 +11,6 @@
+
+
diff --git a/src/components/graph/MiniMapPanel.vue b/src/components/graph/MiniMapPanel.vue
new file mode 100644
index 000000000..9b45f295e
--- /dev/null
+++ b/src/components/graph/MiniMapPanel.vue
@@ -0,0 +1,97 @@
+
+
+
+ $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
+ "
+ />
+
+
+
+
+
+ $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
+ "
+ />
+
+
+
+
+
+ $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
+ "
+ />
+
+
+
+
+
+
+ $emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
+ "
+ />
+
+
+
+
+
+
+ $emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
+ "
+ />
+
+
+
+
+
+
+
diff --git a/src/components/graph/NodeTooltip.vue b/src/components/graph/NodeTooltip.vue
index 48155d0eb..647987000 100644
--- a/src/components/graph/NodeTooltip.vue
+++ b/src/components/graph/NodeTooltip.vue
@@ -10,15 +10,15 @@
diff --git a/src/components/graph/selectionToolbox/RefreshButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue
similarity index 100%
rename from src/components/graph/selectionToolbox/RefreshButton.vue
rename to src/components/graph/selectionToolbox/RefreshSelectionButton.vue
diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue
index f8309f4a5..3da6b6231 100644
--- a/src/components/graph/widgets/DomWidget.vue
+++ b/src/components/graph/widgets/DomWidget.vue
@@ -19,7 +19,7 @@
diff --git a/src/components/load3d/controls/RecordingControls.vue b/src/components/load3d/controls/RecordingControls.vue
index 9eb305a47..92fbf8c24 100644
--- a/src/components/load3d/controls/RecordingControls.vue
+++ b/src/components/load3d/controls/RecordingControls.vue
@@ -75,11 +75,11 @@
+
+
diff --git a/src/components/load3d/controls/viewer/CameraControls.vue b/src/components/load3d/controls/viewer/CameraControls.vue
new file mode 100644
index 000000000..e2edb8cd5
--- /dev/null
+++ b/src/components/load3d/controls/viewer/CameraControls.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/load3d/controls/viewer/ExportControls.vue b/src/components/load3d/controls/viewer/ExportControls.vue
new file mode 100644
index 000000000..6164e5bb0
--- /dev/null
+++ b/src/components/load3d/controls/viewer/ExportControls.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
diff --git a/src/components/load3d/controls/viewer/LightControls.vue b/src/components/load3d/controls/viewer/LightControls.vue
new file mode 100644
index 000000000..d38ccfd4d
--- /dev/null
+++ b/src/components/load3d/controls/viewer/LightControls.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/src/components/load3d/controls/viewer/ModelControls.vue b/src/components/load3d/controls/viewer/ModelControls.vue
new file mode 100644
index 000000000..6f1f38bdb
--- /dev/null
+++ b/src/components/load3d/controls/viewer/ModelControls.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/load3d/controls/viewer/SceneControls.vue b/src/components/load3d/controls/viewer/SceneControls.vue
new file mode 100644
index 000000000..3cabcc7be
--- /dev/null
+++ b/src/components/load3d/controls/viewer/SceneControls.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/node/NodePreview.spec.ts b/src/components/node/NodePreview.spec.ts
new file mode 100644
index 000000000..e8e629e75
--- /dev/null
+++ b/src/components/node/NodePreview.spec.ts
@@ -0,0 +1,293 @@
+import { mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import PrimeVue from 'primevue/config'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import { createApp } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
+import * as markdownRendererUtil from '@/utils/markdownRendererUtil'
+
+import NodePreview from './NodePreview.vue'
+
+describe('NodePreview', () => {
+ let i18n: ReturnType
+ let pinia: ReturnType
+
+ beforeAll(() => {
+ // Create a Vue app instance for PrimeVue
+ const app = createApp({})
+ app.use(PrimeVue)
+
+ // Create i18n instance
+ i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: {
+ preview: 'Preview'
+ }
+ }
+ }
+ })
+
+ // Create pinia instance
+ pinia = createPinia()
+ })
+
+ const mockNodeDef: ComfyNodeDefV2 = {
+ name: 'TestNode',
+ display_name:
+ 'Test Node With A Very Long Display Name That Should Overflow',
+ category: 'test',
+ output_node: false,
+ inputs: {
+ test_input: {
+ name: 'test_input',
+ type: 'STRING',
+ tooltip: 'Test input'
+ }
+ },
+ outputs: [],
+ python_module: 'test_module',
+ description: 'Test node description'
+ }
+
+ const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
+ return mount(NodePreview, {
+ global: {
+ plugins: [PrimeVue, i18n, pinia],
+ stubs: {
+ // Stub stores if needed
+ }
+ },
+ props: {
+ nodeDef
+ }
+ })
+ }
+
+ it('renders node preview with correct structure', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
+ expect(wrapper.find('.node_header').exists()).toBe(true)
+ expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
+ })
+
+ it('applies overflow-ellipsis class to node header for text truncation', () => {
+ const wrapper = mountComponent()
+ const nodeHeader = wrapper.find('.node_header')
+
+ expect(nodeHeader.classes()).toContain('overflow-ellipsis')
+ expect(nodeHeader.classes()).toContain('mr-4')
+ })
+
+ it('sets title attribute on node header with full display name', () => {
+ const wrapper = mountComponent()
+ const nodeHeader = wrapper.find('.node_header')
+
+ expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
+ })
+
+ it('displays truncated long node names with ellipsis', () => {
+ const longNameNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ display_name:
+ 'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
+ }
+
+ const wrapper = mountComponent(longNameNodeDef)
+ const nodeHeader = wrapper.find('.node_header')
+
+ // Verify the title attribute contains the full name
+ expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
+
+ // Verify overflow handling classes are applied
+ expect(nodeHeader.classes()).toContain('overflow-ellipsis')
+
+ // The actual text content should still be the full name (CSS handles truncation)
+ expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
+ })
+
+ it('handles short node names without issues', () => {
+ const shortNameNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ display_name: 'Short'
+ }
+
+ const wrapper = mountComponent(shortNameNodeDef)
+ const nodeHeader = wrapper.find('.node_header')
+
+ expect(nodeHeader.attributes('title')).toBe('Short')
+ expect(nodeHeader.text()).toContain('Short')
+ })
+
+ it('applies proper spacing to the dot element', () => {
+ const wrapper = mountComponent()
+ const headdot = wrapper.find('.headdot')
+
+ expect(headdot.classes()).toContain('pr-3')
+ })
+
+ describe('Description Rendering', () => {
+ it('renders plain text description as HTML', () => {
+ const plainTextNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ description: 'This is a plain text description'
+ }
+
+ const wrapper = mountComponent(plainTextNodeDef)
+ const description = wrapper.find('._sb_description')
+
+ expect(description.exists()).toBe(true)
+ expect(description.html()).toContain('This is a plain text description')
+ })
+
+ it('renders markdown description with formatting', () => {
+ const markdownNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ description: '**Bold text** and *italic text* with `code`'
+ }
+
+ const wrapper = mountComponent(markdownNodeDef)
+ const description = wrapper.find('._sb_description')
+
+ expect(description.exists()).toBe(true)
+ expect(description.html()).toContain('Bold text')
+ expect(description.html()).toContain('italic text')
+ expect(description.html()).toContain('code')
+ })
+
+ it('does not render description element when description is empty', () => {
+ const noDescriptionNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ description: ''
+ }
+
+ const wrapper = mountComponent(noDescriptionNodeDef)
+ const description = wrapper.find('._sb_description')
+
+ expect(description.exists()).toBe(false)
+ })
+
+ it('does not render description element when description is undefined', () => {
+ const { description, ...nodeDefWithoutDescription } = mockNodeDef
+ const wrapper = mountComponent(
+ nodeDefWithoutDescription as ComfyNodeDefV2
+ )
+ const descriptionElement = wrapper.find('._sb_description')
+
+ expect(descriptionElement.exists()).toBe(false)
+ })
+
+ it('calls renderMarkdownToHtml utility function', () => {
+ const spy = vi.spyOn(markdownRendererUtil, 'renderMarkdownToHtml')
+ const testDescription = 'Test **markdown** description'
+
+ const nodeDefWithDescription: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ description: testDescription
+ }
+
+ mountComponent(nodeDefWithDescription)
+
+ expect(spy).toHaveBeenCalledWith(testDescription)
+ spy.mockRestore()
+ })
+
+ it('handles potentially unsafe markdown content safely', () => {
+ const unsafeNodeDef: ComfyNodeDefV2 = {
+ ...mockNodeDef,
+ description:
+ 'Safe **markdown** content with `code` blocks'
+ }
+
+ const wrapper = mountComponent(unsafeNodeDef)
+ const description = wrapper.find('._sb_description')
+
+ // The description should still exist because there's safe content
+ if (description.exists()) {
+ // Should not contain script tags (sanitized by DOMPurify)
+ expect(description.html()).not.toContain('
diff --git a/src/components/sidebar/SidebarIcon.vue b/src/components/sidebar/SidebarIcon.vue
index 1c1dffc1b..5002d7724 100644
--- a/src/components/sidebar/SidebarIcon.vue
+++ b/src/components/sidebar/SidebarIcon.vue
@@ -19,10 +19,12 @@
@click="emit('click', $event)"
>
-
-
-
-
+
+
+
+
+
+
diff --git a/src/components/sidebar/SidebarSettingsToggleIcon.vue b/src/components/sidebar/SidebarSettingsToggleIcon.vue
deleted file mode 100644
index 3b214fb43..000000000
--- a/src/components/sidebar/SidebarSettingsToggleIcon.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
diff --git a/src/components/sidebar/SidebarShortcutsToggleButton.vue b/src/components/sidebar/SidebarShortcutsToggleButton.vue
new file mode 100644
index 000000000..f2e4ec400
--- /dev/null
+++ b/src/components/sidebar/SidebarShortcutsToggleButton.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/sidebar/SidebarThemeToggleIcon.vue b/src/components/sidebar/SidebarThemeToggleIcon.vue
deleted file mode 100644
index 8d64ff4ef..000000000
--- a/src/components/sidebar/SidebarThemeToggleIcon.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
diff --git a/src/components/templates/TemplateSearchBar.vue b/src/components/templates/TemplateSearchBar.vue
new file mode 100644
index 000000000..17b564b12
--- /dev/null
+++ b/src/components/templates/TemplateSearchBar.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ {{ $t('g.resultsCount', { count: filteredCount }) }}
+
+
+
+
+
+
+
diff --git a/src/components/templates/TemplateWorkflowCardSkeleton.vue b/src/components/templates/TemplateWorkflowCardSkeleton.vue
new file mode 100644
index 000000000..00bf73839
--- /dev/null
+++ b/src/components/templates/TemplateWorkflowCardSkeleton.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/templates/TemplateWorkflowView.spec.ts b/src/components/templates/TemplateWorkflowView.spec.ts
index a70e828a5..6860797c6 100644
--- a/src/components/templates/TemplateWorkflowView.spec.ts
+++ b/src/components/templates/TemplateWorkflowView.spec.ts
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
@@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
}
}))
+vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
+ default: {
+ template: '',
+ props: ['searchQuery', 'filteredCount'],
+ emits: ['update:searchQuery', 'clearFilters']
+ }
+}))
+
+vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
+ default: {
+ template: ''
+ }
+}))
+
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
+vi.mock('@/composables/useIntersectionObserver', () => ({
+ useIntersectionObserver: vi.fn()
+}))
+
+vi.mock('@/composables/useLazyPagination', () => ({
+ useLazyPagination: (items: any) => ({
+ paginatedItems: items,
+ isLoading: { value: false },
+ hasMoreItems: { value: false },
+ loadNextPage: vi.fn(),
+ reset: vi.fn()
+ })
+}))
+
+vi.mock('@/composables/useTemplateFiltering', () => ({
+ useTemplateFiltering: (templates: any) => ({
+ searchQuery: { value: '' },
+ filteredTemplates: templates,
+ filteredCount: { value: templates.value?.length || 0 }
+ })
+}))
+
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
@@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => {
})
const mountView = (props = {}) => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ templateWorkflows: {
+ loadingMore: 'Loading more...'
+ }
+ }
+ }
+ })
+
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
@@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
],
loading: null,
...props
+ },
+ global: {
+ plugins: [i18n]
}
})
}
diff --git a/src/components/templates/TemplateWorkflowView.vue b/src/components/templates/TemplateWorkflowView.vue
index 174a91201..8a866cdd1 100644
--- a/src/components/templates/TemplateWorkflowView.vue
+++ b/src/components/templates/TemplateWorkflowView.vue
@@ -1,24 +1,31 @@
-
-
{{ title }}
-
-
-
-
-
+
+
+
{{ title }}
+
+
+
+
+
+
+
reset()"
+ />
@@ -33,18 +40,35 @@
-
-
+
+
+
+
+
+
+
+ {{ t('templateWorkflows.loadingMore') }}
+
+
@@ -54,12 +78,21 @@
import { useLocalStorage } from '@vueuse/core'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
+import { computed, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
+import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
+import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
+import { useLazyPagination } from '@/composables/useLazyPagination'
+import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
-defineProps<{
+const { t } = useI18n()
+
+const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
title: string
sourceModule: string
categoryTitle: string
@@ -72,6 +105,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
'grid'
)
+const skeletonCount = 6
+const loadTrigger = ref
(null)
+
+const templatesRef = computed(() => templates || [])
+
+const { searchQuery, filteredTemplates, filteredCount } =
+ useTemplateFiltering(templatesRef)
+
+// When searching, show all results immediately without pagination
+// When not searching, use lazy pagination
+const shouldUsePagination = computed(() => !searchQuery.value.trim())
+
+// Lazy pagination setup using filtered templates
+const {
+ paginatedItems: paginatedTemplates,
+ isLoading: isLoadingMore,
+ hasMoreItems: hasMoreTemplates,
+ loadNextPage,
+ reset
+} = useLazyPagination(filteredTemplates, {
+ itemsPerPage: 12
+})
+
+// Final templates to display
+const displayTemplates = computed(() => {
+ return shouldUsePagination.value
+ ? paginatedTemplates.value
+ : filteredTemplates.value
+})
+// Intersection observer for auto-loading (only when not searching)
+useIntersectionObserver(
+ loadTrigger,
+ (entries) => {
+ const entry = entries[0]
+ if (
+ entry?.isIntersecting &&
+ shouldUsePagination.value &&
+ hasMoreTemplates.value &&
+ !isLoadingMore.value
+ ) {
+ void loadNextPage()
+ }
+ },
+ {
+ rootMargin: '200px',
+ threshold: 0.1
+ }
+)
+
+watch([() => templates, searchQuery], () => {
+ reset()
+})
+
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
diff --git a/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts b/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts
index 7d0fcc9c9..681d81238 100644
--- a/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts
+++ b/src/components/templates/thumbnails/CompareSliderThumbnail.spec.ts
@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
+vi.mock('@/components/common/LazyImage.vue', () => ({
+ default: {
+ name: 'LazyImage',
+ template:
+ '
',
+ props: ['src', 'alt', 'imageClass', 'imageStyle']
+ }
+}))
+
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
- const images = wrapper.findAll('img')
- expect(images.length).toBe(2)
- expect(images[0].attributes('src')).toBe('/base-image.jpg')
- expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
+ const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
+ expect(lazyImages.length).toBe(2)
+ expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
+ expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
- const images = wrapper.findAll('img')
- expect(images[0].attributes('alt')).toBe('Custom Alt Text')
- expect(images[1].attributes('alt')).toBe('Custom Alt Text')
+ const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
+ expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
+ expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
- const overlay = wrapper.findAll('img')[1]
- expect(overlay.attributes('style')).toContain('clip-path')
+ const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
+ const imageStyle = overlayLazyImage.props('imageStyle')
+ expect(imageStyle.clipPath).toContain('inset')
})
it('renders slider divider', () => {
diff --git a/src/components/templates/thumbnails/CompareSliderThumbnail.vue b/src/components/templates/thumbnails/CompareSliderThumbnail.vue
index 3a6d0e3a2..3633c5dc5 100644
--- a/src/components/templates/thumbnails/CompareSliderThumbnail.vue
+++ b/src/components/templates/thumbnails/CompareSliderThumbnail.vue
@@ -1,24 +1,24 @@
-
-
![]()
@@ -36,6 +36,7 @@
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
+import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50
diff --git a/src/components/templates/thumbnails/DefaultThumbnail.spec.ts b/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
index bb754c0dd..ebe138a9e 100644
--- a/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
+++ b/src/components/templates/thumbnails/DefaultThumbnail.spec.ts
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
+vi.mock('@/components/common/LazyImage.vue', () => ({
+ default: {
+ name: 'LazyImage',
+ template:
+ '
![]()
',
+ props: ['src', 'alt', 'imageClass', 'imageStyle']
+ }
+}))
+
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
- const img = wrapper.find('img')
- expect(img.attributes('src')).toBe('/test-image.jpg')
- expect(img.attributes('alt')).toBe('Test Image')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('src')).toBe('/test-image.jpg')
+ expect(lazyImage.props('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
isHovered: true,
hoverZoom: 10
})
- const img = wrapper.find('img')
- expect(img.attributes('style')).toContain('scale(1.1)')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
- const img = wrapper.find('img')
- expect(img.attributes('style')).toBeUndefined()
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ expect(lazyImage.props('imageStyle')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('w-full')
- expect(img.classes()).toContain('h-full')
- expect(img.classes()).toContain('object-cover')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('w-full')
+ expect(classString).toContain('h-full')
+ expect(classString).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('max-w-full')
- expect(img.classes()).toContain('object-contain')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('max-w-full')
+ expect(classString).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
src: '/test-video.webp',
isVideo: true
})
- const img = wrapper.find('img')
- expect(img.classes()).toContain('object-cover')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('object-cover')
})
it('image is not draggable', () => {
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
it('applies transition classes', () => {
const wrapper = mountThumbnail()
- const img = wrapper.find('img')
- expect(img.classes()).toContain('transform-gpu')
- expect(img.classes()).toContain('transition-transform')
- expect(img.classes()).toContain('duration-300')
- expect(img.classes()).toContain('ease-out')
+ const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
+ const imageClass = lazyImage.props('imageClass')
+ const classString = Array.isArray(imageClass)
+ ? imageClass.join(' ')
+ : imageClass
+ expect(classString).toContain('transform-gpu')
+ expect(classString).toContain('transition-transform')
+ expect(classString).toContain('duration-300')
+ expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {
diff --git a/src/components/templates/thumbnails/DefaultThumbnail.vue b/src/components/templates/thumbnails/DefaultThumbnail.vue
index 479bdc04c..45e053e67 100644
--- a/src/components/templates/thumbnails/DefaultThumbnail.vue
+++ b/src/components/templates/thumbnails/DefaultThumbnail.vue
@@ -1,25 +1,23 @@
-
-
![]()
-
+
diff --git a/src/components/topbar/BottomPanelToggleButton.vue b/src/components/topbar/BottomPanelToggleButton.vue
deleted file mode 100644
index 8af15331b..000000000
--- a/src/components/topbar/BottomPanelToggleButton.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
diff --git a/src/components/topbar/CommandMenubar.vue b/src/components/topbar/CommandMenubar.vue
index faf795b9b..02029d307 100644
--- a/src/components/topbar/CommandMenubar.vue
+++ b/src/components/topbar/CommandMenubar.vue
@@ -1,51 +1,116 @@
-
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ {{ option }}
+
+
+
+
-
+
+
+
+
+
diff --git a/src/components/topbar/TopMenubar.vue b/src/components/topbar/TopMenubar.vue
index e126bad67..d898e2f2a 100644
--- a/src/components/topbar/TopMenubar.vue
+++ b/src/components/topbar/TopMenubar.vue
@@ -1,82 +1,66 @@
-