mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 05:00:03 +00:00
App mode output history UX improvements (#9285)
## Summary - replace reka ui list with normal elements due to rekas aggressive autoscrolling and event blocking - rework layout to fix in progress items outside scrollable area - extract feedback component - avoid scroll position changing when adding new items - add left/right keyboard navigation ## Screenshots (if applicable) Showing fixed active items at start <img width="1292" height="101" alt="image" src="https://github.com/user-attachments/assets/dcd3215c-ac09-4081-b483-8631d17ca6bf" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9285-App-mode-output-history-UX-improvements-3146d73d3650819a9f97edb41db975cc) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -5,8 +5,9 @@ import { useTemplateRef } from 'vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
@@ -40,6 +41,6 @@ whenever(feedbackRef, () => {
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
42
src/renderer/extensions/linearMode/LinearFeedback.vue
Normal file
42
src/renderer/extensions/linearMode/LinearFeedback.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { side, widgetId } = defineProps<{
|
||||
side: 'left' | 'right'
|
||||
widgetId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
const visible = computed(() => sidebarOnLeft.value === (side === 'left'))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 px-4 text-nowrap text-base-foreground self-end pb-4',
|
||||
side === 'right' && 'flex-row-reverse',
|
||||
!visible && 'invisible'
|
||||
)
|
||||
"
|
||||
:aria-hidden="!visible || undefined"
|
||||
>
|
||||
<TypeformPopoverButton
|
||||
:active="visible"
|
||||
:data-tf-widget="widgetId"
|
||||
:align="side === 'left' ? 'start' : 'end'"
|
||||
/>
|
||||
<div class="flex flex-col text-sm text-muted-foreground">
|
||||
<span>{{ t('linearMode.beta') }}</span>
|
||||
<span>{{ t('linearMode.giveFeedback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
@@ -33,10 +34,11 @@ const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const queueStore = useQueueStore()
|
||||
const { mode: appModeValue } = useAppMode()
|
||||
const { runButtonClick } = defineProps<{
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
typeformWidgetId?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
@@ -165,7 +167,30 @@ async function rerun(e: Event) {
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="appModeValue === 'builder:arrange'" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<OutputHistory class="not-md:mx-40" @update-selection="handleSelection" />
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
side="left"
|
||||
:widget-id="typeformWidgetId"
|
||||
/>
|
||||
<OutputHistory
|
||||
v-if="!isBuilderMode"
|
||||
class="min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
side="right"
|
||||
:widget-id="typeformWidgetId"
|
||||
/>
|
||||
</div>
|
||||
<OutputHistory
|
||||
v-else-if="!isBuilderMode"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
|
||||
import { computed, nextTick, useTemplateRef, watch, watchEffect } from 'vue'
|
||||
import {
|
||||
useEventListener,
|
||||
useInfiniteScroll,
|
||||
useResizeObserver
|
||||
} from '@vueuse/core'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
ref,
|
||||
toValue,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
||||
@@ -28,10 +40,6 @@ const queueCount = computed(
|
||||
() => queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
)
|
||||
|
||||
const listboxRef = useTemplateRef<{
|
||||
highlightItem: (value: SelectionValue) => void
|
||||
}>('listboxRef')
|
||||
|
||||
const itemClass = cn(
|
||||
'shrink-0 cursor-pointer p-1 rounded-lg border-2 border-transparent outline-none',
|
||||
'data-[state=checked]:border-interface-panel-job-progress-border'
|
||||
@@ -77,9 +85,22 @@ const selectedValue = computed(() => {
|
||||
return selectionMap.value.get(store.selectedId)
|
||||
})
|
||||
|
||||
function onSelectionChange(val: unknown) {
|
||||
const sv = val as SelectionValue | undefined
|
||||
store.select(sv?.id ?? null)
|
||||
function itemAttrs(id: string) {
|
||||
const selected = store.selectedId === id
|
||||
return {
|
||||
'data-state': selected ? 'checked' : 'unchecked',
|
||||
tabindex: selected ? 0 : -1
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItemEl = ref<Element | null>(null)
|
||||
|
||||
function selectedRef(id: string) {
|
||||
return store.selectedId === id
|
||||
? (el: Element | ComponentPublicInstance | null) => {
|
||||
selectedItemEl.value = el instanceof Element ? el : null
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
function doEmit() {
|
||||
@@ -149,26 +170,34 @@ watch(
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
|
||||
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
|
||||
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
|
||||
// the global keybinding handler on window. Intercept in capture phase
|
||||
// and re-dispatch from above the Listbox.
|
||||
function onModifierEnter(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey || e.shiftKey)) return
|
||||
e.stopImmediatePropagation()
|
||||
outputsRef.value?.parentElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
shiftKey: e.shiftKey,
|
||||
altKey: e.altKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
// Track scrollWidth so we can compensate when items are prepended on the left.
|
||||
let lastScrollWidth = 0
|
||||
useResizeObserver(outputsRef, () => {
|
||||
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
||||
})
|
||||
watch(
|
||||
[
|
||||
() => store.inProgressItems.length,
|
||||
() => visibleHistory.value[0]?.id,
|
||||
queueCount
|
||||
],
|
||||
() => {
|
||||
const el = outputsRef.value
|
||||
if (!el || el.scrollLeft === 0) {
|
||||
lastScrollWidth = el?.scrollWidth ?? 0
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
const delta = el.scrollWidth - lastScrollWidth
|
||||
if (delta !== 0) el.scrollLeft += delta
|
||||
lastScrollWidth = el.scrollWidth
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
})
|
||||
|
||||
function navigateToAdjacent(direction: 1 | -1) {
|
||||
const items = selectableItems.value
|
||||
@@ -177,9 +206,13 @@ function navigateToAdjacent(direction: 1 | -1) {
|
||||
const idx = currentId ? items.findIndex((i) => i.id === currentId) : -1
|
||||
const nextIdx =
|
||||
idx === -1 ? 0 : Math.max(0, Math.min(items.length - 1, idx + direction))
|
||||
const next = items[nextIdx]
|
||||
store.select(next.id)
|
||||
nextTick(() => listboxRef.value?.highlightItem(next))
|
||||
store.select(items[nextIdx].id)
|
||||
nextTick(() => {
|
||||
selectedItemEl.value?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const pointer = new CanvasPointer(document.body)
|
||||
@@ -221,9 +254,15 @@ useEventListener(
|
||||
{ passive: false }
|
||||
)
|
||||
|
||||
const keyHandlers: Record<string, 1 | -1> = {
|
||||
ArrowUp: -1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowRight: 1
|
||||
}
|
||||
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
||||
!(e.key in keyHandlers) ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement
|
||||
)
|
||||
@@ -231,28 +270,26 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'ArrowDown') navigateToAdjacent(1)
|
||||
else navigateToAdjacent(-1)
|
||||
navigateToAdjacent(keyHandlers[e.key])
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ListboxRoot
|
||||
ref="listboxRef"
|
||||
:model-value="selectedValue"
|
||||
orientation="horizontal"
|
||||
selection-behavior="replace"
|
||||
by="id"
|
||||
class="min-w-0 px-4 pb-4"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<ListboxContent as-child>
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="p-3 overflow-y-clip overflow-x-auto min-w-0"
|
||||
@keydown.capture="onModifierEnter"
|
||||
>
|
||||
<div class="flex items-center gap-0.5 mx-auto w-fit">
|
||||
<div role="group" class="min-w-0 px-4 pb-4">
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="py-3 overflow-y-clip overflow-x-auto min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-0.5 mx-auto w-fit">
|
||||
<div
|
||||
v-if="queueCount > 0 || hasActiveContent"
|
||||
:class="
|
||||
cn(
|
||||
'sticky left-0 z-10 shrink-0 flex items-center gap-0.5',
|
||||
'bg-comfy-menu-bg md:bg-comfy-menu-secondary-bg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
|
||||
<OutputHistoryActiveQueueItem :queue-count="queueCount" />
|
||||
<div
|
||||
@@ -261,49 +298,44 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ListboxItem
|
||||
v-for="item in store.activeWorkflowInProgressItems"
|
||||
<div
|
||||
v-for="item in store.inProgressItems"
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:value="{
|
||||
id: `slot:${item.id}`,
|
||||
kind: 'inProgress',
|
||||
itemId: item.id
|
||||
}"
|
||||
:ref="selectedRef(`slot:${item.id}`)"
|
||||
v-bind="itemAttrs(`slot:${item.id}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`slot:${item.id}`)"
|
||||
>
|
||||
<OutputPreviewItem
|
||||
v-if="item.state !== 'image' || !item.output"
|
||||
:latent-preview="item.latentPreviewUrl"
|
||||
/>
|
||||
<OutputHistoryItem v-else :output="item.output" />
|
||||
</ListboxItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
<ListboxItem
|
||||
v-for="(output, key) in allOutputs(asset)"
|
||||
:key
|
||||
:value="{
|
||||
id: `history:${asset.id}:${key}`,
|
||||
kind: 'history',
|
||||
assetId: asset.id,
|
||||
key
|
||||
}"
|
||||
:class="itemClass"
|
||||
>
|
||||
<OutputHistoryItem :output="output" />
|
||||
</ListboxItem>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</ListboxContent>
|
||||
</ListboxRoot>
|
||||
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
<div
|
||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
||||
:key
|
||||
:ref="selectedRef(`history:${asset.id}:${key}`)"
|
||||
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`history:${asset.id}:${key}`)"
|
||||
>
|
||||
<OutputHistoryItem :output="output" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,6 +71,8 @@ function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
|
||||
return SIDEBAR_MIN_SIZE
|
||||
}
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
@@ -104,7 +106,10 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<ModeToggle class="m-2" />
|
||||
</div>
|
||||
<div v-text="t('linearMode.beta')" />
|
||||
<TypeformPopoverButton data-tf-widget="gmVqFi8l" class="mx-2" />
|
||||
<TypeformPopoverButton
|
||||
:data-tf-widget="TYPEFORM_WIDGET_ID"
|
||||
class="mx-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
@@ -150,34 +155,20 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
|
||||
class="flex flex-col min-w-0 gap-4 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
|
||||
class="flex flex-col min-w-0 gap-4 relative text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearProgressBar
|
||||
class="absolute top-0 left-0 w-[calc(100%+16px)] z-21"
|
||||
/>
|
||||
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:typeform-widget-id="TYPEFORM_WIDGET_ID"
|
||||
/>
|
||||
<div class="absolute z-21 top-4 left-4">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-7 left-4" />
|
||||
<div ref="bottomRightRef" class="absolute z-20 bottom-7 right-4" />
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute z-20 bottom-4 text-base-foreground flex items-center gap-2',
|
||||
sidebarOnLeft ? 'left-4' : 'right-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<TypeformPopoverButton
|
||||
data-tf-widget="gmVqFi8l"
|
||||
:align="sidebarOnLeft ? 'start' : 'end'"
|
||||
/>
|
||||
<div class="flex flex-col text-sm text-muted-foreground">
|
||||
<span>{{ t('linearMode.beta') }}</span>
|
||||
<span>{{ t('linearMode.giveFeedback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
Reference in New Issue
Block a user