Compare commits

...

2 Commits

Author SHA1 Message Date
dante01yoon
40b7d306ee test: wait for canvas snackbar toast to dismiss before screenshots
The new toast covers part of the canvas at bottom-center for ~2s after
toggling link visibility / focus mode, breaking existing canvas screenshot
tests. Add a stable testid on the toast and wait for it to be hidden so
the visual-regression checks capture only the persistent canvas state.
2026-04-28 19:32:17 +09:00
dante01yoon
6148440301 feat: add canvas snackbar toast for link visibility and focus mode toggles
Adds a singleton snackbar toast at bottom-center of the canvas to give
users non-blocking feedback when toggling link visibility or focus mode.
The toast surfaces the assigned keybinding when one exists, or an Undo
action button otherwise, so accidental toggles are easy to recover from.

Behavior:
- 2s auto-dismiss; hovering pauses the timer.
- Close (X) button always present.
- On reload, if links are hidden the toast re-appears once with an Undo
  action so the hidden state is recoverable.
- One toast at a time (singleton composable).

Implementation:
- `useSnackbarToast` composable owns the message/visibility/timer state.
- `SnackbarToast.vue` renders via Teleport, uses Reka `ToolbarRoot` /
  `ToolbarButton` for the action group (arrow-key navigation) and adds
  `role=status` / `aria-live=polite` plus an `aria-label` on the X
  button for screen-reader announcement.
- Link-toggle command (`useCoreCommands`) and focus-mode toggle
  (`workspaceStore`) call `show()` with the resolved keybinding combo.
- Reload-time toast lives in `GraphCanvas.vue` setup so the toast
  component stays decoupled from `LinkRenderMode`.
- Storybook stories cover Default / WithShortcut / WithUndoAction /
  Persistent variants.

Visuals match Figma node 6826:77784 in the Comfy Design System.

Supersedes #11287.
2026-04-28 16:47:37 +09:00
10 changed files with 306 additions and 3 deletions

View File

@@ -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',

View File

@@ -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')
}
)

View File

@@ -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'

View File

@@ -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)

View 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>
`
})
}

View 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>

View File

@@ -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'))
}
})
}
}
})(),

View 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
}
}

View File

@@ -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",

View File

@@ -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,