mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 14:59:39 +00:00
App mode - builder toolbar save - 7 (#9030)
## Summary Implements save flow for the builder toolbar. The todo will be done in a future PR once the serailized format is finalized ## Screenshots (if applicable) https://github.com/user-attachments/assets/124cb7d8-e23b-476a-8691-0ee2c4c9150b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9030-App-mode-builder-toolbar-save-7-30d6d73d3650815e8610fced20e95e6e) by [Unito](https://www.unito.io)
This commit is contained in:
41
src/components/builder/BuilderDialog.vue
Normal file
41
src/components/builder/BuilderDialog.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="header-icon" />
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
<slot name="title" />
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="-mr-1"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
117
src/components/builder/BuilderSaveDialogContent.vue
Normal file
117
src/components/builder/BuilderSaveDialogContent.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveAs') }}
|
||||
</template>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label :for="inputId" class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.filename') }}
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="filename"
|
||||
autofocus
|
||||
type="text"
|
||||
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
|
||||
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save as type -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveAsLabel') }}
|
||||
</label>
|
||||
<div role="radiogroup" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="option in saveTypeOptions"
|
||||
:key="option.value.toString()"
|
||||
role="radio"
|
||||
:aria-checked="openAsApp === option.value"
|
||||
:class="
|
||||
cn(
|
||||
itemClasses,
|
||||
openAsApp === option.value && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
variant="textonly"
|
||||
@click="openAsApp = option.value"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(option.icon, 'size-4')" />
|
||||
</div>
|
||||
<div class="mx-2 flex flex-1 flex-col items-start">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ option.title }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ option.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="openAsApp === option.value"
|
||||
class="icon-[lucide--check] size-4 text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="muted-textonly" size="lg" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!filename.trim()"
|
||||
@click="onSave(filename.trim(), openAsApp)"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { defaultFilename, onSave, onClose } = defineProps<{
|
||||
defaultFilename: string
|
||||
onSave: (filename: string, openAsApp: boolean) => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const inputId = useId()
|
||||
const filename = ref(defaultFilename)
|
||||
const openAsApp = ref(true)
|
||||
|
||||
const saveTypeOptions = [
|
||||
{
|
||||
value: true,
|
||||
icon: 'icon-[lucide--app-window]',
|
||||
title: t('builderToolbar.app'),
|
||||
subtitle: t('builderToolbar.appDescription')
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
title: t('builderToolbar.nodeGraph'),
|
||||
subtitle: t('builderToolbar.nodeGraphDescription')
|
||||
}
|
||||
]
|
||||
|
||||
const itemClasses =
|
||||
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
|
||||
</script>
|
||||
51
src/components/builder/BuilderSaveSuccessDialogContent.vue
Normal file
51
src/components/builder/BuilderSaveSuccessDialogContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<BuilderDialog @close="onClose">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ $t('builderToolbar.saveSuccess') }}
|
||||
</template>
|
||||
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
|
||||
size="lg"
|
||||
@click="onClose"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="savedAsApp && onViewApp"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="onViewApp"
|
||||
>
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
</template>
|
||||
</BuilderDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import BuilderDialog from './BuilderDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
workflowName: string
|
||||
savedAsApp: boolean
|
||||
onViewApp?: () => void
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
124
src/components/builder/useBuilderSave.ts
Normal file
124
src/components/builder/useBuilderSave.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const appModeStore = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) {
|
||||
resetSaving()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
||||
|
||||
if (!workflow.isTemporary) {
|
||||
try {
|
||||
workflow.changeTracker?.checkState()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showSaveDialog(workflow.filename)
|
||||
}
|
||||
|
||||
function showSaveDialog(defaultFilename: string) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: SAVE_DIALOG_KEY,
|
||||
component: BuilderSaveDialogContent,
|
||||
props: {
|
||||
defaultFilename,
|
||||
onSave: handleSave,
|
||||
onClose: handleCancelSave
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: resetSaving
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancelSave() {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
|
||||
async function handleSave(filename: string, openAsApp: boolean) {
|
||||
try {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: SUCCESS_DIALOG_KEY,
|
||||
component: BuilderSaveSuccessDialogContent,
|
||||
props: {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
appModeStore.setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: resetSaving
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeSaveDialog() {
|
||||
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
|
||||
}
|
||||
|
||||
function closeSuccessDialog() {
|
||||
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
|
||||
resetSaving()
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
appModeStore.setBuilderSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -3230,6 +3230,18 @@
|
||||
"connectOutput": "Connect an output",
|
||||
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
|
||||
"connectOutputBody2": "Switch to the 'Select' step and click on output nodes to add them here.",
|
||||
"switchToSelect": "Switch to Select"
|
||||
"switchToSelect": "Switch to Select",
|
||||
"saveAs": "Save as",
|
||||
"filename": "Filename",
|
||||
"saveAsLabel": "Save this workflow as a ...",
|
||||
"app": "App",
|
||||
"appDescription": "Opens as an app by default",
|
||||
"nodeGraph": "Node graph",
|
||||
"nodeGraphDescription": "Opens as node graph by default",
|
||||
"saveSuccess": "Successfully saved",
|
||||
"saveSuccessAppMessage": "'{name}' has been saved. It will open in App Mode by default from now on.",
|
||||
"saveSuccessAppPrompt": "Would you like to view it now?",
|
||||
"saveSuccessGraphMessage": "'{name}' has been saved. It will open as a node graph by default.",
|
||||
"viewApp": "View app"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,21 @@ export const useWorkflowService = () => {
|
||||
/**
|
||||
* Save a workflow as a new file
|
||||
* @param workflow The workflow to save
|
||||
* @param options.filename Pre-supplied filename (skips the prompt dialog)
|
||||
* @param options.openAsApp If set, updates linearMode extra before saving
|
||||
*/
|
||||
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
|
||||
const newFilename = await workflow.promptSave()
|
||||
if (!newFilename) return
|
||||
const saveWorkflowAs = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { filename?: string; openAsApp?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const newFilename = options.filename ?? (await workflow.promptSave())
|
||||
if (!newFilename) return false
|
||||
|
||||
if (options.openAsApp !== undefined) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = options.openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
@@ -106,14 +117,14 @@ export const useWorkflowService = () => {
|
||||
itemList: [newPath]
|
||||
})
|
||||
|
||||
if (res !== true) return
|
||||
if (res !== true) return false
|
||||
|
||||
if (existingWorkflow.path === workflow.path) {
|
||||
await saveWorkflow(workflow)
|
||||
return
|
||||
return true
|
||||
}
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
if (!deleted) return
|
||||
if (!deleted) return false
|
||||
}
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
@@ -124,6 +135,7 @@ export const useWorkflowService = () => {
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,10 +87,12 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||
import { useBuilderSave } from '@/components/builder/useBuilderSave'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
|
||||
setupAutoQueueHandler()
|
||||
useBuilderSave()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user