mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Compare commits
2 Commits
core/1.45
...
feat/canva
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b7d306ee | ||
|
|
6148440301 |
@@ -26,6 +26,7 @@ export const TestIds = {
|
||||
minimapViewport: 'minimap-viewport',
|
||||
minimapInteractionOverlay: 'minimap-interaction-overlay',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
snackbarToast: 'canvas-snackbar-toast',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
zoomOutAction: 'zoom-out-action',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -36,6 +37,9 @@ test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.command.executeCommand('Workspace.ToggleFocusMode')
|
||||
await expect(comfyPage.menu.sideToolbar).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.canvas.snackbarToast)
|
||||
).toBeHidden({ timeout: 3000 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -23,7 +23,9 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
const button = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleLinkVisibilityButton
|
||||
)
|
||||
const toast = comfyPage.page.getByTestId(TestIds.canvas.snackbarToast)
|
||||
await button.click()
|
||||
await expect(toast).toBeHidden({ timeout: 3000 })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'canvas-with-hidden-links.png'
|
||||
@@ -36,6 +38,7 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
.toBe(hiddenLinkRenderMode)
|
||||
|
||||
await button.click()
|
||||
await expect(toast).toBeHidden({ timeout: 3000 })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'canvas-with-visible-links.png'
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
|
||||
<SnackbarToast />
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<VueNodeSwitchPopup />
|
||||
@@ -131,6 +132,7 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SnackbarToast from '@/components/graph/SnackbarToast.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
@@ -154,6 +156,7 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
@@ -540,6 +543,16 @@ onMounted(async () => {
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
if (settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK) {
|
||||
useSnackbarToast().show(t('g.linksHidden'), {
|
||||
actionLabel: t('g.undo'),
|
||||
onAction: () => {
|
||||
void settingStore.set('Comfy.LinkRenderMode', LiteGraph.SPLINE_LINK)
|
||||
useSnackbarToast().show(t('g.linksVisible'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
|
||||
112
src/components/graph/SnackbarToast.stories.ts
Normal file
112
src/components/graph/SnackbarToast.stories.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
|
||||
import SnackbarToast from './SnackbarToast.vue'
|
||||
|
||||
const meta: Meta<typeof SnackbarToast> = {
|
||||
title: 'Components/Graph/SnackbarToast',
|
||||
component: SnackbarToast,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="relative h-screen bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToast, Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
function trigger() {
|
||||
toast.show('Toast message')
|
||||
}
|
||||
return { trigger }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base-foreground">Auto-dismiss after 2s. Hover to pause.</p>
|
||||
<Button class="w-fit" @click="trigger">Show toast</Button>
|
||||
<SnackbarToast />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithShortcut: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToast, Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
function trigger() {
|
||||
toast.show('Links hidden', { shortcut: 'Ctrl+A' })
|
||||
}
|
||||
return { trigger }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base-foreground">Toast with assigned keybinding badge.</p>
|
||||
<Button class="w-fit" @click="trigger">Show toast</Button>
|
||||
<SnackbarToast />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithUndoAction: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToast, Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
function trigger() {
|
||||
toast.show('Subgraph unpacked', {
|
||||
actionLabel: 'Undo',
|
||||
onAction: () => {
|
||||
toast.show('Subgraph repacked')
|
||||
}
|
||||
})
|
||||
}
|
||||
return { trigger }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base-foreground">No assigned shortcut: shows an Undo action button.</p>
|
||||
<Button class="w-fit" @click="trigger">Show toast</Button>
|
||||
<SnackbarToast />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Persistent: Story = {
|
||||
render: () => ({
|
||||
components: { SnackbarToast, Button },
|
||||
setup() {
|
||||
const toast = useSnackbarToast()
|
||||
function trigger() {
|
||||
toast.show('Stays open until dismissed', { duration: 60_000 })
|
||||
}
|
||||
return { trigger, dismiss: toast.dismiss }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-base-foreground">Long duration so close-button behavior is testable.</p>
|
||||
<div class="flex gap-2">
|
||||
<Button class="w-fit" @click="trigger">Show toast</Button>
|
||||
<Button class="w-fit" variant="muted-textonly" @click="dismiss">Dismiss</Button>
|
||||
</div>
|
||||
<SnackbarToast />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
75
src/components/graph/SnackbarToast.vue
Normal file
75
src/components/graph/SnackbarToast.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="canvas-snackbar-toast"
|
||||
class="fixed bottom-16 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-4 rounded-lg bg-base-foreground py-1 pr-2 pl-3 text-sm text-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
@mouseenter="pause"
|
||||
@mouseleave="startTimer"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ message }}
|
||||
</span>
|
||||
<kbd
|
||||
v-if="shortcut"
|
||||
class="flex h-4 min-w-3.5 items-center justify-center rounded-sm bg-base-background/70 px-1 text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ shortcut }}
|
||||
</kbd>
|
||||
<ToolbarRoot
|
||||
orientation="horizontal"
|
||||
:aria-label="t('g.dismiss')"
|
||||
class="flex items-center pl-2"
|
||||
>
|
||||
<ToolbarButton v-if="hasAction" as-child @click="handleAction">
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="md"
|
||||
class="text-sm hover:bg-base-foreground/80"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</Button>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton as-child @click="dismiss">
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="md"
|
||||
:aria-label="t('g.dismiss')"
|
||||
class="hover:bg-base-foreground/80"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</ToolbarButton>
|
||||
</ToolbarRoot>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ToolbarButton, ToolbarRoot } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
message,
|
||||
shortcut,
|
||||
visible,
|
||||
actionLabel,
|
||||
onAction,
|
||||
dismiss,
|
||||
pause,
|
||||
startTimer
|
||||
} = useSnackbarToast()
|
||||
|
||||
const hasAction = computed(() => !!onAction.value && !shortcut.value)
|
||||
|
||||
function handleAction() {
|
||||
onAction.value?.()
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
@@ -21,6 +22,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -439,21 +441,38 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
function: (() => {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasToast = useSnackbarToast()
|
||||
let lastLinksRenderMode = LiteGraph.SPLINE_LINK
|
||||
|
||||
return async () => {
|
||||
const currentMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
const keybinding = useKeybindingStore().getKeybindingByCommandId(
|
||||
'Comfy.Canvas.ToggleLinkVisibility'
|
||||
)
|
||||
const shortcut = keybinding?.combo.toString()
|
||||
|
||||
if (currentMode === LiteGraph.HIDDEN_LINK) {
|
||||
// If links are hidden, restore the last positive value or default to spline mode
|
||||
await settingStore.set('Comfy.LinkRenderMode', lastLinksRenderMode)
|
||||
canvasToast.show(t('g.linksVisible'), { shortcut })
|
||||
} else {
|
||||
// If links are visible, store the current mode and hide links
|
||||
lastLinksRenderMode = currentMode
|
||||
await settingStore.set(
|
||||
'Comfy.LinkRenderMode',
|
||||
LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
canvasToast.show(t('g.linksHidden'), {
|
||||
shortcut,
|
||||
actionLabel: shortcut ? undefined : t('g.undo'),
|
||||
onAction: shortcut
|
||||
? undefined
|
||||
: async () => {
|
||||
await settingStore.set(
|
||||
'Comfy.LinkRenderMode',
|
||||
lastLinksRenderMode
|
||||
)
|
||||
canvasToast.show(t('g.linksVisible'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
58
src/composables/useSnackbarToast.ts
Normal file
58
src/composables/useSnackbarToast.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref('')
|
||||
const shortcut = ref('')
|
||||
const visible = ref(false)
|
||||
const actionLabel = ref('')
|
||||
const onAction = ref<(() => void) | null>(null)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let duration = 2000
|
||||
|
||||
export function useSnackbarToast() {
|
||||
function show(
|
||||
msg: string,
|
||||
options?: {
|
||||
shortcut?: string
|
||||
duration?: number
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
) {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
message.value = msg
|
||||
shortcut.value = options?.shortcut ?? ''
|
||||
actionLabel.value = options?.actionLabel ?? ''
|
||||
onAction.value = options?.onAction ?? null
|
||||
duration = options?.duration ?? 2000
|
||||
visible.value = true
|
||||
startTimer()
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
visible.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
shortcut,
|
||||
visible,
|
||||
actionLabel,
|
||||
onAction,
|
||||
show,
|
||||
dismiss,
|
||||
pause,
|
||||
startTimer
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@
|
||||
"bookmark": "Save to Library",
|
||||
"moreOptions": "More Options",
|
||||
"more": "More",
|
||||
"linksVisible": "Links visible",
|
||||
"linksHidden": "Links hidden",
|
||||
"focusModeOn": "Focus mode on",
|
||||
"focusModeOff": "Focus mode off",
|
||||
"undo": "Undo",
|
||||
"show": "Show",
|
||||
"loading": "Loading",
|
||||
"loadingPanel": "Loading {panel} panel...",
|
||||
"preview": "PREVIEW",
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useMagicKeys } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSnackbarToast } from '@/composables/useSnackbarToast'
|
||||
import { t } from '@/i18n'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -96,6 +99,15 @@ function workspaceStoreSetup() {
|
||||
focusMode,
|
||||
toggleFocusMode: () => {
|
||||
focusMode.value = !focusMode.value
|
||||
const canvasToast = useSnackbarToast()
|
||||
const keybinding = useKeybindingStore().getKeybindingByCommandId(
|
||||
'Workspace.ToggleFocusMode'
|
||||
)
|
||||
const shortcut = keybinding?.combo.toString()
|
||||
canvasToast.show(
|
||||
focusMode.value ? t('g.focusModeOn') : t('g.focusModeOff'),
|
||||
{ shortcut }
|
||||
)
|
||||
},
|
||||
toast,
|
||||
queueSettings,
|
||||
|
||||
Reference in New Issue
Block a user