mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-27 01:57:17 +00:00
Compare commits
12 Commits
DynamicGro
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30dd027fe7 | ||
|
|
1802902c26 | ||
|
|
61ebcb514d | ||
|
|
b5fd5fd54c | ||
|
|
70c2e5e70e | ||
|
|
8bd12134b2 | ||
|
|
160d7c7a63 | ||
|
|
51efcf0424 | ||
|
|
0975a7ffbc | ||
|
|
8bebdb3021 | ||
|
|
b8207f2647 | ||
|
|
787815eb09 |
@@ -235,6 +235,9 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
|
||||
@@ -137,6 +137,122 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shares the graph side toolbar, filtered to assets + apps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const sideToolbar = comfyPage.page.getByTestId(TestIds.sidebar.toolbar)
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(
|
||||
sideToolbar.locator('.node-library-tab-button')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode reuses it with only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(sideToolbar.locator('.assets-tab-button')).toBeVisible()
|
||||
await expect(sideToolbar.locator('.apps-tab-button')).toBeVisible()
|
||||
await expect(sideToolbar.locator('.node-library-tab-button')).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
const appActions = comfyPage.page
|
||||
.getByTestId(TestIds.linear.centerPanel)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const appSegment = comfyPage.page.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
await appSegment.focus()
|
||||
await appSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// Keyboard activation of the inactive segment must switch mode without the
|
||||
// keydown bubbling to the trigger and opening the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle returns to app mode after exiting the builder', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
|
||||
--gen-angle must be a registered <angle> so the conic gradient interpolates
|
||||
instead of jumping between keyframes. */
|
||||
@property --gen-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@keyframes gen-angle-spin {
|
||||
to {
|
||||
--gen-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.gen-glow {
|
||||
position: absolute;
|
||||
inset: -28%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from var(--gen-angle),
|
||||
#3b82f63b,
|
||||
#8b5cf633,
|
||||
#d946ef2b,
|
||||
#ec489933,
|
||||
#f9731629,
|
||||
#14b8a62e,
|
||||
#3b82f63b
|
||||
);
|
||||
filter: blur(60px);
|
||||
opacity: 0.34;
|
||||
animation: gen-angle-spin 12s linear infinite;
|
||||
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
|
||||
}
|
||||
|
||||
.gen-glow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from calc(var(--gen-angle) + 120deg),
|
||||
#3b82f629,
|
||||
#8b5cf621,
|
||||
#d946ef1c,
|
||||
#ec489924,
|
||||
#f973161a,
|
||||
#14b8a621,
|
||||
#3b82f629
|
||||
);
|
||||
filter: blur(34px);
|
||||
opacity: 0.39;
|
||||
mask-image: radial-gradient(
|
||||
circle,
|
||||
#000 0%,
|
||||
rgb(0 0 0 / 0.62) 36%,
|
||||
rgb(0 0 0 / 0.22) 50%,
|
||||
rgb(0 0 0 / 0) 64%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gen-glow {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active,
|
||||
.gen-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
|
||||
.genfan-enter-active,
|
||||
.genfan-leave-active {
|
||||
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.genfan-enter-from,
|
||||
.genfan-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -1,119 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
variant="base"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
|
||||
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<WorkflowActionsDropdown
|
||||
v-if="!canvasStore.linearMode"
|
||||
source="breadcrumb_subgraph_menu_selected"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -17,25 +18,67 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
type ViewMode = 'graph' | 'app'
|
||||
|
||||
interface ViewModeSegment {
|
||||
mode: ViewMode
|
||||
icon: string
|
||||
label: string
|
||||
switchLabel: string
|
||||
switchTooltip: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
const toggleShortcut = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
const segments = computed<ViewModeSegment[]>(() => [
|
||||
{
|
||||
mode: 'graph',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
label: t('breadcrumbsMenu.graph'),
|
||||
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
|
||||
active: !canvasStore.displayLinearMode
|
||||
},
|
||||
{
|
||||
mode: 'app',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
label: t('breadcrumbsMenu.app'),
|
||||
switchLabel: t('breadcrumbsMenu.enterAppMode'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
|
||||
active: canvasStore.displayLinearMode
|
||||
}
|
||||
])
|
||||
|
||||
// Inactive segment first (left), active last (right). On mode switch the array
|
||||
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
|
||||
const orderedSegments = computed(() =>
|
||||
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
@@ -46,23 +89,32 @@ function handleOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
function switchMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
// The container is the dropdown trigger, so an inactive segment must stop its
|
||||
// pointer event from bubbling up and opening the menu instead of switching.
|
||||
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
|
||||
if (!seg.active) e.stopPropagation()
|
||||
}
|
||||
|
||||
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
|
||||
// from bubbling to the trigger. The button's native activation still fires
|
||||
// onSegmentClick to switch mode, so the menu stays closed.
|
||||
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
|
||||
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
|
||||
}
|
||||
|
||||
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
|
||||
if (seg.active) return
|
||||
e.stopPropagation()
|
||||
switchMode()
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
@@ -75,7 +127,7 @@ const tooltipPt = {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
style: { left: '16px' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -86,69 +138,81 @@ const tooltipPt = {
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
data-testid="view-mode-toggle"
|
||||
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
}"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
move-class="transition-[background-color,color,transform] duration-200"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
v-for="seg in orderedSegments"
|
||||
:key="seg.mode"
|
||||
v-tooltip.bottom="{
|
||||
value: seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchTooltip,
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
hideDelay: 300,
|
||||
pt: seg.active ? undefined : tooltipPt
|
||||
}"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
:aria-label="
|
||||
seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchLabel
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
|
||||
seg.active
|
||||
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
|
||||
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
|
||||
)
|
||||
"
|
||||
@pointerdown="onSegmentPointerDown(seg, $event)"
|
||||
@keydown="onSegmentKeydown(seg, $event)"
|
||||
@click="onSegmentClick(seg, $event)"
|
||||
>
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
:class="
|
||||
cn(
|
||||
'grid transition-[grid-template-columns,opacity] duration-200',
|
||||
seg.active
|
||||
? 'ml-1.5 grid-cols-[1fr] opacity-100'
|
||||
: 'grid-cols-[0fr] opacity-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
|
||||
>
|
||||
{{ seg.label }}
|
||||
<i
|
||||
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="seg.active && hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
<template #side-toolbar>
|
||||
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
|
||||
@@ -42,8 +42,14 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton
|
||||
v-if="!isCloud && !canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarShortcutsToggleButton
|
||||
v-if="!canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +95,11 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const { visibleTabIds, forceConnected = false } = defineProps<{
|
||||
visibleTabIds?: string[]
|
||||
forceConnected?: boolean
|
||||
}>()
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -115,12 +126,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
forceConnected ||
|
||||
selectedTab.value ||
|
||||
isOverflowing.value ||
|
||||
sidebarStyle.value === 'connected'
|
||||
)
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const tabs = computed(() => {
|
||||
const all = workspaceStore.getSidebarTabs()
|
||||
return visibleTabIds
|
||||
? all.filter((tab) => visibleTabIds.includes(tab.id))
|
||||
: all
|
||||
})
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
<template>
|
||||
<Popover
|
||||
v-if="linearMode"
|
||||
:side="sidebarOnLeft ? 'right' : 'left'"
|
||||
:side-offset="8"
|
||||
>
|
||||
<template #button>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('linearMode.giveFeedback')"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget="typeformId" />
|
||||
</Popover>
|
||||
<SidebarIcon
|
||||
v-else
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
@@ -13,13 +31,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
|
||||
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
|
||||
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
|
||||
const { typeformId } = useTypeformEmbed(
|
||||
feedbackRef,
|
||||
APP_MODE_FEEDBACK_TYPEFORM_ID
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -13,18 +13,26 @@
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-actions="{ hasResults }">
|
||||
<Button
|
||||
v-if="hasResults"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('linearMode.appModeToolbar.create')"
|
||||
@click="createApp"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
|
||||
{{ $t('linearMode.appModeToolbar.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
|
||||
button-icon="icon-[lucide--plus]"
|
||||
:button-label="$t('linearMode.appModeToolbar.createApp')"
|
||||
@action="createApp"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
@@ -33,16 +41,17 @@
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
function createApp() {
|
||||
void commandStore.execute('Comfy.NewBlankWorkflow')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot
|
||||
name="header-actions"
|
||||
:has-results="filteredPersistedWorkflows.length > 0"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SidebarTopArea>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -3594,13 +3594,13 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"generating": "Generating…",
|
||||
"stopGeneration": "Stop generation",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
@@ -3609,7 +3609,6 @@
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
@@ -3628,7 +3627,10 @@
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"buildAnApp": "Build an app",
|
||||
"create": "Create",
|
||||
"createApp": "Create app"
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -8,9 +9,13 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const { appModeState } = vi.hoisted(() => ({
|
||||
appModeState: {} as { isAppMode: Ref<boolean> }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
isAppMode: { value: false },
|
||||
isAppMode: appModeState.isAppMode,
|
||||
setMode: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -43,6 +48,7 @@ describe('useCanvasStore', () => {
|
||||
let store: ReturnType<typeof useCanvasStore>
|
||||
|
||||
beforeEach(() => {
|
||||
appModeState.isAppMode = ref(false)
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useCanvasStore()
|
||||
vi.clearAllMocks()
|
||||
@@ -129,4 +135,35 @@ describe('useCanvasStore', () => {
|
||||
|
||||
expect(store.selectedNodeIds).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('displayLinearMode', () => {
|
||||
it('lags the view mode by two frames so the toggle can animate the switch', async () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
const flushFrames = () => {
|
||||
while (rafCallbacks.length) {
|
||||
for (const cb of rafCallbacks.splice(0)) cb(0)
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
appModeState.isAppMode.value = true
|
||||
await nextTick()
|
||||
|
||||
// The real mode has flipped, but the displayed mode still lags so a toggle
|
||||
// that mounts now renders the old order before animating to the new one.
|
||||
expect(store.linearMode).toBe(true)
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
flushFrames()
|
||||
|
||||
expect(store.displayLinearMode).toBe(true)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef } from 'vue'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -57,6 +57,22 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Frame-lagged mirror of {@link linearMode} that drives the view-mode toggle's
|
||||
* segment morph. Lagging by two frames lets a toggle that mounts mid-switch
|
||||
* render the previous mode first, then animate into the new one. It lives in
|
||||
* the store so the value outlives the graph-mode toggle unmounting and the
|
||||
* app-mode toggle mounting in its place during a switch.
|
||||
*/
|
||||
const displayLinearMode = ref(linearMode.value)
|
||||
watch(linearMode, (next) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
displayLinearMode.value = next
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set up scale synchronization when canvas is available
|
||||
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
|
||||
undefined
|
||||
@@ -188,6 +204,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
rerouteSelected,
|
||||
appScalePercentage,
|
||||
linearMode,
|
||||
displayLinearMode,
|
||||
updateSelectedItems,
|
||||
getCanvas,
|
||||
setAppZoomFromPercentage,
|
||||
|
||||
100
src/renderer/extensions/linearMode/GeneratingCard.vue
Normal file
100
src/renderer/extensions/linearMode/GeneratingCard.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
|
||||
import { computeFanLayout } from '@/renderer/extensions/linearMode/fanLayout'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// depth: recency rank, 0 = newest. total: number of cards in the fan.
|
||||
const { card, depth, total } = defineProps<{
|
||||
card: InProgressItem
|
||||
depth: number
|
||||
total: number
|
||||
}>()
|
||||
|
||||
// Skip the drop-in entrance entirely when the user prefers reduced motion.
|
||||
const reducedMotion = usePreferredReducedMotion()
|
||||
const mounted = ref(reducedMotion.value === 'reduce')
|
||||
onMounted(() => {
|
||||
if (!mounted.value) requestAnimationFrame(() => (mounted.value = true))
|
||||
})
|
||||
|
||||
const mediaType = computed(() =>
|
||||
card.state === 'image' && card.output ? getMediaType(card.output) : 'images'
|
||||
)
|
||||
|
||||
// The still image to show (latent preview, then final image output). Video and
|
||||
// other media render from card.output directly.
|
||||
const imageSrc = computed(() => {
|
||||
if (card.state === 'latent') return card.latentPreviewUrl
|
||||
if (card.state === 'image' && card.output && mediaType.value === 'images')
|
||||
return card.output.url
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Fade the image in once loaded so it never paints half-formed. A later src
|
||||
// (latent -> final) swaps in place: the browser keeps the prior frame until the
|
||||
// new one is ready, so it stays loaded.
|
||||
const loaded = ref(false)
|
||||
|
||||
const layout = computed(() => computeFanLayout(depth, total))
|
||||
|
||||
// New cards drop in and scale up slightly on first mount.
|
||||
const ENTER_DROP_PX = 22
|
||||
const ENTER_SCALE = 0.94
|
||||
|
||||
// Positioning lives on this inner element, not the root: the fan's
|
||||
// <TransitionGroup> rewrites the root's transform to measure moves, which would
|
||||
// otherwise fling cards to the corner for a frame. The root only carries
|
||||
// z-index and the fade in/out opacity.
|
||||
const innerStyle = computed(() => {
|
||||
const f = layout.value
|
||||
const entering = !mounted.value
|
||||
const y = entering ? f.y + ENTER_DROP_PX : f.y
|
||||
const scale = f.scale * (entering ? ENTER_SCALE : 1)
|
||||
return {
|
||||
transform: `translate(-50%, -50%) translateX(${f.x}px) translateY(${y}px) rotate(${f.rotate}deg) scale(${scale})`,
|
||||
opacity: f.opacity
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="absolute inset-0" :style="{ zIndex: layout.z }">
|
||||
<div
|
||||
class="gen-card absolute top-1/2 left-1/2 size-[min(46cqh,300px)] overflow-hidden rounded-2xl bg-secondary-background shadow-[0_24px_60px_-12px_rgba(0,0,0,0.65)] ring-1 ring-border-subtle transition-[transform,opacity] duration-620 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform"
|
||||
:style="innerStyle"
|
||||
>
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
alt=""
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover',
|
||||
loaded ? 'opacity-100' : 'opacity-0',
|
||||
reducedMotion !== 'reduce' && 'transition-opacity duration-300'
|
||||
)
|
||||
"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
<template v-else-if="mediaType === 'video' && card.output">
|
||||
<video
|
||||
class="size-full object-cover"
|
||||
preload="metadata"
|
||||
:src="card.output.url"
|
||||
/>
|
||||
<VideoPlayOverlay size="sm" />
|
||||
</template>
|
||||
<i
|
||||
v-else-if="card.output"
|
||||
:class="cn(mediaTypes[mediaType]?.iconClass, 'm-auto block size-12')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
173
src/renderer/extensions/linearMode/GeneratingScreen.stories.ts
Normal file
173
src/renderer/extensions/linearMode/GeneratingScreen.stories.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import GeneratingCard from './GeneratingCard.vue'
|
||||
import GeneratingScreen from './GeneratingScreen.vue'
|
||||
import { GENERATING_CARD_LIMIT } from './linearOutputStore'
|
||||
import type { InProgressItem } from './linearModeTypes'
|
||||
|
||||
const meta: Meta<typeof GeneratingScreen> = {
|
||||
title: 'LinearMode/GeneratingScreen',
|
||||
component: GeneratingScreen,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: `
|
||||
<div class="h-screen w-screen bg-[var(--color-workspace-bg)]">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function swatch(seed: number): string {
|
||||
const hue = (seed * 67) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="hsl(${hue} 60% 55%)"/><stop offset="100%" stop-color="hsl(${(hue + 40) % 360} 55% 30%)"/></linearGradient></defs><rect width="300" height="300" fill="url(#g)"/></svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
// Newest first, matching the store's generatingCards order.
|
||||
const fanCards: InProgressItem[] = [
|
||||
{
|
||||
id: 'c5',
|
||||
jobId: 'job',
|
||||
seq: 5,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(5)
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
jobId: 'job',
|
||||
seq: 4,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(4)
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
jobId: 'job',
|
||||
seq: 3,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(3)
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
jobId: 'job',
|
||||
seq: 2,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(2)
|
||||
},
|
||||
{
|
||||
id: 'c1',
|
||||
jobId: 'job',
|
||||
seq: 1,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(1)
|
||||
}
|
||||
]
|
||||
|
||||
function fanned(cards: InProgressItem[]) {
|
||||
const total = cards.length
|
||||
return cards.map((card, depth) => ({ card, depth, total })).reverse()
|
||||
}
|
||||
|
||||
// The real screen, wired to the store (empty fan): shows the ambient glow,
|
||||
// status text, progress bar and Stop button.
|
||||
export const Empty: Story = {}
|
||||
|
||||
// Composite preview of the popping-card fan over the ambient glow, using the
|
||||
// same markup the screen builds around GeneratingCard.
|
||||
export const Fan: Story = {
|
||||
render: () => ({
|
||||
components: { GeneratingCard },
|
||||
setup: () => ({ cards: fanned(fanCards) }),
|
||||
template: `
|
||||
<div class="flex h-full w-full items-center justify-center @container-size">
|
||||
<div class="relative flex h-[min(50cqh,320px)] w-[440px] items-center justify-center overflow-visible">
|
||||
<div class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2">
|
||||
<span class="gen-glow" />
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<GeneratingCard
|
||||
v-for="{ card, depth, total } in cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:depth="depth"
|
||||
:total="total"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Interactive harness for the fan: add cards to watch the pop-in entrance and
|
||||
// reflow, remove the newest to watch it leave, and reset to clear. Adding past
|
||||
// GENERATING_CARD_LIMIT evicts the oldest so the eviction fade is visible too.
|
||||
export const Interactive: Story = {
|
||||
render: () => ({
|
||||
components: { GeneratingCard },
|
||||
setup() {
|
||||
const cards = ref<InProgressItem[]>([])
|
||||
let seq = 0
|
||||
|
||||
function add() {
|
||||
seq += 1
|
||||
const next: InProgressItem = {
|
||||
id: `c${seq}`,
|
||||
jobId: 'job',
|
||||
seq,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: swatch(seq)
|
||||
}
|
||||
cards.value = [next, ...cards.value].slice(0, GENERATING_CARD_LIMIT)
|
||||
}
|
||||
function remove() {
|
||||
cards.value = cards.value.slice(1)
|
||||
}
|
||||
function reset() {
|
||||
cards.value = []
|
||||
}
|
||||
|
||||
const fanCards = computed(() => {
|
||||
const total = cards.value.length
|
||||
return cards.value
|
||||
.map((card, depth) => ({ card, depth, total }))
|
||||
.reverse()
|
||||
})
|
||||
|
||||
const btnClass =
|
||||
'rounded-lg bg-secondary-background px-3 py-1.5 text-sm text-muted-foreground ring-1 ring-border-subtle transition-opacity hover:opacity-70'
|
||||
|
||||
return { fanCards, add, remove, reset, btnClass }
|
||||
},
|
||||
template: `
|
||||
<div class="@container-size flex h-full w-full flex-col items-center justify-center gap-10">
|
||||
<div class="flex gap-2">
|
||||
<button :class="btnClass" @click="add">Add card</button>
|
||||
<button :class="btnClass" @click="remove">Remove newest</button>
|
||||
<button :class="btnClass" @click="reset">Reset</button>
|
||||
</div>
|
||||
<div class="relative flex h-[min(50cqh,320px)] w-[440px] items-center justify-center overflow-visible">
|
||||
<div class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2">
|
||||
<span class="gen-glow" />
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="genfan" class="pointer-events-none absolute inset-0">
|
||||
<GeneratingCard
|
||||
v-for="{ card, depth, total } in fanCards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:depth="depth"
|
||||
:total="total"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
110
src/renderer/extensions/linearMode/GeneratingScreen.vue
Normal file
110
src/renderer/extensions/linearMode/GeneratingScreen.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { refThrottled } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import GeneratingCard from '@/renderer/extensions/linearMode/GeneratingCard.vue'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
|
||||
defineEmits<{ stop: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
const { totalPercent } = useQueueProgress()
|
||||
const { generatingCards } = storeToRefs(useLinearOutputStore())
|
||||
|
||||
const statusMessage = computed(
|
||||
() => executionStatusMessage.value ?? t('linearMode.generating')
|
||||
)
|
||||
// Throttle status text so it doesn't flicker as nodes execute in quick succession.
|
||||
const displayStatus = refThrottled(statusMessage, 1000)
|
||||
|
||||
// Only cards with something to show belong in the fan; skeletons and latents
|
||||
// without a preview are skipped. Each card fades its own image in on load.
|
||||
function hasFanContent(card: InProgressItem): boolean {
|
||||
if (card.state === 'image') return card.output != null
|
||||
if (card.state === 'latent') return card.latentPreviewUrl != null
|
||||
return false
|
||||
}
|
||||
|
||||
// generatingCards is newest-first; render oldest-first so the newest paints
|
||||
// last (on top).
|
||||
const fanCards = computed(() => {
|
||||
const cards = generatingCards.value.filter(hasFanContent)
|
||||
const total = cards.length
|
||||
return cards.map((card, depth) => ({ card, depth, total })).reverse()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 items-center justify-center px-6">
|
||||
<div
|
||||
class="@container-size relative flex size-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-[min(100%,440px)] flex-col items-center gap-8 px-4"
|
||||
>
|
||||
<div
|
||||
class="relative flex h-[min(50cqh,320px)] w-full shrink-0 items-center justify-center overflow-visible"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2"
|
||||
>
|
||||
<span class="gen-glow" />
|
||||
</div>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="genfan"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<GeneratingCard
|
||||
v-for="{ card, depth, total } in fanCards"
|
||||
:key="card.id"
|
||||
:card
|
||||
:depth
|
||||
:total
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full max-w-[min(100%,280px)] shrink-0 flex-col items-center gap-7"
|
||||
>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="min-h-[18px] text-center text-[13px] leading-tight text-muted-foreground"
|
||||
>
|
||||
{{ displayStatus }}
|
||||
</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(totalPercent)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="t('linearMode.generating')"
|
||||
class="relative h-0.5 w-full overflow-hidden rounded-full bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
data-testid="generating-progress"
|
||||
class="h-full rounded-full bg-interface-panel-job-progress-primary transition-[width] duration-150 ease-linear"
|
||||
:style="{ width: `${totalPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="md"
|
||||
data-testid="linear-cancel-run"
|
||||
@click="$emit('stop')"
|
||||
>
|
||||
{{ t('linearMode.stopGeneration') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse text-sm text-muted"
|
||||
>
|
||||
{{ executionStatusMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<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 '@comfyorg/tailwind-utils'
|
||||
|
||||
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 self-end px-4 pb-4 text-nowrap text-base-foreground',
|
||||
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>
|
||||
@@ -10,41 +10,38 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import GeneratingScreen from '@/renderer/extensions/linearMode/GeneratingScreen.vue'
|
||||
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 MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
const { runButtonClick, mobile } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
typeformWidgetId?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
canShowPreview.value = sel.canShowPreview
|
||||
latentPreview.value = sel.latentPreviewUrl
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
@@ -73,7 +70,7 @@ async function rerun(e: Event) {
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
|
||||
v-if="!isWorkflowActive && (selectedItem || selectedOutput)"
|
||||
data-testid="linear-output-info"
|
||||
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
|
||||
>
|
||||
@@ -101,15 +98,6 @@ async function rerun(e: Event) {
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
data-testid="linear-cancel-run"
|
||||
variant="destructive"
|
||||
@click="cancelActiveWorkflowJobs()"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('linearMode.cancelThisRun') }}
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="selectedItem"
|
||||
:entries="[
|
||||
@@ -133,8 +121,12 @@ async function rerun(e: Event) {
|
||||
]"
|
||||
/>
|
||||
</section>
|
||||
<GeneratingScreen
|
||||
v-if="isWorkflowActive"
|
||||
@stop="cancelActiveWorkflowJobs()"
|
||||
/>
|
||||
<ImagePreview
|
||||
v-if="canShowPreview && latentPreview"
|
||||
v-else-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
:show-size="false"
|
||||
@@ -144,31 +136,11 @@ async function rerun(e: Event) {
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<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="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
side="right"
|
||||
:widget-id="typeformWidgetId"
|
||||
/>
|
||||
</div>
|
||||
<OutputHistory
|
||||
v-else-if="!isBuilderMode"
|
||||
v-if="!isBuilderMode"
|
||||
:class="cn(!mobile && 'z-10 min-w-0')"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -162,7 +162,7 @@ function makeInProgressItem(
|
||||
state: InProgressItem['state'] = 'skeleton',
|
||||
opts?: Partial<InProgressItem>
|
||||
): InProgressItem {
|
||||
return { id, jobId: `job-${id}`, state, ...opts }
|
||||
return { id, jobId: `job-${id}`, seq: 0, state, ...opts }
|
||||
}
|
||||
|
||||
let activeResult: RenderResult | null = null
|
||||
@@ -388,7 +388,7 @@ describe('OutputHistory', () => {
|
||||
expect(lastEmission(result)).toEqual({ canShowPreview: true })
|
||||
})
|
||||
|
||||
it('emits showSkeleton for in-progress skeleton item', async () => {
|
||||
it('emits canShowPreview for in-progress skeleton item', async () => {
|
||||
activeWorkflowInProgressItemsRef.value = [
|
||||
makeInProgressItem('ip1', 'skeleton')
|
||||
]
|
||||
@@ -398,10 +398,7 @@ describe('OutputHistory', () => {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmission(result)).toMatchObject({
|
||||
canShowPreview: true,
|
||||
showSkeleton: true
|
||||
})
|
||||
expect(lastEmission(result)).toEqual({ canShowPreview: true })
|
||||
})
|
||||
|
||||
it('emits latentPreviewUrl for in-progress latent item', async () => {
|
||||
@@ -499,7 +496,7 @@ describe('OutputHistory', () => {
|
||||
expect(lastEmission(result).canShowPreview).toBe(false)
|
||||
})
|
||||
|
||||
it('emits skeleton for pending slot selection', async () => {
|
||||
it('emits canShowPreview for pending slot selection', async () => {
|
||||
mayBeActiveWorkflowPendingRef.value = true
|
||||
runningTasksRef.value = [{ jobId: 'j1' }]
|
||||
|
||||
@@ -510,10 +507,7 @@ describe('OutputHistory', () => {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmission(result)).toMatchObject({
|
||||
canShowPreview: true,
|
||||
showSkeleton: true
|
||||
})
|
||||
expect(lastEmission(result)).toEqual({ canShowPreview: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ function doEmit() {
|
||||
(i) => i.id === sel.itemId
|
||||
)
|
||||
if (!item || item.state === 'skeleton') {
|
||||
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
} else if (item.state === 'latent') {
|
||||
emit('updateSelection', {
|
||||
canShowPreview: true,
|
||||
@@ -298,10 +298,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
class="flex h-21 min-w-0 items-start justify-center px-4 py-3 pb-4"
|
||||
>
|
||||
<div role="group" class="flex h-21 min-w-0 items-start px-4 py-3 pb-4">
|
||||
<div
|
||||
v-if="queueCount > 0 || hasActiveContent"
|
||||
class="flex h-15 shrink-0 items-start gap-0.5"
|
||||
@@ -347,7 +344,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="min-w-0 overflow-x-auto overflow-y-clip"
|
||||
class="min-w-0 flex-1 overflow-x-auto overflow-y-clip contain-[inline-size]"
|
||||
>
|
||||
<div class="flex h-15 w-fit items-start gap-0.5">
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
|
||||
40
src/renderer/extensions/linearMode/fanLayout.test.ts
Normal file
40
src/renderer/extensions/linearMode/fanLayout.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeFanLayout } from '@/renderer/extensions/linearMode/fanLayout'
|
||||
|
||||
describe('computeFanLayout', () => {
|
||||
it('places the newest card (depth 0) dead centre, frontmost and largest', () => {
|
||||
const newest = computeFanLayout(0, 3)
|
||||
|
||||
expect(newest.x).toBe(0)
|
||||
expect(newest.rotate).toBe(0)
|
||||
expect(newest.scale).toBe(1)
|
||||
expect(newest.opacity).toBe(1)
|
||||
// Highest z of the fan so it paints on top.
|
||||
expect(newest.z).toBe(3)
|
||||
})
|
||||
|
||||
it('fans older cards out to alternating sides, scaled and faded back', () => {
|
||||
const cards = [0, 1, 2].map((depth) => computeFanLayout(depth, 3))
|
||||
const [newest, mid, oldest] = cards
|
||||
|
||||
// Older cards sit off-centre on alternating sides.
|
||||
expect(newest.x).toBe(0)
|
||||
expect(mid.x).toBeGreaterThan(0)
|
||||
expect(oldest.x).toBeLessThan(0)
|
||||
expect(Math.sign(mid.x)).not.toBe(Math.sign(oldest.x))
|
||||
|
||||
// Depth recedes: smaller, more transparent, lower z.
|
||||
expect(mid.scale).toBeLessThan(newest.scale)
|
||||
expect(oldest.scale).toBeLessThan(mid.scale)
|
||||
expect(mid.opacity).toBeLessThan(newest.opacity)
|
||||
expect(oldest.z).toBeLessThan(mid.z)
|
||||
})
|
||||
|
||||
it('clamps scale and opacity so deep cards never vanish', () => {
|
||||
const deep = computeFanLayout(20, 21)
|
||||
|
||||
expect(deep.scale).toBe(0.7)
|
||||
expect(deep.opacity).toBe(0.3)
|
||||
})
|
||||
})
|
||||
38
src/renderer/extensions/linearMode/fanLayout.ts
Normal file
38
src/renderer/extensions/linearMode/fanLayout.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
interface FanLayout {
|
||||
x: number
|
||||
y: number
|
||||
rotate: number
|
||||
scale: number
|
||||
opacity: number
|
||||
z: number
|
||||
}
|
||||
|
||||
const CARD_GAP_PX = 30
|
||||
const CARD_LIFT_PX = 8
|
||||
const CARD_TILT_DEG = 5
|
||||
const DEPTH_SCALE_STEP = 0.06
|
||||
const DEPTH_OPACITY_STEP = 0.14
|
||||
const MIN_SCALE = 0.7
|
||||
const MIN_OPACITY = 0.3
|
||||
|
||||
/**
|
||||
* Lays a card out in the generating-screen fan. Cards spread from the centre by
|
||||
* age: the oldest sit at the outer edges (alternating left/right) and the newest
|
||||
* lands dead centre, frontmost and largest.
|
||||
*
|
||||
* @param depth recency rank, 0 = newest (frontmost)
|
||||
* @param total number of cards in the fan
|
||||
*/
|
||||
export function computeFanLayout(depth: number, total: number): FanLayout {
|
||||
const age = total - 1 - depth // 0 = oldest
|
||||
const slot = age % 2 === 0 ? age / 2 : total - 1 - (age - 1) / 2
|
||||
const fromCenter = slot - (total - 1) / 2
|
||||
return {
|
||||
x: fromCenter * CARD_GAP_PX,
|
||||
y: Math.abs(fromCenter) * CARD_LIFT_PX,
|
||||
rotate: fromCenter * CARD_TILT_DEG,
|
||||
scale: Math.max(MIN_SCALE, 1 - depth * DEPTH_SCALE_STEP),
|
||||
opacity: Math.max(MIN_OPACITY, 1 - depth * DEPTH_OPACITY_STEP),
|
||||
z: total - depth
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
export interface InProgressItem {
|
||||
id: string
|
||||
jobId: string
|
||||
/** Monotonic arrival order, assigned at creation. Used to order the fan. */
|
||||
seq: number
|
||||
state: 'skeleton' | 'latent' | 'image'
|
||||
latentPreviewUrl?: string
|
||||
output?: ResultItemImpl
|
||||
@@ -14,7 +16,6 @@ export interface OutputSelection {
|
||||
output?: ResultItemImpl
|
||||
canShowPreview: boolean
|
||||
latentPreviewUrl?: string
|
||||
showSkeleton?: boolean
|
||||
}
|
||||
|
||||
export type SelectionValue =
|
||||
|
||||
@@ -2,7 +2,10 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import {
|
||||
GENERATING_CARD_LIMIT,
|
||||
useLinearOutputStore
|
||||
} from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
@@ -803,6 +806,125 @@ describe('linearOutputStore', () => {
|
||||
expect(imageItems[0].output?.nodeId).toBe('2')
|
||||
})
|
||||
|
||||
it('pops non-selected outputs into the generating fan without feeding history', () => {
|
||||
selectedOutputsRef.value = ['2']
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Node 1 is not a selected output — it must not enter the feed...
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(0)
|
||||
|
||||
// ...but it still pops as a card in the generating screen.
|
||||
expect(
|
||||
store.generatingCards.filter(
|
||||
(c) => c.state === 'image' && c.output?.nodeId === '1'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('orders generating cards by arrival, non-selected above earlier selected', () => {
|
||||
selectedOutputsRef.value = ['2']
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Selected output completes first (fills the skeleton slot)...
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail(
|
||||
'job-1',
|
||||
[{ filename: 'selected.png', subfolder: '', type: 'output' }],
|
||||
'2'
|
||||
)
|
||||
)
|
||||
// ...then a non-selected output arrives later.
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail(
|
||||
'job-1',
|
||||
[{ filename: 'extra.png', subfolder: '', type: 'output' }],
|
||||
'1'
|
||||
)
|
||||
)
|
||||
|
||||
// The later non-selected card is newest, so it leads the fan.
|
||||
expect(store.generatingCards[0].output?.filename).toBe('extra.png')
|
||||
expect(store.generatingCards[1].output?.filename).toBe('selected.png')
|
||||
})
|
||||
|
||||
it('clears non-selected generating cards when the run ends', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
selectedOutputsRef.value = ['2']
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('caps the generating fan at GENERATING_CARD_LIMIT, keeping the newest', () => {
|
||||
selectedOutputsRef.value = ['1']
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
const outputs = Array.from(
|
||||
{ length: GENERATING_CARD_LIMIT + 2 },
|
||||
(_, i) => ({
|
||||
filename: `out-${i}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
})
|
||||
)
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', outputs, '1'))
|
||||
|
||||
const fan = store.generatingCards
|
||||
expect(fan).toHaveLength(GENERATING_CARD_LIMIT)
|
||||
// The newest GENERATING_CARD_LIMIT outputs survive; the earliest drop off.
|
||||
expect(fan.map((c) => c.output?.filename)).toEqual([
|
||||
'out-4.png',
|
||||
'out-3.png',
|
||||
'out-2.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('clears stale non-selected cards from a prior run on the next job start', () => {
|
||||
selectedOutputsRef.value = ['2']
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
store.onJobStart('job-2')
|
||||
|
||||
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('does not auto-select for jobs belonging to another workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
// Max cards shown in the generating screen's fan before the oldest drop off.
|
||||
export const GENERATING_CARD_LIMIT = 3
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const { isAppMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -20,6 +23,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const inProgressItems = ref<InProgressItem[]>([])
|
||||
// Outputs from nodes not selected in the builder: shown in the generating
|
||||
// screen so every result still "pops", but never added to the output feed.
|
||||
const generatingExtraCards = ref<InProgressItem[]>([])
|
||||
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isFollowing = ref(true)
|
||||
@@ -36,10 +42,23 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Cards for the generating screen's fan: selected (feed) and non-selected
|
||||
// outputs interleaved by true arrival order so the newest is always first,
|
||||
// regardless of which list it came from.
|
||||
const generatingCards = computed<InProgressItem[]>(() =>
|
||||
[...activeWorkflowInProgressItems.value, ...generatingExtraCards.value]
|
||||
.sort((a, b) => b.seq - a.seq)
|
||||
.slice(0, GENERATING_CARD_LIMIT)
|
||||
)
|
||||
|
||||
let nextSeq = 0
|
||||
|
||||
function makeItemId(jobId: JobId): string {
|
||||
return `job-${jobId}-${nextSeq++}`
|
||||
function createItem(
|
||||
jobId: JobId,
|
||||
props: Omit<InProgressItem, 'id' | 'jobId' | 'seq'>
|
||||
): InProgressItem {
|
||||
const seq = nextSeq++
|
||||
return { id: `job-${jobId}-${seq}`, jobId, seq, ...props }
|
||||
}
|
||||
|
||||
function replaceItem(
|
||||
@@ -57,12 +76,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
function onJobStart(jobId: JobId) {
|
||||
executedNodeIds.clear()
|
||||
// Drop any non-selected cards left over from a previous run so back-to-back
|
||||
// jobs don't carry stale cards into the new fan.
|
||||
generatingExtraCards.value = []
|
||||
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'skeleton'
|
||||
}
|
||||
const item = createItem(jobId, { state: 'skeleton' })
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
|
||||
@@ -95,12 +113,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
// Only create on-demand for the tracked job
|
||||
if (jobId !== trackedJobId.value) return
|
||||
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: url
|
||||
}
|
||||
const item = createItem(jobId, { state: 'latent', latentPreviewUrl: url })
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
autoSelect(`slot:${item.id}`, jobId)
|
||||
@@ -117,13 +130,26 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
// Skip output items for nodes not flagged as output nodes
|
||||
// Outputs from nodes not selected in the builder stay out of the feed, but
|
||||
// still pop into the generating screen so the run feels alive.
|
||||
const outputNodeIds = appModeStore.selectedOutputs
|
||||
if (
|
||||
outputNodeIds.length > 0 &&
|
||||
!outputNodeIds.some((id) => String(id) === String(nodeId))
|
||||
)
|
||||
const isSelectedOutput =
|
||||
outputNodeIds.length === 0 ||
|
||||
outputNodeIds.some((id) => String(id) === String(nodeId))
|
||||
if (!isSelectedOutput) {
|
||||
if (jobId === trackedJobId.value) {
|
||||
const extras = newOutputs.map((o) =>
|
||||
createItem(jobId, { state: 'image', output: o })
|
||||
)
|
||||
// Only the newest GENERATING_CARD_LIMIT can ever surface in the fan, so
|
||||
// cap on insert rather than retaining every non-selected output.
|
||||
generatingExtraCards.value = [
|
||||
...extras,
|
||||
...generatingExtraCards.value
|
||||
].slice(0, GENERATING_CARD_LIMIT)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const skeletonItem = inProgressItems.value.find(
|
||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||
@@ -138,12 +164,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
autoSelect(`slot:${imageItem.id}`, jobId)
|
||||
|
||||
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
const extras = newOutputs
|
||||
.slice(1)
|
||||
.map((o) => createItem(jobId, { state: 'image', output: o }))
|
||||
|
||||
const idx = inProgressItems.value.indexOf(skeletonItem)
|
||||
const arr = [...inProgressItems.value]
|
||||
@@ -156,12 +179,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
// No skeleton — create image items directly (only for tracked job)
|
||||
if (jobId !== trackedJobId.value) return
|
||||
|
||||
const newItems: InProgressItem[] = newOutputs.map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
const newItems = newOutputs.map((o) =>
|
||||
createItem(jobId, { state: 'image', output: o })
|
||||
)
|
||||
autoSelect(`slot:${newItems[0].id}`, jobId)
|
||||
inProgressItems.value = [...newItems, ...inProgressItems.value]
|
||||
}
|
||||
@@ -285,6 +305,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
) {
|
||||
onJobStart(jobId)
|
||||
}
|
||||
if (!jobId) generatingExtraCards.value = []
|
||||
}
|
||||
)
|
||||
|
||||
@@ -301,6 +322,8 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
|
||||
function reconcileOnEnter() {
|
||||
// Drop stale non-selected cards from a run that ended while away.
|
||||
if (!executionStore.activeJobId) generatingExtraCards.value = []
|
||||
// Complete any tracked job that finished while we were away.
|
||||
// The activeJobId watcher couldn't fire onJobComplete because
|
||||
// isAppMode was false at the time.
|
||||
@@ -367,6 +390,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
return {
|
||||
activeWorkflowInProgressItems,
|
||||
generatingCards,
|
||||
resolvedOutputsCache,
|
||||
selectedId,
|
||||
pendingResolve,
|
||||
|
||||
@@ -296,12 +296,14 @@ describe(useOutputHistory, () => {
|
||||
{
|
||||
id: 'item-1',
|
||||
jobId: 'job-1',
|
||||
seq: 0,
|
||||
state: 'image',
|
||||
output: makeResult('a.png')
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
jobId: 'job-1',
|
||||
seq: 1,
|
||||
state: 'image',
|
||||
output: makeResult('b.png')
|
||||
}
|
||||
@@ -421,7 +423,7 @@ describe(useOutputHistory, () => {
|
||||
|
||||
it('returns false when there are active in-progress items', () => {
|
||||
activeWorkflowInProgressItemsRef.value = [
|
||||
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
|
||||
{ id: 'item-1', jobId: 'job-1', seq: 0, state: 'skeleton' }
|
||||
]
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
@@ -10,11 +10,11 @@ import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
@@ -86,8 +86,6 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[activeTab, splitterKey]
|
||||
)
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
@@ -99,7 +97,7 @@ function dragDrop(e: DragEvent) {
|
||||
</script>
|
||||
<template>
|
||||
<MobileDisplay v-if="mobileDisplay" />
|
||||
<div v-else class="absolute size-full" @dragover.prevent>
|
||||
<div v-else class="absolute flex size-full flex-col" @dragover.prevent>
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
@@ -109,93 +107,96 @@ function dragDrop(e: DragEvent) {
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
:key="splitterKey"
|
||||
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
<div
|
||||
class="flex flex-1 overflow-hidden bg-secondary-background"
|
||||
:class="sidebarOnLeft ? 'flex-row' : 'flex-row-reverse'"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
<SideToolbar
|
||||
v-if="!isBuilderMode"
|
||||
:visible-tab-ids="['assets', 'apps']"
|
||||
force-connected
|
||||
/>
|
||||
<Splitter
|
||||
:key="splitterKey"
|
||||
class="h-full flex-1 border-none bg-secondary-background"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-header-progress-bar"
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
/>
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:typeform-widget-id="TYPEFORM_WIDGET_ID"
|
||||
/>
|
||||
<div class="absolute top-2 left-4.5 z-21">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
ref="rightPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<AppBuilder v-if="showRightBuilder" />
|
||||
<LinearControls
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-header-progress-bar"
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
/>
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
/>
|
||||
<div class="absolute top-2 left-2 z-21">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<ErrorOverlay app-mode />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
ref="rightPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showLeftBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
|
||||
>
|
||||
<AppBuilder v-if="showRightBuilder" />
|
||||
<LinearControls
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user