mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
54 Commits
codex/clou
...
feat/onboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29fbe78ba | ||
|
|
1e0d98a22c | ||
|
|
24ff3acc93 | ||
|
|
3c11078718 | ||
|
|
f5d407e8ef | ||
|
|
73021dcfab | ||
|
|
7c0d6046e3 | ||
|
|
90bb28eb99 | ||
|
|
40e10a3bb1 | ||
|
|
bb1b8473d7 | ||
|
|
c9fcd52541 | ||
|
|
68b40e123d | ||
|
|
af4eda246b | ||
|
|
9153650e74 | ||
|
|
6d8f6ebe6f | ||
|
|
3173134623 | ||
|
|
0c908c24d0 | ||
|
|
312d47e24d | ||
|
|
18f2f90d9e | ||
|
|
eadbf636a2 | ||
|
|
f6398b99ca | ||
|
|
2ecf41b19f | ||
|
|
64326a21f7 | ||
|
|
c59a6f6c72 | ||
|
|
66ad7f5b09 | ||
|
|
577af01081 | ||
|
|
9869d39a63 | ||
|
|
e6f16b4ec0 | ||
|
|
bf805b0e34 | ||
|
|
8332ff7b92 | ||
|
|
e17a6153e8 | ||
|
|
103d6cdf23 | ||
|
|
ea0b00e162 | ||
|
|
352576cf42 | ||
|
|
7fa292b993 | ||
|
|
45ac184843 | ||
|
|
39eca5ee06 | ||
|
|
32ce41309c | ||
|
|
b3b245323d | ||
|
|
4460f67534 | ||
|
|
8a5013d082 | ||
|
|
d69434182c | ||
|
|
8f6cbfda51 | ||
|
|
4990861a33 | ||
|
|
27eef95fad | ||
|
|
6c16b58d2d | ||
|
|
3ab6c449d2 | ||
|
|
1375b2a383 | ||
|
|
c60d776ce1 | ||
|
|
534547c8ed | ||
|
|
e2d9040f8d | ||
|
|
05d9fef389 | ||
|
|
5af5077550 | ||
|
|
2585a9fb56 |
@@ -11,6 +11,7 @@
|
||||
|
||||
<Panel
|
||||
ref="panelRef"
|
||||
data-testid="comfy-actionbar"
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
|
||||
@@ -180,6 +180,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import { useOnboardingTourController } from '@/renderer/extensions/onboardingTour/useOnboardingTourController'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
@@ -504,6 +505,9 @@ useEventListener(
|
||||
onMounted(async () => {
|
||||
comfyApp.vueAppReady = true
|
||||
workspaceStore.spinner = true
|
||||
let templateFromUrl: Awaited<
|
||||
ReturnType<typeof workflowPersistence.loadTemplateFromUrlIfPresent>
|
||||
>
|
||||
try {
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
@@ -561,11 +565,38 @@ onMounted(async () => {
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
} finally {
|
||||
workspaceStore.spinner = false
|
||||
}
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
const sharedFromUrl =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
// A ?template=/?share= URL loads a workflow directly (Getting Started is
|
||||
// skipped). Start the onboarding tour on it; beginTour() self-gates and reads
|
||||
// the now-loaded graph, so the loaders above must have finished first.
|
||||
const startTourFromUrl = (
|
||||
options?: Parameters<
|
||||
ReturnType<typeof useOnboardingTourController>['beginTour']
|
||||
>[0]
|
||||
) =>
|
||||
useOnboardingTourController()
|
||||
.beginTour(options)
|
||||
.catch((error: unknown) => {
|
||||
console.error('[onboardingTour] failed to start from URL', error)
|
||||
})
|
||||
|
||||
if (templateFromUrl.loaded) {
|
||||
void startTourFromUrl({
|
||||
templateId: templateFromUrl.templateId,
|
||||
entry: 'template_url'
|
||||
})
|
||||
} else if (
|
||||
sharedFromUrl === 'loaded' ||
|
||||
sharedFromUrl === 'loaded-without-assets'
|
||||
) {
|
||||
void startTourFromUrl({ entry: 'share_url' })
|
||||
}
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<div role="tablist" class="flex w-full items-center gap-2">
|
||||
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string = string">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<T>({ required: true })
|
||||
|
||||
function select(value: string) {
|
||||
|
||||
@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +209,13 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get onboardingTourEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
|
||||
remoteConfig.value.onboarding_tour_enabled,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2317,6 +2317,90 @@
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"onboardingTour": {
|
||||
"overlayLabel": "Getting started tour",
|
||||
"preparing": "Building your template…",
|
||||
"skip": "Skip",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"complete": "Finish",
|
||||
"stepCounter": "{current} of {total}",
|
||||
"step": {
|
||||
"upload": {
|
||||
"t2i": {
|
||||
"title": "Start with an image",
|
||||
"body": "This image feeds the workflow. Swap in your own whenever you like."
|
||||
},
|
||||
"i2v": {
|
||||
"title": "Start with an image",
|
||||
"body": "This picture is your first frame. Swap in your own whenever you like."
|
||||
},
|
||||
"image-edit": {
|
||||
"title": "Start with an image",
|
||||
"body": "This is the picture you'll edit. Swap in your own whenever you like."
|
||||
},
|
||||
"other": {
|
||||
"title": "Start with an image",
|
||||
"body": "This image feeds the workflow. Swap in your own whenever you like."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"t2i": {
|
||||
"title": "Describe what you want",
|
||||
"body": "Your image gets built from this description. Try anything you like."
|
||||
},
|
||||
"i2v": {
|
||||
"title": "Describe how it should move",
|
||||
"body": "The image sets the scene. This tells it what happens next."
|
||||
},
|
||||
"image-edit": {
|
||||
"title": "Say what to change",
|
||||
"body": "Your image stays as it is. Describe what you want different."
|
||||
},
|
||||
"other": {
|
||||
"title": "Tell it what to make",
|
||||
"body": "This is your prompt. Change it to change your result."
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"title": "Run your workflow",
|
||||
"body": "Press Run to start generating your result"
|
||||
},
|
||||
"result": {
|
||||
"image": {
|
||||
"title": "And there it is",
|
||||
"body": "Your new image lands right here — ready to download or share."
|
||||
},
|
||||
"video": {
|
||||
"title": "And there it is",
|
||||
"body": "Your new video lands right here — ready to download or share."
|
||||
}
|
||||
}
|
||||
},
|
||||
"generating": "Generating…",
|
||||
"gettingStarted": {
|
||||
"title": "Get started with graph",
|
||||
"subtitle": "Start a workflow from scratch or load one that's ready to go.",
|
||||
"screenLabel": "Get started with graph",
|
||||
"tabsLabel": "Getting started options",
|
||||
"tabs": {
|
||||
"templates": "Templates",
|
||||
"tutorials": "Tutorials"
|
||||
},
|
||||
"tutorials": {
|
||||
"interfaceOverview": "Interface overview",
|
||||
"textToImage": "Text to image",
|
||||
"imageToImage": "Image to image",
|
||||
"inpaint": "Inpainting"
|
||||
}
|
||||
},
|
||||
"nudge": {
|
||||
"title": "That was one of hundreds",
|
||||
"body": "You just made your first. Explore what else you can build.",
|
||||
"dismiss": "Not now",
|
||||
"explore": "Explore templates"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
|
||||
@@ -13,6 +13,9 @@ const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
/** Dialog keys the subscription/upgrade flow opens; callers gate around these. */
|
||||
export const UPGRADE_DIALOG_KEYS = [FREE_TIER_DIALOG_KEY, DIALOG_KEY] as const
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: PaymentIntentSource
|
||||
/**
|
||||
|
||||
@@ -116,6 +116,7 @@ export type RemoteConfig = {
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
onboarding_tour_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -17,6 +17,12 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourCompletedMetadata,
|
||||
OnboardingTourRunTriggeredMetadata,
|
||||
OnboardingTourSkippedMetadata,
|
||||
OnboardingTourStartedMetadata,
|
||||
OnboardingTourStepViewedMetadata,
|
||||
OnboardingTourUpgradeShownMetadata,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -291,4 +297,54 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
|
||||
trackOnboardingTourStarted(metadata: OnboardingTourStartedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackOnboardingTourStarted?.(metadata))
|
||||
}
|
||||
|
||||
trackOnboardingTourStepViewed(
|
||||
metadata: OnboardingTourStepViewedMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackOnboardingTourStepViewed?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackOnboardingTourRunTriggered(
|
||||
metadata: OnboardingTourRunTriggeredMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackOnboardingTourRunTriggered?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackOnboardingTourCompleted(
|
||||
metadata: OnboardingTourCompletedMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackOnboardingTourCompleted?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackOnboardingTourSkipped(metadata: OnboardingTourSkippedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackOnboardingTourSkipped?.(metadata))
|
||||
}
|
||||
|
||||
trackOnboardingTourUpgradeShown(
|
||||
metadata: OnboardingTourUpgradeShownMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackOnboardingTourUpgradeShown?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackOnboardingTourNudgeShown(): void {
|
||||
this.dispatch((provider) => provider.trackOnboardingTourNudgeShown?.())
|
||||
}
|
||||
|
||||
trackOnboardingTourExploreTemplatesClicked(): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackOnboardingTourExploreTemplatesClicked?.()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
OnboardingTourCompletedMetadata,
|
||||
OnboardingTourRunTriggeredMetadata,
|
||||
OnboardingTourSkippedMetadata,
|
||||
OnboardingTourStartedMetadata,
|
||||
OnboardingTourStepViewedMetadata,
|
||||
OnboardingTourUpgradeShownMetadata,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
@@ -575,4 +581,44 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
...properties
|
||||
})
|
||||
}
|
||||
|
||||
trackOnboardingTourStarted(metadata: OnboardingTourStartedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_STARTED, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourStepViewed(
|
||||
metadata: OnboardingTourStepViewedMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_STEP_VIEWED, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourRunTriggered(
|
||||
metadata: OnboardingTourRunTriggeredMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourCompleted(
|
||||
metadata: OnboardingTourCompletedMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourSkipped(metadata: OnboardingTourSkippedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_SKIPPED, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourUpgradeShown(
|
||||
metadata: OnboardingTourUpgradeShownMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN, metadata)
|
||||
}
|
||||
|
||||
trackOnboardingTourNudgeShown(): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN)
|
||||
}
|
||||
|
||||
trackOnboardingTourExploreTemplatesClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +543,56 @@ export interface WorkspaceInviteMetadata extends Record<string, unknown> {
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding tour telemetry. `shape` labels the role-derived sequence, not the
|
||||
* template — `'other'` is the honest bucket for graphs the resolver handles
|
||||
* best-effort but that aren't a named shape. `step_key`/`template_id` carry no
|
||||
* user content or share id, so no PII.
|
||||
*/
|
||||
export type OnboardingTourShape = 't2i' | 'i2v' | 'image-edit' | 'other'
|
||||
export type OnboardingTourEntry =
|
||||
| 'getting_started'
|
||||
| 'share_url'
|
||||
| 'template_url'
|
||||
export type OnboardingTourStepKey = 'upload' | 'prompt' | 'run' | 'result'
|
||||
|
||||
export interface OnboardingTourStartedMetadata {
|
||||
template_id?: string
|
||||
shape: OnboardingTourShape
|
||||
entry: OnboardingTourEntry
|
||||
}
|
||||
|
||||
export interface OnboardingTourStepViewedMetadata {
|
||||
template_id?: string
|
||||
step_key: OnboardingTourStepKey
|
||||
step_index: number
|
||||
step_total: number
|
||||
}
|
||||
|
||||
export type OnboardingTourRunStatus = 'success' | 'error' | 'interrupted'
|
||||
|
||||
export interface OnboardingTourRunTriggeredMetadata {
|
||||
template_id?: string
|
||||
shape: OnboardingTourShape
|
||||
status: OnboardingTourRunStatus
|
||||
}
|
||||
|
||||
export interface OnboardingTourCompletedMetadata {
|
||||
template_id?: string
|
||||
shape: OnboardingTourShape
|
||||
}
|
||||
|
||||
export interface OnboardingTourSkippedMetadata {
|
||||
template_id?: string
|
||||
step_key: OnboardingTourStepKey
|
||||
step_index: number
|
||||
step_total: number
|
||||
}
|
||||
|
||||
export interface OnboardingTourUpgradeShownMetadata {
|
||||
template_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -644,6 +694,22 @@ export interface TelemetryProvider {
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
|
||||
// Onboarding tour events
|
||||
trackOnboardingTourStarted?(metadata: OnboardingTourStartedMetadata): void
|
||||
trackOnboardingTourStepViewed?(
|
||||
metadata: OnboardingTourStepViewedMetadata
|
||||
): void
|
||||
trackOnboardingTourRunTriggered?(
|
||||
metadata: OnboardingTourRunTriggeredMetadata
|
||||
): void
|
||||
trackOnboardingTourCompleted?(metadata: OnboardingTourCompletedMetadata): void
|
||||
trackOnboardingTourSkipped?(metadata: OnboardingTourSkippedMetadata): void
|
||||
trackOnboardingTourUpgradeShown?(
|
||||
metadata: OnboardingTourUpgradeShownMetadata
|
||||
): void
|
||||
trackOnboardingTourNudgeShown?(): void
|
||||
trackOnboardingTourExploreTemplatesClicked?(): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -746,7 +812,18 @@ export const TelemetryEvents = {
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
PAGE_VIEW: 'app:page_view',
|
||||
|
||||
// Onboarding Tour
|
||||
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
|
||||
ONBOARDING_TOUR_STEP_VIEWED: 'app:onboarding_tour_step_viewed',
|
||||
ONBOARDING_TOUR_RUN_TRIGGERED: 'app:onboarding_tour_run_triggered',
|
||||
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
|
||||
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
|
||||
ONBOARDING_TOUR_UPGRADE_SHOWN: 'app:onboarding_tour_upgrade_shown',
|
||||
ONBOARDING_TOUR_NUDGE_SHOWN: 'app:onboarding_tour_nudge_shown',
|
||||
ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED:
|
||||
'app:onboarding_tour_explore_templates_clicked'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
@@ -803,3 +880,9 @@ export type TelemetryEventProperties =
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
| SubscriptionSuccessMetadata
|
||||
| OnboardingTourStartedMetadata
|
||||
| OnboardingTourStepViewedMetadata
|
||||
| OnboardingTourRunTriggeredMetadata
|
||||
| OnboardingTourCompletedMetadata
|
||||
| OnboardingTourSkippedMetadata
|
||||
| OnboardingTourUpgradeShownMetadata
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useOnboardingEntryStore } from '../onboardingEntryStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
|
||||
|
||||
@@ -58,11 +59,17 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const templateLoaderMocks = vi.hoisted(() => ({
|
||||
loadTemplateFromUrl: vi.fn(
|
||||
async () => ({ loaded: false }) as { loaded: boolean; templateId?: string }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrl: vi.fn()
|
||||
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -125,7 +132,48 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
get isCloud() {
|
||||
return onboardingMocks.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
const onboardingMocks = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
onboardingTourEnabled: false,
|
||||
isNewUser: null as boolean | null,
|
||||
isSubscriptionEnabled: true,
|
||||
loadWorkflowTemplates: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
loadWorkflowTemplates: onboardingMocks.loadWorkflowTemplates
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get onboardingTourEnabled() {
|
||||
return onboardingMocks.onboardingTourEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/useNewUserService', () => ({
|
||||
useNewUserService: () => ({
|
||||
isNewUser: () => onboardingMocks.isNewUser
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isSubscriptionEnabled: () => onboardingMocks.isSubscriptionEnabled
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
@@ -206,6 +254,12 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
commandStoreMocks.execute.mockReset()
|
||||
routeMocks.query = {}
|
||||
preservedQueryMocks.payloads = {}
|
||||
onboardingMocks.isCloud = false
|
||||
onboardingMocks.onboardingTourEnabled = false
|
||||
onboardingMocks.isNewUser = null
|
||||
onboardingMocks.isSubscriptionEnabled = true
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockReset()
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({ loaded: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -617,5 +671,156 @@ describe('useWorkflowPersistenceV2', () => {
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Getting Started instead of the templates browser for a flagged new user', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(loadBlankWorkflowMock).toHaveBeenCalled()
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(true)
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser off the Cloud build, matching the tour gate', async () => {
|
||||
onboardingMocks.isCloud = false
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefetches templates when Getting Started is shown so its cards are ready', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(onboardingMocks.loadWorkflowTemplates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the templates browser when the flag is on but the user is not new', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = false
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser when the flag is off', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = false
|
||||
onboardingMocks.isNewUser = true
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the templates browser, not Getting Started, when subscriptions are disabled', async () => {
|
||||
// The tour refuses when subscriptions are off, so the takeover must not show
|
||||
// — otherwise it would dismiss into a bare canvas with no tour.
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
onboardingMocks.isSubscriptionEnabled = false
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show Getting Started for a flagged new user arriving via a template URL', async () => {
|
||||
onboardingMocks.isCloud = true
|
||||
onboardingMocks.onboardingTourEnabled = true
|
||||
onboardingMocks.isNewUser = true
|
||||
routeMocks.query = { template: 'default-template-id' }
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
|
||||
const { initializeWorkflow } = mountWorkflowPersistence()
|
||||
await initializeWorkflow()
|
||||
|
||||
expect(entryStore.shouldShowGettingStarted).toBe(false)
|
||||
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
|
||||
'Comfy.BrowseTemplates'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadTemplateFromUrlIfPresent', () => {
|
||||
it('surfaces the validated template id the loader reports', async () => {
|
||||
routeMocks.query = { template: 'image_z_image_turbo' }
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
|
||||
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports not-loaded when the loader loads nothing', async () => {
|
||||
routeMocks.query = {}
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: false
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
|
||||
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
|
||||
loaded: false
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates preserved template intent before delegating to the loader', async () => {
|
||||
preservedQueryMocks.payloads.template = {
|
||||
template: 'image_z_image_turbo'
|
||||
}
|
||||
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
|
||||
loaded: true,
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
|
||||
await loadTemplateFromUrlIfPresent()
|
||||
|
||||
expect(templateLoaderMocks.loadTemplateFromUrl).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,11 +16,13 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -28,9 +30,17 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
|
||||
import { clearAllV2Storage } from '../base/storageIO'
|
||||
import { migrateV1toV2 } from '../migration/migrateV1toV2'
|
||||
import type { OnboardingCandidateDeps } from '../onboardingEntryStore'
|
||||
import {
|
||||
isOnboardingCandidate,
|
||||
useOnboardingEntryStore
|
||||
} from '../onboardingEntryStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowTabState } from './useWorkflowTabState'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
@@ -52,6 +62,13 @@ export function useWorkflowPersistenceV2() {
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
const tabState = useWorkflowTabState()
|
||||
const toast = useToast()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
const onboardingDeps: OnboardingCandidateDeps = {
|
||||
subscription: useSubscription(),
|
||||
newUserService: useNewUserService(),
|
||||
featureFlags: useFeatureFlags()
|
||||
}
|
||||
const { onUserLogout } = useCurrentUser()
|
||||
|
||||
// Run migration on module load, passing clientId for tab state migration
|
||||
@@ -179,7 +196,12 @@ export function useWorkflowPersistenceV2() {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
if (isOnboardingCandidate(onboardingDeps)) {
|
||||
void templatesStore.loadWorkflowTemplates()
|
||||
entryStore.showGettingStarted()
|
||||
} else {
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
@@ -223,12 +245,10 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
|
||||
const loadTemplateFromUrlIfPresent = async () => {
|
||||
const query = await ensureTemplateQueryFromIntent()
|
||||
const hasTemplateUrl = query.template && typeof query.template === 'string'
|
||||
|
||||
if (hasTemplateUrl) {
|
||||
await templateUrlLoader.loadTemplateFromUrl()
|
||||
}
|
||||
// Hydrate any preserved ?template= intent into the route first; the loader
|
||||
// reads the route and returns the id only after validating it.
|
||||
await ensureTemplateQueryFromIntent()
|
||||
return templateUrlLoader.loadTemplateFromUrl()
|
||||
}
|
||||
|
||||
const loadSharedWorkflowFromUrlIfPresent = async () => {
|
||||
|
||||
53
src/platform/workflow/persistence/onboardingEntryStore.ts
Normal file
53
src/platform/workflow/persistence/onboardingEntryStore.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
export interface OnboardingCandidateDeps {
|
||||
subscription: ReturnType<typeof useSubscription>
|
||||
newUserService: ReturnType<typeof useNewUserService>
|
||||
featureFlags: ReturnType<typeof useFeatureFlags>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the Getting Started screen and the tour: the screen dismisses even
|
||||
* when the tour then declines, so a looser gate on either side strands the user
|
||||
* on a bare canvas. Deps are resolved by callers during setup — this runs after
|
||||
* an await, where a first composable call would have no injection context.
|
||||
*/
|
||||
export function isOnboardingCandidate({
|
||||
subscription,
|
||||
newUserService,
|
||||
featureFlags
|
||||
}: OnboardingCandidateDeps): boolean {
|
||||
if (!isCloud) return false
|
||||
if (!subscription.isSubscriptionEnabled()) return false
|
||||
if (newUserService.isNewUser() !== true) return false
|
||||
if (!featureFlags.flags.onboardingTourEnabled) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Seam between the persistence layer and the onboarding Getting Started screen.
|
||||
*
|
||||
* `loadDefaultWorkflow` decides when a fresh user should land on Getting
|
||||
* Started, but the screen lives in `renderer/` and cannot be imported from this
|
||||
* layer. Both sides share this flag: persistence turns it on; the
|
||||
* renderer-mounted overlay shows itself while set and clears it on exit.
|
||||
*/
|
||||
export const useOnboardingEntryStore = defineStore('onboardingEntry', () => {
|
||||
const shouldShowGettingStarted = ref(false)
|
||||
|
||||
function showGettingStarted() {
|
||||
shouldShowGettingStarted.value = true
|
||||
}
|
||||
|
||||
function dismissGettingStarted() {
|
||||
shouldShowGettingStarted.value = false
|
||||
}
|
||||
|
||||
return { shouldShowGettingStarted, showGettingStarted, dismissGettingStarted }
|
||||
})
|
||||
@@ -97,6 +97,51 @@ describe('useTemplateUrlLoader', () => {
|
||||
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the loaded template id when the template loads successfully', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({
|
||||
loaded: true,
|
||||
templateId: 'flux_simple'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns not-loaded when no template param is present', async () => {
|
||||
mockQueryParams = {}
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded without a template id when the template fails to load', async () => {
|
||||
mockQueryParams = { template: 'invalid-template' }
|
||||
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded when loading throws', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('returns not-loaded for an invalid template parameter', async () => {
|
||||
mockQueryParams = { template: '../../../etc/passwd' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
|
||||
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
|
||||
})
|
||||
|
||||
it('loads template when query param is present', async () => {
|
||||
mockQueryParams = { template: 'flux_simple' }
|
||||
|
||||
|
||||
@@ -64,18 +64,21 @@ export function useTemplateUrlLoader() {
|
||||
* Loads template from URL query parameters if present
|
||||
* Handles errors internally and shows appropriate user feedback
|
||||
*/
|
||||
const loadTemplateFromUrl = async () => {
|
||||
const loadTemplateFromUrl = async (): Promise<{
|
||||
loaded: boolean
|
||||
templateId?: string
|
||||
}> => {
|
||||
const templateParam = route.query.template
|
||||
|
||||
if (!templateParam || typeof templateParam !== 'string') {
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (!isValidParameter(templateParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
const sourceParam = (route.query.source as string | undefined) || 'default'
|
||||
@@ -84,7 +87,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
@@ -96,7 +99,7 @@ export function useTemplateUrlLoader() {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
@@ -121,11 +124,16 @@ export function useTemplateUrlLoader() {
|
||||
templateName: templateParam
|
||||
})
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
return { loaded: false }
|
||||
}
|
||||
|
||||
if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
|
||||
return { loaded: true, templateId: templateParam }
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useTemplateUrlLoader] Failed to load template from URL:',
|
||||
@@ -136,6 +144,7 @@ export function useTemplateUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.errorLoadingTemplate')
|
||||
})
|
||||
return { loaded: false }
|
||||
} finally {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(TEMPLATE_NAMESPACE)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="skeleton"
|
||||
:data-testid="testid"
|
||||
class="relative aspect-square overflow-hidden rounded-2xl"
|
||||
>
|
||||
<Skeleton class="absolute inset-0 rounded-2xl" />
|
||||
<Skeleton class="absolute inset-x-4 bottom-4 h-4 w-2/3 rounded-md" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
role="button"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
:data-testid="testid"
|
||||
:aria-label="title"
|
||||
:aria-busy="loading"
|
||||
class="group/card focus-visible:ring-ring relative aspect-square cursor-pointer overflow-hidden rounded-2xl focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="onSelect"
|
||||
@keydown.enter.prevent="onSelect"
|
||||
@keydown.space.prevent="onSelect"
|
||||
>
|
||||
<LazyImage
|
||||
:src="imageSrc"
|
||||
:alt="title"
|
||||
image-class="size-full object-cover opacity-60 transition-all duration-300 ease-out group-hover/card:scale-105 group-hover/card:opacity-100"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-black/40 via-transparent via-50% to-black/40 transition-opacity duration-300 ease-out group-hover/card:opacity-60"
|
||||
/>
|
||||
<div
|
||||
v-if="badgeIcon"
|
||||
aria-hidden="true"
|
||||
data-testid="getting-started-card-badge"
|
||||
class="pointer-events-none absolute top-3 right-3 flex size-7 items-center justify-center rounded-full bg-black/50 text-base-foreground backdrop-blur-sm"
|
||||
>
|
||||
<i :class="cn(badgeIcon, 'size-4')" />
|
||||
</div>
|
||||
<h3
|
||||
class="absolute inset-x-0 bottom-0 m-0 truncate p-4 text-sm font-semibold text-base-foreground drop-shadow-md"
|
||||
:title
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="loading"
|
||||
aria-live="polite"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-base-background/70 backdrop-blur-md"
|
||||
>
|
||||
<DotSpinner :size="24" />
|
||||
<span class="text-xs font-medium text-base-foreground/80">
|
||||
{{ t('onboardingTour.preparing') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
|
||||
const {
|
||||
imageSrc = '',
|
||||
title = '',
|
||||
loading = false,
|
||||
skeleton = false,
|
||||
badgeIcon = '',
|
||||
testid
|
||||
} = defineProps<{
|
||||
imageSrc?: string
|
||||
title?: string
|
||||
loading?: boolean
|
||||
skeleton?: boolean
|
||||
badgeIcon?: string
|
||||
testid?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function onSelect() {
|
||||
if (loading) return
|
||||
emit('select')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
|
||||
|
||||
import GettingStartedScreen from './GettingStartedScreen.vue'
|
||||
|
||||
const meta: Meta<typeof GettingStartedScreen> = {
|
||||
title: 'Renderer/OnboardingTour/GettingStartedScreen',
|
||||
component: GettingStartedScreen,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
useOnboardingEntryStore().showGettingStarted()
|
||||
// Template cards need the served template package; without a backend the
|
||||
// curated lookups return nothing, so this catalogs the screen chrome
|
||||
// (heading, tabs, placeholders). Card rendering is covered by the unit
|
||||
// test.
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const render: Story['render'] = () => ({
|
||||
components: { GettingStartedScreen },
|
||||
template: '<GettingStartedScreen />'
|
||||
})
|
||||
|
||||
export const Default: Story = { render }
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadWorkflowTemplate: vi.fn(async () => true),
|
||||
getTemplateThumbnailUrl: vi.fn(() => 'thumb.jpg'),
|
||||
getTemplateTitle: vi.fn(
|
||||
(template: TemplateInfo) => template.title ?? template.name
|
||||
),
|
||||
controllerBeginTour: vi.fn(async () => {}),
|
||||
templatesByName: new Map<string, TemplateInfo>(),
|
||||
templatesLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn(async () => {})
|
||||
}))
|
||||
|
||||
function makeTemplate(name: string, title: string): TemplateInfo {
|
||||
return {
|
||||
name,
|
||||
title,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
const CARD_IDS = [
|
||||
'image_krea2_turbo_t2i',
|
||||
'image_z_image_turbo',
|
||||
'video_ltx2_3_i2v',
|
||||
'video_wan2_2_14B_i2v'
|
||||
] as const
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateWorkflows',
|
||||
() => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
loadWorkflowTemplate: mocks.loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl: mocks.getTemplateThumbnailUrl,
|
||||
getTemplateTitle: mocks.getTemplateTitle
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
getTemplateByName: (name: string) => mocks.templatesByName.get(name),
|
||||
get isLoaded() {
|
||||
return mocks.templatesLoaded
|
||||
},
|
||||
loadWorkflowTemplates: mocks.loadWorkflowTemplates
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./useOnboardingTourController', () => ({
|
||||
useOnboardingTourController: () => ({ beginTour: mocks.controllerBeginTour })
|
||||
}))
|
||||
|
||||
import GettingStartedScreen from './GettingStartedScreen.vue'
|
||||
import { tutorialCards } from './tutorialCards'
|
||||
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderScreen() {
|
||||
return render(GettingStartedScreen, { global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('GettingStartedScreen', () => {
|
||||
let store: ReturnType<typeof useOnboardingEntryStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useOnboardingEntryStore()
|
||||
store.showGettingStarted()
|
||||
|
||||
mocks.loadWorkflowTemplate.mockClear()
|
||||
mocks.controllerBeginTour.mockClear()
|
||||
mocks.loadWorkflowTemplates.mockClear()
|
||||
mocks.templatesLoaded = true
|
||||
mocks.templatesByName.clear()
|
||||
CARD_IDS.forEach((id, i) =>
|
||||
mocks.templatesByName.set(id, makeTemplate(id, `Template ${i + 1}`))
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the four curated template cards on the Templates tab', () => {
|
||||
renderScreen()
|
||||
|
||||
for (const id of CARD_IDS) {
|
||||
expect(screen.getByTestId(`getting-started-card-${id}`)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads the templates store when opened unloaded so cards can resolve', () => {
|
||||
mocks.templatesLoaded = false
|
||||
renderScreen()
|
||||
|
||||
expect(mocks.loadWorkflowTemplates).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not reload the templates store when it is already loaded', () => {
|
||||
mocks.templatesLoaded = true
|
||||
renderScreen()
|
||||
|
||||
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load the templates store while the screen is hidden', () => {
|
||||
store.dismissGettingStarted()
|
||||
mocks.templatesLoaded = false
|
||||
renderScreen()
|
||||
|
||||
expect(mocks.loadWorkflowTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the template then starts the tour when a card is picked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId('getting-started-card-image_z_image_turbo')
|
||||
)
|
||||
|
||||
expect(mocks.loadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'image_z_image_turbo',
|
||||
'default'
|
||||
)
|
||||
expect(mocks.controllerBeginTour).toHaveBeenCalledWith({
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
const loadOrder = mocks.loadWorkflowTemplate.mock.invocationCallOrder[0]
|
||||
const startOrder = mocks.controllerBeginTour.mock.invocationCallOrder[0]
|
||||
expect(loadOrder).toBeLessThan(startOrder)
|
||||
expect(store.shouldShowGettingStarted).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the picked card busy while the template loads and the tour prepares', async () => {
|
||||
let resolveLoad: (loaded: boolean) => void = () => {}
|
||||
mocks.loadWorkflowTemplate.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveLoad = resolve
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
const cardId = 'getting-started-card-image_z_image_turbo'
|
||||
await user.click(screen.getByTestId(cardId))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByTestId(cardId)).toHaveAttribute('aria-busy', 'true')
|
||||
)
|
||||
|
||||
resolveLoad(true)
|
||||
})
|
||||
|
||||
it('keeps the screen up and does not start the tour when loading fails', async () => {
|
||||
mocks.loadWorkflowTemplate.mockResolvedValueOnce(false)
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId('getting-started-card-video_ltx2_3_i2v')
|
||||
)
|
||||
|
||||
expect(mocks.loadWorkflowTemplate).toHaveBeenCalled()
|
||||
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
|
||||
expect(store.shouldShowGettingStarted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start the tour or dismiss when loading rejects', async () => {
|
||||
mocks.loadWorkflowTemplate.mockRejectedValueOnce(new Error('load failed'))
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId('getting-started-card-video_wan2_2_14B_i2v')
|
||||
)
|
||||
|
||||
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
|
||||
expect(store.shouldShowGettingStarted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not offer the Import workflow tab', () => {
|
||||
renderScreen()
|
||||
|
||||
expect(screen.queryByRole('tab', { name: /import workflow/i })).toBeNull()
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows skeleton cards instead of an empty grid while templates load', () => {
|
||||
mocks.templatesLoaded = false
|
||||
renderScreen()
|
||||
|
||||
expect(screen.getAllByTestId('getting-started-card-skeleton')).toHaveLength(
|
||||
CARD_IDS.length
|
||||
)
|
||||
expect(
|
||||
screen.queryByTestId('getting-started-card-image_z_image_turbo')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the tutorial docs in a new tab when a tutorial card is picked', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
|
||||
await user.click(
|
||||
screen.getByTestId('getting-started-tutorial-text-to-image')
|
||||
)
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/basic/text-to-image',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(mocks.controllerBeginTour).not.toHaveBeenCalled()
|
||||
expect(store.shouldShowGettingStarted).toBe(true)
|
||||
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders a card per tutorial on the Tutorials tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
|
||||
|
||||
for (const tutorial of tutorialCards) {
|
||||
expect(
|
||||
screen.getByTestId(`getting-started-tutorial-${tutorial.id}`)
|
||||
).toBeTruthy()
|
||||
}
|
||||
expect(
|
||||
screen.queryByTestId('getting-started-card-image_z_image_turbo')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('badges tutorial cards so they read apart from the template cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderScreen()
|
||||
|
||||
expect(screen.queryByTestId('getting-started-card-badge')).toBeNull()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /tutorials/i }))
|
||||
|
||||
expect(screen.getAllByTestId('getting-started-card-badge')).toHaveLength(
|
||||
tutorialCards.length
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render when the entry flag is off', () => {
|
||||
store.dismissGettingStarted()
|
||||
renderScreen()
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
203
src/renderer/extensions/onboardingTour/GettingStartedScreen.vue
Normal file
203
src/renderer/extensions/onboardingTour/GettingStartedScreen.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<Teleport v-if="visible" to="body">
|
||||
<div
|
||||
ref="screenRef"
|
||||
class="fixed inset-0 z-2000 flex flex-col items-center justify-center bg-base-background px-8 focus:outline-none"
|
||||
role="dialog"
|
||||
:aria-label="t('onboardingTour.gettingStarted.screenLabel')"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex w-full max-w-5xl flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<h1
|
||||
class="m-0 text-center text-[2.5rem]/11 font-medium text-base-foreground"
|
||||
>
|
||||
{{ t('onboardingTour.gettingStarted.title') }}
|
||||
</h1>
|
||||
<p class="m-0 text-center text-base/5 text-muted-foreground">
|
||||
{{ t('onboardingTour.gettingStarted.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabList
|
||||
v-model="activeTab"
|
||||
class="w-auto gap-1 rounded-full border border-border-subtle p-0.5"
|
||||
:aria-label="t('onboardingTour.gettingStarted.tabsLabel')"
|
||||
>
|
||||
<Tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="gap-2.5 rounded-full px-3 text-xs font-medium text-base-foreground hover:opacity-100 data-[state=active]:bg-tertiary-background data-[state=inactive]:opacity-70"
|
||||
>
|
||||
<i :class="cn(tab.icon, 'size-3.5')" aria-hidden="true" />
|
||||
{{ t(tab.labelKey) }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<div class="w-full">
|
||||
<TabPanel
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
:model-value="activeTab"
|
||||
>
|
||||
<div
|
||||
v-if="tab.value === 'templates'"
|
||||
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
<template v-if="templatesStore.isLoaded">
|
||||
<GettingStartedTemplateCard
|
||||
v-for="template in cards"
|
||||
:key="template.name"
|
||||
:template
|
||||
:loading="loadingTemplateId === template.name"
|
||||
class="min-w-0"
|
||||
@select="onSelectTemplate"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<GettingStartedCard
|
||||
v-for="id in CURATED_TEMPLATE_IDS"
|
||||
:key="id"
|
||||
skeleton
|
||||
testid="getting-started-card-skeleton"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="tab.value === 'tutorials'"
|
||||
class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
<GettingStartedCard
|
||||
v-for="tutorial in tutorialCards"
|
||||
:key="tutorial.id"
|
||||
:image-src="tutorialThumbnail(tutorial.thumbnailTemplate)"
|
||||
:title="t(tutorial.titleKey)"
|
||||
:badge-icon="TUTORIAL_BADGE_ICON"
|
||||
:testid="`getting-started-tutorial-${tutorial.id}`"
|
||||
class="min-w-0"
|
||||
@select="openTutorial(tutorial.url)"
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import TabPanel from '@/components/tab/TabPanel.vue'
|
||||
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
|
||||
import GettingStartedCard from './GettingStartedCard.vue'
|
||||
import GettingStartedTemplateCard from './GettingStartedTemplateCard.vue'
|
||||
import type { TutorialCard } from './tutorialCards'
|
||||
import {
|
||||
CURATED_TEMPLATE_IDS,
|
||||
TUTORIAL_BADGE_ICON,
|
||||
tutorialCards
|
||||
} from './tutorialCards'
|
||||
import { useOnboardingTourController } from './useOnboardingTourController'
|
||||
|
||||
type TabValue = 'templates' | 'tutorials'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
value: 'templates' as const,
|
||||
labelKey: 'onboardingTour.gettingStarted.tabs.templates',
|
||||
icon: 'icon-[lucide--layout-template]'
|
||||
},
|
||||
{
|
||||
value: 'tutorials' as const,
|
||||
labelKey: 'onboardingTour.gettingStarted.tabs.tutorials',
|
||||
icon: 'icon-[lucide--tv-minimal-play]'
|
||||
}
|
||||
]
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const entryStore = useOnboardingEntryStore()
|
||||
const { shouldShowGettingStarted: visible } = storeToRefs(entryStore)
|
||||
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const { loadWorkflowTemplate, getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const controller = useOnboardingTourController()
|
||||
|
||||
const activeTab = ref<TabValue>('templates')
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
const screenRef = useTemplateRef<HTMLElement>('screenRef')
|
||||
|
||||
const cards = computed(() =>
|
||||
CURATED_TEMPLATE_IDS.map((id) => templatesStore.getTemplateByName(id)).filter(
|
||||
(template) => template !== undefined
|
||||
)
|
||||
)
|
||||
|
||||
// Cards and per-card loads no-op until the templates store is loaded; nothing
|
||||
// else loads it on this path, so load it (and focus the takeover) on open.
|
||||
watch(
|
||||
visible,
|
||||
(isVisible) => {
|
||||
if (!isVisible) return
|
||||
if (!templatesStore.isLoaded) {
|
||||
templatesStore.loadWorkflowTemplates().catch((error: unknown) => {
|
||||
console.error('Failed to load onboarding templates:', error)
|
||||
})
|
||||
}
|
||||
void nextTick(() => screenRef.value?.focus())
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function tutorialThumbnail(id: TutorialCard['thumbnailTemplate']) {
|
||||
const template = templatesStore.getTemplateByName(id)
|
||||
return template ? getTemplateThumbnailUrl(template, 'default') : ''
|
||||
}
|
||||
|
||||
function openTutorial(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function onSelectTemplate(id: string) {
|
||||
if (loadingTemplateId.value) return
|
||||
// Keep the screen up (the card shows a spinner) through the load and the
|
||||
// tour's readiness gate: a failed load leaves the user here to pick again,
|
||||
// and the tour overlay takes over on top before the screen is dismissed, so
|
||||
// the canvas never flashes bare.
|
||||
loadingTemplateId.value = id
|
||||
try {
|
||||
let loaded = false
|
||||
try {
|
||||
loaded = await loadWorkflowTemplate(id, 'default')
|
||||
} catch (error) {
|
||||
console.error('Failed to load onboarding template:', error)
|
||||
}
|
||||
if (!loaded) return
|
||||
try {
|
||||
await controller.beginTour({ templateId: id })
|
||||
entryStore.dismissGettingStarted()
|
||||
} catch (error) {
|
||||
// Keep the screen up so the user can retry rather than landing on a
|
||||
// half-started tour behind a dismissed takeover.
|
||||
console.error('Failed to start onboarding tour:', error)
|
||||
}
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<GettingStartedCard
|
||||
:image-src="thumbnailSrc"
|
||||
:title
|
||||
:loading
|
||||
:testid="`getting-started-card-${template.name}`"
|
||||
@select="emit('select', template.name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
import GettingStartedCard from './GettingStartedCard.vue'
|
||||
|
||||
const { template, loading = false } = defineProps<{
|
||||
template: TemplateInfo
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [id: string] }>()
|
||||
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
const thumbnailSrc = computed(() =>
|
||||
getTemplateThumbnailUrl(template, 'default')
|
||||
)
|
||||
const title = computed(() => getTemplateTitle(template, 'default'))
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import OnboardingTourNudge from './OnboardingTourNudge.vue'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
const SAMPLE_IMAGE =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
|
||||
)
|
||||
|
||||
const meta: Meta<typeof OnboardingTourNudge> = {
|
||||
title: 'Renderer/OnboardingTour/OnboardingTourNudge',
|
||||
component: OnboardingTourNudge,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
const store = useOnboardingTourStore()
|
||||
store.resultMedia = { url: SAMPLE_IMAGE, kind: 'image' }
|
||||
store.showNudge()
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { OnboardingTourNudge },
|
||||
template: '<OnboardingTourNudge />'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
show: vi.fn(),
|
||||
trackExploreTemplatesClicked: vi.fn(),
|
||||
trackNudgeShown: vi.fn()
|
||||
}))
|
||||
|
||||
// A reactive backing so the component's modal-close watch actually fires, the
|
||||
// same way the real store's `isDialogOpen` reads a reactive dialog stack.
|
||||
const openDialogs = ref<string[]>([])
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: () => ({ show: mocks.show })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackOnboardingTourExploreTemplatesClicked:
|
||||
mocks.trackExploreTemplatesClicked,
|
||||
trackOnboardingTourNudgeShown: mocks.trackNudgeShown
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
isDialogOpen: (key: string) => openDialogs.value.includes(key)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
|
||||
|
||||
import OnboardingTourNudge from './OnboardingTourNudge.vue'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderNudge() {
|
||||
// No appear delay: these assert what the nudge shows, not when it fades in.
|
||||
return render(OnboardingTourNudge, {
|
||||
props: { appearDelayMs: 0 },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const nudgeTitle = enMessages.onboardingTour.nudge.title
|
||||
|
||||
describe('OnboardingTourNudge', () => {
|
||||
let store: ReturnType<typeof useOnboardingTourStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useOnboardingTourStore()
|
||||
mocks.show.mockReset()
|
||||
mocks.trackExploreTemplatesClicked.mockReset()
|
||||
mocks.trackNudgeShown.mockReset()
|
||||
openDialogs.value = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing until the nudge is requested', () => {
|
||||
renderNudge()
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the post-run prompt once the store requests it', async () => {
|
||||
renderNudge()
|
||||
store.showNudge()
|
||||
|
||||
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('appear delay', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('holds the nudge back so the fresh result gets a beat on its own', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(OnboardingTourNudge, {
|
||||
props: { appearDelayMs: 2000 },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
store.showNudge()
|
||||
await vi.advanceTimersByTimeAsync(1999)
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(screen.getByText(nudgeTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never appears when the nudge is withdrawn inside the delay', async () => {
|
||||
// Dismissing during the wait must cancel the pending appearance, not fire late.
|
||||
vi.useFakeTimers()
|
||||
render(OnboardingTourNudge, {
|
||||
props: { appearDelayMs: 2000 },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
store.showNudge()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
store.dismissNudge()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports the nudge as shown only when it actually appears', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(OnboardingTourNudge, {
|
||||
props: { appearDelayMs: 2000 },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
store.showNudge()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(mocks.trackNudgeShown).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(mocks.trackNudgeShown).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('leads with the generated image when the result media is an image', async () => {
|
||||
renderNudge()
|
||||
store.resultMedia = { url: 'result.png', kind: 'image' }
|
||||
store.showNudge()
|
||||
|
||||
const image = await screen.findByRole('img', { name: nudgeTitle })
|
||||
expect(image).toHaveAttribute('src', 'result.png')
|
||||
})
|
||||
|
||||
it('leads with a looping video when the result media is a video', async () => {
|
||||
renderNudge()
|
||||
store.resultMedia = { url: 'result.mp4', kind: 'video' }
|
||||
store.showNudge()
|
||||
|
||||
const video = await screen.findByTestId('onboarding-nudge-video')
|
||||
expect(video).toHaveAttribute('src', 'result.mp4')
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the template library and reports the click on Explore templates', async () => {
|
||||
renderNudge()
|
||||
store.showNudge()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: enMessages.onboardingTour.nudge.explore
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.show).toHaveBeenCalledOnce()
|
||||
expect(mocks.trackExploreTemplatesClicked).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('permanently dismisses on Not now and does not resurface', async () => {
|
||||
renderNudge()
|
||||
store.showNudge()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: enMessages.onboardingTour.nudge.dismiss
|
||||
})
|
||||
)
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
|
||||
// A later trigger must not bring it back this session.
|
||||
store.showNudge()
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defers past the open upgrade modal and surfaces once it closes', async () => {
|
||||
openDialogs.value = ['free-tier-info']
|
||||
// showNudge while the modal is open must defer, not overlap the paywall.
|
||||
store.showNudge()
|
||||
renderNudge()
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
|
||||
openDialogs.value = []
|
||||
|
||||
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden on modal close when the nudge was never requested', async () => {
|
||||
openDialogs.value = ['subscription-required']
|
||||
renderNudge()
|
||||
|
||||
openDialogs.value = []
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces the deferred nudge only once across repeated modal cycles', async () => {
|
||||
openDialogs.value = ['free-tier-info']
|
||||
store.showNudge()
|
||||
renderNudge()
|
||||
const user = userEvent.setup()
|
||||
|
||||
openDialogs.value = []
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: enMessages.onboardingTour.nudge.dismiss
|
||||
})
|
||||
)
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
|
||||
// A second upgrade-modal cycle must not resurface the dismissed nudge:
|
||||
// the arm was consumed on the first surfacing and dismissal is permanent.
|
||||
openDialogs.value = ['free-tier-info']
|
||||
await nextTick()
|
||||
openDialogs.value = []
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
123
src/renderer/extensions/onboardingTour/OnboardingTourNudge.vue
Normal file
123
src/renderer/extensions/onboardingTour/OnboardingTourNudge.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shown"
|
||||
role="status"
|
||||
class="pointer-events-auto fixed right-0 bottom-0 z-1000 flex w-80 animate-in flex-col overflow-hidden rounded-tl-xl border-t border-l border-border-default/50 bg-base-background shadow-lg duration-500 fade-in-0"
|
||||
>
|
||||
<div class="relative h-50 w-full bg-secondary-background">
|
||||
<video
|
||||
v-if="media?.kind === 'video'"
|
||||
:src="media.url"
|
||||
data-testid="onboarding-nudge-video"
|
||||
class="size-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="media?.url ?? FALLBACK_MEDIA"
|
||||
:alt="t('onboardingTour.nudge.title')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
class="absolute top-2 right-2 size-8 opacity-50 hover:opacity-100"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.close')"
|
||||
@click="store.dismissNudge()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 border-t border-border-default px-4 pt-6 pb-4"
|
||||
>
|
||||
<p class="m-0 text-sm/5 font-bold text-base-foreground">
|
||||
{{ t('onboardingTour.nudge.title') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ t('onboardingTour.nudge.body') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 px-4 pb-4">
|
||||
<Button
|
||||
variant="link"
|
||||
size="unset"
|
||||
class="h-6 text-sm font-normal"
|
||||
@click="store.dismissNudge()"
|
||||
>
|
||||
{{ t('onboardingTour.nudge.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="onExplore"
|
||||
>
|
||||
{{ t('onboardingTour.nudge.explore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
import {
|
||||
isUpgradeModalOpen,
|
||||
useOnboardingTourStore
|
||||
} from './onboardingTourStore'
|
||||
|
||||
const FALLBACK_MEDIA = '/assets/images/og-image.png'
|
||||
|
||||
/** Delayed, so the fresh result is seen before the nudge fades in over it. */
|
||||
const { appearDelayMs = 1500 } = defineProps<{ appearDelayMs?: number }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useOnboardingTourStore()
|
||||
|
||||
const { resultMedia: media } = storeToRefs(store)
|
||||
|
||||
const shouldShow = computed(() => store.shouldShowNudge)
|
||||
|
||||
const shown = ref(false)
|
||||
const { start: startAppearDelay, stop: cancelAppearDelay } = useTimeoutFn(
|
||||
() => {
|
||||
shown.value = true
|
||||
useTelemetry()?.trackOnboardingTourNudgeShown?.()
|
||||
},
|
||||
() => appearDelayMs,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(shouldShow, (visible) => {
|
||||
cancelAppearDelay()
|
||||
shown.value = false
|
||||
if (visible) startAppearDelay()
|
||||
})
|
||||
|
||||
// The run-step gate arms the nudge behind the upgrade modal; surface it only once
|
||||
// that modal closes, so the two never overlap.
|
||||
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
|
||||
|
||||
watch(upgradeModalOpen, (open, wasOpen) => {
|
||||
if (wasOpen && !open && store.nudgeArmed) store.showNudge()
|
||||
})
|
||||
|
||||
function onExplore() {
|
||||
useWorkflowTemplateSelectorDialog().show('command')
|
||||
useTelemetry()?.trackOnboardingTourExploreTemplatesClicked?.()
|
||||
store.dismissNudge()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import OnboardingTourOverlay from './OnboardingTourOverlay.vue'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
import type { TourStep } from './tourSequence'
|
||||
|
||||
interface StoryArgs {
|
||||
stepIndex: number
|
||||
revealedCount: number
|
||||
}
|
||||
|
||||
const SAMPLE_IMAGE =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
|
||||
)
|
||||
|
||||
const steps: TourStep[] = [
|
||||
{ kind: 'upload', nodeId: toNodeId(1) },
|
||||
{
|
||||
kind: 'prompt',
|
||||
nodeId: null,
|
||||
prompt: {
|
||||
subgraphNodeId: toNodeId(2),
|
||||
innerNodeId: toNodeId(3),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
}
|
||||
},
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(4), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Renderer/OnboardingTour/OnboardingTourOverlay',
|
||||
component: OnboardingTourOverlay,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
stepIndex: { control: { type: 'number', min: 0, max: 3 } },
|
||||
revealedCount: { control: { type: 'number', min: 0, max: 4 } }
|
||||
},
|
||||
decorators: [
|
||||
(_, context) => {
|
||||
const store = useOnboardingTourStore()
|
||||
store.phase = 'active'
|
||||
store.steps = steps
|
||||
store.stepIndex = context.args.stepIndex
|
||||
store.revealedNodeIds = new Set(
|
||||
Array.from({ length: context.args.revealedCount }, (_, i) =>
|
||||
toNodeId(i + 1)
|
||||
)
|
||||
)
|
||||
const activeStep = steps[context.args.stepIndex]
|
||||
store.resultMedia =
|
||||
activeStep?.kind === 'result'
|
||||
? { url: SAMPLE_IMAGE, kind: activeStep.mediaKind ?? 'image' }
|
||||
: null
|
||||
// Spotlight geometry needs a live litegraph canvas (absent in Storybook),
|
||||
// so holes are exercised by the unit test; this catalogs the coach-mark.
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const render: Story['render'] = () => ({
|
||||
components: { OnboardingTourOverlay },
|
||||
template: '<OnboardingTourOverlay />'
|
||||
})
|
||||
|
||||
export const FirstStep: Story = {
|
||||
args: { stepIndex: 0, revealedCount: 1 },
|
||||
render
|
||||
}
|
||||
|
||||
export const MultiReveal: Story = {
|
||||
args: { stepIndex: 1, revealedCount: 2 },
|
||||
render
|
||||
}
|
||||
|
||||
export const LastStep: Story = {
|
||||
args: { stepIndex: 3, revealedCount: 1 },
|
||||
render
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type * as adapterModule from './canvasSpotlightAdapter'
|
||||
import type { ScreenRect } from './canvasSpotlightAdapter'
|
||||
import type { TourStep } from './tourSequence'
|
||||
|
||||
type AdapterModule = typeof adapterModule
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
maskRects: [] as ScreenRect[],
|
||||
// Optional per-node-id rects: when set, `maskRectsFor` returns one rect per id
|
||||
// that has an entry, so the revealed (holes) and spotlit (rings) calls resolve
|
||||
// by which ids they carry — not by how many. Falls back to `maskRects`.
|
||||
rectsById: null as Record<string, ScreenRect> | null,
|
||||
focusNodes: vi.fn(),
|
||||
// A camera already at rest: these assert what a step renders, not the settle
|
||||
// machine (covered against the real `trackSettle` in the adapter's own tests).
|
||||
transformKey: 'settled' as string | null,
|
||||
viewport: { left: 0, top: 0, width: 1440, height: 900 } as ScreenRect,
|
||||
controller: {
|
||||
end: vi.fn(),
|
||||
back: vi.fn(),
|
||||
advance: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Only the litegraph-backed reads are stubbed; the pure geometry helpers stay real,
|
||||
// so a test that pans a target off screen exercises the same code prod does.
|
||||
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
|
||||
...(await importOriginal<AdapterModule>()),
|
||||
// Zero so a step's copy is gated only on the camera settling, never on a wait.
|
||||
TOUR_FOCUS_DURATION_MS: 0,
|
||||
// Return a fresh array (like prod) so a caller pushing to it can't grow the mock.
|
||||
maskRectsFor: (ids: unknown[]) =>
|
||||
mocks.rectsById === null
|
||||
? [...mocks.maskRects]
|
||||
: (ids as { toString(): string }[])
|
||||
.map((id) => mocks.rectsById?.[String(id)])
|
||||
.filter((r): r is ScreenRect => r !== undefined),
|
||||
focusNodes: mocks.focusNodes,
|
||||
canvasViewport: () => mocks.viewport,
|
||||
canvasTransformKey: () => mocks.transformKey,
|
||||
canvasElement: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./useOnboardingTourController', () => ({
|
||||
useOnboardingTourController: () => mocks.controller
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
|
||||
|
||||
import OnboardingTourOverlay from './OnboardingTourOverlay.vue'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const promptStep: TourStep = {
|
||||
kind: 'prompt',
|
||||
nodeId: null,
|
||||
prompt: {
|
||||
subgraphNodeId: toNodeId(2),
|
||||
innerNodeId: toNodeId(3),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
}
|
||||
}
|
||||
|
||||
const runStep: TourStep = { kind: 'run', nodeId: null }
|
||||
|
||||
const videoResultStep: TourStep = {
|
||||
kind: 'result',
|
||||
nodeId: toNodeId(4),
|
||||
mediaKind: 'video'
|
||||
}
|
||||
|
||||
const imageResultStep: TourStep = {
|
||||
kind: 'result',
|
||||
nodeId: toNodeId(4),
|
||||
mediaKind: 'image'
|
||||
}
|
||||
|
||||
function rect(left: number): ScreenRect {
|
||||
return { left, top: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
return render(OnboardingTourOverlay, { global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
describe('OnboardingTourOverlay', () => {
|
||||
let store: ReturnType<typeof useOnboardingTourStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useOnboardingTourStore()
|
||||
mocks.maskRects = []
|
||||
mocks.rectsById = null
|
||||
mocks.transformKey = 'settled'
|
||||
mocks.viewport = { left: 0, top: 0, width: 1440, height: 900 }
|
||||
mocks.focusNodes.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing while the tour is idle', () => {
|
||||
renderOverlay()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the dialog for assistive technology when active', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
renderOverlay()
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Getting started tour' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws a focus ring for each spotlit node when active', async () => {
|
||||
mocks.maskRects = [rect(0), rect(200)]
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('rings only the current step, not every revealed node', async () => {
|
||||
// At the prompt step both the upload node (1) and the prompt host (2) are
|
||||
// revealed, but only the prompt host is spotlit. Keying rects by id proves
|
||||
// the ring set is the current step's targets, not the accumulated reveals.
|
||||
const uploadStep: TourStep = { kind: 'upload', nodeId: toNodeId(1) }
|
||||
mocks.rectsById = { '1': rect(0), '2': rect(200) }
|
||||
store.phase = 'active'
|
||||
store.steps = [uploadStep, promptStep, runStep]
|
||||
store.stepIndex = 1
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Holes cover both revealed nodes (1 & 2); rings cover only spotlit node 2.
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the step counter derived from stepIndex and the step total', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, videoResultStep]
|
||||
store.stepIndex = 1
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(await screen.findByText('2 of 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: 'renders the t2i prompt copy',
|
||||
shape: 't2i',
|
||||
body: 'Your image gets built from this description. Try anything you like.'
|
||||
},
|
||||
{
|
||||
name: 'renders the i2v prompt copy',
|
||||
shape: 'i2v',
|
||||
body: 'The image sets the scene. This tells it what happens next.'
|
||||
},
|
||||
{
|
||||
name: 'renders the image-edit prompt copy',
|
||||
shape: 'image-edit',
|
||||
body: 'Your image stays as it is. Describe what you want different.'
|
||||
},
|
||||
{
|
||||
name: 'renders the other prompt copy',
|
||||
shape: 'other',
|
||||
body: 'This is your prompt. Change it to change your result.'
|
||||
}
|
||||
] as const)('$name', async ({ shape, body }) => {
|
||||
store.phase = 'active'
|
||||
store.steps = [{ ...promptStep, shape }, runStep]
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(await screen.findByText(body)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: 'renders the i2v upload copy',
|
||||
shape: 'i2v',
|
||||
body: 'This picture is your first frame. Swap in your own whenever you like.'
|
||||
},
|
||||
{
|
||||
name: 'renders the image-edit upload copy',
|
||||
shape: 'image-edit',
|
||||
body: "This is the picture you'll edit. Swap in your own whenever you like."
|
||||
},
|
||||
{
|
||||
name: 'renders the other upload copy',
|
||||
shape: 'other',
|
||||
body: 'This image feeds the workflow. Swap in your own whenever you like.'
|
||||
}
|
||||
] as const)('$name', async ({ shape, body }) => {
|
||||
store.phase = 'active'
|
||||
store.steps = [{ kind: 'upload', nodeId: toNodeId(1), shape }, runStep]
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(await screen.findByText(body)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders video result copy for a video sink once the media is captured', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, videoResultStep]
|
||||
store.stepIndex = 1
|
||||
store.resultMedia = { url: 'blob:video-output', kind: 'video' }
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Your new video lands right here — ready to download or share.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps result media out of the coach-mark (it lands on the canvas node)', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
store.resultMedia = { url: 'blob:image-output', kind: 'image' }
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await screen.findByText(
|
||||
'Your new image lands right here — ready to download or share.'
|
||||
)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.queryByTestId('onboarding-result-video')).toBeNull()
|
||||
})
|
||||
|
||||
it('ends the tour with skip when the Skip control is pressed', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Skip' }))
|
||||
expect(mocks.controller.end).toHaveBeenCalledWith('skip')
|
||||
})
|
||||
|
||||
it('advances on Next before the last step', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, videoResultStep]
|
||||
store.stepIndex = 0
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Next' }))
|
||||
expect(mocks.controller.advance).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Complete and finishes the tour on the last step', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, videoResultStep]
|
||||
store.stepIndex = 1
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Finish' }))
|
||||
expect(mocks.controller.end).toHaveBeenCalledWith('done')
|
||||
})
|
||||
|
||||
it('hides Back on the first step', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, videoResultStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await screen.findByRole('button', { name: 'Next' })
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Back after the first step and goes back on press', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, videoResultStep]
|
||||
store.stepIndex = 1
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(mocks.controller.back).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('points the decorative agent cursor at the focused target', async () => {
|
||||
mocks.maskRects = [rect(0)]
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
|
||||
renderOverlay()
|
||||
|
||||
const cursor = await screen.findByTestId('onboarding-cursor')
|
||||
expect(cursor).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('hides the cursor but keeps the coach-mark and Skip reachable with no target', async () => {
|
||||
mocks.maskRects = []
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Skip' })).toBeVisible()
|
||||
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers no Next escape on the Run step (the run is the only way forward)', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await screen.findByText('Press Run to start generating your result')
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull()
|
||||
// Skip and Back remain so the user is never trapped.
|
||||
expect(screen.getByRole('button', { name: 'Skip' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('lights the action bar on the Result step without ringing it', async () => {
|
||||
const actionbar = document.createElement('div')
|
||||
actionbar.setAttribute('data-testid', 'comfy-actionbar')
|
||||
actionbar.getBoundingClientRect = () =>
|
||||
({ left: 5, top: 5, width: 80, height: 8 }) as DOMRect
|
||||
document.body.append(actionbar)
|
||||
mocks.maskRects = [rect(0)] // the sink node
|
||||
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
|
||||
try {
|
||||
renderOverlay()
|
||||
|
||||
// Holes: sink node + action bar. Ring: the sink node only.
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-hole')).toHaveLength(2)
|
||||
})
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
} finally {
|
||||
actionbar.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('spotlights only the sink on the Result step when no run bar is present', async () => {
|
||||
mocks.maskRects = [rect(0)]
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the result copy and shows a generating indicator while the run is live', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
store.resultMedia = null
|
||||
store.runFinished = false
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Your new image lands right here — ready to download or share.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Generating…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stops generating once the run reports, even if no media was captured', async () => {
|
||||
// The capture polls the sink and gives up silently on timeout. Hanging the
|
||||
// spinner off the media left it running forever when no URL ever landed.
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
store.resultMedia = null
|
||||
store.runFinished = true
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await screen.findByText(
|
||||
'Your new image lands right here — ready to download or share.'
|
||||
)
|
||||
expect(screen.queryByText('Generating…')).toBeNull()
|
||||
})
|
||||
|
||||
it('stops generating once the media lands', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep, imageResultStep]
|
||||
store.stepIndex = 1
|
||||
store.resultMedia = { url: 'blob:result', kind: 'image' }
|
||||
store.runFinished = true
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await screen.findByText(
|
||||
'Your new image lands right here — ready to download or share.'
|
||||
)
|
||||
expect(screen.queryByText('Generating…')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Skip and Back on the Result step so Complete is the only exit', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, imageResultStep]
|
||||
store.stepIndex = 2
|
||||
|
||||
renderOverlay()
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Finish' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the ring and holds the coach-mark when the target is panned off screen', async () => {
|
||||
// The user can pan the spotlit node out of view mid-step. The ring must not be
|
||||
// drawn off screen, and the mark must hold rather than chase a target that
|
||||
// isn't there.
|
||||
mocks.maskRects = [rect(0)]
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
})
|
||||
|
||||
mocks.maskRects = [{ left: -900, top: -900, width: 100, height: 50 }]
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId('onboarding-spotlight')).toBeNull()
|
||||
})
|
||||
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the coach-mark on screen when the target fills the viewport', async () => {
|
||||
// A zoomed-in node can be larger than the canvas region; the mark must still be
|
||||
// fully placed inside it rather than spilling off an edge.
|
||||
mocks.viewport = { left: 0, top: 40, width: 800, height: 600 }
|
||||
mocks.maskRects = [{ left: 0, top: 40, width: 800, height: 600 }]
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
|
||||
const mark = await screen.findByTestId('onboarding-coach-mark')
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
})
|
||||
const top = Number.parseFloat(mark.style.top)
|
||||
const left = Number.parseFloat(mark.style.left)
|
||||
|
||||
expect(top).toBeGreaterThanOrEqual(mocks.viewport.top)
|
||||
expect(left).toBeGreaterThanOrEqual(mocks.viewport.left)
|
||||
})
|
||||
|
||||
it('zooms in once, then pans without re-zooming on later steps', async () => {
|
||||
// Reserving room on the first framing sizes the node so the mark fits beside
|
||||
// it; later steps pass none, which pans at the scale already reached.
|
||||
mocks.maskRects = [rect(0)]
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep, imageResultStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
|
||||
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
|
||||
expect(mocks.focusNodes.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ width: expect.any(Number) })
|
||||
)
|
||||
|
||||
mocks.focusNodes.mockClear()
|
||||
store.stepIndex = 2
|
||||
|
||||
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
|
||||
expect(mocks.focusNodes.mock.calls[0][1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not move the camera on the Run step, which points at the toolbar', async () => {
|
||||
store.phase = 'active'
|
||||
store.steps = [promptStep, runStep]
|
||||
store.stepIndex = 0
|
||||
|
||||
renderOverlay()
|
||||
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
|
||||
|
||||
mocks.focusNodes.mockClear()
|
||||
store.stepIndex = 1
|
||||
await screen.findByText('Press Run to start generating your result')
|
||||
|
||||
expect(mocks.focusNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spotlights the toolbar Run button on the Run step', async () => {
|
||||
const runButton = document.createElement('div')
|
||||
runButton.setAttribute('data-testid', 'queue-button')
|
||||
runButton.getBoundingClientRect = () =>
|
||||
({ left: 10, top: 20, width: 40, height: 16 }) as DOMRect
|
||||
document.body.append(runButton)
|
||||
|
||||
store.phase = 'active'
|
||||
store.steps = [runStep]
|
||||
|
||||
try {
|
||||
renderOverlay()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
|
||||
})
|
||||
} finally {
|
||||
runButton.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
412
src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue
Normal file
412
src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<Teleport v-if="isActive" to="body">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-3000"
|
||||
role="dialog"
|
||||
:aria-label="t('onboardingTour.overlayLabel')"
|
||||
>
|
||||
<svg class="absolute inset-0 size-full" aria-hidden="true">
|
||||
<defs>
|
||||
<mask id="onboarding-tour-spotlight">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
v-for="(hole, i) in visibleHoleRects"
|
||||
:key="i"
|
||||
data-testid="onboarding-hole"
|
||||
:x="hole.left"
|
||||
:y="hole.top"
|
||||
:width="hole.width"
|
||||
:height="hole.height"
|
||||
rx="12"
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
:class="
|
||||
cn(
|
||||
'fill-base-background/95',
|
||||
!reduceMotion && 'transition-opacity duration-500 ease-out',
|
||||
revealed ? 'opacity-100' : 'opacity-0'
|
||||
)
|
||||
"
|
||||
mask="url(#onboarding-tour-spotlight)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-for="(hole, i) in visibleSpotRects"
|
||||
:key="i"
|
||||
data-testid="onboarding-spotlight"
|
||||
:class="
|
||||
cn(
|
||||
'absolute border-node-component-outline',
|
||||
!reduceMotion && 'transition-opacity duration-300 ease-out',
|
||||
revealed ? 'opacity-100' : 'opacity-0',
|
||||
isRunStep ? 'rounded-lg border' : 'rounded-xl border-2'
|
||||
)
|
||||
"
|
||||
:style="ringStyle(hole)"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="bubbleRef"
|
||||
data-testid="onboarding-coach-mark"
|
||||
:class="
|
||||
cn(
|
||||
'absolute flex h-fit w-full max-w-xs flex-col gap-6 rounded-2xl bg-secondary-background p-5 shadow-interface',
|
||||
!reduceMotion &&
|
||||
(markGlides
|
||||
? 'transition-[top,left,opacity] ease-in-out'
|
||||
: 'transition-opacity duration-300 ease-out'),
|
||||
copyVisible
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
)
|
||||
"
|
||||
:style="bubbleStyle"
|
||||
tabindex="-1"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i
|
||||
v-if="placement"
|
||||
data-testid="onboarding-cursor"
|
||||
:class="
|
||||
cn(
|
||||
'absolute icon-[lucide--mouse-pointer-2] size-4 text-base-foreground drop-shadow-md',
|
||||
cursorEdgeClass
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-base-foreground opacity-50">
|
||||
{{
|
||||
t('onboardingTour.stepCounter', {
|
||||
current: stepIndex + 1,
|
||||
total: totalSteps
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="isGenerating"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<DotSpinner :size="12" />
|
||||
{{ t('onboardingTour.generating') }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ t(copy.title) }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ t(copy.body) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Button
|
||||
v-if="!isResultStep"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="font-normal"
|
||||
@click="controller.end('skip')"
|
||||
>
|
||||
{{ t('onboardingTour.skip') }}
|
||||
</Button>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
v-if="stepIndex > 0 && !isResultStep"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-1 border border-muted-background px-3 py-2 font-normal"
|
||||
@click="controller.back()"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-4" aria-hidden="true" />
|
||||
{{ t('onboardingTour.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showNextButton"
|
||||
variant="inverted"
|
||||
size="md"
|
||||
class="gap-1 px-3 py-2 font-normal"
|
||||
@click="onNext"
|
||||
>
|
||||
<i
|
||||
v-if="isLastStep"
|
||||
class="icon-[lucide--check] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{
|
||||
isLastStep
|
||||
? t('onboardingTour.complete')
|
||||
: t('onboardingTour.next')
|
||||
}}
|
||||
<i
|
||||
v-if="!isLastStep"
|
||||
class="icon-[lucide--arrow-right] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useElementBounding,
|
||||
usePreferredReducedMotion,
|
||||
useResizeObserver
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
|
||||
import {
|
||||
COACH_MARK_GAP,
|
||||
canvasElement,
|
||||
coachMarkPosition,
|
||||
focusNodes,
|
||||
rectIntersectsViewport
|
||||
} from './canvasSpotlightAdapter'
|
||||
import type { CoachMarkEdge, ScreenRect } from './canvasSpotlightAdapter'
|
||||
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
|
||||
import { useOnboardingTourController } from './useOnboardingTourController'
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
import { useTourSpotlightRects } from './useTourSpotlightRects'
|
||||
import type { TourStep } from './tourSequence'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useOnboardingTourStore()
|
||||
const controller = useOnboardingTourController()
|
||||
const {
|
||||
phase,
|
||||
stepIndex,
|
||||
revealedNodeIds,
|
||||
spotlitNodeIds,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
resultMedia,
|
||||
runFinished,
|
||||
tourRunId
|
||||
} = storeToRefs(store)
|
||||
|
||||
const isActive = computed(() => phase.value === 'active')
|
||||
const isLastStep = computed(() => stepIndex.value >= totalSteps.value - 1)
|
||||
|
||||
const isRunStep = computed(() => currentStep.value?.kind === 'run')
|
||||
const isResultStep = computed(() => currentStep.value?.kind === 'result')
|
||||
|
||||
/** Gated on the run reporting, not on media: a timed-out capture must not spin forever. */
|
||||
const isGenerating = computed(
|
||||
() => isResultStep.value && !runFinished.value && !resultMedia.value
|
||||
)
|
||||
|
||||
/** The Run step advances on click, so it offers no Next. */
|
||||
const showNextButton = computed(() => !isRunStep.value)
|
||||
|
||||
function stepCopyKey(step: TourStep): { title: string; body: string } {
|
||||
const base = `onboardingTour.step.${step.kind}`
|
||||
switch (step.kind) {
|
||||
case 'upload':
|
||||
case 'prompt': {
|
||||
const shape = step.shape ?? 'other'
|
||||
return { title: `${base}.${shape}.title`, body: `${base}.${shape}.body` }
|
||||
}
|
||||
case 'run':
|
||||
return { title: `${base}.title`, body: `${base}.body` }
|
||||
case 'result': {
|
||||
const media = step.mediaKind ?? 'image'
|
||||
return { title: `${base}.${media}.title`, body: `${base}.${media}.body` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copy = computed(() =>
|
||||
currentStep.value ? stepCopyKey(currentStep.value) : { title: '', body: '' }
|
||||
)
|
||||
|
||||
function onNext() {
|
||||
if (isLastStep.value) {
|
||||
controller.end('done')
|
||||
} else {
|
||||
void controller.advance()
|
||||
}
|
||||
}
|
||||
|
||||
// Called at setup, not inside the computed: a computed getter has no effect scope,
|
||||
// so the matchMedia listener VueUse registers there would never be disposed.
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const reduceMotion = computed(() => preferredMotion.value === 'reduce')
|
||||
|
||||
const bubbleRef = useTemplateRef<HTMLElement>('bubbleRef')
|
||||
const { width: bubbleWidth, height: bubbleHeight } =
|
||||
useElementBounding(bubbleRef)
|
||||
|
||||
/** True once the tour has zoomed in; later steps pan at that scale. */
|
||||
const hasZoomed = ref(false)
|
||||
|
||||
/**
|
||||
* The side the mark sits on, held for the step so it doesn't jump as the user pans.
|
||||
* Latched only once the framing settles, so the choice reflects the final view.
|
||||
*/
|
||||
const lockedEdge = ref<CoachMarkEdge | undefined>()
|
||||
|
||||
/**
|
||||
* Frame the current step, reserving the mark's real footprint on the first framing
|
||||
* so the node is sized to leave room for it. Later steps pan at that scale.
|
||||
*/
|
||||
function frameTarget() {
|
||||
const reserve = {
|
||||
width: bubbleWidth.value + COACH_MARK_GAP,
|
||||
height: bubbleHeight.value + COACH_MARK_GAP
|
||||
}
|
||||
focusNodes([...spotlitNodeIds.value], hasZoomed.value ? undefined : reserve)
|
||||
hasZoomed.value = true
|
||||
}
|
||||
|
||||
const choreography = useTourChoreography({
|
||||
reduceMotion,
|
||||
frameTarget,
|
||||
isStatic: isRunStep
|
||||
})
|
||||
const { revealed, copyVisible, cameraSettled, markGlides } = choreography
|
||||
|
||||
const rects = useTourSpotlightRects({
|
||||
isRunStep,
|
||||
isResultStep,
|
||||
revealedNodeIds,
|
||||
spotlitNodeIds,
|
||||
onFrame: choreography.sampleFrame
|
||||
})
|
||||
const { viewport, focusRect } = rects
|
||||
|
||||
/** Holes are cut only once the steps begin, so the intro preview reads undimmed. */
|
||||
const visibleHoleRects = computed(() =>
|
||||
revealed.value ? rects.holeRects.value : []
|
||||
)
|
||||
const visibleSpotRects = computed(() =>
|
||||
revealed.value ? rects.visibleSpotRects.value : []
|
||||
)
|
||||
|
||||
watch(
|
||||
[isActive, stepIndex, tourRunId],
|
||||
([active, , runId], previous) => {
|
||||
if (!active) {
|
||||
choreography.endTour()
|
||||
hasZoomed.value = false
|
||||
return
|
||||
}
|
||||
// A fresh tour, not a step change. Keyed to the run id rather than an
|
||||
// idle→active edge: `start()` passes through idle within one tick, so the
|
||||
// watcher only ever sees active→active and would carry the last tour's zoom in.
|
||||
if (previous?.[2] === runId) {
|
||||
choreography.resetStep()
|
||||
lockedEdge.value = undefined
|
||||
choreography.beginStep()
|
||||
return
|
||||
}
|
||||
hasZoomed.value = false
|
||||
lockedEdge.value = undefined
|
||||
choreography.openTour()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[isActive, revealedNodeIds, spotlitNodeIds, currentStep],
|
||||
([active]) => (active ? rects.start() : rects.stop()),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// animateToBounds captures the canvas size when the tween starts, so a resize
|
||||
// mid-flight lands the camera short. Re-frame against the new size.
|
||||
useResizeObserver(
|
||||
computed(() => (isActive.value ? canvasElement() : null)),
|
||||
() => {
|
||||
if (!isActive.value || choreography.isAwaitingSettle()) return
|
||||
if (!revealed.value || isRunStep.value || !hasZoomed.value) return
|
||||
focusNodes([...spotlitNodeIds.value])
|
||||
}
|
||||
)
|
||||
|
||||
// Routing away mid-tour must end the tour, so the controller's grace timer can't
|
||||
// outlive the UI it drives.
|
||||
onUnmounted(() => {
|
||||
choreography.clearTimers()
|
||||
if (isActive.value) controller.end('skip')
|
||||
})
|
||||
|
||||
/** Px the node ring sits outside the box, mirroring litegraph's selection overlay. */
|
||||
const NODE_RING_OFFSET = 3
|
||||
|
||||
/** The node ring frames the box from outside; the Run button ring hugs it tightly. */
|
||||
function ringStyle(rect: ScreenRect) {
|
||||
const offset = isRunStep.value ? 0 : NODE_RING_OFFSET
|
||||
return {
|
||||
left: `${rect.left - offset}px`,
|
||||
top: `${rect.top - offset}px`,
|
||||
width: `${rect.width + offset * 2}px`,
|
||||
height: `${rect.height + offset * 2}px`
|
||||
}
|
||||
}
|
||||
|
||||
/** Null while the target is off-screen, so the mark holds its last placement. */
|
||||
const placement = computed(() => {
|
||||
const rect = focusRect.value
|
||||
if (!rect) return null
|
||||
if (!rectIntersectsViewport(rect, viewport.value)) return null
|
||||
return coachMarkPosition(
|
||||
rect,
|
||||
{ width: bubbleWidth.value, height: bubbleHeight.value },
|
||||
viewport.value,
|
||||
lockedEdge.value
|
||||
)
|
||||
})
|
||||
|
||||
// Once latched, the side only changes if the user pans far enough to break its fit.
|
||||
watch([placement, cameraSettled], ([pos, settled]) => {
|
||||
if (settled && pos) lockedEdge.value = pos.pointerEdge
|
||||
})
|
||||
|
||||
// Shares MARK_GLIDE_MS with the camera delay, so the mark lands before it starts.
|
||||
const bubbleStyle = computed(() => {
|
||||
const glide = markGlides.value
|
||||
? { transitionDuration: `${MARK_GLIDE_MS}ms` }
|
||||
: {}
|
||||
return placement.value
|
||||
? {
|
||||
...glide,
|
||||
position: 'fixed' as const,
|
||||
top: `${placement.value.top}px`,
|
||||
left: `${placement.value.left}px`
|
||||
}
|
||||
: {
|
||||
...glide,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}
|
||||
})
|
||||
|
||||
/** Floats the cursor mid-gap on the target-facing edge, tip rotated toward the node. */
|
||||
const CURSOR_EDGE_CLASS: Record<CoachMarkEdge, string> = {
|
||||
top: '-top-7 left-1/2 -translate-x-1/2 rotate-45',
|
||||
bottom: '-bottom-7 left-1/2 -translate-x-1/2 -rotate-[135deg]',
|
||||
left: '-left-7 top-1/2 -translate-y-1/2 -rotate-45',
|
||||
right: '-right-7 top-1/2 -translate-y-1/2 rotate-[135deg]'
|
||||
}
|
||||
const cursorEdgeClass = computed(() =>
|
||||
placement.value ? CURSOR_EDGE_CLASS[placement.value.pointerEdge] : ''
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { CuratedTemplateId } from '../roleResolver'
|
||||
|
||||
const TEMPLATES_DIR = path.join(import.meta.dirname, 'templates')
|
||||
|
||||
/**
|
||||
* Loads a curated template's real serialized workflow from the committed
|
||||
* fixtures. Used to prove the resolver's heuristics against ground truth.
|
||||
*/
|
||||
export function loadTemplateWorkflow(id: CuratedTemplateId): ComfyWorkflowJSON {
|
||||
const file = path.join(TEMPLATES_DIR, `${id}.json`)
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as ComfyWorkflowJSON
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canvas: null as unknown,
|
||||
currentGraph: null as LGraph | null
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get canvas() {
|
||||
return mocks.canvas
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get currentGraph() {
|
||||
return mocks.currentGraph
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
INITIAL_SETTLE,
|
||||
TOUR_FOCUS_DURATION_MS,
|
||||
canvasTransformValid,
|
||||
coachMarkPosition,
|
||||
focusFillFor,
|
||||
focusNodes,
|
||||
maskRectsFor,
|
||||
nodeClientRect,
|
||||
nodesPresent,
|
||||
rectIntersectsViewport,
|
||||
trackSettle
|
||||
} from './canvasSpotlightAdapter'
|
||||
import type { ScreenRect } from './canvasSpotlightAdapter'
|
||||
|
||||
function fakeCanvas(options: {
|
||||
offset?: [number, number]
|
||||
scale?: number
|
||||
clientLeft?: number
|
||||
clientTop?: number
|
||||
}) {
|
||||
const { offset = [0, 0], scale = 1, clientLeft = 0, clientTop = 0 } = options
|
||||
return {
|
||||
canvas: {
|
||||
getBoundingClientRect: () =>
|
||||
({ left: clientLeft, top: clientTop }) as DOMRect
|
||||
},
|
||||
ds: { offset, scale }
|
||||
}
|
||||
}
|
||||
|
||||
function fakeNode(
|
||||
id: number,
|
||||
boundingRect: [number, number, number, number]
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({ id: toNodeId(id), boundingRect })
|
||||
}
|
||||
|
||||
describe('canvasSpotlightAdapter', () => {
|
||||
beforeEach(() => {
|
||||
mocks.canvas = fakeCanvas({})
|
||||
mocks.currentGraph = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('nodeClientRect', () => {
|
||||
it('maps graph coords to client coords at scale 1, no offset', () => {
|
||||
mocks.canvas = fakeCanvas({ clientLeft: 100, clientTop: 50 })
|
||||
const node = fakeNode(1, [10, 20, 200, 80])
|
||||
|
||||
expect(nodeClientRect(node)).toEqual({
|
||||
left: 110,
|
||||
top: 70,
|
||||
width: 200,
|
||||
height: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('applies pan offset and zoom scale', () => {
|
||||
mocks.canvas = fakeCanvas({
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
offset: [5, 10],
|
||||
scale: 2
|
||||
})
|
||||
const node = fakeNode(1, [10, 20, 100, 40])
|
||||
|
||||
// left = 0 + (10 + 5) * 2 = 30; top = (20 + 10) * 2 = 60; size scaled ×2
|
||||
expect(nodeClientRect(node)).toEqual({
|
||||
left: 30,
|
||||
top: 60,
|
||||
width: 200,
|
||||
height: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the canvas is unavailable', () => {
|
||||
mocks.canvas = null
|
||||
expect(nodeClientRect(fakeNode(1, [0, 0, 10, 10]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskRectsFor', () => {
|
||||
it('resolves node ids through the current graph, hugging each node box', () => {
|
||||
const node = fakeNode(7, [0, 0, 100, 50])
|
||||
mocks.currentGraph = fromPartial<LGraph>({
|
||||
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
|
||||
})
|
||||
|
||||
const rects = maskRectsFor([toNodeId(7)])
|
||||
|
||||
expect(rects).toHaveLength(1)
|
||||
expect(rects[0]).toEqual({ left: 0, top: 0, width: 100, height: 50 })
|
||||
})
|
||||
|
||||
it('drops ids that do not resolve, never throwing', () => {
|
||||
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
|
||||
|
||||
expect(maskRectsFor([toNodeId(99)])).toEqual([])
|
||||
})
|
||||
|
||||
it('drops ids that resolve to undefined without reading boundingRect', () => {
|
||||
// getNodeById returns undefined (not null) for ids absent from the graph —
|
||||
// e.g. a nested executing node — which must not crash on `.boundingRect`.
|
||||
mocks.currentGraph = fromPartial<LGraph>({
|
||||
getNodeById: () => undefined as unknown as null
|
||||
})
|
||||
|
||||
expect(() => maskRectsFor([toNodeId(99)])).not.toThrow()
|
||||
expect(maskRectsFor([toNodeId(99)])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty when there is no current graph', () => {
|
||||
mocks.currentGraph = null
|
||||
expect(maskRectsFor([toNodeId(1)])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusNodes', () => {
|
||||
it('pans to the resolved nodes bounds without re-zooming when no room is reserved', () => {
|
||||
const animate = vi.fn()
|
||||
const node = fakeNode(3, [0, 0, 100, 100])
|
||||
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
|
||||
mocks.currentGraph = fromPartial<LGraph>({
|
||||
getNodeById: (id: NodeId) => (id === toNodeId(3) ? node : null)
|
||||
})
|
||||
|
||||
focusNodes([toNodeId(3)])
|
||||
|
||||
// createBounds pads the [0,0,100,100] node by 10 on every side; zoom 0 keeps
|
||||
// the current scale, so later steps pan rather than re-zoom.
|
||||
expect(animate).toHaveBeenCalledWith([-10, -10, 120, 120], {
|
||||
zoom: 0,
|
||||
duration: TOUR_FOCUS_DURATION_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing when no nodes resolve', () => {
|
||||
const animate = vi.fn()
|
||||
mocks.canvas = { ...fakeCanvas({}), animateToBounds: animate }
|
||||
mocks.currentGraph = fromPartial<LGraph>({ getNodeById: () => null })
|
||||
|
||||
focusNodes([toNodeId(99)])
|
||||
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodesPresent', () => {
|
||||
it('is true only when every id resolves on the current graph', () => {
|
||||
const node = fakeNode(7, [0, 0, 10, 10])
|
||||
mocks.currentGraph = fromPartial<LGraph>({
|
||||
getNodeById: (id: NodeId) => (id === toNodeId(7) ? node : null)
|
||||
})
|
||||
|
||||
expect(nodesPresent([toNodeId(7)])).toBe(true)
|
||||
expect(nodesPresent([toNodeId(7), toNodeId(8)])).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when there is no current graph', () => {
|
||||
mocks.currentGraph = null
|
||||
expect(nodesPresent([toNodeId(1)])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rectIntersectsViewport', () => {
|
||||
const viewport = { left: 0, top: 100, width: 1000, height: 800 }
|
||||
|
||||
it.for([
|
||||
['fully inside', { left: 200, top: 200, width: 50, height: 50 }, true],
|
||||
[
|
||||
'straddling an edge',
|
||||
{ left: -20, top: 200, width: 50, height: 50 },
|
||||
true
|
||||
],
|
||||
[
|
||||
'above the region',
|
||||
{ left: 200, top: 10, width: 50, height: 50 },
|
||||
false
|
||||
],
|
||||
[
|
||||
'past the right',
|
||||
{ left: 1000, top: 200, width: 50, height: 50 },
|
||||
false
|
||||
],
|
||||
[
|
||||
'below the region',
|
||||
{ left: 200, top: 900, width: 50, height: 50 },
|
||||
false
|
||||
]
|
||||
] as const)('is %s => %s', ([, rect, expected]) => {
|
||||
expect(rectIntersectsViewport(rect, viewport)).toBe(expected)
|
||||
})
|
||||
|
||||
it('respects the region origin, not the window', () => {
|
||||
// Sits under a 100px top bar: inside the window, outside the canvas region.
|
||||
const underTopBar = { left: 200, top: 20, width: 50, height: 50 }
|
||||
expect(rectIntersectsViewport(underTopBar, viewport)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackSettle', () => {
|
||||
const feed = (keys: (string | null)[]) =>
|
||||
keys.reduce((state, key) => trackSettle(state, key), INITIAL_SETTLE)
|
||||
|
||||
it('does not settle while the transform keeps changing', () => {
|
||||
// A camera mid-tween reports a new transform every frame.
|
||||
expect(feed(['a', 'b', 'c', 'd']).settled).toBe(false)
|
||||
})
|
||||
|
||||
it('settles once the transform holds still for two frames', () => {
|
||||
expect(feed(['a', 'a']).settled).toBe(false)
|
||||
expect(feed(['a', 'a', 'a']).settled).toBe(true)
|
||||
})
|
||||
|
||||
it('un-settles when the user grabs the canvas after it landed', () => {
|
||||
const landed = feed(['a', 'a', 'a'])
|
||||
expect(landed.settled).toBe(true)
|
||||
expect(trackSettle(landed, 'b').settled).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an absent transform as settled — there is no camera to wait for', () => {
|
||||
expect(trackSettle(INITIAL_SETTLE, null).settled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not settle on a tween that pauses on one value for a single frame', () => {
|
||||
// Two overlapping tweens can repeat a rounded value for one frame; requiring
|
||||
// two consecutive matches keeps that from reading as a landing.
|
||||
expect(feed(['a', 'b', 'b', 'c']).settled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusFillFor', () => {
|
||||
const viewport = { left: 0, top: 0, width: 1500, height: 900 }
|
||||
const reserve = { width: 360, height: 300 }
|
||||
|
||||
/** Litegraph's own fit (DragAndScale), so we assert the scale it will land on. */
|
||||
const scaleFor = (
|
||||
fill: number,
|
||||
bounds: { width: number; height: number }
|
||||
) =>
|
||||
Math.min(
|
||||
(fill * viewport.width) / Math.max(bounds.width, 300),
|
||||
(fill * viewport.height) / Math.max(bounds.height, 300),
|
||||
10
|
||||
)
|
||||
|
||||
it('caps a tiny node at the same scale a roomy one gets, never zooming further', () => {
|
||||
// Both fit with room to spare, so both land on the ceiling.
|
||||
const tiny = { width: 60, height: 40 }
|
||||
const roomy = { width: 400, height: 300 }
|
||||
|
||||
const tinyScale = scaleFor(focusFillFor(tiny, viewport, reserve), tiny)
|
||||
const roomyScale = scaleFor(focusFillFor(roomy, viewport, reserve), roomy)
|
||||
|
||||
expect(tinyScale).toBeCloseTo(roomyScale, 2)
|
||||
// And the cap holds the node near life size, not blown up.
|
||||
expect(tinyScale).toBeLessThanOrEqual(1.2)
|
||||
})
|
||||
|
||||
it('shrinks a tall node until the mark still has room beside it', () => {
|
||||
const tall = { width: 400, height: 1600 }
|
||||
const scale = scaleFor(focusFillFor(tall, viewport, reserve), tall)
|
||||
|
||||
expect(scale).toBeLessThan(1)
|
||||
// The node leaves a full mark's width of the region free.
|
||||
expect(tall.width * scale).toBeLessThanOrEqual(
|
||||
viewport.width - reserve.width
|
||||
)
|
||||
expect(tall.height * scale).toBeLessThanOrEqual(viewport.height)
|
||||
})
|
||||
|
||||
it('keeps a wide node on screen by stacking the mark below it', () => {
|
||||
const wide = { width: 2400, height: 300 }
|
||||
const scale = scaleFor(focusFillFor(wide, viewport, reserve), wide)
|
||||
|
||||
expect(wide.width * scale).toBeLessThanOrEqual(viewport.width)
|
||||
expect(wide.height * scale).toBeLessThanOrEqual(
|
||||
viewport.height - reserve.height
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the mark room for any node, without a floor forcing overflow', () => {
|
||||
const nodes = [
|
||||
{ width: 240, height: 140 },
|
||||
{ width: 500, height: 650 },
|
||||
{ width: 600, height: 1100 },
|
||||
{ width: 800, height: 1600 },
|
||||
{ width: 3000, height: 2400 }
|
||||
]
|
||||
|
||||
for (const node of nodes) {
|
||||
const scale = scaleFor(focusFillFor(node, viewport, reserve), node)
|
||||
const fitsBeside =
|
||||
node.width * scale <= viewport.width - reserve.width &&
|
||||
node.height * scale <= viewport.height
|
||||
const fitsBelow =
|
||||
node.width * scale <= viewport.width &&
|
||||
node.height * scale <= viewport.height - reserve.height
|
||||
expect(fitsBeside || fitsBelow).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('adapts to the region it is given, not a fixed screen size', () => {
|
||||
const node = { width: 500, height: 400 }
|
||||
const small = { left: 0, top: 0, width: 800, height: 600 }
|
||||
const large = { left: 0, top: 0, width: 2560, height: 1400 }
|
||||
|
||||
const smallScale =
|
||||
(focusFillFor(node, small, reserve) * small.width) /
|
||||
Math.max(node.width, 300)
|
||||
const largeScale =
|
||||
(focusFillFor(node, large, reserve) * large.width) /
|
||||
Math.max(node.width, 300)
|
||||
|
||||
expect(smallScale).toBeLessThan(largeScale)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coachMarkPosition', () => {
|
||||
const bubble = { width: 320, height: 200 }
|
||||
const viewport = { left: 0, top: 0, width: 1440, height: 900 }
|
||||
|
||||
it('centers below the target with room on all sides', () => {
|
||||
const target = { left: 600, top: 300, width: 100, height: 60 }
|
||||
|
||||
// left = 600 + 50 - 160 = 490; top = 300 + 60 + 40 = 400
|
||||
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
|
||||
left: 490,
|
||||
top: 400,
|
||||
pointerEdge: 'top'
|
||||
})
|
||||
})
|
||||
|
||||
it('flips above when it would overflow the bottom edge', () => {
|
||||
const target = { left: 600, top: 800, width: 100, height: 60 }
|
||||
|
||||
// below (900) + 200 overflows 900; flip above: 800 - 200 - 40 = 560
|
||||
expect(coachMarkPosition(target, bubble, viewport).top).toBe(560)
|
||||
})
|
||||
|
||||
it('places to the right when below/above would clip the left edge', () => {
|
||||
const target = { left: 0, top: 300, width: 40, height: 40 }
|
||||
|
||||
// centered-below/above overflow the left edge; the right placement fits.
|
||||
// The card sits to the right, so the cursor points off its left edge.
|
||||
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
|
||||
left: 0 + 40 + 40,
|
||||
top: 300 + 20 - 100,
|
||||
pointerEdge: 'left'
|
||||
})
|
||||
})
|
||||
|
||||
it('places to the left when the right edge has no room', () => {
|
||||
const target = { left: 1400, top: 300, width: 40, height: 40 }
|
||||
|
||||
// right placement would overflow; the left placement fits.
|
||||
expect(coachMarkPosition(target, bubble, viewport)).toEqual({
|
||||
left: 1400 - 320 - 40,
|
||||
top: 300 + 20 - 100,
|
||||
pointerEdge: 'right'
|
||||
})
|
||||
})
|
||||
|
||||
it('sits beside a zoomed-in node instead of covering it', () => {
|
||||
// A node tall enough that below/above clip vertically but a side still fits
|
||||
// (the realistic zoomed case) — the card must not overlap the node.
|
||||
const target = { left: 500, top: 60, width: 400, height: 780 }
|
||||
const pos = coachMarkPosition(target, bubble, viewport)
|
||||
|
||||
const overlapsTarget =
|
||||
pos.left < target.left + target.width &&
|
||||
pos.left + bubble.width > target.left &&
|
||||
pos.top < target.top + target.height &&
|
||||
pos.top + bubble.height > target.top
|
||||
expect(overlapsTarget).toBe(false)
|
||||
})
|
||||
|
||||
it('clamps to the padded corner when the bubble is larger than the viewport', () => {
|
||||
// Bubble bigger than the viewport: no candidate fits, so the fallback pins
|
||||
// it on screen and points from the side with the most room (here, right).
|
||||
const tiny = { left: 0, top: 0, width: 300, height: 180 }
|
||||
const target = { left: 100, top: 80, width: 40, height: 40 }
|
||||
|
||||
// Every clamp max collapses to VIEWPORT_PADDING (viewport - bubble < 0).
|
||||
expect(coachMarkPosition(target, bubble, tiny)).toEqual({
|
||||
left: 12,
|
||||
top: 12,
|
||||
pointerEdge: 'left'
|
||||
})
|
||||
})
|
||||
|
||||
/** Whether a placement sits entirely inside the region. */
|
||||
const isInside = (
|
||||
pos: { left: number; top: number },
|
||||
box: { width: number; height: number },
|
||||
region: ScreenRect
|
||||
) =>
|
||||
pos.left >= region.left &&
|
||||
pos.top >= region.top &&
|
||||
pos.left + box.width <= region.left + region.width &&
|
||||
pos.top + box.height <= region.top + region.height
|
||||
|
||||
describe('staying inside an inset region', () => {
|
||||
// The canvas sits below the top bar and beside the panels, so the mark must
|
||||
// be placed against that region — measuring the whole window put it under
|
||||
// the toolbar.
|
||||
const inset = { left: 64, top: 100, width: 1200, height: 700 }
|
||||
|
||||
it('never lands under the chrome above the region', () => {
|
||||
const target = { left: 400, top: 120, width: 80, height: 60 }
|
||||
const pos = coachMarkPosition(target, bubble, inset)
|
||||
expect(pos.top).toBeGreaterThanOrEqual(inset.top)
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'taller than the region',
|
||||
{ left: 400, top: 110, width: 200, height: 900 }
|
||||
],
|
||||
[
|
||||
'wider than the region',
|
||||
{ left: 70, top: 300, width: 1400, height: 200 }
|
||||
],
|
||||
[
|
||||
'larger on both axes',
|
||||
{ left: 70, top: 110, width: 1400, height: 900 }
|
||||
],
|
||||
[
|
||||
'hard against the top-left',
|
||||
{ left: 64, top: 100, width: 40, height: 40 }
|
||||
],
|
||||
[
|
||||
'hard against the bottom-right',
|
||||
{ left: 1200, top: 750, width: 60, height: 45 }
|
||||
]
|
||||
] as const)('keeps the mark on screen with a target %s', ([, target]) => {
|
||||
expect(
|
||||
isInside(coachMarkPosition(target, bubble, inset), bubble, inset)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preferred edge', () => {
|
||||
const target = { left: 600, top: 300, width: 100, height: 60 }
|
||||
|
||||
it('holds the preferred side while it still fits', () => {
|
||||
// Below fits and would win on its own; the caller's latched side wins.
|
||||
expect(
|
||||
coachMarkPosition(target, bubble, viewport, 'left').pointerEdge
|
||||
).toBe('left')
|
||||
})
|
||||
|
||||
it('abandons the preferred side once it stops fitting', () => {
|
||||
// Nothing fits to the right of a target hard against the right edge.
|
||||
const atRightEdge = { left: 1380, top: 300, width: 50, height: 60 }
|
||||
expect(
|
||||
coachMarkPosition(atRightEdge, bubble, viewport, 'left').pointerEdge
|
||||
).not.toBe('left')
|
||||
})
|
||||
|
||||
it('falls back to first-fit when no side is preferred', () => {
|
||||
expect(coachMarkPosition(target, bubble, viewport).pointerEdge).toBe(
|
||||
'top'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasTransformValid', () => {
|
||||
it('is true for a finite positive transform', () => {
|
||||
mocks.canvas = fakeCanvas({ scale: 1, offset: [0, 0] })
|
||||
expect(canvasTransformValid()).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the canvas is absent', () => {
|
||||
mocks.canvas = null
|
||||
expect(canvasTransformValid()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a zero or non-finite scale', () => {
|
||||
mocks.canvas = fakeCanvas({ scale: 0 })
|
||||
expect(canvasTransformValid()).toBe(false)
|
||||
|
||||
mocks.canvas = fakeCanvas({ scale: Number.NaN })
|
||||
expect(canvasTransformValid()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a non-finite offset', () => {
|
||||
mocks.canvas = fakeCanvas({ offset: [Number.NaN, 0] })
|
||||
expect(canvasTransformValid()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
src/renderer/extensions/onboardingTour/canvasSpotlightAdapter.ts
Normal file
369
src/renderer/extensions/onboardingTour/canvasSpotlightAdapter.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { clamp } from 'es-toolkit/math'
|
||||
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
/** The toolbar Run button (queue on desktop, subscribe-to-run on cloud). */
|
||||
export const RUN_BUTTON_SELECTOR =
|
||||
'[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]'
|
||||
|
||||
/** The floating action bar the Run button sits in. */
|
||||
export const ACTIONBAR_SELECTOR = '[data-testid="comfy-actionbar"]'
|
||||
|
||||
/** A rectangle in client (viewport) coordinates. */
|
||||
export interface ScreenRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** The box edge the cursor sits on, pointing back at the target. */
|
||||
export type CoachMarkEdge = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** A viewport-space position for the coach-mark plus the edge that points at the target. */
|
||||
interface CoachMarkPosition {
|
||||
left: number
|
||||
top: number
|
||||
pointerEdge: CoachMarkEdge
|
||||
}
|
||||
|
||||
/** Whether any part of `rect` is inside `viewport`. */
|
||||
export function rectIntersectsViewport(
|
||||
rect: ScreenRect,
|
||||
viewport: ScreenRect
|
||||
): boolean {
|
||||
return (
|
||||
rect.left < viewport.left + viewport.width &&
|
||||
rect.left + rect.width > viewport.left &&
|
||||
rect.top < viewport.top + viewport.height &&
|
||||
rect.top + rect.height > viewport.top
|
||||
)
|
||||
}
|
||||
|
||||
/** Space between the coach-mark and the target it points at. */
|
||||
export const COACH_MARK_GAP = 40
|
||||
const VIEWPORT_PADDING = 12
|
||||
|
||||
function fitsViewport(
|
||||
pos: CoachMarkPosition,
|
||||
bubble: Size,
|
||||
viewport: ScreenRect
|
||||
) {
|
||||
return (
|
||||
pos.left >= viewport.left + VIEWPORT_PADDING &&
|
||||
pos.top >= viewport.top + VIEWPORT_PADDING &&
|
||||
pos.left + bubble.width <=
|
||||
viewport.left + viewport.width - VIEWPORT_PADDING &&
|
||||
pos.top + bubble.height <= viewport.top + viewport.height - VIEWPORT_PADDING
|
||||
)
|
||||
}
|
||||
|
||||
function overlaps(pos: CoachMarkPosition, bubble: Size, target: ScreenRect) {
|
||||
return (
|
||||
pos.left < target.left + target.width &&
|
||||
pos.left + bubble.width > target.left &&
|
||||
pos.top < target.top + target.height &&
|
||||
pos.top + bubble.height > target.top
|
||||
)
|
||||
}
|
||||
|
||||
/** Pin a placement inside the padded viewport, so the mark is never cut off. */
|
||||
function clampToViewport(
|
||||
pos: CoachMarkPosition,
|
||||
bubble: Size,
|
||||
viewport: ScreenRect
|
||||
): CoachMarkPosition {
|
||||
const minLeft = viewport.left + VIEWPORT_PADDING
|
||||
const minTop = viewport.top + VIEWPORT_PADDING
|
||||
return {
|
||||
...pos,
|
||||
left: clamp(
|
||||
pos.left,
|
||||
minLeft,
|
||||
Math.max(
|
||||
minLeft,
|
||||
viewport.left + viewport.width - bubble.width - VIEWPORT_PADDING
|
||||
)
|
||||
),
|
||||
top: clamp(
|
||||
pos.top,
|
||||
minTop,
|
||||
Math.max(
|
||||
minTop,
|
||||
viewport.top + viewport.height - bubble.height - VIEWPORT_PADDING
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function candidatesFor(target: ScreenRect, bubble: Size): CoachMarkPosition[] {
|
||||
const centerX = target.left + target.width / 2 - bubble.width / 2
|
||||
const centerY = target.top + target.height / 2 - bubble.height / 2
|
||||
return [
|
||||
{
|
||||
left: centerX,
|
||||
top: target.top + target.height + COACH_MARK_GAP,
|
||||
pointerEdge: 'top'
|
||||
},
|
||||
{
|
||||
left: centerX,
|
||||
top: target.top - bubble.height - COACH_MARK_GAP,
|
||||
pointerEdge: 'bottom'
|
||||
},
|
||||
{
|
||||
left: target.left + target.width + COACH_MARK_GAP,
|
||||
top: centerY,
|
||||
pointerEdge: 'left'
|
||||
},
|
||||
{
|
||||
left: target.left - bubble.width - COACH_MARK_GAP,
|
||||
top: centerY,
|
||||
pointerEdge: 'right'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the coach-mark near `target` without covering it: below, above, right, then
|
||||
* left, taking the first placement that fits and clears the target. Always clamped
|
||||
* into the viewport, so the mark stays on screen even when no candidate fits.
|
||||
*
|
||||
* @param preferredEdge Wins while it still fits. Without it, a first-fit search would
|
||||
* snap the mark between edges mid-zoom as the target grows.
|
||||
*/
|
||||
export function coachMarkPosition(
|
||||
target: ScreenRect,
|
||||
bubble: Size,
|
||||
viewport: ScreenRect,
|
||||
preferredEdge?: CoachMarkEdge
|
||||
): CoachMarkPosition {
|
||||
const candidates = candidatesFor(target, bubble)
|
||||
const viable = (pos: CoachMarkPosition) =>
|
||||
fitsViewport(pos, bubble, viewport) && !overlaps(pos, bubble, target)
|
||||
|
||||
const preferred = candidates.find((pos) => pos.pointerEdge === preferredEdge)
|
||||
const placed =
|
||||
(preferred && viable(preferred) ? preferred : undefined) ??
|
||||
candidates.find(viable)
|
||||
if (placed) return placed
|
||||
|
||||
// Nothing fits: the target is too big to sit clear of. Overlap its roomiest edge
|
||||
// rather than spill off screen.
|
||||
return clampToViewport(
|
||||
candidates[freestEdgeIndex(target, viewport)],
|
||||
bubble,
|
||||
viewport
|
||||
)
|
||||
}
|
||||
|
||||
/** Index into `candidatesFor`'s order (below, above, right, left) with most room. */
|
||||
function freestEdgeIndex(target: ScreenRect, viewport: ScreenRect): number {
|
||||
const room = [
|
||||
viewport.top + viewport.height - (target.top + target.height), // below
|
||||
target.top - viewport.top, // above
|
||||
viewport.left + viewport.width - (target.left + target.width), // right
|
||||
target.left - viewport.left // left
|
||||
]
|
||||
return room.indexOf(Math.max(...room))
|
||||
}
|
||||
|
||||
interface CanvasFrame {
|
||||
left: number
|
||||
top: number
|
||||
offset: [number, number]
|
||||
scale: number
|
||||
}
|
||||
|
||||
/** Client-space origin + transform of the litegraph canvas, or null if absent. */
|
||||
function canvasFrame(): CanvasFrame | null {
|
||||
const canvas = app.canvas
|
||||
if (!canvas) return null
|
||||
const rect = canvas.canvas.getBoundingClientRect()
|
||||
const { offset, scale } = canvas.ds
|
||||
return { left: rect.left, top: rect.top, offset, scale }
|
||||
}
|
||||
|
||||
/**
|
||||
* The region the overlay may draw in: the canvas's client rect, not the window.
|
||||
* Measuring the canvas excludes the toolbar and panels without assuming their size.
|
||||
* Null when the canvas is absent or unlaid-out.
|
||||
*/
|
||||
export function canvasViewport(): ScreenRect | null {
|
||||
const canvas = app.canvas
|
||||
if (!canvas) return null
|
||||
const { left, top, width, height } = canvas.canvas.getBoundingClientRect()
|
||||
if (!(width > 0) || !(height > 0)) return null
|
||||
return { left, top, width, height }
|
||||
}
|
||||
|
||||
function toClient(frame: CanvasFrame, [x, y]: Point): Point {
|
||||
return [
|
||||
frame.left + (x + frame.offset[0]) * frame.scale,
|
||||
frame.top + (y + frame.offset[1]) * frame.scale
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the canvas has a usable transform yet — a finite positive scale and
|
||||
* finite offset. Used to gate the tour start until the just-loaded graph has
|
||||
* actually laid out, so the spotlight never paints against a stale/absent frame.
|
||||
*/
|
||||
export function canvasTransformValid(): boolean {
|
||||
const frame = canvasFrame()
|
||||
return (
|
||||
frame !== null &&
|
||||
Number.isFinite(frame.scale) &&
|
||||
frame.scale > 0 &&
|
||||
Number.isFinite(frame.offset[0]) &&
|
||||
Number.isFinite(frame.offset[1])
|
||||
)
|
||||
}
|
||||
|
||||
/** A node's bounding box in client coordinates, or null if the canvas is absent. */
|
||||
export function nodeClientRect(node: LGraphNode): ScreenRect | null {
|
||||
const frame = canvasFrame()
|
||||
if (!frame) return null
|
||||
const [bx, by, bw, bh] = node.boundingRect
|
||||
const [left, top] = toClient(frame, [bx, by])
|
||||
return { left, top, width: bw * frame.scale, height: bh * frame.scale }
|
||||
}
|
||||
|
||||
function resolveNodes(nodeIds: NodeId[]): LGraphNode[] {
|
||||
const graph = useCanvasStore().currentGraph
|
||||
if (!graph) return []
|
||||
return nodeIds
|
||||
.map((id) => graph.getNodeById(id))
|
||||
.filter((node): node is LGraphNode => node != null)
|
||||
}
|
||||
|
||||
/** Whether every id resolves to a node on the live graph (vacuously true if none). */
|
||||
export function nodesPresent(nodeIds: NodeId[]): boolean {
|
||||
return resolveNodes(nodeIds).length === nodeIds.length
|
||||
}
|
||||
|
||||
/** Client rects for the revealed nodes, hugging each node box; unresolvable ids are dropped. */
|
||||
export function maskRectsFor(nodeIds: NodeId[]): ScreenRect[] {
|
||||
return resolveNodes(nodeIds)
|
||||
.map((node) => nodeClientRect(node))
|
||||
.filter((rect): rect is ScreenRect => rect !== null)
|
||||
}
|
||||
|
||||
/** Duration of the tour's framing animation; bounds the overlay's wait for it. */
|
||||
export const TOUR_FOCUS_DURATION_MS = 450
|
||||
|
||||
/** Never magnify past this: the aim is a node that reads, not one that dominates. */
|
||||
const MAX_FOCUS_SCALE = 0.6
|
||||
|
||||
/**
|
||||
* The fill fraction that frames `bounds` with room left for a `reserve`-sized
|
||||
* coach-mark, in the units `animateToBounds` expects.
|
||||
*
|
||||
* The mark sits beside the node *or* below it, never both, so each arrangement is
|
||||
* costed separately and the roomier one wins. Deliberately unfloored: forcing a
|
||||
* minimum scale is what pushes a big node past the viewport and strands the mark.
|
||||
*/
|
||||
export function focusFillFor(
|
||||
bounds: Size,
|
||||
viewport: ScreenRect,
|
||||
reserve: Size
|
||||
): number {
|
||||
const width = Math.max(bounds.width, 1)
|
||||
const height = Math.max(bounds.height, 1)
|
||||
const freeWidth = viewport.width - COACH_MARK_GAP * 2
|
||||
const freeHeight = viewport.height - COACH_MARK_GAP * 2
|
||||
|
||||
const beside = Math.min(
|
||||
(freeWidth - reserve.width) / width,
|
||||
freeHeight / height
|
||||
)
|
||||
const below = Math.min(
|
||||
freeWidth / width,
|
||||
(freeHeight - reserve.height) / height
|
||||
)
|
||||
const scale = Math.min(Math.max(beside, below), MAX_FOCUS_SCALE)
|
||||
|
||||
// Invert litegraph's fit: it takes scale = min(fillX, fillY), each solved as
|
||||
// (fill * side) / max(bound, 300). To land on `scale`, the binding (smaller)
|
||||
// axis must equal it — so take the larger of the two per-axis fills.
|
||||
return Math.max(
|
||||
(scale * Math.max(bounds.width, 300)) / viewport.width,
|
||||
(scale * Math.max(bounds.height, 300)) / viewport.height
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame the view around the given nodes. No-op when none resolve.
|
||||
*
|
||||
* @param reserve Space to leave for the coach-mark. Omit to pan at the current
|
||||
* scale, so the tour zooms once and only pans after.
|
||||
*/
|
||||
export function focusNodes(nodeIds: NodeId[], reserve?: Size): void {
|
||||
const nodes = resolveNodes(nodeIds)
|
||||
if (nodes.length === 0) return
|
||||
const bounds = createBounds(nodes)
|
||||
if (!bounds) return
|
||||
|
||||
const viewport = canvasViewport()
|
||||
const zoom =
|
||||
reserve && viewport
|
||||
? focusFillFor({ width: bounds[2], height: bounds[3] }, viewport, reserve)
|
||||
: 0
|
||||
|
||||
app.canvas?.animateToBounds(bounds, {
|
||||
zoom,
|
||||
duration: TOUR_FOCUS_DURATION_MS
|
||||
})
|
||||
}
|
||||
|
||||
/** The canvas element, or null when it is absent — for observing its size. */
|
||||
export function canvasElement(): HTMLCanvasElement | null {
|
||||
return app.canvas?.canvas ?? null
|
||||
}
|
||||
|
||||
/** Consecutive identical frames that mean the camera has stopped. */
|
||||
const SETTLE_FRAMES = 2
|
||||
|
||||
/** How many frames the transform has held still, and whether that means settled. */
|
||||
export interface SettleState {
|
||||
key: string | null
|
||||
frames: number
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export const INITIAL_SETTLE: SettleState = {
|
||||
key: null,
|
||||
frames: 0,
|
||||
settled: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one frame's transform into the settle state. `animateToBounds` reports no
|
||||
* completion and cannot be cancelled, so an unchanged transform is the only honest
|
||||
* "camera stopped" signal. An absent transform counts as settled: no camera to wait for.
|
||||
*/
|
||||
export function trackSettle(
|
||||
state: SettleState,
|
||||
key: string | null
|
||||
): SettleState {
|
||||
if (key === null) return { key, frames: 0, settled: true }
|
||||
if (key !== state.key) return { key, frames: 0, settled: false }
|
||||
const frames = state.frames + 1
|
||||
return { key, frames, settled: frames >= SETTLE_FRAMES }
|
||||
}
|
||||
|
||||
/** The canvas transform as a comparable string, or null when the canvas is absent. */
|
||||
export function canvasTransformKey(): string | null {
|
||||
const frame = canvasFrame()
|
||||
if (!frame) return null
|
||||
return `${frame.scale}:${frame.offset[0]}:${frame.offset[1]}`
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const restoreView = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./subgraphNavigation', () => ({ restoreView }))
|
||||
|
||||
const resolveTourRoles = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./roleResolution', () => ({ resolveTourRoles }))
|
||||
|
||||
const sinkNode = vi.hoisted(() => ({}) as object)
|
||||
const resolveNode = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/utils/litegraphUtil', () => ({ resolveNode }))
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ getNodeImageUrls })
|
||||
}))
|
||||
|
||||
const isDialogOpen = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ isDialogOpen })
|
||||
}))
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { useOnboardingTourStore } from './onboardingTourStore'
|
||||
import type { ResolvedRoles } from './tourSequence'
|
||||
|
||||
const workflow = {} as ComfyWorkflowJSON
|
||||
|
||||
const i2vRoles: ResolvedRoles = {
|
||||
source: { nodeId: toNodeId(97) },
|
||||
prompt: {
|
||||
subgraphNodeId: toNodeId(10),
|
||||
innerNodeId: toNodeId(93),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
},
|
||||
engine: { nodeId: toNodeId(86) },
|
||||
sink: { nodeId: toNodeId(108) },
|
||||
mediaKind: 'video'
|
||||
}
|
||||
|
||||
const t2iRoles: ResolvedRoles = {
|
||||
source: null,
|
||||
prompt: {
|
||||
subgraphNodeId: toNodeId(10),
|
||||
innerNodeId: toNodeId(27),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
},
|
||||
engine: { nodeId: toNodeId(3) },
|
||||
sink: { nodeId: toNodeId(9) },
|
||||
mediaKind: 'image'
|
||||
}
|
||||
|
||||
describe('onboardingTourStore', () => {
|
||||
let store: ReturnType<typeof useOnboardingTourStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useOnboardingTourStore()
|
||||
resolveTourRoles.mockReset()
|
||||
restoreView.mockReset()
|
||||
resolveNode.mockReset()
|
||||
getNodeImageUrls.mockReset()
|
||||
resolveNode.mockReturnValue(sinkNode)
|
||||
getNodeImageUrls.mockReturnValue(['blob:sink-output'])
|
||||
isDialogOpen.mockReset()
|
||||
isDialogOpen.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('start() on I2V roles builds [Upload, Prompt, Run, Result] and reveals the source', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
|
||||
store.start(workflow)
|
||||
|
||||
expect(resolveTourRoles).toHaveBeenCalledWith(workflow, undefined)
|
||||
expect(store.phase).toBe('active')
|
||||
expect(store.stepIndex).toBe(0)
|
||||
expect(store.steps.map((s) => s.kind)).toEqual([
|
||||
'upload',
|
||||
'prompt',
|
||||
'run',
|
||||
'result'
|
||||
])
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
|
||||
})
|
||||
|
||||
it('start() passes the templateId through to the resolver', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
|
||||
store.start(workflow, 'image_z_image_turbo')
|
||||
|
||||
expect(resolveTourRoles).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'image_z_image_turbo'
|
||||
)
|
||||
})
|
||||
|
||||
it('start() on T2I roles omits the Upload step', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
|
||||
store.start(workflow)
|
||||
|
||||
expect(store.steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
|
||||
// T2I opens on the prompt step, revealing its collapsed subgraph host.
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(10)])
|
||||
})
|
||||
|
||||
it('advance() accumulates revealed nodes so the graph builds up', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
|
||||
store.advance() // → prompt (nodeId null, reveals the subgraph host)
|
||||
expect(store.stepIndex).toBe(1)
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
|
||||
|
||||
store.advance() // → run (no node)
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
|
||||
})
|
||||
|
||||
it('collapses to only the sink on the Result step', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
store.advance() // prompt
|
||||
store.advance() // run
|
||||
store.advance() // result
|
||||
|
||||
// The final step focuses solely on the generated output, hiding the rest.
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
|
||||
})
|
||||
|
||||
it('spotlitNodeIds tracks only the current step while reveals accumulate', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
|
||||
// Upload step: source revealed and spotlit.
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
|
||||
expect([...store.spotlitNodeIds]).toEqual([toNodeId(97)])
|
||||
|
||||
store.advance() // → prompt (reveals the subgraph host)
|
||||
// Reveals accumulate; the spotlight narrows to just the prompt host.
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97), toNodeId(10)])
|
||||
expect([...store.spotlitNodeIds]).toEqual([toNodeId(10)])
|
||||
|
||||
store.advance() // → run (no node)
|
||||
expect([...store.spotlitNodeIds]).toEqual([])
|
||||
|
||||
store.advance() // → result (sink)
|
||||
// Result collapses to only the sink for both spotlight and reveal.
|
||||
expect([...store.spotlitNodeIds]).toEqual([toNodeId(108)])
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(108)])
|
||||
})
|
||||
|
||||
it('advance() does not run past the last step', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
|
||||
store.advance()
|
||||
store.advance()
|
||||
store.advance()
|
||||
store.advance()
|
||||
|
||||
expect(store.stepIndex).toBe(store.steps.length - 1)
|
||||
})
|
||||
|
||||
it('back() restores the prior reveal set', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
store.advance()
|
||||
|
||||
store.back()
|
||||
|
||||
expect(store.stepIndex).toBe(0)
|
||||
expect([...store.revealedNodeIds]).toEqual([toNodeId(97)])
|
||||
})
|
||||
|
||||
it('back() does not run before the first step', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
|
||||
store.back()
|
||||
|
||||
expect(store.stepIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('end() restores the view and resets to idle', () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
store.advance()
|
||||
|
||||
store.end()
|
||||
|
||||
expect(restoreView).toHaveBeenCalledOnce()
|
||||
expect(store.phase).toBe('idle')
|
||||
expect(store.stepIndex).toBe(0)
|
||||
expect(store.resolvedRoles).toBeNull()
|
||||
expect(store.steps).toEqual([])
|
||||
expect(store.revealedNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('degrades to a lone Run step when prompt and sink are unresolved', () => {
|
||||
resolveTourRoles.mockReturnValue({ ...t2iRoles, prompt: null, sink: null })
|
||||
|
||||
store.start(workflow)
|
||||
|
||||
// No prompt AND no sink → the machine still starts but degrades to the
|
||||
// always-present Run step rather than crashing.
|
||||
expect(store.steps.map((s) => s.kind)).toEqual(['run'])
|
||||
expect(store.phase).toBe('active')
|
||||
})
|
||||
|
||||
it('captureResultMedia() records the sink output URL with the resolved media kind', async () => {
|
||||
resolveTourRoles.mockReturnValue(i2vRoles)
|
||||
store.start(workflow)
|
||||
|
||||
await store.captureResultMedia()
|
||||
|
||||
expect(resolveNode).toHaveBeenCalledWith(toNodeId(108))
|
||||
expect(getNodeImageUrls).toHaveBeenCalledWith(sinkNode)
|
||||
expect(store.resultMedia).toEqual({
|
||||
url: 'blob:sink-output',
|
||||
kind: 'video'
|
||||
})
|
||||
})
|
||||
|
||||
it('captureResultMedia() waits for the URL to appear before recording it', async () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
// The cloud queue refresh fills the output just after execution_success.
|
||||
getNodeImageUrls.mockReturnValueOnce(undefined)
|
||||
getNodeImageUrls.mockReturnValue(['blob:late-output'])
|
||||
|
||||
await store.captureResultMedia()
|
||||
|
||||
expect(store.resultMedia).toEqual({
|
||||
url: 'blob:late-output',
|
||||
kind: 'image'
|
||||
})
|
||||
})
|
||||
|
||||
it('captureResultMedia() is a no-op while the tour is idle', async () => {
|
||||
await store.captureResultMedia()
|
||||
|
||||
expect(resolveNode).not.toHaveBeenCalled()
|
||||
expect(store.resultMedia).toBeNull()
|
||||
})
|
||||
|
||||
it('captureResultMedia() discards the URL if the tour ends mid-wait', async () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
getNodeImageUrls.mockReturnValueOnce(undefined)
|
||||
getNodeImageUrls.mockReturnValue(['blob:late-output'])
|
||||
|
||||
// The tour ends (user skips) during the wait; the URL then resolves, but the
|
||||
// post-await phase guard must drop it rather than record into a dead tour.
|
||||
const pending = store.captureResultMedia()
|
||||
store.end()
|
||||
await pending
|
||||
|
||||
expect(store.resultMedia).toBeNull()
|
||||
})
|
||||
|
||||
it('captureResultMedia() is idempotent once the media is set', async () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
await store.captureResultMedia()
|
||||
getNodeImageUrls.mockReturnValue(['blob:second-run'])
|
||||
|
||||
await store.captureResultMedia()
|
||||
|
||||
// A second success must not overwrite the first captured result.
|
||||
expect(store.resultMedia).toEqual({
|
||||
url: 'blob:sink-output',
|
||||
kind: 'image'
|
||||
})
|
||||
})
|
||||
|
||||
it('captureResultMedia() gives up after the timeout without recording', async () => {
|
||||
vi.useFakeTimers()
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
getNodeImageUrls.mockReturnValue(undefined)
|
||||
|
||||
const pending = store.captureResultMedia()
|
||||
await vi.runAllTimersAsync()
|
||||
await pending
|
||||
|
||||
expect(store.resultMedia).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('start() clears a previous tour’s finished run so the next one can generate', () => {
|
||||
// The flag drives the Result step's "Generating…"; carried over, a second tour
|
||||
// would show its result as already done.
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
store.runFinished = true
|
||||
|
||||
store.start(workflow)
|
||||
|
||||
expect(store.runFinished).toBe(false)
|
||||
})
|
||||
|
||||
it('showNudge() surfaces the post-run nudge', () => {
|
||||
expect(store.shouldShowNudge).toBe(false)
|
||||
|
||||
store.showNudge()
|
||||
|
||||
expect(store.shouldShowNudge).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the nudge visible after the tour ends so it outlives the run', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
store.showNudge()
|
||||
|
||||
store.end()
|
||||
|
||||
expect(store.shouldShowNudge).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the captured media after the tour ends so the nudge still shows it', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.start(workflow)
|
||||
store.resultMedia = { url: 'blob:first-run', kind: 'image' }
|
||||
store.showNudge()
|
||||
|
||||
store.end()
|
||||
|
||||
expect(store.resultMedia).toEqual({ url: 'blob:first-run', kind: 'image' })
|
||||
})
|
||||
|
||||
it('defers the nudge while the upgrade modal is open, then surfaces it on close', () => {
|
||||
isDialogOpen.mockReturnValue(true)
|
||||
|
||||
store.showNudge()
|
||||
|
||||
// Held back so it never overlaps the paywall; the arm flag drives the retry.
|
||||
expect(store.shouldShowNudge).toBe(false)
|
||||
expect(store.nudgeArmed).toBe(true)
|
||||
|
||||
// The end of the tour must not lose the deferred nudge.
|
||||
store.end()
|
||||
expect(store.nudgeArmed).toBe(true)
|
||||
|
||||
isDialogOpen.mockReturnValue(false)
|
||||
store.showNudge()
|
||||
|
||||
expect(store.shouldShowNudge).toBe(true)
|
||||
expect(store.nudgeArmed).toBe(false)
|
||||
})
|
||||
|
||||
it('dismissNudge() hides it and blocks any later re-trigger this session', () => {
|
||||
store.showNudge()
|
||||
expect(store.shouldShowNudge).toBe(true)
|
||||
|
||||
store.dismissNudge()
|
||||
expect(store.shouldShowNudge).toBe(false)
|
||||
|
||||
store.showNudge()
|
||||
expect(store.shouldShowNudge).toBe(false)
|
||||
})
|
||||
|
||||
it('start() resets the nudge lifecycle for a fresh tour', () => {
|
||||
resolveTourRoles.mockReturnValue(t2iRoles)
|
||||
store.showNudge()
|
||||
store.dismissNudge()
|
||||
store.resultMedia = { url: 'blob:prior-run', kind: 'image' }
|
||||
|
||||
store.start(workflow)
|
||||
|
||||
expect(store.shouldShowNudge).toBe(false)
|
||||
expect(store.nudgeArmed).toBe(false)
|
||||
// The prior run's media is cleared with the rest of the nudge lifecycle.
|
||||
expect(store.resultMedia).toBeNull()
|
||||
|
||||
// Dismissal from the prior tour no longer blocks the new one.
|
||||
store.showNudge()
|
||||
expect(store.shouldShowNudge).toBe(true)
|
||||
})
|
||||
})
|
||||
213
src/renderer/extensions/onboardingTour/onboardingTourStore.ts
Normal file
213
src/renderer/extensions/onboardingTour/onboardingTourStore.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { UPGRADE_DIALOG_KEYS } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import { resolveTourRoles } from './roleResolution'
|
||||
import { restoreView } from './subgraphNavigation'
|
||||
import { sequenceBuilder } from './tourSequence'
|
||||
import type { MediaKind, ResolvedRoles, TourStep } from './tourSequence'
|
||||
|
||||
export type TourPhase = 'idle' | 'active'
|
||||
export type TourEndReason = 'done' | 'skip' | 'error'
|
||||
|
||||
export interface ResultMedia {
|
||||
url: string
|
||||
kind: MediaKind
|
||||
}
|
||||
|
||||
/** How long to wait for the sink's output URL after a run before giving up. */
|
||||
const RESULT_MEDIA_TIMEOUT_MS = 8000
|
||||
|
||||
export function isUpgradeModalOpen(): boolean {
|
||||
const dialogStore = useDialogStore()
|
||||
return UPGRADE_DIALOG_KEYS.some((key) => dialogStore.isDialogOpen(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph nodes a step points at: its own target, plus the prompt's collapsed host
|
||||
* (the subgraph is never entered). Shared by the spotlight and reveal derivations so
|
||||
* they can't drift.
|
||||
*/
|
||||
function stepTargets(step: TourStep): NodeId[] {
|
||||
const ids: NodeId[] = []
|
||||
if (step.nodeId !== null) ids.push(step.nodeId)
|
||||
if (step.prompt) ids.push(step.prompt.subgraphNodeId)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Tour state. All litegraph reads happen through the canvas adapter, never here. */
|
||||
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
|
||||
const phase = ref<TourPhase>('idle')
|
||||
const stepIndex = ref(0)
|
||||
const steps = ref<TourStep[]>([])
|
||||
const resolvedRoles = ref<ResolvedRoles | null>(null)
|
||||
const revealedNodeIds = ref<Set<NodeId>>(new Set())
|
||||
const resultMedia = ref<ResultMedia | null>(null)
|
||||
/**
|
||||
* Set on any run outcome. Drives "Generating…", which `resultMedia` alone cannot:
|
||||
* a capture that times out must not read as generating forever.
|
||||
*/
|
||||
const runFinished = ref(false)
|
||||
/**
|
||||
* Bumped once per `start()`, which passes through idle within one tick — so a
|
||||
* watcher on `phase` cannot tell a restart from a step change, but can tell on this.
|
||||
*/
|
||||
const tourRunId = ref(0)
|
||||
/** Drives the bottom-right nudge; outlives the tour so it can show after it ends. */
|
||||
const shouldShowNudge = ref(false)
|
||||
/** Set when `showNudge` defers because the upgrade modal is open; the modal-close watch drains it. */
|
||||
const nudgeArmed = ref(false)
|
||||
/** "Not now" latches this so no later trigger can resurface the nudge this session. */
|
||||
const nudgeDismissed = ref(false)
|
||||
|
||||
const currentStep = computed<TourStep | null>(
|
||||
() => steps.value[stepIndex.value] ?? null
|
||||
)
|
||||
const totalSteps = computed(() => steps.value.length)
|
||||
|
||||
/** Nodes the current step targets, spotlit brightly while prior reveals stay dim. */
|
||||
const spotlitNodeIds = computed<Set<NodeId>>(() => {
|
||||
const step = currentStep.value
|
||||
return step ? new Set(stepTargets(step)) : new Set()
|
||||
})
|
||||
|
||||
/**
|
||||
* Reveal every node targeted by steps up to and including `stepIndex` so the
|
||||
* graph builds up — except the final Result step, which collapses back to just
|
||||
* the sink so the generated output is the sole focus.
|
||||
*/
|
||||
function syncRevealed() {
|
||||
const step = currentStep.value
|
||||
if (step?.kind === 'result' && step.nodeId !== null) {
|
||||
revealedNodeIds.value = new Set([step.nodeId])
|
||||
return
|
||||
}
|
||||
const revealed = new Set<NodeId>()
|
||||
for (const prior of steps.value.slice(0, stepIndex.value + 1)) {
|
||||
for (const id of stepTargets(prior)) revealed.add(id)
|
||||
}
|
||||
revealedNodeIds.value = revealed
|
||||
}
|
||||
|
||||
function start(workflow: ComfyWorkflowJSON, templateId?: string) {
|
||||
reset()
|
||||
resetNudge()
|
||||
const roles = resolveTourRoles(workflow, templateId)
|
||||
resolvedRoles.value = roles
|
||||
steps.value = sequenceBuilder(roles)
|
||||
tourRunId.value += 1
|
||||
phase.value = 'active'
|
||||
syncRevealed()
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (stepIndex.value >= steps.value.length - 1) return
|
||||
stepIndex.value += 1
|
||||
syncRevealed()
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (stepIndex.value <= 0) return
|
||||
stepIndex.value -= 1
|
||||
syncRevealed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the sink's output as the Result step's media. The URL lands just after the
|
||||
* run finishes (the cloud queue refresh fetches it), so wait rather than drop it.
|
||||
* Kind comes from the resolved sink, not the MIME, so restored results still render.
|
||||
*/
|
||||
async function captureResultMedia() {
|
||||
if (phase.value !== 'active' || resultMedia.value) return
|
||||
const sink = resolvedRoles.value?.sink
|
||||
if (!sink) return
|
||||
|
||||
const sinkUrl = () => {
|
||||
const node = resolveNode(sink.nodeId)
|
||||
return node
|
||||
? (useNodeOutputStore().getNodeImageUrls(node)?.[0] ?? '')
|
||||
: ''
|
||||
}
|
||||
|
||||
const url = await until(sinkUrl).toMatch((value) => value.length > 0, {
|
||||
timeout: RESULT_MEDIA_TIMEOUT_MS,
|
||||
throwOnTimeout: false
|
||||
})
|
||||
if (!url || phase.value !== 'active') return
|
||||
|
||||
resultMedia.value = { url, kind: resolvedRoles.value?.mediaKind ?? 'image' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the bottom-right nudge once the tour ends — on every outcome. If the
|
||||
* upgrade modal is open, defer instead (arm it) so the two never overlap; the
|
||||
* modal-close watch re-runs this once the modal clears. Dismissal wins over both.
|
||||
*/
|
||||
function showNudge() {
|
||||
if (nudgeDismissed.value) return
|
||||
if (isUpgradeModalOpen()) {
|
||||
nudgeArmed.value = true
|
||||
return
|
||||
}
|
||||
nudgeArmed.value = false
|
||||
shouldShowNudge.value = true
|
||||
}
|
||||
|
||||
/** "Not now": hide the nudge and block any later trigger this session. */
|
||||
function dismissNudge() {
|
||||
shouldShowNudge.value = false
|
||||
nudgeDismissed.value = true
|
||||
}
|
||||
|
||||
/** Nudge state outlives the tour, so only a fresh tour clears it. */
|
||||
function resetNudge() {
|
||||
shouldShowNudge.value = false
|
||||
nudgeArmed.value = false
|
||||
nudgeDismissed.value = false
|
||||
resultMedia.value = null
|
||||
runFinished.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
phase.value = 'idle'
|
||||
stepIndex.value = 0
|
||||
steps.value = []
|
||||
resolvedRoles.value = null
|
||||
revealedNodeIds.value = new Set()
|
||||
}
|
||||
|
||||
function end() {
|
||||
restoreView()
|
||||
reset()
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
stepIndex,
|
||||
steps,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
resolvedRoles,
|
||||
revealedNodeIds,
|
||||
spotlitNodeIds,
|
||||
resultMedia,
|
||||
runFinished,
|
||||
tourRunId,
|
||||
shouldShowNudge,
|
||||
nudgeArmed,
|
||||
start,
|
||||
advance,
|
||||
back,
|
||||
captureResultMedia,
|
||||
showNudge,
|
||||
dismissNudge,
|
||||
end
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const resolveRoles = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./roleResolver', () => ({ resolveRoles }))
|
||||
|
||||
interface FakeNodeDef {
|
||||
output_node: boolean
|
||||
outputs: { type: string }[]
|
||||
}
|
||||
const nodeDefsByName = vi.hoisted(() => ({}) as Record<string, FakeNodeDef>)
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ nodeDefsByName })
|
||||
}))
|
||||
|
||||
import { resolveTourRoles } from './roleResolution'
|
||||
|
||||
type Lookup = (
|
||||
type: string
|
||||
) => { isOutputNode: boolean; producesVideo: boolean } | null
|
||||
|
||||
const workflow = {} as ComfyWorkflowJSON
|
||||
|
||||
function capturedLookup(): Lookup {
|
||||
return resolveRoles.mock.calls[0][2] as Lookup
|
||||
}
|
||||
|
||||
describe('resolveTourRoles', () => {
|
||||
beforeEach(() => {
|
||||
resolveRoles.mockReset()
|
||||
for (const key of Object.keys(nodeDefsByName)) delete nodeDefsByName[key]
|
||||
})
|
||||
|
||||
it('forwards the workflow and templateId to resolveRoles', () => {
|
||||
resolveTourRoles(workflow, 'image_z_image_turbo')
|
||||
|
||||
expect(resolveRoles).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'image_z_image_turbo',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('injects a lookup that rejects prototype keys and maps registry defs', () => {
|
||||
// The lookup indexes `nodeDefsByName` by an attacker-craftable node type, so
|
||||
// it must reject prototype members and only report real registry defs.
|
||||
nodeDefsByName.SaveVideo = {
|
||||
output_node: true,
|
||||
outputs: [{ type: 'VIDEO' }]
|
||||
}
|
||||
nodeDefsByName.SaveImage = {
|
||||
output_node: true,
|
||||
outputs: [{ type: 'IMAGE' }]
|
||||
}
|
||||
|
||||
resolveTourRoles(workflow)
|
||||
const lookup = capturedLookup()
|
||||
|
||||
expect(lookup('toString')).toBeNull()
|
||||
expect(lookup('constructor')).toBeNull()
|
||||
expect(lookup('unregistered')).toBeNull()
|
||||
expect(lookup('SaveVideo')).toEqual({
|
||||
isOutputNode: true,
|
||||
producesVideo: true
|
||||
})
|
||||
expect(lookup('SaveImage')).toEqual({
|
||||
isOutputNode: true,
|
||||
producesVideo: false
|
||||
})
|
||||
})
|
||||
})
|
||||
35
src/renderer/extensions/onboardingTour/roleResolution.ts
Normal file
35
src/renderer/extensions/onboardingTour/roleResolution.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { resolveRoles } from './roleResolver'
|
||||
import type { NodeDefLookup } from './roleResolver'
|
||||
import type { ResolvedRoles } from './tourSequence'
|
||||
|
||||
/** Output types that mark a registry sink as producing video rather than an image. */
|
||||
const VIDEO_OUTPUT_TYPES = new Set(['VIDEO', 'VHS_VIDEOINFO'])
|
||||
|
||||
/** Reads the node registry so the resolver can widen sink detection to custom output nodes. */
|
||||
const nodeDefLookup: NodeDefLookup = (type) => {
|
||||
const defs = useNodeDefStore().nodeDefsByName
|
||||
if (!Object.hasOwn(defs, type)) return null
|
||||
const def = defs[type]
|
||||
return {
|
||||
isOutputNode: def.output_node,
|
||||
producesVideo: def.outputs.some((output) =>
|
||||
VIDEO_OUTPUT_TYPES.has(output.type)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tour roles with the live node registry injected, so registry-only
|
||||
* custom sinks are detected. The single entry point the store and the readiness
|
||||
* gate share, so both see the same roles (the gate reads the live graph the
|
||||
* spotlight reads — they must not diverge on which sink exists).
|
||||
*/
|
||||
export function resolveTourRoles(
|
||||
workflow: ComfyWorkflowJSON,
|
||||
templateId?: string
|
||||
): ResolvedRoles {
|
||||
return resolveRoles(workflow, templateId, nodeDefLookup)
|
||||
}
|
||||
564
src/renderer/extensions/onboardingTour/roleResolver.test.ts
Normal file
564
src/renderer/extensions/onboardingTour/roleResolver.test.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { loadTemplateWorkflow } from './__fixtures__/loadTemplateWorkflow'
|
||||
import {
|
||||
fromWorkflowJson,
|
||||
resolveRoles,
|
||||
templateOverrides
|
||||
} from './roleResolver'
|
||||
import type { CuratedTemplateId } from './roleResolver'
|
||||
|
||||
interface CuratedExpectation {
|
||||
sourceId: number | null
|
||||
promptInnerId: number
|
||||
promptWidget: string
|
||||
engineId: number
|
||||
sinkId: number
|
||||
mediaKind: 'image' | 'video'
|
||||
}
|
||||
|
||||
const CURATED: Record<CuratedTemplateId, CuratedExpectation> = {
|
||||
image_krea2_turbo_t2i: {
|
||||
sourceId: null,
|
||||
promptInnerId: 19,
|
||||
promptWidget: 'value',
|
||||
engineId: 3,
|
||||
sinkId: 29,
|
||||
mediaKind: 'image'
|
||||
},
|
||||
image_z_image_turbo: {
|
||||
sourceId: null,
|
||||
promptInnerId: 27,
|
||||
promptWidget: 'text',
|
||||
engineId: 3,
|
||||
sinkId: 9,
|
||||
mediaKind: 'image'
|
||||
},
|
||||
video_ltx2_3_i2v: {
|
||||
sourceId: 269,
|
||||
promptInnerId: 319,
|
||||
promptWidget: 'value',
|
||||
engineId: 283,
|
||||
sinkId: 75,
|
||||
mediaKind: 'video'
|
||||
},
|
||||
video_wan2_2_14B_i2v: {
|
||||
sourceId: 97,
|
||||
promptInnerId: 93,
|
||||
promptWidget: 'text',
|
||||
engineId: 86,
|
||||
sinkId: 108,
|
||||
mediaKind: 'video'
|
||||
},
|
||||
flux_kontext_dev_basic: {
|
||||
sourceId: 190,
|
||||
promptInnerId: 6,
|
||||
promptWidget: 'text',
|
||||
engineId: 31,
|
||||
sinkId: 136,
|
||||
mediaKind: 'image'
|
||||
}
|
||||
}
|
||||
|
||||
const curatedIds = Object.keys(CURATED) as CuratedTemplateId[]
|
||||
|
||||
describe('resolveRoles — curated templates, heuristics only (no override)', () => {
|
||||
it.for(curatedIds)('resolves %s from its real JSON', (id) => {
|
||||
const expected = CURATED[id]
|
||||
const roles = resolveRoles(loadTemplateWorkflow(id))
|
||||
|
||||
if (expected.sourceId === null) {
|
||||
expect(roles.source).toBeNull()
|
||||
} else {
|
||||
expect(roles.source?.nodeId).toBe(toNodeId(expected.sourceId))
|
||||
}
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(expected.promptInnerId))
|
||||
expect(roles.prompt?.widgetName).toBe(expected.promptWidget)
|
||||
expect(roles.engine?.nodeId).toBe(toNodeId(expected.engineId))
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(expected.sinkId))
|
||||
expect(roles.mediaKind).toBe(expected.mediaKind)
|
||||
})
|
||||
|
||||
it('rejects the internal-fed decoy CLIPTextEncode (krea 6, ltx 303)', () => {
|
||||
expect(
|
||||
resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i')).prompt
|
||||
?.innerNodeId
|
||||
).not.toBe(toNodeId(6))
|
||||
expect(
|
||||
resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v')).prompt?.innerNodeId
|
||||
).not.toBe(toNodeId(303))
|
||||
})
|
||||
|
||||
it('picks the boundary-fed user prompt over a sibling system prompt (krea)', () => {
|
||||
const roles = resolveRoles(loadTemplateWorkflow('image_krea2_turbo_t2i'))
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(19))
|
||||
})
|
||||
|
||||
it('picks the positive CLIPTextEncode over the negative one (wan)', () => {
|
||||
const roles = resolveRoles(loadTemplateWorkflow('video_wan2_2_14B_i2v'))
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(93))
|
||||
})
|
||||
|
||||
it('picks one source when several LoadImage exist (flux)', () => {
|
||||
const roles = resolveRoles(loadTemplateWorkflow('flux_kontext_dev_basic'))
|
||||
expect(roles.source?.nodeId).toBe(toNodeId(190))
|
||||
})
|
||||
|
||||
it('exposes the subgraph node id and prompt port for fallback', () => {
|
||||
const zImage = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
|
||||
expect(zImage.prompt?.subgraphNodeId).toBe(toNodeId(57))
|
||||
expect(zImage.prompt?.portFallback).toBe('text')
|
||||
|
||||
const ltx = resolveRoles(loadTemplateWorkflow('video_ltx2_3_i2v'))
|
||||
expect(ltx.prompt?.portFallback).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('templateOverrides pin the heuristic result', () => {
|
||||
it('matches the heuristic result for every curated id', () => {
|
||||
for (const id of curatedIds) {
|
||||
const roles = resolveRoles(loadTemplateWorkflow(id))
|
||||
const pin = templateOverrides[id]
|
||||
expect(pin.promptNodeId).toBe(roles.prompt?.innerNodeId)
|
||||
expect(pin.engineNodeId).toBe(roles.engine?.nodeId)
|
||||
expect(pin.sinkNodeId).toBe(roles.sink?.nodeId)
|
||||
expect(pin.mediaKind).toBe(roles.mediaKind)
|
||||
if (roles.source) expect(pin.sourceNodeId).toBe(roles.source.nodeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('override sink, engine, and media win over the heuristics on the graph', () => {
|
||||
const pin = templateOverrides.image_z_image_turbo
|
||||
const roles = resolveRoles(
|
||||
workflow([
|
||||
node(900, 'SaveVideo', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
|
||||
})
|
||||
]),
|
||||
'image_z_image_turbo'
|
||||
)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(pin.sinkNodeId)
|
||||
expect(roles.engine?.nodeId).toBe(pin.engineNodeId)
|
||||
expect(roles.mediaKind).toBe(pin.mediaKind)
|
||||
expect(roles.sink?.nodeId).not.toBe(toNodeId(900))
|
||||
expect(roles.mediaKind).not.toBe('video')
|
||||
})
|
||||
|
||||
it('degrades the prompt to null when the pinned inner node is absent', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([
|
||||
node(900, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 1 }]
|
||||
})
|
||||
]),
|
||||
'image_z_image_turbo'
|
||||
)
|
||||
|
||||
expect(roles.prompt).toBeNull()
|
||||
})
|
||||
|
||||
it('spotlights the subgraph host, not the pinned inner node, for a nested prompt', () => {
|
||||
const roles = resolveRoles(
|
||||
loadTemplateWorkflow('flux_kontext_dev_basic'),
|
||||
'flux_kontext_dev_basic'
|
||||
)
|
||||
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
|
||||
expect(roles.prompt?.subgraphNodeId).toBe(toNodeId(192))
|
||||
})
|
||||
|
||||
it('ignores an unknown template id and falls back to heuristics', () => {
|
||||
const withUnknown = resolveRoles(
|
||||
loadTemplateWorkflow('image_z_image_turbo'),
|
||||
'not_a_real_template'
|
||||
)
|
||||
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
|
||||
expect(withUnknown).toEqual(heuristic)
|
||||
})
|
||||
})
|
||||
|
||||
function workflow(nodes: unknown[], links: unknown[] = []): ComfyWorkflowJSON {
|
||||
return {
|
||||
version: 0.4,
|
||||
nodes,
|
||||
links,
|
||||
extra: {}
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
function node(
|
||||
id: number,
|
||||
type: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
pos: [0, 0],
|
||||
size: [1, 1],
|
||||
flags: {},
|
||||
order: id,
|
||||
mode: 0,
|
||||
properties: {},
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function cte(id: number, conditioningLink: number, text: string) {
|
||||
return node(id, 'CLIPTextEncode', {
|
||||
inputs: [{ name: 'text', type: 'STRING' }],
|
||||
outputs: [
|
||||
{ name: 'CONDITIONING', type: 'CONDITIONING', links: [conditioningLink] }
|
||||
],
|
||||
widgets_values: [text]
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveRoles — arbitrary (non-curated) graphs', () => {
|
||||
it('resolves a top-level T2I, choosing the positive prompt by conditioning', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
cte(6, 4, 'a red fox'),
|
||||
cte(7, 6, 'blurry'),
|
||||
node(3, 'KSampler', {
|
||||
inputs: [
|
||||
{ name: 'positive', type: 'CONDITIONING', link: 4 },
|
||||
{ name: 'negative', type: 'CONDITIONING', link: 6 }
|
||||
]
|
||||
}),
|
||||
node(9, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
|
||||
})
|
||||
],
|
||||
[
|
||||
[4, 6, 0, 3, 1, 'CONDITIONING'],
|
||||
[6, 7, 0, 3, 2, 'CONDITIONING'],
|
||||
[8, 3, 0, 9, 0, 'IMAGE']
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
expect(roles.source).toBeNull()
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(6))
|
||||
expect(roles.prompt?.widgetName).toBe('text')
|
||||
expect(roles.engine?.nodeId).toBe(toNodeId(3))
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
expect(roles.mediaKind).toBe('image')
|
||||
})
|
||||
|
||||
it('resolves a top-level I2V with a video sink', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
node(1, 'LoadImage', {
|
||||
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [10] }]
|
||||
}),
|
||||
cte(2, 11, 'dancing'),
|
||||
node(3, 'KSamplerAdvanced', {
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 11 }]
|
||||
}),
|
||||
node(4, 'SaveVideo', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 12 }]
|
||||
})
|
||||
],
|
||||
[
|
||||
[10, 1, 0, 3, 3, 'IMAGE'],
|
||||
[11, 2, 0, 3, 1, 'CONDITIONING'],
|
||||
[12, 3, 0, 4, 0, 'VIDEO']
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
expect(roles.source?.nodeId).toBe(toNodeId(1))
|
||||
expect(roles.prompt?.innerNodeId).toBe(toNodeId(2))
|
||||
expect(roles.engine?.nodeId).toBe(toNodeId(3))
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(4))
|
||||
expect(roles.mediaKind).toBe('video')
|
||||
})
|
||||
|
||||
it('prefers a Save sink over a Preview sink', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
cte(6, 4, 'x'),
|
||||
node(3, 'KSampler', {
|
||||
inputs: [{ name: 'positive', type: 'CONDITIONING', link: 4 }]
|
||||
}),
|
||||
node(9, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
|
||||
}),
|
||||
node(19, 'PreviewImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 20 }]
|
||||
})
|
||||
],
|
||||
[
|
||||
[4, 6, 0, 3, 1, 'CONDITIONING'],
|
||||
[8, 3, 0, 9, 0, 'IMAGE'],
|
||||
[20, 3, 0, 19, 0, 'IMAGE']
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromWorkflowJson normalizes the serialized shape', () => {
|
||||
it('reads tuple links at the top level', () => {
|
||||
const graph = fromWorkflowJson(
|
||||
workflow(
|
||||
[
|
||||
node(1, 'KSampler', {
|
||||
outputs: [{ name: 'LATENT', type: 'LATENT', links: [5] }]
|
||||
}),
|
||||
node(2, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
|
||||
})
|
||||
],
|
||||
[[5, 1, 0, 2, 0, 'IMAGE']]
|
||||
)
|
||||
)
|
||||
|
||||
expect(graph.nodes[0].hasOutgoingLinks).toBe(true)
|
||||
expect(graph.nodes[1].inputs[0].origin).toEqual({
|
||||
kind: 'node',
|
||||
nodeId: toNodeId(1),
|
||||
slot: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('reads object links and the boundary origin inside subgraphs', () => {
|
||||
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
|
||||
const subgraph = graph.subgraphs[0]
|
||||
const clip = subgraph.nodes.find((n) => n.id === toNodeId(27))
|
||||
const textInput = clip?.inputs.find((i) => i.name === 'text')
|
||||
|
||||
expect(textInput?.origin).toEqual({ kind: 'boundary', slot: 0 })
|
||||
})
|
||||
|
||||
it('flags the hosting subgraph node', () => {
|
||||
const graph = fromWorkflowJson(loadTemplateWorkflow('image_z_image_turbo'))
|
||||
const host = graph.nodes.find((n) => n.subgraphId !== null)
|
||||
|
||||
expect(host?.subgraphId).toBe(graph.subgraphs[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRoles — graceful degradation for any input', () => {
|
||||
it('returns all-null roles for a graph with no sink or prompt', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([node(1, 'MarkdownNote'), node(2, 'Reroute')])
|
||||
)
|
||||
expect(roles.source).toBeNull()
|
||||
expect(roles.prompt).toBeNull()
|
||||
expect(roles.engine).toBeNull()
|
||||
expect(roles.sink).toBeNull()
|
||||
expect(roles.mediaKind).toBe('image')
|
||||
})
|
||||
|
||||
it('does not throw on an empty graph', () => {
|
||||
expect(() => resolveRoles(workflow([]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('resolves the sink even when the prompt is missing', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([
|
||||
node(9, 'SaveVideo', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
expect(roles.mediaKind).toBe('video')
|
||||
expect(roles.prompt).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the prompt null when a subgraph port leads to no editable node', () => {
|
||||
const subgraphId = '11111111-1111-1111-1111-111111111111'
|
||||
const graph = {
|
||||
...workflow([
|
||||
node(1, subgraphId, { inputs: [] }),
|
||||
node(2, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 5 }]
|
||||
})
|
||||
]),
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: subgraphId,
|
||||
inputs: [{ name: 'text', type: 'STRING', label: 'prompt' }],
|
||||
nodes: [node(10, 'VAEDecode', { inputs: [] })],
|
||||
links: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const roles = resolveRoles(graph)
|
||||
expect(roles.prompt).toBeNull()
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(2))
|
||||
})
|
||||
|
||||
it('skips malformed link entries without throwing', () => {
|
||||
const graph = {
|
||||
...workflow(
|
||||
[
|
||||
node(9, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
|
||||
})
|
||||
],
|
||||
[null, 'garbage', [8, 3, 0, 9, 0, 'IMAGE']]
|
||||
)
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
expect(() => resolveRoles(graph)).not.toThrow()
|
||||
expect(resolveRoles(graph).sink?.nodeId).toBe(toNodeId(9))
|
||||
})
|
||||
|
||||
it('prefers a terminal Save sink over a Save that feeds onward', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
node(1, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 4 }],
|
||||
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [5] }]
|
||||
}),
|
||||
node(2, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 6 }]
|
||||
})
|
||||
],
|
||||
[
|
||||
[5, 1, 0, 2, 0, 'IMAGE'],
|
||||
[6, 1, 0, 2, 0, 'IMAGE']
|
||||
]
|
||||
)
|
||||
)
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRoles — hostile node types do not match prototype members', () => {
|
||||
it('does not treat a node typed "constructor" as a sink', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([node(1, 'constructor', { inputs: [] })])
|
||||
)
|
||||
expect(roles.sink).toBeNull()
|
||||
expect(roles.mediaKind).toBe('image')
|
||||
})
|
||||
|
||||
it('ignores a template id that names a prototype member', () => {
|
||||
const heuristic = resolveRoles(loadTemplateWorkflow('image_z_image_turbo'))
|
||||
for (const id of ['constructor', 'toString', 'hasOwnProperty']) {
|
||||
expect(
|
||||
resolveRoles(loadTemplateWorkflow('image_z_image_turbo'), id)
|
||||
).toEqual(heuristic)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRoles — registry-backed sink fallback', () => {
|
||||
// A custom save node outside the hardcoded SINK_MEDIA list, only recognizable
|
||||
// as a sink through the injected registry lookup.
|
||||
const customSinkGraph = workflow([
|
||||
node(9, 'MyCustomVideoSave', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }]
|
||||
})
|
||||
])
|
||||
|
||||
const lookup = (type: string) =>
|
||||
type === 'MyCustomVideoSave'
|
||||
? { isOutputNode: true, producesVideo: true }
|
||||
: null
|
||||
|
||||
it('resolves a custom output node as the sink when the type list misses', () => {
|
||||
const roles = resolveRoles(customSinkGraph, undefined, lookup)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
expect(roles.mediaKind).toBe('video')
|
||||
})
|
||||
|
||||
it('leaves the sink null for the same graph without a registry lookup', () => {
|
||||
const roles = resolveRoles(customSinkGraph)
|
||||
|
||||
expect(roles.sink).toBeNull()
|
||||
expect(roles.mediaKind).toBe('image')
|
||||
})
|
||||
|
||||
it('does not use the fallback when a known sink type already matches', () => {
|
||||
// A registry that would (wrongly) label the KSampler an output node must not
|
||||
// steal the sink from the real SaveImage.
|
||||
const greedyLookup = () => ({ isOutputNode: true, producesVideo: false })
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
node(3, 'KSampler', {
|
||||
outputs: [{ name: 'LATENT', type: 'LATENT', links: [8] }]
|
||||
}),
|
||||
node(9, 'SaveImage', {
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 8 }]
|
||||
})
|
||||
],
|
||||
[[8, 3, 0, 9, 0, 'IMAGE']]
|
||||
),
|
||||
undefined,
|
||||
greedyLookup
|
||||
)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
})
|
||||
|
||||
it('ignores a registry output node that still feeds downstream', () => {
|
||||
// Only terminal output nodes are sinks; one with outgoing links is not.
|
||||
const roles = resolveRoles(
|
||||
workflow(
|
||||
[
|
||||
node(9, 'MyCustomVideoSave', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 1 }],
|
||||
outputs: [{ name: 'VIDEO', type: 'VIDEO', links: [2] }]
|
||||
}),
|
||||
node(10, 'MyCustomVideoSave', {
|
||||
inputs: [{ name: 'video', type: 'VIDEO', link: 2 }]
|
||||
})
|
||||
],
|
||||
[[2, 9, 0, 10, 0, 'VIDEO']]
|
||||
),
|
||||
undefined,
|
||||
lookup
|
||||
)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(10))
|
||||
})
|
||||
|
||||
it('does not treat a non-output registry node as a sink', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
|
||||
undefined,
|
||||
(type: string) =>
|
||||
type === 'MyCustomImageSave'
|
||||
? { isOutputNode: false, producesVideo: false }
|
||||
: null
|
||||
)
|
||||
|
||||
// isOutputNode:false → not a sink at all, so it stays null.
|
||||
expect(roles.sink).toBeNull()
|
||||
})
|
||||
|
||||
it('infers image media for an accepted output node with no video output', () => {
|
||||
const roles = resolveRoles(
|
||||
workflow([node(9, 'MyCustomImageSave', { inputs: [] })]),
|
||||
undefined,
|
||||
(type: string) =>
|
||||
type === 'MyCustomImageSave'
|
||||
? { isOutputNode: true, producesVideo: false }
|
||||
: null
|
||||
)
|
||||
|
||||
expect(roles.sink?.nodeId).toBe(toNodeId(9))
|
||||
expect(roles.mediaKind).toBe('image')
|
||||
})
|
||||
})
|
||||
540
src/renderer/extensions/onboardingTour/roleResolver.ts
Normal file
540
src/renderer/extensions/onboardingTour/roleResolver.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type {
|
||||
MediaKind,
|
||||
NodeRole,
|
||||
PromptRole,
|
||||
ResolvedRoles
|
||||
} from './tourSequence'
|
||||
|
||||
/**
|
||||
* A minimal, faithful view of the parts of a workflow the resolver reads,
|
||||
* projected from a serialized `ComfyWorkflowJSON` (see {@link fromWorkflowJson}).
|
||||
* Keeping the resolver on this view makes it pure and testable against real
|
||||
* template JSON without instantiating a live graph.
|
||||
*/
|
||||
export interface WorkflowGraph {
|
||||
nodes: WorkflowGraphNode[]
|
||||
subgraphs: WorkflowSubgraph[]
|
||||
}
|
||||
|
||||
interface WorkflowGraphNode {
|
||||
id: NodeId
|
||||
type: string
|
||||
inputs: WorkflowInput[]
|
||||
/** True when this node hosts a subgraph, keyed to a {@link WorkflowSubgraph}. */
|
||||
subgraphId: string | null
|
||||
hasOutgoingLinks: boolean
|
||||
}
|
||||
|
||||
interface WorkflowInput {
|
||||
name: string
|
||||
/** Origin of the link feeding this input, or null when driven by its widget. */
|
||||
origin: WorkflowLinkOrigin | null
|
||||
}
|
||||
|
||||
interface WorkflowSubgraph {
|
||||
id: string
|
||||
nodes: WorkflowGraphNode[]
|
||||
exposedInputs: ExposedInput[]
|
||||
}
|
||||
|
||||
interface ExposedInput {
|
||||
name: string
|
||||
type: string
|
||||
label: string | null
|
||||
slot: number
|
||||
}
|
||||
|
||||
/** Where a link originates: an inner node, or the subgraph's input boundary. */
|
||||
type WorkflowLinkOrigin =
|
||||
| { kind: 'node'; nodeId: NodeId; slot: number }
|
||||
| { kind: 'boundary'; slot: number }
|
||||
|
||||
/**
|
||||
* Sink node types that terminate a generation, and the media each yields. A Map
|
||||
* (not an object) so an attacker-crafted `node.type` like `"constructor"` can't
|
||||
* match a prototype member and smuggle a non-`MediaKind` value through.
|
||||
*/
|
||||
const SINK_MEDIA = new Map<string, MediaKind>([
|
||||
['SaveImage', 'image'],
|
||||
['PreviewImage', 'image'],
|
||||
['SaveAnimatedWEBP', 'image'],
|
||||
['SaveVideo', 'video'],
|
||||
['VHS_VideoCombine', 'video'],
|
||||
['SaveWEBM', 'video']
|
||||
])
|
||||
|
||||
const IMAGE_SOURCE_TYPES = new Set([
|
||||
'LoadImage',
|
||||
'LoadImageMask',
|
||||
'LoadImageOutput'
|
||||
])
|
||||
|
||||
const SAMPLER_TYPES = new Set([
|
||||
'KSampler',
|
||||
'KSamplerAdvanced',
|
||||
'SamplerCustom',
|
||||
'SamplerCustomAdvanced'
|
||||
])
|
||||
|
||||
/**
|
||||
* A read of the node registry, injected so the resolver stays pure and testable
|
||||
* without a live `nodeDefStore`. Returns null for unknown types.
|
||||
*/
|
||||
export type NodeDefLookup = (
|
||||
type: string
|
||||
) => { isOutputNode: boolean; producesVideo: boolean } | null
|
||||
|
||||
/** Exposed-port names/labels that mark the editable prompt boundary. */
|
||||
const PROMPT_PORT_NAMES = new Set(['prompt', 'text', 'value'])
|
||||
|
||||
const CLIP_TEXT_ENCODE = 'CLIPTextEncode'
|
||||
const PROMPT_PRIMITIVE = 'PrimitiveStringMultiline'
|
||||
|
||||
/** Pinned node ids for a curated template — a confidence layer over heuristics. */
|
||||
interface TemplateOverride {
|
||||
sourceNodeId?: NodeId
|
||||
promptNodeId: NodeId
|
||||
engineNodeId: NodeId
|
||||
sinkNodeId: NodeId
|
||||
mediaKind: MediaKind
|
||||
}
|
||||
|
||||
export type CuratedTemplateId =
|
||||
| 'image_krea2_turbo_t2i'
|
||||
| 'image_z_image_turbo'
|
||||
| 'video_ltx2_3_i2v'
|
||||
| 'video_wan2_2_14B_i2v'
|
||||
| 'flux_kontext_dev_basic'
|
||||
|
||||
export const templateOverrides: Record<CuratedTemplateId, TemplateOverride> = {
|
||||
image_krea2_turbo_t2i: {
|
||||
promptNodeId: toNodeId(19),
|
||||
engineNodeId: toNodeId(3),
|
||||
sinkNodeId: toNodeId(29),
|
||||
mediaKind: 'image'
|
||||
},
|
||||
image_z_image_turbo: {
|
||||
promptNodeId: toNodeId(27),
|
||||
engineNodeId: toNodeId(3),
|
||||
sinkNodeId: toNodeId(9),
|
||||
mediaKind: 'image'
|
||||
},
|
||||
video_ltx2_3_i2v: {
|
||||
sourceNodeId: toNodeId(269),
|
||||
promptNodeId: toNodeId(319),
|
||||
engineNodeId: toNodeId(283),
|
||||
sinkNodeId: toNodeId(75),
|
||||
mediaKind: 'video'
|
||||
},
|
||||
video_wan2_2_14B_i2v: {
|
||||
sourceNodeId: toNodeId(97),
|
||||
promptNodeId: toNodeId(93),
|
||||
engineNodeId: toNodeId(86),
|
||||
sinkNodeId: toNodeId(108),
|
||||
mediaKind: 'video'
|
||||
},
|
||||
flux_kontext_dev_basic: {
|
||||
sourceNodeId: toNodeId(190),
|
||||
promptNodeId: toNodeId(6),
|
||||
engineNodeId: toNodeId(31),
|
||||
sinkNodeId: toNodeId(136),
|
||||
mediaKind: 'image'
|
||||
}
|
||||
}
|
||||
|
||||
interface SinkMatch {
|
||||
node: WorkflowGraphNode
|
||||
mediaKind: MediaKind
|
||||
}
|
||||
|
||||
function findSink(
|
||||
graph: WorkflowGraph,
|
||||
nodeDefLookup: NodeDefLookup
|
||||
): SinkMatch | null {
|
||||
const candidates = graph.nodes.flatMap((node) => {
|
||||
const mediaKind = SINK_MEDIA.get(node.type)
|
||||
return mediaKind ? [{ node, mediaKind }] : []
|
||||
})
|
||||
if (candidates.length === 0) return findRegistrySink(graph, nodeDefLookup)
|
||||
|
||||
const isSave = (match: SinkMatch) => match.node.type.startsWith('Save')
|
||||
const [best] = candidates.sort((a, b) => {
|
||||
if (isSave(a) !== isSave(b)) return isSave(a) ? -1 : 1
|
||||
if (a.node.hasOutgoingLinks !== b.node.hasOutgoingLinks) {
|
||||
return a.node.hasOutgoingLinks ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when no known save/preview type matches: a terminal node the registry
|
||||
* marks as an output node. Widens sink detection to custom save nodes on
|
||||
* arbitrary share/`?template=` workflows, where the hardcoded type list misses.
|
||||
*/
|
||||
function findRegistrySink(
|
||||
graph: WorkflowGraph,
|
||||
nodeDefLookup: NodeDefLookup
|
||||
): SinkMatch | null {
|
||||
const node = graph.nodes.find((n) => {
|
||||
if (n.hasOutgoingLinks) return false
|
||||
return nodeDefLookup(n.type)?.isOutputNode === true
|
||||
})
|
||||
if (!node) return null
|
||||
const producesVideo = nodeDefLookup(node.type)?.producesVideo === true
|
||||
return { node, mediaKind: producesVideo ? 'video' : 'image' }
|
||||
}
|
||||
|
||||
function findSource(graph: WorkflowGraph): WorkflowGraphNode | null {
|
||||
return graph.nodes.find((n) => IMAGE_SOURCE_TYPES.has(n.type)) ?? null
|
||||
}
|
||||
|
||||
function firstSampler(nodes: WorkflowGraphNode[]): WorkflowGraphNode | null {
|
||||
return nodes.find((n) => SAMPLER_TYPES.has(n.type)) ?? null
|
||||
}
|
||||
|
||||
function subgraphHost(
|
||||
graph: WorkflowGraph
|
||||
): { node: WorkflowGraphNode; subgraph: WorkflowSubgraph } | null {
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.subgraphId) continue
|
||||
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
|
||||
if (subgraph) return { node, subgraph }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function promptPort(subgraph: WorkflowSubgraph): ExposedInput | null {
|
||||
const strings = subgraph.exposedInputs.filter((p) => p.type === 'STRING')
|
||||
const named = strings.find(
|
||||
(p) => PROMPT_PORT_NAMES.has(p.label ?? '') || PROMPT_PORT_NAMES.has(p.name)
|
||||
)
|
||||
return named ?? strings[0] ?? null
|
||||
}
|
||||
|
||||
/** Inner node whose named widget-input is fed from the given boundary port. */
|
||||
function boundaryFedNode(
|
||||
subgraph: WorkflowSubgraph,
|
||||
portSlot: number,
|
||||
widgetName: string
|
||||
): WorkflowGraphNode | null {
|
||||
return (
|
||||
subgraph.nodes.find((node) =>
|
||||
node.inputs.some(
|
||||
(input) =>
|
||||
input.name === widgetName &&
|
||||
input.origin?.kind === 'boundary' &&
|
||||
input.origin.slot === portSlot
|
||||
)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSubgraphPrompt(
|
||||
subgraphNodeId: NodeId,
|
||||
subgraph: WorkflowSubgraph
|
||||
): PromptRole | null {
|
||||
const port = promptPort(subgraph)
|
||||
if (!port) return null
|
||||
|
||||
const primitive = boundaryFedNode(subgraph, port.slot, 'value')
|
||||
if (primitive?.type === PROMPT_PRIMITIVE) {
|
||||
return {
|
||||
subgraphNodeId,
|
||||
innerNodeId: primitive.id,
|
||||
widgetName: 'value',
|
||||
portFallback: port.name
|
||||
}
|
||||
}
|
||||
|
||||
const clip = boundaryFedNode(subgraph, port.slot, 'text')
|
||||
if (clip?.type === CLIP_TEXT_ENCODE) {
|
||||
return {
|
||||
subgraphNodeId,
|
||||
innerNodeId: clip.id,
|
||||
widgetName: 'text',
|
||||
portFallback: port.name
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLIPTextEncode feeding the first sampler's positive input, else the first
|
||||
* CLIPTextEncode anywhere (best-effort for graphs with no wired sampler).
|
||||
*/
|
||||
function positivePromptNode(graph: WorkflowGraph): WorkflowGraphNode | null {
|
||||
const sampler = firstSampler(graph.nodes)
|
||||
const positive = sampler?.inputs.find((i) => i.name === 'positive')
|
||||
if (positive?.origin?.kind === 'node') {
|
||||
const origin = positive.origin
|
||||
const feeder = graph.nodes.find((n) => n.id === origin.nodeId)
|
||||
if (feeder?.type === CLIP_TEXT_ENCODE) return feeder
|
||||
}
|
||||
return graph.nodes.find((n) => n.type === CLIP_TEXT_ENCODE) ?? null
|
||||
}
|
||||
|
||||
function resolveTopLevelPrompt(graph: WorkflowGraph): PromptRole | null {
|
||||
const node = positivePromptNode(graph)
|
||||
if (!node) return null
|
||||
return {
|
||||
subgraphNodeId: node.id,
|
||||
innerNodeId: node.id,
|
||||
widgetName: 'text',
|
||||
portFallback: null
|
||||
}
|
||||
}
|
||||
|
||||
function toNodeRole(nodeId: NodeId): NodeRole {
|
||||
return { nodeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned override for a curated template, if one exists. `Object.hasOwn`
|
||||
* guards against a `?template=` id like `"constructor"` matching a prototype
|
||||
* member and yielding a non-override value.
|
||||
*/
|
||||
function overrideFor(templateId: string | undefined): TemplateOverride | null {
|
||||
if (!templateId || !Object.hasOwn(templateOverrides, templateId)) return null
|
||||
return templateOverrides[templateId as CuratedTemplateId]
|
||||
}
|
||||
|
||||
/**
|
||||
* The root-graph node to spotlight for a prompt whose editable widget lives on
|
||||
* `innerNodeId`: the node itself when it sits on the root graph, else the
|
||||
* collapsed host of the subgraph that contains it. Null when the id belongs to
|
||||
* no known node — the tour never enters a subgraph, so an inner id can't be a
|
||||
* spotlight target.
|
||||
*/
|
||||
function promptHostId(
|
||||
graph: WorkflowGraph,
|
||||
innerNodeId: NodeId
|
||||
): NodeId | null {
|
||||
if (graph.nodes.some((n) => n.id === innerNodeId)) return innerNodeId
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.subgraphId) continue
|
||||
const subgraph = graph.subgraphs.find((s) => s.id === node.subgraphId)
|
||||
if (subgraph?.nodes.some((n) => n.id === innerNodeId)) return node.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt role from the override's pinned inner node. `subgraphNodeId`
|
||||
* (the spotlight target) is resolved to a root-graph node so it never points at
|
||||
* a raw inner node; widget/port detail comes from the heuristic only when it
|
||||
* resolved the same inner node. Prompt degrades to null if the inner id is
|
||||
* unreachable.
|
||||
*/
|
||||
function overridePrompt(
|
||||
graph: WorkflowGraph,
|
||||
override: TemplateOverride,
|
||||
heuristicPrompt: PromptRole | null
|
||||
): PromptRole | null {
|
||||
const subgraphNodeId = promptHostId(graph, override.promptNodeId)
|
||||
if (subgraphNodeId === null) return null
|
||||
|
||||
const sameInner = heuristicPrompt?.innerNodeId === override.promptNodeId
|
||||
return {
|
||||
subgraphNodeId,
|
||||
innerNodeId: override.promptNodeId,
|
||||
widgetName: sameInner ? heuristicPrompt.widgetName : 'text',
|
||||
portFallback: sameInner ? heuristicPrompt.portFallback : null
|
||||
}
|
||||
}
|
||||
|
||||
function applyOverride(
|
||||
graph: WorkflowGraph,
|
||||
override: TemplateOverride,
|
||||
heuristicPrompt: PromptRole | null
|
||||
): ResolvedRoles {
|
||||
return {
|
||||
source: override.sourceNodeId ? toNodeRole(override.sourceNodeId) : null,
|
||||
prompt: overridePrompt(graph, override, heuristicPrompt),
|
||||
engine: toNodeRole(override.engineNodeId),
|
||||
sink: toNodeRole(override.sinkNodeId),
|
||||
mediaKind: override.mediaKind
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects a loaded workflow and returns the roles the tour targets. Works on
|
||||
* any graph — curated templates, arbitrary share/`?template=` workflows, and
|
||||
* custom user templates alike — via structural heuristics. Never throws; any
|
||||
* role that cannot be resolved is left null so the sequence builder degrades
|
||||
* gracefully. When `templateId` names a curated template, its pinned ids take
|
||||
* precedence over the heuristics.
|
||||
*/
|
||||
export function resolveRoles(
|
||||
workflow: ComfyWorkflowJSON,
|
||||
templateId?: string,
|
||||
nodeDefLookup: NodeDefLookup = () => null
|
||||
): ResolvedRoles {
|
||||
const graph = fromWorkflowJson(workflow)
|
||||
const host = subgraphHost(graph)
|
||||
|
||||
const prompt = host
|
||||
? resolveSubgraphPrompt(host.node.id, host.subgraph)
|
||||
: resolveTopLevelPrompt(graph)
|
||||
|
||||
const override = overrideFor(templateId)
|
||||
if (override) return applyOverride(graph, override, prompt)
|
||||
|
||||
const sink = findSink(graph, nodeDefLookup)
|
||||
const source = findSource(graph)
|
||||
const engine = firstSampler(host ? host.subgraph.nodes : graph.nodes)
|
||||
|
||||
return {
|
||||
source: source ? toNodeRole(source.id) : null,
|
||||
prompt,
|
||||
engine: engine ? toNodeRole(engine.id) : null,
|
||||
sink: sink ? toNodeRole(sink.node.id) : null,
|
||||
mediaKind: sink?.mediaKind ?? 'image'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Projection from serialized ComfyWorkflowJSON onto the resolver view. ---
|
||||
|
||||
interface RawNode {
|
||||
id: number | string
|
||||
type?: string
|
||||
inputs?: RawInput[]
|
||||
outputs?: { links?: number[] | null }[]
|
||||
}
|
||||
|
||||
interface RawInput {
|
||||
name?: string
|
||||
link?: number | null
|
||||
}
|
||||
|
||||
interface RawExposedInput {
|
||||
name?: string
|
||||
type?: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
interface RawSubgraph {
|
||||
id: string
|
||||
nodes?: RawNode[]
|
||||
links?: unknown
|
||||
inputs?: RawExposedInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a serialized workflow this resolver reads. `ComfyWorkflowJSON`'s
|
||||
* own type does not statically expose `definitions.subgraphs[].nodes/links`
|
||||
* (its recursive subgraph type omits them), so the projection asserts this
|
||||
* narrower view once at {@link fromWorkflowJson} and re-parses links loosely.
|
||||
*/
|
||||
interface RawWorkflow {
|
||||
nodes?: RawNode[]
|
||||
links?: unknown
|
||||
definitions?: { subgraphs?: RawSubgraph[] }
|
||||
}
|
||||
|
||||
interface RawLink {
|
||||
id: number
|
||||
originId: NodeId
|
||||
originSlot: number
|
||||
targetId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
const SUBGRAPH_INPUT_ORIGIN = '-10'
|
||||
|
||||
function readLink(raw: unknown): RawLink | null {
|
||||
if (Array.isArray(raw) && raw.length >= 6) {
|
||||
return {
|
||||
id: Number(raw[0]),
|
||||
originId: toNodeId(String(raw[1])),
|
||||
originSlot: Number(raw[2]),
|
||||
targetId: toNodeId(String(raw[3])),
|
||||
targetSlot: Number(raw[4])
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object' && 'origin_id' in raw) {
|
||||
const linkObject = raw as Record<string, unknown>
|
||||
return {
|
||||
id: Number(linkObject.id),
|
||||
originId: toNodeId(String(linkObject.origin_id)),
|
||||
originSlot: Number(linkObject.origin_slot),
|
||||
targetId: toNodeId(String(linkObject.target_id)),
|
||||
targetSlot: Number(linkObject.target_slot)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readLinks(raw: unknown): RawLink[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(readLink).filter((l): l is RawLink => l !== null)
|
||||
}
|
||||
|
||||
function originFor(
|
||||
input: RawInput,
|
||||
links: RawLink[]
|
||||
): WorkflowLinkOrigin | null {
|
||||
if (typeof input.link !== 'number') return null
|
||||
const link = links.find((l) => l.id === input.link)
|
||||
if (!link) return null
|
||||
if (String(link.originId) === SUBGRAPH_INPUT_ORIGIN) {
|
||||
return { kind: 'boundary', slot: link.originSlot }
|
||||
}
|
||||
return { kind: 'node', nodeId: link.originId, slot: link.originSlot }
|
||||
}
|
||||
|
||||
function readNode(
|
||||
raw: RawNode,
|
||||
links: RawLink[],
|
||||
subgraphIds: Set<string>
|
||||
): WorkflowGraphNode {
|
||||
const id = toNodeId(raw.id)
|
||||
const type = raw.type ?? ''
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
subgraphId: subgraphIds.has(type) ? type : null,
|
||||
hasOutgoingLinks: (raw.outputs ?? []).some(
|
||||
(o) => (o.links?.length ?? 0) > 0
|
||||
),
|
||||
inputs: (raw.inputs ?? []).map((input) => ({
|
||||
name: input.name ?? '',
|
||||
origin: originFor(input, links)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrows the loosely-typed serialized workflow into the resolver's view. */
|
||||
export function fromWorkflowJson(workflow: ComfyWorkflowJSON): WorkflowGraph {
|
||||
const raw: RawWorkflow = workflow
|
||||
|
||||
const definitions = raw.definitions?.subgraphs ?? []
|
||||
const subgraphIds = new Set(definitions.map((d) => d.id))
|
||||
const topLinks = readLinks(raw.links)
|
||||
|
||||
const subgraphs: WorkflowSubgraph[] = definitions.map((def) => {
|
||||
const innerLinks = readLinks(def.links)
|
||||
return {
|
||||
id: def.id,
|
||||
nodes: (def.nodes ?? []).map((n) => readNode(n, innerLinks, subgraphIds)),
|
||||
exposedInputs: (def.inputs ?? []).map((input, slot) => ({
|
||||
name: input.name ?? '',
|
||||
type: input.type ?? '',
|
||||
label: input.label ?? null,
|
||||
slot
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: (raw.nodes ?? []).map((n) => readNode(n, topLinks, subgraphIds)),
|
||||
subgraphs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const canvasObj = {
|
||||
setGraph: vi.fn()
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canvas: null as { setGraph: ReturnType<typeof vi.fn> } | null,
|
||||
rootGraph: { id: 'root' }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: mocks.canvas })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mocks.rootGraph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { restoreView } from './subgraphNavigation'
|
||||
|
||||
describe('subgraphNavigation', () => {
|
||||
beforeEach(() => {
|
||||
canvasObj.setGraph.mockReset()
|
||||
mocks.canvas = canvasObj
|
||||
})
|
||||
|
||||
it('returns the canvas to the root graph', () => {
|
||||
restoreView()
|
||||
expect(canvasObj.setGraph).toHaveBeenCalledWith(mocks.rootGraph)
|
||||
})
|
||||
|
||||
it('does not throw without a canvas', () => {
|
||||
mocks.canvas = null
|
||||
expect(() => restoreView()).not.toThrow()
|
||||
})
|
||||
})
|
||||
11
src/renderer/extensions/onboardingTour/subgraphNavigation.ts
Normal file
11
src/renderer/extensions/onboardingTour/subgraphNavigation.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Return the canvas to the root graph. Called defensively before each step in
|
||||
* case the user opened a subgraph manually — view-only, no graph mutation
|
||||
* (ADR-0008). The tour itself never enters a subgraph.
|
||||
*/
|
||||
export function restoreView() {
|
||||
useCanvasStore().canvas?.setGraph(app.rootGraph)
|
||||
}
|
||||
120
src/renderer/extensions/onboardingTour/tourSequence.test.ts
Normal file
120
src/renderer/extensions/onboardingTour/tourSequence.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type { ResolvedRoles } from './tourSequence'
|
||||
import { sequenceBuilder, shapeOf } from './tourSequence'
|
||||
|
||||
function nodeRole(id: number) {
|
||||
return { nodeId: toNodeId(id) }
|
||||
}
|
||||
|
||||
function promptRole(subgraphId: number, innerId: number) {
|
||||
return {
|
||||
subgraphNodeId: toNodeId(subgraphId),
|
||||
innerNodeId: toNodeId(innerId),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
}
|
||||
}
|
||||
|
||||
const i2vRoles: ResolvedRoles = {
|
||||
source: nodeRole(97),
|
||||
prompt: promptRole(129, 93),
|
||||
engine: nodeRole(86),
|
||||
sink: nodeRole(108),
|
||||
mediaKind: 'video'
|
||||
}
|
||||
|
||||
const t2iRoles: ResolvedRoles = {
|
||||
source: null,
|
||||
prompt: promptRole(57, 27),
|
||||
engine: nodeRole(3),
|
||||
sink: nodeRole(9),
|
||||
mediaKind: 'image'
|
||||
}
|
||||
|
||||
describe('sequenceBuilder', () => {
|
||||
it('builds Upload → Prompt → Run → Result for an I2V graph', () => {
|
||||
const steps = sequenceBuilder(i2vRoles)
|
||||
|
||||
expect(steps.map((s) => s.kind)).toEqual([
|
||||
'upload',
|
||||
'prompt',
|
||||
'run',
|
||||
'result'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the Upload step when there is no source (T2I)', () => {
|
||||
const steps = sequenceBuilder(t2iRoles)
|
||||
|
||||
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run', 'result'])
|
||||
})
|
||||
|
||||
it('targets the source node on the Upload step', () => {
|
||||
const upload = sequenceBuilder(i2vRoles).find((s) => s.kind === 'upload')
|
||||
|
||||
expect(upload?.nodeId).toBe(toNodeId(97))
|
||||
})
|
||||
|
||||
it('carries the prompt path on the Prompt step', () => {
|
||||
const prompt = sequenceBuilder(t2iRoles).find((s) => s.kind === 'prompt')
|
||||
|
||||
expect(prompt?.prompt).toEqual(promptRole(57, 27))
|
||||
})
|
||||
|
||||
it('carries the sink and media kind on the Result step', () => {
|
||||
const result = sequenceBuilder(i2vRoles).find((s) => s.kind === 'result')
|
||||
|
||||
expect(result?.nodeId).toBe(toNodeId(108))
|
||||
expect(result?.mediaKind).toBe('video')
|
||||
})
|
||||
|
||||
it('omits the Prompt step when the prompt role is unresolved', () => {
|
||||
const steps = sequenceBuilder({ ...t2iRoles, prompt: null })
|
||||
|
||||
expect(steps.map((s) => s.kind)).toEqual(['run', 'result'])
|
||||
})
|
||||
|
||||
it('omits the Result step when the sink role is unresolved', () => {
|
||||
const steps = sequenceBuilder({ ...t2iRoles, sink: null })
|
||||
|
||||
expect(steps.map((s) => s.kind)).toEqual(['prompt', 'run'])
|
||||
})
|
||||
|
||||
it('always includes the Run step, which targets no node', () => {
|
||||
const run = sequenceBuilder(t2iRoles).find((s) => s.kind === 'run')
|
||||
|
||||
expect(run).toBeDefined()
|
||||
expect(run?.nodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('tags the Prompt and Upload steps with the shape that picks their copy', () => {
|
||||
const steps = sequenceBuilder(i2vRoles)
|
||||
|
||||
expect(steps.find((s) => s.kind === 'upload')?.shape).toBe('i2v')
|
||||
expect(steps.find((s) => s.kind === 'prompt')?.shape).toBe('i2v')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shapeOf', () => {
|
||||
it('is t2i when nothing feeds the prompt', () => {
|
||||
expect(shapeOf(t2iRoles)).toBe('t2i')
|
||||
})
|
||||
|
||||
it('is i2v when a source image drives video', () => {
|
||||
expect(shapeOf(i2vRoles)).toBe('i2v')
|
||||
})
|
||||
|
||||
it('is image-edit when a source image drives an image', () => {
|
||||
// The prompt edits the picture rather than describing a new one, so this
|
||||
// must not collapse into t2i: the copy would tell the wrong story.
|
||||
expect(shapeOf({ ...i2vRoles, mediaKind: 'image' })).toBe('image-edit')
|
||||
})
|
||||
|
||||
it('is other when the tour cannot tell what the workflow does', () => {
|
||||
expect(shapeOf({ ...t2iRoles, sink: null })).toBe('other')
|
||||
expect(shapeOf({ ...t2iRoles, prompt: null })).toBe('other')
|
||||
})
|
||||
})
|
||||
96
src/renderer/extensions/onboardingTour/tourSequence.ts
Normal file
96
src/renderer/extensions/onboardingTour/tourSequence.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
OnboardingTourShape,
|
||||
OnboardingTourStepKey
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
/**
|
||||
* The kind of media a resolved sink produces, used to pick the Result step's
|
||||
* renderer (image vs video player) and to shape telemetry.
|
||||
*/
|
||||
export type MediaKind = 'image' | 'video'
|
||||
|
||||
/**
|
||||
* How to reach the editable prompt widget. The widget is (in every known
|
||||
* template) nested inside a subgraph node, so the resolver returns the path to
|
||||
* it plus the subgraph's exposed `prompt` input port as a fallback spotlight
|
||||
* target when programmatic focus of the inner widget fails.
|
||||
*/
|
||||
export interface PromptRole {
|
||||
subgraphNodeId: NodeId
|
||||
innerNodeId: NodeId
|
||||
widgetName: string
|
||||
/** Name of the exposed input port on the collapsed subgraph node. */
|
||||
portFallback: string | null
|
||||
}
|
||||
|
||||
/** A resolved graph node targeted by a tour step. */
|
||||
export interface NodeRole {
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles the resolver extracts from a loaded graph. Any role may be null
|
||||
* when it cannot be resolved; the sequence builder omits the corresponding
|
||||
* step rather than failing (graceful degradation).
|
||||
*/
|
||||
export interface ResolvedRoles {
|
||||
/** Top-level input image node (absent for text-to-image → Upload step skipped). */
|
||||
source: NodeRole | null
|
||||
prompt: PromptRole | null
|
||||
engine: NodeRole | null
|
||||
sink: NodeRole | null
|
||||
mediaKind: MediaKind
|
||||
}
|
||||
|
||||
/** Step kinds are the telemetry step keys, so the two can never drift. */
|
||||
type TourStepKind = OnboardingTourStepKey
|
||||
|
||||
/**
|
||||
* What the workflow does, derived from the roles that resolved. Drives both the
|
||||
* step copy (the prompt means something different in each) and telemetry tags.
|
||||
*/
|
||||
export function shapeOf(roles: ResolvedRoles): OnboardingTourShape {
|
||||
if (!roles.prompt || !roles.sink) return 'other'
|
||||
if (!roles.source) return 't2i'
|
||||
return roles.mediaKind === 'video' ? 'i2v' : 'image-edit'
|
||||
}
|
||||
|
||||
export interface TourStep {
|
||||
kind: TourStepKind
|
||||
/** Spotlight target; null for Run, which points at the toolbar button. */
|
||||
nodeId: NodeId | null
|
||||
/** The subgraph-aware path to the editable prompt widget. */
|
||||
prompt?: PromptRole
|
||||
/** Picks the Result renderer. */
|
||||
mediaKind?: MediaKind
|
||||
/** Picks the Upload/Prompt copy. */
|
||||
shape?: OnboardingTourShape
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns resolved roles into the ordered step list by shape: an Upload step only
|
||||
* when a source image resolved, then always Prompt → Run → Result. A role that
|
||||
* failed to resolve omits its step instead of crashing.
|
||||
*/
|
||||
export function sequenceBuilder(roles: ResolvedRoles): TourStep[] {
|
||||
const steps: TourStep[] = []
|
||||
const shape = shapeOf(roles)
|
||||
|
||||
if (roles.source) {
|
||||
steps.push({ kind: 'upload', nodeId: roles.source.nodeId, shape })
|
||||
}
|
||||
if (roles.prompt) {
|
||||
steps.push({ kind: 'prompt', nodeId: null, prompt: roles.prompt, shape })
|
||||
}
|
||||
steps.push({ kind: 'run', nodeId: null })
|
||||
if (roles.sink) {
|
||||
steps.push({
|
||||
kind: 'result',
|
||||
nodeId: roles.sink.nodeId,
|
||||
mediaKind: roles.mediaKind
|
||||
})
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
56
src/renderer/extensions/onboardingTour/tutorialCards.ts
Normal file
56
src/renderer/extensions/onboardingTour/tutorialCards.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { CuratedTemplateId } from './roleResolver'
|
||||
|
||||
/**
|
||||
* The curated templates the Getting Started screen loads. Tutorial thumbnails are
|
||||
* drawn from this set so they only ever reference a template that resolves.
|
||||
*/
|
||||
export const CURATED_TEMPLATE_IDS = [
|
||||
'image_krea2_turbo_t2i',
|
||||
'image_z_image_turbo',
|
||||
'video_ltx2_3_i2v',
|
||||
'video_wan2_2_14B_i2v'
|
||||
] as const satisfies readonly CuratedTemplateId[]
|
||||
|
||||
type LoadedTemplateId = (typeof CURATED_TEMPLATE_IDS)[number]
|
||||
|
||||
/**
|
||||
* Tutorial cards on the Getting Started "Tutorials" tab. Each reuses a loaded
|
||||
* template's server-served thumbnail for its image and opens `url` in a new tab.
|
||||
* Thumbnails are offset from the Templates tab's order so the two grids don't
|
||||
* read as the same four images in the same places.
|
||||
*/
|
||||
export interface TutorialCard {
|
||||
id: string
|
||||
titleKey: string
|
||||
url: string
|
||||
thumbnailTemplate: LoadedTemplateId
|
||||
}
|
||||
|
||||
export const TUTORIAL_BADGE_ICON = 'icon-[lucide--graduation-cap]'
|
||||
|
||||
export const tutorialCards: readonly TutorialCard[] = [
|
||||
{
|
||||
id: 'interface-overview',
|
||||
titleKey: 'onboardingTour.gettingStarted.tutorials.interfaceOverview',
|
||||
url: 'https://docs.comfy.org/interface/overview',
|
||||
thumbnailTemplate: 'image_krea2_turbo_t2i'
|
||||
},
|
||||
{
|
||||
id: 'text-to-image',
|
||||
titleKey: 'onboardingTour.gettingStarted.tutorials.textToImage',
|
||||
url: 'https://docs.comfy.org/tutorials/basic/text-to-image',
|
||||
thumbnailTemplate: 'video_ltx2_3_i2v'
|
||||
},
|
||||
{
|
||||
id: 'image-to-image',
|
||||
titleKey: 'onboardingTour.gettingStarted.tutorials.imageToImage',
|
||||
url: 'https://docs.comfy.org/tutorials/basic/image-to-image',
|
||||
thumbnailTemplate: 'video_wan2_2_14B_i2v'
|
||||
},
|
||||
{
|
||||
id: 'inpaint',
|
||||
titleKey: 'onboardingTour.gettingStarted.tutorials.inpaint',
|
||||
url: 'https://docs.comfy.org/tutorials/basic/inpaint',
|
||||
thumbnailTemplate: 'image_z_image_turbo'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,851 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { effectScope, nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import type * as CanvasSpotlightAdapter from './canvasSpotlightAdapter'
|
||||
import type { PromptRole, ResolvedRoles, TourStep } from './tourSequence'
|
||||
|
||||
const activeState = {} as ComfyWorkflowJSON
|
||||
|
||||
const promptRole: PromptRole = {
|
||||
subgraphNodeId: toNodeId(10),
|
||||
innerNodeId: toNodeId(27),
|
||||
widgetName: 'text',
|
||||
portFallback: 'prompt'
|
||||
}
|
||||
|
||||
const imageEditRoles: ResolvedRoles = {
|
||||
source: { nodeId: toNodeId(1) },
|
||||
prompt: promptRole,
|
||||
engine: { nodeId: toNodeId(3) },
|
||||
sink: { nodeId: toNodeId(9) },
|
||||
mediaKind: 'image'
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isCloud: true,
|
||||
isSubscriptionEnabled: vi.fn(() => true),
|
||||
isNewUser: vi.fn<() => boolean | null>(() => true),
|
||||
onboardingTourEnabled: true,
|
||||
tutorialCompleted: false as boolean,
|
||||
resolveTourRoles: vi.fn(),
|
||||
restoreView: vi.fn(),
|
||||
nodesPresent: vi.fn(() => true),
|
||||
canvasTransformValid: vi.fn(() => true),
|
||||
activeWorkflowState: {} as ComfyWorkflowJSON | null,
|
||||
storeStart: vi.fn(),
|
||||
storeAdvance: vi.fn(),
|
||||
storeEnd: vi.fn(),
|
||||
steps: [] as TourStep[],
|
||||
stepIndex: { value: 0 },
|
||||
phase: { value: 'active' as 'idle' | 'active' },
|
||||
resolvedRoles: { value: null as ResolvedRoles | null },
|
||||
telemetry: {
|
||||
trackOnboardingTourStarted: vi.fn(),
|
||||
trackOnboardingTourStepViewed: vi.fn(),
|
||||
trackOnboardingTourSkipped: vi.fn(),
|
||||
trackOnboardingTourCompleted: vi.fn(),
|
||||
trackOnboardingTourRunTriggered: vi.fn(),
|
||||
trackOnboardingTourUpgradeShown: vi.fn()
|
||||
},
|
||||
hasFunds: true as boolean,
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
storeShowNudge: vi.fn(),
|
||||
storeCaptureResultMedia: vi.fn()
|
||||
}))
|
||||
|
||||
const upgradeModalOpen = await vi.hoisted(async () => {
|
||||
const { ref } = await import('vue')
|
||||
return ref(false)
|
||||
})
|
||||
|
||||
/** Captures the api-bus handlers the controller registers so tests can dispatch a specific run outcome. */
|
||||
type ApiEventHandler = (event: CustomEvent) => void
|
||||
const apiEventHandlers = vi.hoisted(() => new Map<string, ApiEventHandler>())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn((event: string, handler: ApiEventHandler) => {
|
||||
apiEventHandlers.set(event, handler)
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string) => {
|
||||
apiEventHandlers.delete(event)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
function emitExecution(event: string) {
|
||||
apiEventHandlers.get(event)?.(new CustomEvent(event, { detail: {} }))
|
||||
}
|
||||
|
||||
/** Simulate a run finishing successfully. */
|
||||
async function runJob() {
|
||||
emitExecution('execution_success')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
/** Simulate a run ending with a non-success outcome. */
|
||||
async function failJob(event: 'execution_error' | 'execution_interrupted') {
|
||||
emitExecution(event)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
/** Click the toolbar Run button (the Run step's advance trigger). */
|
||||
function clickRunButton() {
|
||||
const button = document.createElement('button')
|
||||
button.setAttribute('data-testid', 'queue-button')
|
||||
document.body.append(button)
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
button.remove()
|
||||
}
|
||||
|
||||
/** Click an element that is not the Run button (must not advance the step). */
|
||||
function clickElsewhere() {
|
||||
const el = document.createElement('div')
|
||||
document.body.append(el)
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
el.remove()
|
||||
}
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mocks.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isSubscriptionEnabled: mocks.isSubscriptionEnabled
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/useNewUserService', () => ({
|
||||
useNewUserService: () => ({ isNewUser: mocks.isNewUser })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get onboardingTourEnabled() {
|
||||
return mocks.onboardingTourEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.TutorialCompleted' ? mocks.tutorialCompleted : undefined,
|
||||
set: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscription: {
|
||||
get value() {
|
||||
return { hasFunds: mocks.hasFunds }
|
||||
}
|
||||
},
|
||||
showSubscriptionDialog: mocks.showSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
isDialogOpen: () => upgradeModalOpen.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
get activeState() {
|
||||
return mocks.activeWorkflowState
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./subgraphNavigation', () => ({
|
||||
restoreView: mocks.restoreView
|
||||
}))
|
||||
|
||||
vi.mock('./roleResolution', () => ({
|
||||
resolveTourRoles: mocks.resolveTourRoles
|
||||
}))
|
||||
|
||||
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CanvasSpotlightAdapter>()),
|
||||
nodesPresent: mocks.nodesPresent,
|
||||
canvasTransformValid: mocks.canvasTransformValid
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => mocks.telemetry
|
||||
}))
|
||||
|
||||
vi.mock('./onboardingTourStore', () => ({
|
||||
isUpgradeModalOpen: () => upgradeModalOpen.value,
|
||||
useOnboardingTourStore: () => ({
|
||||
start: mocks.storeStart,
|
||||
advance: mocks.storeAdvance,
|
||||
back: vi.fn(),
|
||||
end: mocks.storeEnd,
|
||||
showNudge: mocks.storeShowNudge,
|
||||
captureResultMedia: mocks.storeCaptureResultMedia,
|
||||
get phase() {
|
||||
return mocks.phase.value
|
||||
},
|
||||
get steps() {
|
||||
return mocks.steps
|
||||
},
|
||||
get stepIndex() {
|
||||
return mocks.stepIndex.value
|
||||
},
|
||||
get currentStep() {
|
||||
return mocks.steps[mocks.stepIndex.value] ?? null
|
||||
},
|
||||
get totalSteps() {
|
||||
return mocks.steps.length
|
||||
},
|
||||
get resolvedRoles() {
|
||||
return mocks.resolvedRoles.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
import { useOnboardingTourController } from './useOnboardingTourController'
|
||||
|
||||
describe('useOnboardingTourController.shouldStartTour', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mocks.isCloud = true
|
||||
mocks.isSubscriptionEnabled.mockReturnValue(true)
|
||||
mocks.isNewUser.mockReturnValue(true)
|
||||
mocks.onboardingTourEnabled = true
|
||||
mocks.tutorialCompleted = false
|
||||
mocks.activeWorkflowState = activeState
|
||||
mocks.steps = []
|
||||
mocks.stepIndex.value = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts when every condition holds', () => {
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start off the Cloud build', () => {
|
||||
mocks.isCloud = false
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start when subscription mode is off', () => {
|
||||
mocks.isSubscriptionEnabled.mockReturnValue(false)
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start for a returning user', () => {
|
||||
mocks.isNewUser.mockReturnValue(false)
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start before the new-user verdict is known', () => {
|
||||
mocks.isNewUser.mockReturnValue(null)
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start when the feature flag is off', () => {
|
||||
mocks.onboardingTourEnabled = false
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(false)
|
||||
})
|
||||
|
||||
it('still starts when the tutorial flag is already set but the user is new', () => {
|
||||
mocks.tutorialCompleted = true
|
||||
mocks.isNewUser.mockReturnValue(true)
|
||||
expect(useOnboardingTourController().shouldStartTour()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnboardingTourController.start', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mocks.isCloud = true
|
||||
mocks.isSubscriptionEnabled.mockReturnValue(true)
|
||||
mocks.isNewUser.mockReturnValue(true)
|
||||
mocks.onboardingTourEnabled = true
|
||||
mocks.tutorialCompleted = false
|
||||
mocks.activeWorkflowState = activeState
|
||||
mocks.storeStart.mockReset()
|
||||
mocks.storeEnd.mockReset()
|
||||
mocks.storeAdvance.mockReset()
|
||||
mocks.steps = []
|
||||
mocks.stepIndex.value = 0
|
||||
mocks.phase.value = 'active'
|
||||
mocks.resolvedRoles.value = imageEditRoles
|
||||
mocks.hasFunds = true
|
||||
mocks.showSubscriptionDialog.mockReset()
|
||||
upgradeModalOpen.value = false
|
||||
mocks.storeShowNudge.mockReset()
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered.mockReset()
|
||||
mocks.telemetry.trackOnboardingTourUpgradeShown.mockReset()
|
||||
mocks.storeCaptureResultMedia.mockReset()
|
||||
apiEventHandlers.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useOnboardingTourController().end('skip')
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not start the store when gating fails', async () => {
|
||||
mocks.isNewUser.mockReturnValue(false)
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
expect(mocks.storeStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes the active workflow and starts the store', async () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
|
||||
expect(mocks.storeStart).toHaveBeenCalledWith(
|
||||
activeState,
|
||||
'image_z_image_turbo'
|
||||
)
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: 'image_z_image_turbo',
|
||||
shape: 'image-edit'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the resolved shape (t2i has no source image)', async () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
mocks.resolvedRoles.value = { ...imageEditRoles, source: null }
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ shape: 't2i' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the entry to getting_started when none is passed', async () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entry: 'getting_started' })
|
||||
)
|
||||
})
|
||||
|
||||
it('threads the template_url entry for a ?template= arrival', async () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start(
|
||||
'image_z_image_turbo',
|
||||
'template_url'
|
||||
)
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: 'image_z_image_turbo',
|
||||
entry: 'template_url'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('threads the share_url entry with no template id for a ?share= arrival', async () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
mocks.resolvedRoles.value = { ...imageEditRoles, prompt: null, sink: null }
|
||||
|
||||
await useOnboardingTourController().start(undefined, 'share_url')
|
||||
|
||||
expect(mocks.storeStart).toHaveBeenCalledWith(activeState, undefined)
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: undefined,
|
||||
shape: 'other',
|
||||
entry: 'share_url'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ends the tour when no active workflow is present', async () => {
|
||||
mocks.activeWorkflowState = null
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
expect(mocks.storeStart).not.toHaveBeenCalled()
|
||||
expect(mocks.storeEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports completion when the tour finishes', () => {
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
useOnboardingTourController().end('done')
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spotlights the collapsed port on the prompt step without entering a subgraph', async () => {
|
||||
mocks.steps = [{ kind: 'prompt', nodeId: null, prompt: promptRole }]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
// The tour never opens the subgraph; the prompt step restores the root view
|
||||
// so the collapsed host's exposed port stays spotlit.
|
||||
expect(mocks.restoreView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never auto-advances a step — the user always drives Next', async () => {
|
||||
vi.useFakeTimers()
|
||||
// Even a purely informational step (upload) must not advance on its own.
|
||||
mocks.steps = [
|
||||
{ kind: 'upload', nodeId: toNodeId(1) },
|
||||
{ kind: 'prompt', nodeId: null, prompt: promptRole }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
|
||||
expect(mocks.storeAdvance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates a no-funds user at the Run step: upgrade modal, nudge, end', async () => {
|
||||
mocks.hasFunds = false
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
// The gate opens the real upgrade modal, so the paywall watcher fires too;
|
||||
// ending must stay one-shot across both paths.
|
||||
mocks.showSubscriptionDialog.mockImplementation(() => {
|
||||
upgradeModalOpen.value = true
|
||||
})
|
||||
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledWith({
|
||||
reason: 'out_of_credits'
|
||||
})
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourUpgradeShown
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ template_id: 'image_z_image_turbo' })
|
||||
)
|
||||
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
|
||||
expect(mocks.storeEnd).toHaveBeenCalledOnce()
|
||||
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates via store.end when the user cannot fund a run', async () => {
|
||||
mocks.hasFunds = false
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
expect(mocks.storeEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a funded user reach the Run step without reporting a run yet', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
expect(mocks.storeEnd).not.toHaveBeenCalled()
|
||||
// Merely arriving at the Run step is not a run — telemetry waits for a real
|
||||
// execution so nothing can fabricate an activation event.
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ends the tour when the upgrade modal opens mid-run so it never hangs', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
expect(mocks.storeEnd).not.toHaveBeenCalled()
|
||||
|
||||
// A no-subscription run is blocked by the paywall before it queues, so no
|
||||
// execution event fires; the opening modal must end the tour on its own.
|
||||
upgradeModalOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(mocks.storeEnd).toHaveBeenCalled()
|
||||
expect(mocks.storeShowNudge).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the run only when a run actually completes', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
// Merely starting the tour reports nothing — the run must finish first.
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).not.toHaveBeenCalled()
|
||||
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ status: 'success' }))
|
||||
})
|
||||
|
||||
it('captures the media when the run reports after the click advanced to Result', async () => {
|
||||
// Production ordering: the Run click advances synchronously, so the execution
|
||||
// event lands while Result is current. `advance` is a spy, so move it by hand.
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
mocks.stepIndex.value = 1
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ status: 'success' }))
|
||||
})
|
||||
|
||||
it('ignores execution events that arrive before the Run step', async () => {
|
||||
// A run kicked off from an earlier step must not resolve the Result or report.
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'prompt', nodeId: null },
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
mocks.stepIndex.value = 0
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the run at most once across repeated runs', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await runJob()
|
||||
await runJob()
|
||||
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports the true status and skips media capture when a run errors', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await failJob('execution_error')
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' }))
|
||||
})
|
||||
|
||||
it('reports the true status and skips media capture when a run is interrupted', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await failJob('execution_interrupted')
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).not.toHaveBeenCalled()
|
||||
expect(
|
||||
mocks.telemetry.trackOnboardingTourRunTriggered
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ status: 'interrupted' }))
|
||||
})
|
||||
|
||||
it('completes the tour when a run finishes on the terminal Run step', async () => {
|
||||
// No sink resolved → Run is the last step; a finished run must fire Completed.
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
await runJob()
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourCompleted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not complete on run when a Result step follows', async () => {
|
||||
mocks.hasFunds = true
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
await runJob()
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourCompleted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advances off the Run step the instant the Run button is clicked', async () => {
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
expect(mocks.storeAdvance).not.toHaveBeenCalled()
|
||||
|
||||
clickRunButton()
|
||||
|
||||
// The click alone advances — no waiting for the run to finish.
|
||||
expect(mocks.storeAdvance).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not advance when a click misses the Run button', async () => {
|
||||
// The listener must discriminate on the selector, not advance on any click.
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
clickElsewhere()
|
||||
|
||||
expect(mocks.storeAdvance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures the media when the run finishes (not the step advance)', async () => {
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeCaptureResultMedia).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still advances after the launching component unmounts', async () => {
|
||||
// start() runs inside the Getting Started screen's setup, which unmounts right
|
||||
// after; the Run-click listener must survive that teardown.
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
|
||||
const launchingScope = effectScope()
|
||||
await launchingScope.run(async () => {
|
||||
await useOnboardingTourController().start()
|
||||
})
|
||||
launchingScope.stop() // the Getting Started screen unmounts
|
||||
|
||||
clickRunButton()
|
||||
|
||||
expect(mocks.storeAdvance).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not advance on the Result step when a run completes', async () => {
|
||||
// Started already on Result: no Run-click listener is bound, so a completing
|
||||
// run resolves media but never advances the step.
|
||||
mocks.steps = [
|
||||
{ kind: 'run', nodeId: null },
|
||||
{ kind: 'result', nodeId: toNodeId(9), mediaKind: 'image' }
|
||||
]
|
||||
mocks.stepIndex.value = 1
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeAdvance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only gates when the funds check reaches the Run step', async () => {
|
||||
mocks.hasFunds = false
|
||||
mocks.steps = [
|
||||
{ kind: 'prompt', nodeId: null, prompt: promptRole },
|
||||
{ kind: 'run', nodeId: null }
|
||||
]
|
||||
|
||||
await useOnboardingTourController().start()
|
||||
|
||||
// First step is Prompt, not Run — no gate yet.
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnboardingTourController post-run nudge', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mocks.isCloud = true
|
||||
mocks.isSubscriptionEnabled.mockReturnValue(true)
|
||||
mocks.isNewUser.mockReturnValue(true)
|
||||
mocks.onboardingTourEnabled = true
|
||||
mocks.activeWorkflowState = activeState
|
||||
mocks.storeStart.mockReset()
|
||||
mocks.storeShowNudge.mockReset()
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
mocks.stepIndex.value = 0
|
||||
mocks.phase.value = 'active'
|
||||
mocks.hasFunds = true
|
||||
apiEventHandlers.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useOnboardingTourController().end('skip')
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('surfaces the nudge on the first completed run after the tour starts', async () => {
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('surfaces the nudge only once even across repeated runs', async () => {
|
||||
await useOnboardingTourController().start('image_z_image_turbo')
|
||||
|
||||
await runJob()
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeShowNudge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not surface the nudge before any tour has started', async () => {
|
||||
// No tour is live, so no run watcher is registered; a stray job completion
|
||||
// must not pop the nudge.
|
||||
await runJob()
|
||||
|
||||
expect(mocks.storeShowNudge).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnboardingTourController.beginTour', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mocks.isCloud = true
|
||||
mocks.isSubscriptionEnabled.mockReturnValue(true)
|
||||
mocks.isNewUser.mockReturnValue(true)
|
||||
mocks.onboardingTourEnabled = true
|
||||
mocks.activeWorkflowState = activeState
|
||||
mocks.resolveTourRoles.mockReturnValue(imageEditRoles)
|
||||
mocks.nodesPresent.mockReturnValue(true)
|
||||
mocks.canvasTransformValid.mockReturnValue(true)
|
||||
mocks.storeStart.mockReset()
|
||||
mocks.steps = [{ kind: 'run', nodeId: null }]
|
||||
mocks.stepIndex.value = 0
|
||||
mocks.phase.value = 'idle'
|
||||
mocks.resolvedRoles.value = imageEditRoles
|
||||
mocks.hasFunds = true
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
apiEventHandlers.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useOnboardingTourController().end('skip')
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts the tour once the live graph holds the resolved nodes', async () => {
|
||||
await useOnboardingTourController().beginTour({
|
||||
templateId: 'image_z_image_turbo'
|
||||
})
|
||||
|
||||
expect(mocks.storeStart).toHaveBeenCalledWith(
|
||||
activeState,
|
||||
'image_z_image_turbo'
|
||||
)
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: 'image_z_image_turbo',
|
||||
entry: 'getting_started'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('threads the share_url entry through when launched from a URL', async () => {
|
||||
await useOnboardingTourController().beginTour({ entry: 'share_url' })
|
||||
|
||||
expect(mocks.telemetry.trackOnboardingTourStarted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entry: 'share_url' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not start when gating fails', async () => {
|
||||
mocks.isNewUser.mockReturnValue(false)
|
||||
|
||||
await useOnboardingTourController().beginTour({ templateId: 'x' })
|
||||
|
||||
expect(mocks.storeStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start a second tour while one is already active', async () => {
|
||||
mocks.phase.value = 'active'
|
||||
|
||||
await useOnboardingTourController().beginTour({ templateId: 'x' })
|
||||
|
||||
expect(mocks.storeStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts degraded rather than trapping when the graph never becomes ready', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.nodesPresent.mockReturnValue(false)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const pending = useOnboardingTourController().beginTour({ templateId: 'x' })
|
||||
await vi.runAllTimersAsync()
|
||||
await pending
|
||||
|
||||
expect(mocks.storeStart).toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,337 @@
|
||||
import { createSharedComposable, until, useEventListener } from '@vueuse/core'
|
||||
import { computed, effectScope, ref, watch } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import type {
|
||||
OnboardingTourEntry,
|
||||
OnboardingTourRunStatus,
|
||||
OnboardingTourShape
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { OnboardingCandidateDeps } from '@/platform/workflow/persistence/onboardingEntryStore'
|
||||
import { isOnboardingCandidate } from '@/platform/workflow/persistence/onboardingEntryStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import {
|
||||
RUN_BUTTON_SELECTOR,
|
||||
canvasTransformValid,
|
||||
nodesPresent
|
||||
} from './canvasSpotlightAdapter'
|
||||
import { resolveTourRoles } from './roleResolution'
|
||||
import {
|
||||
isUpgradeModalOpen,
|
||||
useOnboardingTourStore
|
||||
} from './onboardingTourStore'
|
||||
import type { TourEndReason } from './onboardingTourStore'
|
||||
import { restoreView } from './subgraphNavigation'
|
||||
import { shapeOf } from './tourSequence'
|
||||
import type { ResolvedRoles } from './tourSequence'
|
||||
|
||||
/** Budget for the serialized snapshot to appear (guards the URL blank-load race). */
|
||||
const ACTIVE_STATE_TIMEOUT_MS = 1500
|
||||
/** Budget for the live canvas graph to contain the resolved role nodes. */
|
||||
const READY_TIMEOUT_MS = 3000
|
||||
|
||||
/** Top-level nodes the sequence actually spotlights (upload, prompt host, sink). */
|
||||
function roleAnchorIds(roles: ResolvedRoles): NodeId[] {
|
||||
return [
|
||||
roles.source?.nodeId,
|
||||
roles.prompt?.subgraphNodeId,
|
||||
roles.sink?.nodeId
|
||||
].filter((id): id is NodeId => id != null)
|
||||
}
|
||||
|
||||
/** The live graph holds the resolved anchors and the canvas has a usable transform. */
|
||||
function liveGraphReady(roles: ResolvedRoles): boolean {
|
||||
return nodesPresent(roleAnchorIds(roles)) && canvasTransformValid()
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
interface BeginTourOptions {
|
||||
templateId?: string
|
||||
entry?: OnboardingTourEntry
|
||||
}
|
||||
|
||||
function _useOnboardingTourController() {
|
||||
const store = useOnboardingTourStore()
|
||||
const onboardingDeps: OnboardingCandidateDeps = {
|
||||
subscription: useSubscription(),
|
||||
newUserService: useNewUserService(),
|
||||
featureFlags: useFeatureFlags()
|
||||
}
|
||||
|
||||
/** True while the loader is up: template chosen, tour not yet started. */
|
||||
const isPreparing = ref(false)
|
||||
|
||||
/** Retained from `start` so completion/skip telemetry carries the same tags. */
|
||||
let activeTemplateId: string | undefined
|
||||
let activeShape: OnboardingTourShape = 'other'
|
||||
/** Guards RunTriggered against re-firing on a second run. */
|
||||
let runReported = false
|
||||
/** Stops the per-tour run watcher; set while a tour is live. */
|
||||
let stopRunListener: (() => void) | undefined
|
||||
/** Stops the Run-button click listener; set only while on the Run step. */
|
||||
let stopRunClick: (() => void) | undefined
|
||||
/** Guards the post-run outcome so only the first finished run acts. */
|
||||
let runOutcomeHandled = false
|
||||
|
||||
/** True when the sequence has no step after Run, so a finished run ends the tour. */
|
||||
function runIsTerminalStep(): boolean {
|
||||
return store.steps.at(-1)?.kind === 'run'
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the run finishing: capture the media on success, report the real status,
|
||||
* and surface the nudge. When Run is the last step, this completes the tour;
|
||||
* otherwise the Result step does.
|
||||
*/
|
||||
function handleRunOutcome(status: OnboardingTourRunStatus) {
|
||||
// The Run click advances to Result before the run reports, so accept either.
|
||||
const step = store.currentStep?.kind
|
||||
if (step !== 'run' && step !== 'result') return
|
||||
if (runOutcomeHandled) return
|
||||
runOutcomeHandled = true
|
||||
store.runFinished = true
|
||||
if (status === 'success') void store.captureResultMedia()
|
||||
reportRunTriggered(status)
|
||||
if (runIsTerminalStep()) {
|
||||
end('done')
|
||||
} else {
|
||||
store.showNudge()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for the run to finish, keyed to the real execution outcome rather than the
|
||||
* `activeJobId` reset, which also fires on failure. Owned by a detached scope:
|
||||
* `start()` runs in the Getting Started screen's setup, which unmounts right after,
|
||||
* so a component-scoped listener would die before the user reaches Run.
|
||||
*/
|
||||
function listenForFirstRun() {
|
||||
stopRunListener?.()
|
||||
runOutcomeHandled = false
|
||||
const scope = effectScope(true)
|
||||
scope.run(() => {
|
||||
useEventListener(api, 'execution_success', () =>
|
||||
handleRunOutcome('success')
|
||||
)
|
||||
useEventListener(api, 'execution_error', () => handleRunOutcome('error'))
|
||||
useEventListener(api, 'execution_interrupted', () =>
|
||||
handleRunOutcome('interrupted')
|
||||
)
|
||||
|
||||
// A no-subscription run is blocked by the paywall before it queues, so no
|
||||
// execution event ever fires; end the tour when that modal opens so it
|
||||
// doesn't hang on the Result step. The nudge defers itself until it closes.
|
||||
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
|
||||
watch(upgradeModalOpen, (open) => {
|
||||
if (open && store.phase === 'active') end('done')
|
||||
})
|
||||
})
|
||||
stopRunListener = () => scope.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance on the Run click rather than on execution completion, which left the mark
|
||||
* stuck on Run. Capture-phase, so it fires despite the scrim above the toolbar.
|
||||
*/
|
||||
function listenForRunClick() {
|
||||
stopRunClick?.()
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null
|
||||
if (target?.closest(RUN_BUTTON_SELECTOR)) void advance()
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
stopRunClick = () => document.removeEventListener('click', onClick, true)
|
||||
}
|
||||
|
||||
function reportRunTriggered(status: OnboardingTourRunStatus) {
|
||||
if (runReported) return
|
||||
runReported = true
|
||||
useTelemetry()?.trackOnboardingTourRunTriggered?.({
|
||||
template_id: activeTemplateId,
|
||||
shape: activeShape,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the onboarding tour should run for this session. */
|
||||
function shouldStartTour(): boolean {
|
||||
return isOnboardingCandidate(onboardingDeps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the loaded graph and run the tour on it. Aborts cleanly if gating fails
|
||||
* or no workflow is active.
|
||||
*
|
||||
* @param entry Where the tour was launched from, for telemetry.
|
||||
*/
|
||||
async function start(
|
||||
templateId?: string,
|
||||
entry: OnboardingTourEntry = 'getting_started'
|
||||
) {
|
||||
if (!shouldStartTour()) return
|
||||
|
||||
const workflow = useWorkflowStore().activeWorkflow?.activeState
|
||||
if (!workflow) {
|
||||
store.end()
|
||||
return
|
||||
}
|
||||
|
||||
store.start(workflow, templateId)
|
||||
activeTemplateId = templateId
|
||||
const roles = store.resolvedRoles
|
||||
activeShape = roles ? shapeOf(roles) : 'other'
|
||||
runReported = false
|
||||
listenForFirstRun()
|
||||
useTelemetry()?.trackOnboardingTourStarted?.({
|
||||
template_id: activeTemplateId,
|
||||
shape: activeShape,
|
||||
entry
|
||||
})
|
||||
await enterStep()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared entry point for the Getting Started card and the URL loaders. Waits until
|
||||
* the template is present on the live canvas before starting, so the overlay never
|
||||
* paints over an unrendered graph. On timeout it starts degraded rather than trap.
|
||||
*/
|
||||
async function beginTour({
|
||||
templateId,
|
||||
entry = 'getting_started'
|
||||
}: BeginTourOptions = {}) {
|
||||
if (!shouldStartTour()) return
|
||||
if (isPreparing.value || store.phase === 'active') return
|
||||
|
||||
isPreparing.value = true
|
||||
try {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Guards the URL path, where loadBlankWorkflow() precedes the template load.
|
||||
await until(() => workflowStore.activeWorkflow?.activeState).toBeTruthy({
|
||||
timeout: ACTIVE_STATE_TIMEOUT_MS,
|
||||
throwOnTimeout: false
|
||||
})
|
||||
const workflow = workflowStore.activeWorkflow?.activeState
|
||||
if (!workflow) return
|
||||
|
||||
// Resolve from the same snapshot start() uses, then hold until the live
|
||||
// graph agrees — the spotlight reads the live graph, so they must match.
|
||||
const roles = resolveTourRoles(workflow, templateId)
|
||||
const ready = await until(() => liveGraphReady(roles)).toBe(true, {
|
||||
timeout: READY_TIMEOUT_MS,
|
||||
throwOnTimeout: false
|
||||
})
|
||||
if (!ready) {
|
||||
console.warn(
|
||||
'[onboardingTour] canvas readiness timed out; starting degraded',
|
||||
{ templateId }
|
||||
)
|
||||
}
|
||||
|
||||
await nextFrame()
|
||||
await start(templateId, entry)
|
||||
await nextFrame()
|
||||
} finally {
|
||||
isPreparing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** UX-only paywall check; real enforcement is server-side. Never gate on signup method. */
|
||||
function hasFunds(): boolean {
|
||||
return useBillingContext().subscription.value?.hasFunds === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate the Run step: no-funds users get the upgrade modal and the tour ends, with
|
||||
* the nudge deferring itself until that modal closes.
|
||||
*
|
||||
* @returns True when gated.
|
||||
*/
|
||||
function gateRunStep(): boolean {
|
||||
if (hasFunds()) return false
|
||||
|
||||
useBillingContext().showSubscriptionDialog({ reason: 'out_of_credits' })
|
||||
useTelemetry()?.trackOnboardingTourUpgradeShown?.({
|
||||
template_id: activeTemplateId
|
||||
})
|
||||
end('done')
|
||||
return true
|
||||
}
|
||||
|
||||
/** Set up the current step: gate the run, arm the run-click, spotlight the prompt. */
|
||||
async function enterStep() {
|
||||
stopRunClick?.()
|
||||
stopRunClick = undefined
|
||||
|
||||
const step = store.currentStep
|
||||
if (!step) return
|
||||
|
||||
if (step.kind === 'run') {
|
||||
if (gateRunStep()) return
|
||||
listenForRunClick()
|
||||
}
|
||||
|
||||
// Defensive: the tour never enters a subgraph, but the user may have opened one.
|
||||
restoreView()
|
||||
|
||||
useTelemetry()?.trackOnboardingTourStepViewed?.({
|
||||
template_id: activeTemplateId,
|
||||
step_key: step.kind,
|
||||
step_index: store.stepIndex,
|
||||
step_total: store.totalSteps
|
||||
})
|
||||
}
|
||||
|
||||
async function advance() {
|
||||
store.advance()
|
||||
await enterStep()
|
||||
}
|
||||
|
||||
async function back() {
|
||||
store.back()
|
||||
await enterStep()
|
||||
}
|
||||
|
||||
function end(reason: TourEndReason) {
|
||||
if (store.phase !== 'active') return
|
||||
stopRunListener?.()
|
||||
stopRunListener = undefined
|
||||
stopRunClick?.()
|
||||
stopRunClick = undefined
|
||||
const telemetry = useTelemetry()
|
||||
if (reason === 'skip') {
|
||||
const step = store.currentStep
|
||||
if (step) {
|
||||
telemetry?.trackOnboardingTourSkipped?.({
|
||||
template_id: activeTemplateId,
|
||||
step_key: step.kind,
|
||||
step_index: store.stepIndex,
|
||||
step_total: store.totalSteps
|
||||
})
|
||||
}
|
||||
} else if (reason === 'done') {
|
||||
telemetry?.trackOnboardingTourCompleted?.({
|
||||
template_id: activeTemplateId,
|
||||
shape: activeShape
|
||||
})
|
||||
}
|
||||
store.showNudge()
|
||||
store.end()
|
||||
}
|
||||
|
||||
return { shouldStartTour, beginTour, start, advance, back, end }
|
||||
}
|
||||
|
||||
export const useOnboardingTourController = createSharedComposable(
|
||||
_useOnboardingTourController
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type * as adapterModule from './canvasSpotlightAdapter'
|
||||
import { TOUR_FOCUS_DURATION_MS } from './canvasSpotlightAdapter'
|
||||
|
||||
type AdapterModule = typeof adapterModule
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
transformKey: 'a' as string | null
|
||||
}))
|
||||
|
||||
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
|
||||
...(await importOriginal<AdapterModule>()),
|
||||
canvasTransformKey: () => mocks.transformKey
|
||||
}))
|
||||
|
||||
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
|
||||
|
||||
const INTRO_PREVIEW_MS = 500
|
||||
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
|
||||
|
||||
function setup(options: { reduceMotion?: boolean; isStatic?: boolean } = {}) {
|
||||
const frameTarget = vi.fn()
|
||||
const choreography = useTourChoreography({
|
||||
reduceMotion: ref(options.reduceMotion ?? false),
|
||||
isStatic: ref(options.isStatic ?? false),
|
||||
frameTarget
|
||||
})
|
||||
return { ...choreography, frameTarget }
|
||||
}
|
||||
|
||||
/** Drive the rAF sampler until the real trackSettle sees a still transform. */
|
||||
function sampleUntilSettled(sampleFrame: () => void) {
|
||||
for (let i = 0; i < 4; i++) sampleFrame()
|
||||
}
|
||||
|
||||
describe('useTourChoreography', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.transformKey = 'a'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('holds the framing until the mark has glided', () => {
|
||||
const { beginStep, frameTarget } = setup()
|
||||
|
||||
beginStep()
|
||||
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
expect(frameTarget).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reveals the copy once the canvas transform holds still', () => {
|
||||
const { beginStep, sampleFrame, copyVisible, cameraSettled } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
expect(copyVisible.value).toBe(false)
|
||||
|
||||
sampleUntilSettled(sampleFrame)
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(cameraSettled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the copy hidden while the camera is still moving', () => {
|
||||
const { beginStep, sampleFrame, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
for (const key of ['b', 'c', 'd', 'e']) {
|
||||
mocks.transformKey = key
|
||||
sampleFrame()
|
||||
}
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the copy on the watchdog when the camera never settles', () => {
|
||||
const { beginStep, sampleFrame, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
mocks.transformKey = 'moving'
|
||||
sampleFrame()
|
||||
expect(copyVisible.value).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unpins the mark whenever the canvas moves under it', () => {
|
||||
const { beginStep, sampleFrame, markGlides } = setup()
|
||||
|
||||
beginStep()
|
||||
expect(markGlides.value).toBe(true)
|
||||
|
||||
mocks.transformKey = 'moved'
|
||||
sampleFrame()
|
||||
|
||||
expect(markGlides.value).toBe(false)
|
||||
})
|
||||
|
||||
it('frames a static step immediately and shows its copy without waiting', () => {
|
||||
const { beginStep, frameTarget, copyVisible } = setup({ isStatic: true })
|
||||
|
||||
beginStep()
|
||||
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews the workflow undimmed before dimming to the first step', () => {
|
||||
const { openTour, revealed, frameTarget } = setup()
|
||||
|
||||
openTour()
|
||||
expect(revealed.value).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(INTRO_PREVIEW_MS)
|
||||
|
||||
expect(revealed.value).toBe(true)
|
||||
// The first step frames without a glide delay: there is no prior mark to move.
|
||||
expect(frameTarget).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips the preview and the glide when motion is reduced', () => {
|
||||
const { openTour, revealed, copyVisible, frameTarget } = setup({
|
||||
reduceMotion: true
|
||||
})
|
||||
|
||||
openTour()
|
||||
|
||||
expect(revealed.value).toBe(true)
|
||||
expect(copyVisible.value).toBe(true)
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the copy again on a step change', () => {
|
||||
const { beginStep, sampleFrame, resetStep, copyVisible, cameraSettled } =
|
||||
setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
sampleUntilSettled(sampleFrame)
|
||||
expect(copyVisible.value).toBe(true)
|
||||
|
||||
resetStep()
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
expect(cameraSettled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a pending framing when the step is reset before it runs', () => {
|
||||
const { beginStep, resetStep, frameTarget } = setup()
|
||||
|
||||
beginStep()
|
||||
resetStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS + SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(frameTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops sampling for a settle once the step is reset', () => {
|
||||
const { beginStep, resetStep, isAwaitingSettle } = setup()
|
||||
|
||||
beginStep()
|
||||
expect(isAwaitingSettle()).toBe(true)
|
||||
|
||||
resetStep()
|
||||
|
||||
expect(isAwaitingSettle()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no timer able to reveal a scrim after the tour ends', () => {
|
||||
const { openTour, endTour, revealed } = setup()
|
||||
|
||||
openTour()
|
||||
endTour()
|
||||
vi.advanceTimersByTime(INTRO_PREVIEW_MS + SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(revealed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves no watchdog able to fire after the tour ends', () => {
|
||||
const { beginStep, endTour, copyVisible } = setup()
|
||||
|
||||
beginStep()
|
||||
vi.advanceTimersByTime(MARK_GLIDE_MS)
|
||||
endTour()
|
||||
vi.advanceTimersByTime(SETTLE_WATCHDOG_MS)
|
||||
|
||||
expect(copyVisible.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
141
src/renderer/extensions/onboardingTour/useTourChoreography.ts
Normal file
141
src/renderer/extensions/onboardingTour/useTourChoreography.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
INITIAL_SETTLE,
|
||||
TOUR_FOCUS_DURATION_MS,
|
||||
canvasTransformKey,
|
||||
trackSettle
|
||||
} from './canvasSpotlightAdapter'
|
||||
import type { SettleState } from './canvasSpotlightAdapter'
|
||||
|
||||
/** Undimmed preview of the whole workflow before the tour dims to the first target. */
|
||||
const INTRO_PREVIEW_MS = 500
|
||||
/** The camera waits this out, so the mark's glide and the framing never run at once. */
|
||||
export const MARK_GLIDE_MS = 400
|
||||
/** A camera that never settles (the user keeps panning) must not strand the copy. */
|
||||
const SETTLE_WATCHDOG_MS = TOUR_FOCUS_DURATION_MS + 200
|
||||
|
||||
interface ChoreographyOptions {
|
||||
reduceMotion: Ref<boolean>
|
||||
/** Frame the current step's target. Called once the mark has finished gliding. */
|
||||
frameTarget: () => void
|
||||
/** True when the step points at the toolbar, so the camera must not move. */
|
||||
isStatic: Ref<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequences a step's motion so only one thing moves at a time: the mark glides, the
|
||||
* camera frames it, then the copy fades in once the transform holds still.
|
||||
*
|
||||
* The camera is watched rather than counted out: `animateToBounds` reports no
|
||||
* completion and cannot be cancelled, so an unchanged transform is the only honest
|
||||
* "it stopped" signal. A watchdog bounds that wait.
|
||||
*/
|
||||
export function useTourChoreography({
|
||||
reduceMotion,
|
||||
frameTarget,
|
||||
isStatic
|
||||
}: ChoreographyOptions) {
|
||||
/** Scrim and rings are drawn; false during the intro preview. */
|
||||
const revealed = ref(false)
|
||||
const copyVisible = ref(false)
|
||||
/** True once this step's framing is final, so the mark's side can latch. */
|
||||
const cameraSettled = ref(false)
|
||||
/** Pinned while the canvas moves, so the mark rides it rather than chasing it. */
|
||||
const markGlides = ref(false)
|
||||
|
||||
let timers: ReturnType<typeof setTimeout>[] = []
|
||||
let watchdogTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let settle: SettleState = INITIAL_SETTLE
|
||||
let awaitingSettle = false
|
||||
let lastTransformKey: string | null = null
|
||||
|
||||
function after(ms: number, run: () => void) {
|
||||
timers.push(setTimeout(run, ms))
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
timers = []
|
||||
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels only the watchdog, never a pending framing: on a step whose transform is
|
||||
* already at rest the camera settles before it has moved, and the framing must still run.
|
||||
*/
|
||||
function showCopy() {
|
||||
awaitingSettle = false
|
||||
if (watchdogTimer !== null) clearTimeout(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
cameraSettled.value = true
|
||||
copyVisible.value = true
|
||||
}
|
||||
|
||||
function sampleFrame() {
|
||||
const key = canvasTransformKey()
|
||||
if (key !== lastTransformKey) {
|
||||
lastTransformKey = key
|
||||
markGlides.value = false
|
||||
}
|
||||
if (!awaitingSettle) return
|
||||
settle = trackSettle(settle, key)
|
||||
if (settle.settled) showCopy()
|
||||
}
|
||||
|
||||
function beginStep({ glideFirst = true } = {}) {
|
||||
revealed.value = true
|
||||
|
||||
if (isStatic.value || reduceMotion.value) {
|
||||
markGlides.value = !reduceMotion.value
|
||||
showCopy()
|
||||
return
|
||||
}
|
||||
|
||||
markGlides.value = true
|
||||
awaitingSettle = true
|
||||
settle = INITIAL_SETTLE
|
||||
|
||||
const delay = glideFirst ? MARK_GLIDE_MS : 0
|
||||
if (delay === 0) frameTarget()
|
||||
else after(delay, frameTarget)
|
||||
watchdogTimer = setTimeout(showCopy, delay + SETTLE_WATCHDOG_MS)
|
||||
}
|
||||
|
||||
function resetStep() {
|
||||
clearTimers()
|
||||
awaitingSettle = false
|
||||
copyVisible.value = false
|
||||
cameraSettled.value = false
|
||||
}
|
||||
|
||||
/** Open a fresh tour on the undimmed workflow before dimming to the first target. */
|
||||
function openTour() {
|
||||
resetStep()
|
||||
revealed.value = false
|
||||
if (reduceMotion.value) return beginStep({ glideFirst: false })
|
||||
after(INTRO_PREVIEW_MS, () => beginStep({ glideFirst: false }))
|
||||
}
|
||||
|
||||
function endTour() {
|
||||
resetStep()
|
||||
revealed.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
revealed,
|
||||
copyVisible,
|
||||
cameraSettled,
|
||||
markGlides,
|
||||
/** True while the camera is still being waited on, so a resize must not re-frame. */
|
||||
isAwaitingSettle: () => awaitingSettle,
|
||||
sampleFrame,
|
||||
beginStep,
|
||||
resetStep,
|
||||
openTour,
|
||||
endTour,
|
||||
clearTimers
|
||||
}
|
||||
}
|
||||
163
src/renderer/extensions/onboardingTour/useTourSpotlightRects.ts
Normal file
163
src/renderer/extensions/onboardingTour/useTourSpotlightRects.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useRafFn, useWindowSize } from '@vueuse/core'
|
||||
import { computed, reactive, ref, toRef } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
ACTIONBAR_SELECTOR,
|
||||
RUN_BUTTON_SELECTOR,
|
||||
canvasViewport,
|
||||
maskRectsFor,
|
||||
rectIntersectsViewport
|
||||
} from './canvasSpotlightAdapter'
|
||||
import type { ScreenRect } from './canvasSpotlightAdapter'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
function domClientRect(selector: string): ScreenRect | null {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) return null
|
||||
const { left, top, width, height } = el.getBoundingClientRect()
|
||||
return { left, top, width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigning primitives rather than replacing the object keeps Vue's equality check in
|
||||
* play: a stationary canvas writes identical numbers and nothing recomputes. Replacing
|
||||
* it every frame re-rendered the overlay at 60fps at rest.
|
||||
*/
|
||||
function assignRect(target: ScreenRect, rect: ScreenRect): void {
|
||||
target.left = rect.left
|
||||
target.top = rect.top
|
||||
target.width = rect.width
|
||||
target.height = rect.height
|
||||
}
|
||||
|
||||
/** Whether two rect lists hold the same geometry, so an unchanged frame can bail. */
|
||||
function sameRects(
|
||||
a: readonly ScreenRect[],
|
||||
b: readonly ScreenRect[]
|
||||
): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((rect, i) => {
|
||||
const other = b[i]
|
||||
return (
|
||||
rect.left === other.left &&
|
||||
rect.top === other.top &&
|
||||
rect.width === other.width &&
|
||||
rect.height === other.height
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
interface SpotlightRectsOptions {
|
||||
isRunStep: Ref<boolean>
|
||||
isResultStep: Ref<boolean>
|
||||
revealedNodeIds: Ref<Set<NodeId>>
|
||||
spotlitNodeIds: Ref<Set<NodeId>>
|
||||
/** Per-frame hook for the choreography, which samples the same transform. */
|
||||
onFrame?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame source of the rects the overlay draws: the scrim's holes, the current
|
||||
* step's rings, and the region the mark may occupy.
|
||||
*
|
||||
* Rects are only published when the geometry actually changes, so a still canvas costs
|
||||
* one layout read per frame and no re-render.
|
||||
*/
|
||||
export function useTourSpotlightRects({
|
||||
isRunStep,
|
||||
isResultStep,
|
||||
revealedNodeIds,
|
||||
spotlitNodeIds,
|
||||
onFrame
|
||||
}: SpotlightRectsOptions) {
|
||||
const holeRects = ref<ScreenRect[]>([])
|
||||
const spotRects = ref<ScreenRect[]>([])
|
||||
|
||||
/**
|
||||
* The canvas rect, not the window: the app insets the canvas below the top bar and
|
||||
* beside the panels, so placing against the window put the mark under that chrome.
|
||||
* Falls back to the window until the canvas exists.
|
||||
*/
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize()
|
||||
const canvasRect = reactive<ScreenRect>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
const hasCanvasRect = ref(false)
|
||||
const viewport = computed<ScreenRect>(() =>
|
||||
hasCanvasRect.value
|
||||
? canvasRect
|
||||
: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: windowWidth.value,
|
||||
height: windowHeight.value
|
||||
}
|
||||
)
|
||||
|
||||
function readRects() {
|
||||
const canvas = canvasViewport()
|
||||
hasCanvasRect.value = canvas !== null
|
||||
if (canvas) assignRect(canvasRect, canvas)
|
||||
|
||||
if (isRunStep.value) {
|
||||
const rect = domClientRect(RUN_BUTTON_SELECTOR)
|
||||
return { holes: rect ? [rect] : [], spots: rect ? [rect] : [] }
|
||||
}
|
||||
|
||||
const holes = maskRectsFor([...revealedNodeIds.value])
|
||||
const spots = maskRectsFor([...spotlitNodeIds.value])
|
||||
// Result cuts the toolbar out of the scrim unringed, so the eye stays on the node.
|
||||
const toolbar = isResultStep.value
|
||||
? domClientRect(ACTIONBAR_SELECTOR)
|
||||
: null
|
||||
if (toolbar) holes.push(toolbar)
|
||||
return { holes, spots }
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
const { holes, spots } = readRects()
|
||||
if (!sameRects(holes, holeRects.value)) holeRects.value = holes
|
||||
if (!sameRects(spots, spotRects.value)) spotRects.value = spots
|
||||
}
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
recompute()
|
||||
onFrame?.()
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function start() {
|
||||
recompute()
|
||||
resume()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
pause()
|
||||
holeRects.value = []
|
||||
spotRects.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
holeRects: toRef(() => holeRects.value),
|
||||
spotRects: toRef(() => spotRects.value),
|
||||
viewport,
|
||||
/** Nodes off the canvas region are not ringed; the mark holds instead of chasing. */
|
||||
visibleSpotRects: computed(() =>
|
||||
spotRects.value.filter((rect) =>
|
||||
rectIntersectsViewport(rect, viewport.value)
|
||||
)
|
||||
),
|
||||
focusRect: computed(() => spotRects.value[0] ?? null),
|
||||
recompute,
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,18 @@ vi.mock(
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/onboardingTour/GettingStartedScreen.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/onboardingTour/OnboardingTourNudge.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
|
||||
vi.mock('@/components/MenuHamburger.vue', () => stubModule)
|
||||
vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
<GettingStartedScreen />
|
||||
<OnboardingTourOverlay />
|
||||
<OnboardingTourNudge />
|
||||
<InviteAcceptedToast />
|
||||
<RerouteMigrationToast />
|
||||
<ModelImportProgressDialog />
|
||||
@@ -50,6 +53,9 @@ import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import GettingStartedScreen from '@/renderer/extensions/onboardingTour/GettingStartedScreen.vue'
|
||||
import OnboardingTourNudge from '@/renderer/extensions/onboardingTour/OnboardingTourNudge.vue'
|
||||
import OnboardingTourOverlay from '@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue'
|
||||
import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAcceptedToast.vue'
|
||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
|
||||
Reference in New Issue
Block a user