Compare commits

..

1 Commits

Author SHA1 Message Date
Kelly Yang
207487eca2 refactor(load3d): replace PrimeVue Select/Slider/Checkbox with Reka UI
Replace PrimeVue components in 3D node viewer controls with the project's
Reka UI equivalents:
- Select → @/components/ui/select/* compound components (4 files)
- Slider → @/components/ui/slider/Slider.vue (3 files)
- Checkbox → native <input type="checkbox"> (ViewerSceneControls)

Numeric model values for Select (speed, animation index) are stringified at
the binding boundary and converted back on update, matching Reka Select's
string-only modelValue contract.

Slider bindings follow the same array pattern already established in
LightControls.vue.
2026-05-05 22:58:19 -07:00
10 changed files with 146 additions and 214 deletions

View File

@@ -1,42 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -21,20 +21,40 @@
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
:model-value="String(selectedSpeed)"
@update:model-value="(val) => (selectedSpeed = Number(val))"
>
<SelectTrigger size="md" class="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in speedOptions"
:key="opt.value"
:value="String(opt.value)"
>
{{ opt.name }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
:model-value="String(selectedAnimation)"
@update:model-value="(val) => (selectedAnimation = Number(val))"
>
<SelectTrigger size="md" class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="anim in animations"
:key="anim.index"
:value="String(anim.index)"
>
{{ anim.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
@@ -54,10 +74,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }

View File

@@ -15,21 +15,22 @@
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
>
<Slider
v-model="value"
:model-value="sliderValue"
class="w-full"
:min="min"
:max="max"
:step="step"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
const {
icon = 'pi-expand',
@@ -47,6 +48,12 @@ const {
const value = defineModel<number>()
const showSlider = ref(false)
const sliderValue = computed(() => [value.value ?? min])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) value.value = val[0]
}
const toggleSlider = () => {
showSlider.value = !showSlider.value
}

View File

@@ -2,34 +2,46 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
<Select v-model="cameraType">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="cam in cameras"
:key="cam.value"
:value="cam.value"
>
{{ cam.title }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:model-value="fovSliderValue"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
@update:model-value="onFovUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
@@ -41,4 +53,10 @@ const cameras = [
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const fovSliderValue = computed(() => [fov.value ?? 10])
function onFovUpdate(val: number[] | undefined) {
if (val?.length) fov.value = val[0]
}
</script>

View File

@@ -1,11 +1,18 @@
<template>
<div class="space-y-4">
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
<Select v-model="exportFormat">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in exportFormats"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
@@ -19,10 +26,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
const emit = defineEmits<{
(e: 'exportModel', format: string): void

View File

@@ -3,18 +3,20 @@
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@update:model-value="onSliderUpdate"
/>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderValue = computed(() => [
lightIntensity.value ?? lightIntensityMinimum
])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) lightIntensity.value = val[0]
}
</script>

View File

@@ -2,31 +2,51 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
<Select v-model="upDirection">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in upDirectionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
<Select v-model="materialMode">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in materialModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type {
MaterialMode,
UpDirection

View File

@@ -7,9 +7,15 @@
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
</div>
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
<div class="flex items-center gap-2">
<input
id="showGrid"
v-model="showGrid"
type="checkbox"
name="showGrid"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="showGrid" class="cursor-pointer">
{{ $t('load3d.showGrid') }}
</label>
</div>
@@ -58,7 +64,6 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
const capturedExtensions: ComfyExtension[] = []
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: ComfyExtension) => {
capturedExtensions.push(ext)
}
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
const createdWidgets: MockWidget[] = []
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
}
})
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
})
})

View File

@@ -57,7 +57,6 @@ useExtensionService().registerExtension({
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
@@ -65,14 +64,8 @@ useExtensionService().registerExtension({
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted