mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +00:00
App mode mobile redesign (#9047)
Reworks the app mode display for mobile devices. Adds multiple bottom tabs that can be swiped between.  To be handled in followup PRs - Nicer error display - Support for even smaller screens - UX improvements for the 'Outputs' pane - Was postponed to minimize conflicts with non-mobile development. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9047-App-mode-mobile-redesign-30e6d73d365081388e4adea4df886522) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
65
src/components/common/DropdownItem.vue
Normal file
65
src/components/common/DropdownItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
v-if="item.separator"
|
||||
class="h-[1px] bg-border-subtle m-1"
|
||||
/>
|
||||
<DropdownMenuSub v-else-if="item.items">
|
||||
<DropdownMenuSubTrigger
|
||||
:class="itemClass"
|
||||
:disabled="toValue(item.disabled) ?? !item.items?.length"
|
||||
>
|
||||
{{ item.label }}
|
||||
<i class="ml-auto icon-[lucide--chevron-right]" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent
|
||||
:class="contentClass"
|
||||
:side-offset="2"
|
||||
:align-offset="-5"
|
||||
>
|
||||
<DropdownItem
|
||||
v-for="(subitem, index) in item.items"
|
||||
:key="toValue(subitem.label) ?? index"
|
||||
:item="subitem"
|
||||
:item-class
|
||||
:content-class
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
74
src/components/common/DropdownMenu.vue
Normal file
74
src/components/common/DropdownMenu.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
itemClass?: string
|
||||
contentClass?: string
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'data-[highlighted]:bg-secondary-background-hover data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button size="icon">
|
||||
<i :class="icon ?? 'icon-[lucide--menu]'" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal :to>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
v-for="(item, index) in entries ?? []"
|
||||
:key="toValue(item.label) ?? index"
|
||||
:item-class
|
||||
:content-class
|
||||
:item
|
||||
/>
|
||||
</slot>
|
||||
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@@ -53,7 +53,7 @@
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
const keybindingSuffix = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
function toggleLinearMode() {
|
||||
useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'button' }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
data-testid="mode-toggle"
|
||||
class="p-1 bg-secondary-background rounded-lg w-10"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.linearMode') + keybindingSuffix,
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.graphMode') + keybindingSuffix,
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -81,7 +81,7 @@ const assetItems = computed<AssetGridItem[]>(() =>
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
@@ -966,7 +966,8 @@
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"helpAndFeedback": "Help & Feedback",
|
||||
"queue": "Queue Panel"
|
||||
"queue": "Queue Panel",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicate Tab",
|
||||
@@ -3024,10 +3025,13 @@
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download All",
|
||||
"viewJob": "View Job",
|
||||
"enterNodeGraph": "Enter node graph",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
|
||||
@@ -31,7 +31,7 @@ const height = ref('')
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="w-full"
|
||||
class="contain-size grow-1 object-contain"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -17,20 +18,16 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
@@ -45,9 +42,11 @@ const props = defineProps<{
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ navigateAssets: [] }>()
|
||||
|
||||
const jobFinishedQueue = ref(true)
|
||||
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
5000,
|
||||
8000,
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
|
||||
@@ -134,17 +133,6 @@ const partitionedNodes = computed(() => {
|
||||
return parts
|
||||
})
|
||||
|
||||
const batchCountWidget: SimplifiedWidget<number> = {
|
||||
options: {
|
||||
precision: 0,
|
||||
min: 1,
|
||||
max: settingStore.get('Comfy.QueueButton.BatchCountLimit')
|
||||
},
|
||||
value: 1,
|
||||
name: t('linearMode.runCount'),
|
||||
type: 'number'
|
||||
} as const
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
@@ -179,44 +167,13 @@ defineExpose({ runButtonClick })
|
||||
<template>
|
||||
<div
|
||||
v-if="!isBuilderMode && hasOutputs"
|
||||
class="flex flex-col min-w-80 md:h-full"
|
||||
class="flex flex-col min-w-80 h-full"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
root-class="text-base-foreground grid-cols-[auto_96px]"
|
||||
class="*:[.min-w-0]:w-24"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
class="w-10 p-2"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="!mobile"
|
||||
data-testid="linear-workflow-info"
|
||||
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
|
||||
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center contain-size"
|
||||
>
|
||||
<span
|
||||
class="font-bold truncate"
|
||||
@@ -254,11 +211,11 @@ defineExpose({ runButtonClick })
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
</section>
|
||||
<div
|
||||
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
|
||||
class="border-x md:border-y gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
|
||||
>
|
||||
<section
|
||||
data-testid="linear-widgets"
|
||||
class="grow-1 md:overflow-y-auto md:contain-size"
|
||||
class="grow-1 overflow-y-auto contain-size"
|
||||
>
|
||||
<template
|
||||
v-for="(nodeData, index) of appModeStore.selectedInputs.length
|
||||
@@ -280,7 +237,7 @@ defineExpose({ runButtonClick })
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
|
||||
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg **:[.h-7]:h-10',
|
||||
nodeData.hasErrors &&
|
||||
'ring-2 ring-inset ring-node-stroke-error'
|
||||
)
|
||||
@@ -289,16 +246,89 @@ defineExpose({ runButtonClick })
|
||||
</DropZone>
|
||||
</template>
|
||||
</section>
|
||||
<Teleport
|
||||
v-if="!jobToastTimeout || !jobFinishedQueue"
|
||||
defer
|
||||
:disabled="mobile"
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-base-foreground md:bg-secondary-background text-base-background md:text-base-foreground rounded-lg flex h-10 md:h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<template v-if="jobFinishedQueue">
|
||||
<i
|
||||
class="icon-[lucide--check] size-5 not-md:bg-success-background"
|
||||
/>
|
||||
<span class="mr-auto" v-text="t('queue.jobAddedToQueue')" />
|
||||
<Button
|
||||
v-if="mobile"
|
||||
variant="inverted"
|
||||
@click="$emit('navigateAssets')"
|
||||
>
|
||||
{{ t('linearMode.viewJob') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span v-text="t('queue.jobQueueing')" />
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
<section
|
||||
v-if="!mobile"
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="w-full mt-4"
|
||||
/>
|
||||
<div v-else class="flex mt-4">
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
<template #button>
|
||||
<Button size="lg" class="-mr-3 pr-7">
|
||||
<i v-if="batchCount == 1" class="icon-[lucide--chevron-down]" />
|
||||
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="mb-2 m-1 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-10 min-w-40"
|
||||
/>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<div
|
||||
class="mb-2 m-1 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
root-class="text-base-foreground grid-cols-[auto_96px]"
|
||||
class="*:[.min-w-0]:w-24"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
@@ -317,24 +347,4 @@ defineExpose({ runButtonClick })
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
|
||||
defer
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<i
|
||||
v-if="jobFinishedQueue"
|
||||
class="icon-[lucide--check] size-5 text-muted-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span
|
||||
v-text="
|
||||
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
228
src/renderer/extensions/linearMode/MobileDisplay.vue
Normal file
228
src/renderer/extensions/linearMode/MobileDisplay.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { useFullscreen, usePointerSwipe } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const tabs = [
|
||||
['linearMode.mobileControls', 'icon-[lucide--play]'],
|
||||
['nodeHelpPage.outputs', 'icon-[comfy--image-ai-edit]'],
|
||||
['sideToolbar.assets', 'icon-[lucide--images]']
|
||||
]
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { commandIdToMenuItem } = useMenuItemStore()
|
||||
const queueStore = useQueueStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { toggle: toggleFullscreen } = useFullscreen(undefined, {
|
||||
autoExit: true
|
||||
})
|
||||
|
||||
const activeIndex = ref(2)
|
||||
const sliderPaneRef = useTemplateRef('sliderPaneRef')
|
||||
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
|
||||
|
||||
const { distanceX, isSwiping } = usePointerSwipe(sliderPaneRef, {
|
||||
disableTextSelect: true,
|
||||
onSwipeEnd() {
|
||||
if (
|
||||
!sliderWidth.value ||
|
||||
Math.abs(distanceX.value) / sliderWidth.value < 0.4
|
||||
)
|
||||
return
|
||||
if (distanceX.value < 0)
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
else activeIndex.value = Math.min(activeIndex.value + 1, tabs.length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
const translate = computed(() => {
|
||||
const slideOffset =
|
||||
isSwiping.value && sliderWidth.value
|
||||
? distanceX.value / sliderWidth.value
|
||||
: 0
|
||||
const totalOffset = slideOffset + activeIndex.value
|
||||
return `${totalOffset * -100}vw`
|
||||
})
|
||||
|
||||
function onClick(index: number) {
|
||||
if (Math.abs(distanceX.value) > 30) return
|
||||
activeIndex.value = index
|
||||
}
|
||||
|
||||
const workflowsEntries = computed(() => {
|
||||
return workflowStore.openWorkflows.map((w) => ({
|
||||
label: w.filename,
|
||||
icon: w.activeState?.extra?.linearMode
|
||||
? 'icon-[lucide--panels-top-left] bg-primary-background'
|
||||
: undefined,
|
||||
command: () => workflowService.openWorkflow(w)
|
||||
}))
|
||||
})
|
||||
|
||||
const menuEntries = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('linearMode.appModeToolbar.apps'),
|
||||
icon: 'icon-[lucide--panels-top-left]'
|
||||
},
|
||||
{
|
||||
...commandIdToMenuItem('Comfy.BrowseTemplates'),
|
||||
label: t('sideToolbar.templates'),
|
||||
icon: 'icon-[comfy--template]'
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: t('icon.file'),
|
||||
items: [
|
||||
commandIdToMenuItem('Comfy.RenameWorkflow'),
|
||||
commandIdToMenuItem('Comfy.DuplicateWorkflow'),
|
||||
{ separator: true },
|
||||
commandIdToMenuItem('Comfy.SaveWorkflow'),
|
||||
commandIdToMenuItem('Comfy.SaveWorkflowAs'),
|
||||
{ separator: true },
|
||||
commandIdToMenuItem('Comfy.ExportWorkflow'),
|
||||
commandIdToMenuItem('Comfy.ExportWorkflowAPI')
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('g.edit'),
|
||||
items: [
|
||||
commandIdToMenuItem('Comfy.Undo'),
|
||||
commandIdToMenuItem('Comfy.Redo'),
|
||||
{ separator: true },
|
||||
commandIdToMenuItem('Comfy.RefreshNodeDefinitions'),
|
||||
commandIdToMenuItem('Comfy.Memory.UnloadModels'),
|
||||
commandIdToMenuItem('Comfy.Memory.UnloadModelsAndExecutionCache')
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('linearMode.enterNodeGraph'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
new: true,
|
||||
command: () => (canvasStore.linearMode = false)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: t('menu.theme'),
|
||||
items: colorPaletteStore.palettes.map((palette) => ({
|
||||
label: palette.name,
|
||||
icon:
|
||||
colorPaletteStore.activePaletteId === palette.id
|
||||
? 'icon-[lucide--check]'
|
||||
: '',
|
||||
command: () => colorPaletteService.loadColorPalette(palette.id)
|
||||
}))
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
...commandIdToMenuItem('Comfy.ShowSettingsDialog'),
|
||||
label: t('menu.settings')
|
||||
},
|
||||
{ ...commandIdToMenuItem('Comfy.ToggleHelpCenter'), label: t('menu.help') },
|
||||
{
|
||||
label: t('menu.fullscreen'),
|
||||
icon: 'icon-[lucide--fullscreen]',
|
||||
command: toggleFullscreen
|
||||
}
|
||||
])
|
||||
</script>
|
||||
<template>
|
||||
<section class="absolute w-full h-full flex flex-col bg-secondary-background">
|
||||
<header
|
||||
class="w-full h-16 px-4 py-3 flex border-border-subtle border-b items-center gap-3 bg-base-background"
|
||||
>
|
||||
<DropdownMenu :entries="menuEntries" />
|
||||
<Popover
|
||||
:entries="workflowsEntries"
|
||||
class="w-(--reka-popover-content-available-width)"
|
||||
:collision-padding="20"
|
||||
>
|
||||
<template #button>
|
||||
<!--TODO: Use button here? Probably too much work to destyle-->
|
||||
<div
|
||||
class="bg-secondary-background h-10 rounded-sm grow-1 flex items-center p-2 gap-2"
|
||||
>
|
||||
<i
|
||||
class="shrink-0 icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
class="truncate contain-size h-full w-full"
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<i
|
||||
class="shrink-0 icon-[lucide--chevron-down] bg-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
|
||||
</header>
|
||||
<div class="size-full contain-content rounded-b-4xl">
|
||||
<div
|
||||
:class="
|
||||
cn('size-full relative', !isSwiping && 'transition-[translate]')
|
||||
"
|
||||
:style="{ translate }"
|
||||
>
|
||||
<div class="overflow-y-auto contain-size h-full w-screen absolute">
|
||||
<LinearControls mobile @navigate-assets="activeIndex = 2" />
|
||||
</div>
|
||||
<div
|
||||
class="w-screen absolute h-full bg-base-background left-[100vw] top-0 flex flex-col"
|
||||
>
|
||||
<LinearPreview mobile />
|
||||
</div>
|
||||
<AssetsSidebarTab
|
||||
class="h-full w-screen absolute bg-base-background left-[200vw] top-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="sliderPaneRef"
|
||||
class="h-22 p-4 w-full flex gap-4 items-center justify-around bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-for="([label, icon], index) in tabs"
|
||||
:key="label"
|
||||
:variant="index === activeIndex ? 'secondary' : 'muted-textonly'"
|
||||
class="flex-col h-14 grow-1"
|
||||
@click="onClick(index)"
|
||||
>
|
||||
<div class="relative size-4">
|
||||
<i :class="cn('size-4', icon)" />
|
||||
<div
|
||||
v-if="
|
||||
index === 1 &&
|
||||
(queueStore.runningTasks.length > 0 ||
|
||||
queueStore.pendingTasks.length > 0)
|
||||
"
|
||||
class="absolute bg-primary-background size-2 -top-1 -right-1 rounded-full animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
{{ t(label) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<CollapsibleRoot class="flex flex-col">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
|
||||
<i class="icon-[lucide--menu] size-8" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="flex gap-2 flex-col">
|
||||
<div class="w-full border-b-2 border-border-subtle" />
|
||||
<Popover>
|
||||
<template #button>
|
||||
<Button variant="secondary" size="lg" class="w-full">
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
{{ t('Workflows') }}
|
||||
</Button>
|
||||
</template>
|
||||
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
|
||||
</Popover>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@click="useWorkflowTemplateSelectorDialog().show('menu')"
|
||||
>
|
||||
<i class="icon-[comfy--template]" />
|
||||
{{ t('sideToolbar.templates') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@click="
|
||||
useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'button' }
|
||||
})
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--log-out]" />
|
||||
{{ t('linearMode.graphMode') }}
|
||||
</Button>
|
||||
<div class="w-full border-b-2 border-border-subtle" />
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -65,21 +65,20 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
function commandIdToMenuItem(commandId: string, path?: string[]): MenuItem {
|
||||
const command = commandStore.getCommand(commandId)
|
||||
return {
|
||||
command: () => commandStore.execute(command.id),
|
||||
label: command.menubarLabel,
|
||||
icon: command.icon,
|
||||
tooltip: command.tooltip,
|
||||
comfyCommand: command,
|
||||
parentPath: path?.join('.')
|
||||
}
|
||||
}
|
||||
|
||||
const registerCommands = (path: string[], commandIds: string[]) => {
|
||||
const items = commandIds
|
||||
.map((commandId) => commandStore.getCommand(commandId))
|
||||
.map(
|
||||
(command) =>
|
||||
({
|
||||
command: () => commandStore.execute(command.id),
|
||||
label: command.menubarLabel,
|
||||
icon: command.icon,
|
||||
tooltip: command.tooltip,
|
||||
comfyCommand: command,
|
||||
parentPath: path.join('.')
|
||||
}) as MenuItem
|
||||
)
|
||||
const items = commandIds.map((id) => commandIdToMenuItem(id, path))
|
||||
registerMenuGroup(path, items)
|
||||
}
|
||||
|
||||
@@ -114,6 +113,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
|
||||
loadExtensionMenuCommands,
|
||||
registerCoreMenuCommands,
|
||||
menuItemHasActiveStateChildren,
|
||||
hasSeenLinear
|
||||
hasSeenLinear,
|
||||
commandIdToMenuItem
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,22 +4,19 @@ import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
|
||||
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
@@ -30,7 +27,6 @@ import {
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
@@ -78,7 +74,8 @@ const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</script>
|
||||
<template>
|
||||
<div class="absolute w-full h-full">
|
||||
<MobileDisplay v-if="mobileDisplay" />
|
||||
<div v-else class="absolute w-full h-full">
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
@@ -88,32 +85,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="mobileDisplay"
|
||||
class="justify-center border-border-subtle border-t overflow-y-scroll h-[calc(100%-var(--workflow-tabs-height))] bg-comfy-menu-bg"
|
||||
>
|
||||
<MobileMenu />
|
||||
<LinearProgressBar class="w-full" />
|
||||
<div class="flex flex-col text-muted-foreground">
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
mobile
|
||||
/>
|
||||
</div>
|
||||
<LinearControls ref="linearWorkflowRef" mobile />
|
||||
<div class="text-base-foreground flex items-center gap-4">
|
||||
<div class="border-r border-border-subtle mr-auto">
|
||||
<ModeToggle class="m-2" />
|
||||
</div>
|
||||
<div v-text="t('linearMode.beta')" />
|
||||
<TypeformPopoverButton
|
||||
:data-tf-widget="TYPEFORM_WIDGET_ID"
|
||||
class="mx-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
v-else
|
||||
:key="isArrangeMode ? 'arrange' : 'normal'"
|
||||
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-comfy-menu-secondary-bg"
|
||||
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
|
||||
|
||||
Reference in New Issue
Block a user