mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 08:49:09 +00:00
Compare commits
2 Commits
split/allo
...
feat/onboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6a6ff8d1 | ||
|
|
22e6746212 |
@@ -4496,5 +4496,22 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"onboardingTour": {
|
||||
"guide": "Guide",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"finish": "Finish",
|
||||
"skip": "Skip tour",
|
||||
"hint": {
|
||||
"welcome": "This is your starting image. In a minute you'll remix it into something totally new.",
|
||||
"connect": "Grab this green dot and drag it over to the editor — that's how nodes talk to each other.",
|
||||
"prompt": "Here's the fun part. Tell it what you want — a style, a scene, anything. Then hit Next.",
|
||||
"save": "Whatever you create lands right here.",
|
||||
"run": "Alright, moment of truth — hit Run and watch it come to life.",
|
||||
"generating": "Working on it… this is where the magic happens.",
|
||||
"result": "Here's your edited image, saved and ready. This was just one of hundreds of things you can build."
|
||||
},
|
||||
"exploreCta": "Explore workflows"
|
||||
}
|
||||
}
|
||||
|
||||
138
src/renderer/extensions/onboardingTour/HANDOFF.md
Normal file
138
src/renderer/extensions/onboardingTour/HANDOFF.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Onboarding Tour POC — Handoff
|
||||
|
||||
> Branch: `feat/onboarding-tour-poc` (cut from `main`). This is a **POC / prototype** — hardcoded, no
|
||||
> gating, no tests. Goal: a game-style guided onboarding tour on the ComfyUI cloud canvas that walks a
|
||||
> zero-knowledge user to their first "aha" (create an image) and hooks them toward more workflows.
|
||||
|
||||
## What this is
|
||||
|
||||
When the canvas loads, a hand-held, video-game-style tutorial runs on top of a real template. It uses the
|
||||
**spotlight + coach-mark + progressive-disclosure** pattern: dim the canvas, spotlight one node at a time,
|
||||
show an emerald **agent cursor + speech bubble** pointing at the target, and let the user perform real
|
||||
actions (drag to connect, type a prompt, click Run). It carries through generation and celebrates the
|
||||
result, then offers a CTA to explore more workflows.
|
||||
|
||||
## How to run it
|
||||
|
||||
```bash
|
||||
pnpm dev:cloud:staging # points DEV_SERVER_COMFYUI_URL at https://stagingcloud.comfy.org/
|
||||
# open http://localhost:5173/ (sign in to staging if prompted)
|
||||
```
|
||||
|
||||
The tour **auto-starts on canvas ready** (no gating — this is intentional for the POC). It's wired in
|
||||
`src/views/GraphView.vue` (`onboardingTour.start()` in `onGraphReady`, and `<OnboardingTourOverlay />`
|
||||
mounted among the global overlays).
|
||||
|
||||
- `pnpm dev:cloud:test` → testcloud; `pnpm dev:cloud:staging` → staging. Both proxy `/templates/*` + the
|
||||
API to that backend.
|
||||
- **Real generation (the `generating` → `result` beats) needs a working Run.** On staging/test Run is
|
||||
currently the paywalled "Subscribe to Run" button, so `execution_success` won't fire — see "Grace
|
||||
fallback" below. A **follow-up PR will enable free-user Run**; once it lands, the live progress bar +
|
||||
real result image light up automatically.
|
||||
|
||||
## Files (all new unless noted)
|
||||
|
||||
- `src/renderer/extensions/onboardingTour/tourSteps.ts` — **the "level design".** Declarative `tourSteps[]`
|
||||
- the `TourStep` discriminated union. Hardcoded template constants: `TOUR_TEMPLATE_ID =
|
||||
'flux_kontext_dev_basic'`, `EDIT_ENGINE_TYPE` (the subgraph node's type id),
|
||||
`RUN_BUTTON_SELECTOR`.
|
||||
- `src/renderer/extensions/onboardingTour/useOnboardingTour.ts` — **the controller** (shared composable).
|
||||
Owns `stepIndex`/`isActive`, loads + simplifies the graph, drives per-step spotlight/completion, exposes
|
||||
everything the overlay renders.
|
||||
- `src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue` — **the presentation.** Teleports to
|
||||
`body`, `fixed inset-0 z-3000`. SVG spotlight mask (dim + cutouts), emerald node outlines, ghost-drag
|
||||
SVG line, agent cursor + bubble, progress dots, skip. No business logic.
|
||||
- `src/locales/en/main.json` — `onboardingTour` block (all copy + `hint.*` per step `key`).
|
||||
- `src/views/GraphView.vue` — **edited**: import + mount overlay, auto-start in `onGraphReady`.
|
||||
|
||||
Lives under `renderer/extensions/` (not `platform/`) because it drives litegraph directly — `platform/`
|
||||
can't import the canvas store (layer-architecture ESLint rule).
|
||||
|
||||
## The flow (why it's shaped this way)
|
||||
|
||||
Sequenced for **momentum → ownership → aha → expansion**, NOT a feature tour (deliberately dropped
|
||||
canvas-intro, node-selection trivia, model dropdown, and a left-menu tour — those are power features best
|
||||
revealed just-in-time later). Product rationale is in the chat; the short version: the tour's job is to get
|
||||
a nervous newcomer to their first successful result feeling "I made that."
|
||||
|
||||
1. **welcome** (reveal, manual Next) — points at Load Image: "This is your starting image…"
|
||||
2. **connect** (drag, auto) — pre-clears the LoadImage→Edit link, ghost line + cursor guide the drag;
|
||||
advances when the input is re-linked.
|
||||
3. **prompt** (type, manual Next) — focuses the Edit node's `text` widget (prefilled demo prompt kept);
|
||||
user types freely, hits Next. **Typing does NOT auto-advance** (continuous action → manual).
|
||||
4. **save** (reveal, manual Next) — points at Save Image: "Whatever you create lands right here."
|
||||
5. **run** (click, auto) — cursor jumps to the top-bar Run button; advances on click.
|
||||
6. **generating** (auto) — spotlights the **Edit node** (where the blurry live preview renders), shows a
|
||||
live progress bar; advances on the real `execution_success` event.
|
||||
7. **result** (last, manual) — spotlights the **Save node**, shows the **final image inline** + a
|
||||
prominent **"Explore workflows →" CTA** that opens the template modal (`useWorkflowTemplateSelectorDialog`)
|
||||
and ends the tour.
|
||||
|
||||
### Step-kind → behavior
|
||||
|
||||
- `reveal` / `result`: talk-steps, wait for **Next**.
|
||||
- `connect` / `run` / `generating`: action-steps (`auto: true`), advance on the real action/event.
|
||||
- **Grace fallback**: any `auto` step that hasn't completed in **8s** flips `autoStepStalled` → shows a
|
||||
subtle Next so the user is never trapped (critical while Run is paywalled and `generating` can't
|
||||
complete). A `connect` step that couldn't pre-clear its link also shows Next.
|
||||
|
||||
## Key integrations (already wired, verified in-repo)
|
||||
|
||||
- **Template load**: `useTemplateWorkflows().loadTemplates()` + `loadWorkflowTemplate('flux_kontext_dev_basic', 'default')`.
|
||||
- **Graph simplify**: `simplifyGraph()` removes the duplicate LoadImage + all MarkdownNotes, caps oversized
|
||||
node widths, lays the 3 kept nodes (Load → Edit → Save) in a clean row. `frameWorkflow()` fits at
|
||||
`zoom: 0.85`.
|
||||
- **Node resolution**: `resolveNode(type, occ)` against `canvasStore.currentGraph`.
|
||||
- **Connect detection**: poll `toNode.getInputNode(slot)` (robust across subgraph nodes; event-agnostic).
|
||||
- **Ghost line hides during drag**: `app.canvas.linkConnector.isConnecting`.
|
||||
- **Run detection**: capture-phase `click` listener matching `RUN_BUTTON_SELECTOR`
|
||||
(`[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]`).
|
||||
- **Generation**: `api.addEventListener('execution_success', …)`; progress from
|
||||
`useExecutionStore().executionProgress` (0–1 → %). Result image via
|
||||
`useNodeOutputStore().getNodeImageUrls(saveNode)[0]`.
|
||||
- **Explore CTA**: `useWorkflowTemplateSelectorDialog().show('command', { initialCategory: 'all' })`, then
|
||||
`stop()`. Overlay uses `showOverlay` so it never sheets over the modal.
|
||||
|
||||
## Design / theme
|
||||
|
||||
- Accent: **emerald** (`emerald-400/500`, `#34d399`) — deliberately distinct from ComfyUI's node/link
|
||||
blues so it reads as "tutorial UI." (Earlier used azure `#31b9f4`; user rejected — clashed with nodes.)
|
||||
- Bubble: sleek dark glass (`bg-neutral-900/85 backdrop-blur-md`, emerald hairline border), "GUIDE" label,
|
||||
minimal "Next →" text link (user rejected the earlier chunky white pill). Result step swaps in a solid
|
||||
emerald CTA button.
|
||||
- Dim: `rgba(10, 12, 16, 0.66)` neutral cool-black (earlier greenish `rgba(6,10,8,0.72)` was murkier).
|
||||
- Ghost-drag line: emerald dashed, marching-ants via a scoped `@keyframes` (only place a `<style>` block is
|
||||
used; precedented in the repo — Tailwind has no stroke-dashoffset util).
|
||||
|
||||
## Known limitations / next steps (POC debt, intentional)
|
||||
|
||||
1. **Free-run dependency** — `generating`/`result` only show live progress + the real image once free-user
|
||||
Run works (follow-up PR). Until then they fall back to Next after 8s. **This is the #1 thing to finish
|
||||
for the full aha.**
|
||||
2. **Prompt lives inside the subgraph** — the `flux_kontext` template's editable prompt is a promoted
|
||||
widget on the subgraph node; the type step targets it and focuses the reachable DOM textarea. If the
|
||||
widget renders on the legacy canvas (no DOM input), auto-focus won't fire (Next still works). For prod,
|
||||
consider a template whose prompt is a first-class **surface** widget (e.g. `image_flux2_klein_text_to_image`
|
||||
has a `PrimitiveStringMultiline` surface prompt) — see chat for the template survey.
|
||||
3. **Own input (parked)** — the biggest remaining product lever: let the user upload their own image / type
|
||||
their own idea (ownership → retention). Deferred per user.
|
||||
4. **Scalability** — the _pattern_ scales (data-driven step list + reusable primitives `connect`/`type`/
|
||||
`run`/`reveal`/`generating`/`result`). To port to another template: swap the step list + generalize
|
||||
`resolveNode`/`simplifyGraph` from hardcoded node types to ids/roles. Contained refactor, not a rewrite.
|
||||
Real end-state is a small per-template tour-definition format; you'd only tour the 5–10 hero templates,
|
||||
not all 200.
|
||||
5. **Graph is mutated** — `simplifyGraph` deletes nodes / relayouts on start; the user is left with the
|
||||
trimmed graph when the tour ends. Fine for POC.
|
||||
6. **No gating / no persistence / no tests** — POC. Auto-starts every canvas load.
|
||||
|
||||
## Gotchas for the next session
|
||||
|
||||
- The overlay teleports to `body` (not `#graph-canvas-container`) so it can point at the top-bar Run button;
|
||||
all coords are computed in **client space** from `app.canvas.canvas.getBoundingClientRect()` + `ds.offset/scale`.
|
||||
- HMR on the overlay throws a benign `Cannot read properties of null (reading 'flags')` / `nextSibling` in
|
||||
the dev console — it's a hot-reload reconciliation artifact, **not** a cold-load bug. Verify on a fresh
|
||||
page load, not after an HMR patch.
|
||||
- `hintLabel` keys copy by each step's **`key`** field (not `kind`) — every step's `key` must have a
|
||||
matching `onboardingTour.hint.<key>` entry or you'll see the raw key string.
|
||||
- Standards: no `dark:`, `!important`, arbitrary %/px where a util exists, `:class="[]"` (use `cn()`),
|
||||
PrimeVue, barrel files. `pnpm format` before committing.
|
||||
335
src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue
Normal file
335
src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<Teleport v-if="showOverlay" to="body">
|
||||
<div class="pointer-events-none fixed inset-0 z-3000" aria-live="polite">
|
||||
<!-- Dim everything except soft cutouts around the lit target(s). -->
|
||||
<svg class="absolute inset-0 size-full" aria-hidden="true">
|
||||
<defs>
|
||||
<mask id="onboarding-spotlight">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
v-for="(hole, i) in litRects"
|
||||
:key="i"
|
||||
:x="hole.left - 10"
|
||||
:y="hole.top - 10"
|
||||
:width="hole.width + 20"
|
||||
:height="hole.height + 20"
|
||||
rx="16"
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(10, 12, 16, 0.66)"
|
||||
mask="url(#onboarding-spotlight)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Emerald outline around each lit target. -->
|
||||
<div
|
||||
v-for="(hole, i) in litRects"
|
||||
:key="i"
|
||||
class="absolute rounded-2xl ring-2 ring-emerald-400/80 transition-all duration-500 ease-out"
|
||||
:style="rectStyle(hole)"
|
||||
/>
|
||||
|
||||
<!-- Ghost drag line for connect steps. -->
|
||||
<svg
|
||||
v-if="dragPath"
|
||||
class="absolute inset-0 size-full"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
:d="dragPath"
|
||||
stroke="#34d399"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="7 9"
|
||||
class="onboarding-drag-line"
|
||||
/>
|
||||
<circle r="7" fill="#34d399">
|
||||
<animateMotion dur="1.5s" repeatCount="indefinite" :path="dragPath" />
|
||||
</circle>
|
||||
</svg>
|
||||
|
||||
<!-- Agent cursor + speech bubble, pointing at the target. -->
|
||||
<div
|
||||
v-if="cursorPos"
|
||||
class="pointer-events-auto absolute transition-all duration-500 ease-out"
|
||||
:style="cursorStyle"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--mouse-pointer-2] size-5 rotate-[-8deg] text-emerald-400 drop-shadow-[0_1px_4px_rgba(52,211,153,0.5)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-4 left-5 w-64 rounded-2xl rounded-tl-md border border-emerald-400/25 bg-neutral-900/85 p-3 text-white shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-1.5">
|
||||
<span class="size-1.5 rounded-full bg-emerald-400" />
|
||||
<span
|
||||
class="text-[11px] font-semibold tracking-wide text-emerald-400/90 uppercase"
|
||||
>
|
||||
{{ t('onboardingTour.guide') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm/relaxed text-white/90">{{ hintLabel }}</p>
|
||||
|
||||
<!-- Live progress while the image generates -->
|
||||
<div
|
||||
v-if="currentStep?.kind === 'generating'"
|
||||
class="mt-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
class="h-full rounded-full bg-emerald-400 transition-all duration-300"
|
||||
:style="{ width: `${Math.max(executionProgress, 6)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The result the user created -->
|
||||
<img
|
||||
v-if="currentStep?.kind === 'result' && resultImageUrl"
|
||||
:src="resultImageUrl"
|
||||
class="mt-3 aspect-square w-full rounded-lg object-cover"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<!-- Result step: a prominent CTA to explore more workflows -->
|
||||
<button
|
||||
v-if="currentStep?.kind === 'result'"
|
||||
type="button"
|
||||
class="group mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg bg-emerald-500 px-3 py-2 text-sm font-semibold text-white transition-colors hover:bg-emerald-400"
|
||||
@click="openExplore"
|
||||
>
|
||||
{{ t('onboardingTour.exploreCta') }}
|
||||
<i
|
||||
class="icon-[lucide--arrow-right] size-4 transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-else class="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
v-if="canGoBack"
|
||||
type="button"
|
||||
class="text-xs text-white/45 transition-colors hover:text-white/80"
|
||||
@click="back"
|
||||
>
|
||||
{{ t('onboardingTour.back') }}
|
||||
</button>
|
||||
<span v-else />
|
||||
<button
|
||||
v-if="canGoNext"
|
||||
type="button"
|
||||
class="group flex items-center gap-1 text-xs font-medium text-emerald-400 transition-colors hover:text-emerald-300"
|
||||
@click="next"
|
||||
>
|
||||
{{
|
||||
isLast ? t('onboardingTour.finish') : t('onboardingTour.next')
|
||||
}}
|
||||
<i
|
||||
class="icon-[lucide--arrow-right] size-3.5 transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots + skip -->
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
v-for="i in totalSteps"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i - 1 === stepIndex ? 'w-6 bg-emerald-400' : 'w-1.5 bg-white/25'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto text-xs text-white/45 transition-colors hover:text-white/80"
|
||||
@click="stop"
|
||||
>
|
||||
{{ t('onboardingTour.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
import type { ConnectStep } from './tourSteps'
|
||||
import { useOnboardingTour } from './useOnboardingTour'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
isActive,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
currentStep,
|
||||
isLast,
|
||||
focusNode,
|
||||
litNodes,
|
||||
domTargetSelector,
|
||||
canGoNext,
|
||||
canGoBack,
|
||||
resultImageUrl,
|
||||
executionProgress,
|
||||
resolveNode,
|
||||
next,
|
||||
back,
|
||||
stop,
|
||||
openExplore
|
||||
} = useOnboardingTour()
|
||||
|
||||
const showOverlay = computed(() => isActive.value && !!currentStep.value)
|
||||
|
||||
interface ScreenRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const litRects = ref<ScreenRect[]>([])
|
||||
const cursorPos = ref<{ x: number; y: number } | null>(null)
|
||||
const dragPath = ref('')
|
||||
|
||||
/** Client-space origin of the litegraph canvas (nodes live in canvas coords). */
|
||||
function canvasFrame() {
|
||||
const canvas = app.canvas
|
||||
if (!canvas) return null
|
||||
const rect = canvas.canvas.getBoundingClientRect()
|
||||
const { offset, scale } = canvas.ds
|
||||
return { rect, offset, scale }
|
||||
}
|
||||
|
||||
/** Node canvas coords -> client (viewport) coords. */
|
||||
function nodeClientRect(node: LGraphNode): ScreenRect | null {
|
||||
const f = canvasFrame()
|
||||
if (!f) return null
|
||||
const [bx, by, bw, bh] = node.boundingRect
|
||||
return {
|
||||
left: f.rect.left + (bx + f.offset[0]) * f.scale,
|
||||
top: f.rect.top + (by + f.offset[1]) * f.scale,
|
||||
width: bw * f.scale,
|
||||
height: bh * f.scale
|
||||
}
|
||||
}
|
||||
|
||||
function domClientRect(selector: string): ScreenRect | null {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height }
|
||||
}
|
||||
|
||||
function slotClient(pos: [number, number]): [number, number] {
|
||||
const f = canvasFrame()
|
||||
if (!f) return [0, 0]
|
||||
return [
|
||||
f.rect.left + (pos[0] + f.offset[0]) * f.scale,
|
||||
f.rect.top + (pos[1] + f.offset[1]) * f.scale
|
||||
]
|
||||
}
|
||||
|
||||
useRafFn(() => {
|
||||
const step = currentStep.value
|
||||
if (!step) return
|
||||
|
||||
const domSel = domTargetSelector.value
|
||||
if (domSel) {
|
||||
const r = domClientRect(domSel)
|
||||
litRects.value = r ? [r] : []
|
||||
cursorPos.value = r
|
||||
? { x: r.left + r.width / 2, y: r.top + r.height + 6 }
|
||||
: null
|
||||
dragPath.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
litRects.value = litNodes.value
|
||||
.map((n) => nodeClientRect(n))
|
||||
.filter((r): r is ScreenRect => !!r)
|
||||
|
||||
if (step.kind === 'connect') {
|
||||
// Point the cursor right at the output slot the user drags from.
|
||||
const from = resolveNode(step.targetNodeType, step.targetNodeOccurrence)
|
||||
const slot = from ? slotClient(from.getOutputPos(step.fromSlot)) : null
|
||||
cursorPos.value = slot ? { x: slot[0] + 6, y: slot[1] + 6 } : null
|
||||
// Hide the guide line the moment the user grabs a link, so their real drag
|
||||
// line is the only one visible.
|
||||
const dragging = app.canvas?.linkConnector.isConnecting ?? false
|
||||
dragPath.value = dragging ? '' : buildDragPath(step)
|
||||
return
|
||||
}
|
||||
|
||||
const focus = focusNode.value
|
||||
const fr = focus ? nodeClientRect(focus) : null
|
||||
cursorPos.value = fr
|
||||
? { x: fr.left + fr.width * 0.5, y: fr.top + fr.height + 8 }
|
||||
: null
|
||||
dragPath.value = ''
|
||||
})
|
||||
|
||||
function buildDragPath(step: ConnectStep): string {
|
||||
const fromNode = resolveNode(step.targetNodeType, step.targetNodeOccurrence)
|
||||
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
|
||||
if (!fromNode || !toNode) return ''
|
||||
const [x1, y1] = slotClient(fromNode.getOutputPos(step.fromSlot))
|
||||
const [x2, y2] = slotClient(toNode.getInputPos(step.toSlot))
|
||||
const cx = (x1 + x2) / 2
|
||||
return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`
|
||||
}
|
||||
|
||||
function rectStyle(r: ScreenRect) {
|
||||
return {
|
||||
left: `${r.left - 8}px`,
|
||||
top: `${r.top - 8}px`,
|
||||
width: `${r.width + 16}px`,
|
||||
height: `${r.height + 16}px`
|
||||
}
|
||||
}
|
||||
|
||||
const cursorStyle = computed(() => {
|
||||
const c = cursorPos.value
|
||||
if (!c) return {}
|
||||
return { left: `${c.x}px`, top: `${c.y}px` }
|
||||
})
|
||||
|
||||
const hintLabel = computed(() => {
|
||||
const step = currentStep.value
|
||||
return step ? t(`onboardingTour.hint.${step.key}`) : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-drag-line {
|
||||
animation: onboarding-dash 0.6s linear infinite;
|
||||
}
|
||||
@keyframes onboarding-dash {
|
||||
to {
|
||||
stroke-dashoffset: -16;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
src/renderer/extensions/onboardingTour/tourSteps.ts
Normal file
133
src/renderer/extensions/onboardingTour/tourSteps.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
type TourStepKind =
|
||||
| 'reveal'
|
||||
| 'type'
|
||||
| 'connect'
|
||||
| 'run'
|
||||
| 'generating'
|
||||
| 'result'
|
||||
| 'done'
|
||||
|
||||
interface TourStepBase {
|
||||
kind: TourStepKind
|
||||
/** i18n key suffix under `onboardingTour.hint` for the coach-mark copy. */
|
||||
key: string
|
||||
/** Node type to spotlight; resolved to a live node id at runtime. */
|
||||
targetNodeType?: string
|
||||
/** When a template has duplicates of a type, pick the Nth (0-based). */
|
||||
targetNodeOccurrence?: number
|
||||
/** CSS selector for a DOM target (e.g. a toolbar button) instead of a node. */
|
||||
domTarget?: string
|
||||
/** Whether the step advances on its own action; talk-steps use Next. */
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
interface RevealStep extends TourStepBase {
|
||||
kind: 'reveal'
|
||||
}
|
||||
|
||||
interface DoneStep extends TourStepBase {
|
||||
kind: 'done'
|
||||
}
|
||||
|
||||
interface TypeStep extends TourStepBase {
|
||||
kind: 'type'
|
||||
/** Widget name on the node whose value the user must fill in, if any. */
|
||||
widgetName?: string
|
||||
}
|
||||
|
||||
/** Point at a DOM element (e.g. the Run button) and advance when it is clicked. */
|
||||
interface RunStep extends TourStepBase {
|
||||
kind: 'run'
|
||||
domTarget: string
|
||||
}
|
||||
|
||||
/** Waits, showing live progress, until the workflow finishes generating. */
|
||||
interface GeneratingStep extends TourStepBase {
|
||||
kind: 'generating'
|
||||
}
|
||||
|
||||
/** Celebrates the produced image with a CTA to explore more workflows. */
|
||||
interface ResultStep extends TourStepBase {
|
||||
kind: 'result'
|
||||
}
|
||||
|
||||
/**
|
||||
* The user must drag a link from `targetNodeType`'s output slot into the input
|
||||
* slot below. On tour start this exact link is pre-cleared so the drag is real.
|
||||
*/
|
||||
export interface ConnectStep extends TourStepBase {
|
||||
kind: 'connect'
|
||||
targetNodeType: string
|
||||
fromSlot: number
|
||||
toNodeType: string
|
||||
toNodeOccurrence?: number
|
||||
toSlot: number
|
||||
}
|
||||
|
||||
export type TourStep =
|
||||
| RevealStep
|
||||
| TypeStep
|
||||
| ConnectStep
|
||||
| RunStep
|
||||
| GeneratingStep
|
||||
| ResultStep
|
||||
| DoneStep
|
||||
|
||||
export const TOUR_TEMPLATE_ID = 'flux_kontext_dev_basic'
|
||||
export const TOUR_TEMPLATE_SOURCE = 'default'
|
||||
|
||||
/** The Image Edit (Flux.1 Kontext Dev) subgraph node — one box that does the edit. */
|
||||
export const EDIT_ENGINE_TYPE = '654c828f-2572-47e8-ba85-8a832c89b30c'
|
||||
|
||||
/** Run button in the top bar; the cloud paywalled variant falls back to this list. */
|
||||
const RUN_BUTTON_SELECTOR =
|
||||
'[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]'
|
||||
|
||||
/**
|
||||
* Beginner image-edit flow, sequenced for momentum → ownership → aha:
|
||||
* intro → wire it up → make it yours (prompt) → run → see your result.
|
||||
*/
|
||||
export const tourSteps: TourStep[] = [
|
||||
{
|
||||
kind: 'reveal',
|
||||
key: 'welcome',
|
||||
targetNodeType: 'LoadImage'
|
||||
},
|
||||
{
|
||||
kind: 'connect',
|
||||
key: 'connect',
|
||||
targetNodeType: 'LoadImage',
|
||||
fromSlot: 0,
|
||||
toNodeType: EDIT_ENGINE_TYPE,
|
||||
toSlot: 0,
|
||||
auto: true
|
||||
},
|
||||
{
|
||||
kind: 'type',
|
||||
key: 'prompt',
|
||||
targetNodeType: EDIT_ENGINE_TYPE,
|
||||
widgetName: 'text'
|
||||
},
|
||||
{
|
||||
kind: 'reveal',
|
||||
key: 'save',
|
||||
targetNodeType: 'SaveImage'
|
||||
},
|
||||
{
|
||||
kind: 'run',
|
||||
key: 'run',
|
||||
domTarget: RUN_BUTTON_SELECTOR,
|
||||
auto: true
|
||||
},
|
||||
{
|
||||
kind: 'generating',
|
||||
key: 'generating',
|
||||
targetNodeType: EDIT_ENGINE_TYPE,
|
||||
auto: true
|
||||
},
|
||||
{
|
||||
kind: 'result',
|
||||
key: 'result',
|
||||
targetNodeType: 'SaveImage'
|
||||
}
|
||||
]
|
||||
326
src/renderer/extensions/onboardingTour/useOnboardingTour.ts
Normal file
326
src/renderer/extensions/onboardingTour/useOnboardingTour.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
|
||||
import type { ConnectStep, TourStep } from './tourSteps'
|
||||
import {
|
||||
EDIT_ENGINE_TYPE,
|
||||
TOUR_TEMPLATE_ID,
|
||||
TOUR_TEMPLATE_SOURCE,
|
||||
tourSteps
|
||||
} from './tourSteps'
|
||||
|
||||
interface ClearedLink {
|
||||
fromNodeId: LGraphNode['id']
|
||||
fromSlot: number
|
||||
toNode: LGraphNode
|
||||
toSlot: number
|
||||
}
|
||||
|
||||
function useOnboardingTourImpl() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const templateWorkflows = useTemplateWorkflows()
|
||||
const executionStore = useExecutionStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const templateDialog = useWorkflowTemplateSelectorDialog()
|
||||
|
||||
const isActive = ref(false)
|
||||
const stepIndex = ref(0)
|
||||
const clearedLink = shallowRef<ClearedLink | null>(null)
|
||||
const resultImageUrl = ref<string | null>(null)
|
||||
|
||||
let detachStep: (() => void) | null = null
|
||||
|
||||
const currentStep = computed<TourStep | null>(() =>
|
||||
isActive.value ? (tourSteps[stepIndex.value] ?? null) : null
|
||||
)
|
||||
const totalSteps = computed(() => tourSteps.length)
|
||||
const isLast = computed(() => stepIndex.value === tourSteps.length - 1)
|
||||
|
||||
function resolveNode(type?: string, occurrence = 0): LGraphNode | null {
|
||||
const graph = canvasStore.currentGraph
|
||||
if (!graph || !type) return null
|
||||
const matches = graph.nodes.filter((node) => node.type === type)
|
||||
return matches[occurrence] ?? null
|
||||
}
|
||||
|
||||
/** The node the cursor points at (its rect anchors the coach mark). */
|
||||
const focusNode = computed(() => {
|
||||
const step = currentStep.value
|
||||
return step
|
||||
? resolveNode(step.targetNodeType, step.targetNodeOccurrence)
|
||||
: null
|
||||
})
|
||||
|
||||
/** The DOM element a step points at (Run button), if it targets one. */
|
||||
const domTargetSelector = computed(() => currentStep.value?.domTarget ?? null)
|
||||
|
||||
/** Nodes kept fully lit this step; all others stay dimmed the whole time. */
|
||||
const litNodes = computed<LGraphNode[]>(() => {
|
||||
const step = currentStep.value
|
||||
if (!step) return []
|
||||
const nodes = [resolveNode(step.targetNodeType, step.targetNodeOccurrence)]
|
||||
if (step.kind === 'connect') {
|
||||
nodes.push(resolveNode(step.toNodeType, step.toNodeOccurrence))
|
||||
}
|
||||
return nodes.filter((n): n is LGraphNode => !!n)
|
||||
})
|
||||
|
||||
/**
|
||||
* Talk-steps wait for Next; action-steps advance on their own action. A
|
||||
* connect step that couldn't pre-clear its link (nothing to drag), or any
|
||||
* auto-step that hasn't completed within a grace period, also shows Next so
|
||||
* the user is never trapped (e.g. a run that never executes).
|
||||
*/
|
||||
const autoStepStalled = ref(false)
|
||||
const canGoNext = computed(() => {
|
||||
const step = currentStep.value
|
||||
if (!step?.auto) return true
|
||||
if (step.kind === 'connect' && !clearedLink.value) return true
|
||||
return autoStepStalled.value
|
||||
})
|
||||
const canGoBack = computed(() => isActive.value && stepIndex.value > 0)
|
||||
|
||||
function stepTargetNode(step: TourStep): LGraphNode | null {
|
||||
return resolveNode(step.targetNodeType, step.targetNodeOccurrence)
|
||||
}
|
||||
|
||||
function preClearConnectLink(step: ConnectStep) {
|
||||
const fromNode = stepTargetNode(step)
|
||||
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
|
||||
if (!fromNode || !toNode) return
|
||||
toNode.disconnectInput(step.toSlot, true)
|
||||
// Only gate the step on a real reconnect if the disconnect actually took.
|
||||
if (toNode.getInputNode(step.toSlot)) return
|
||||
clearedLink.value = {
|
||||
fromNodeId: fromNode.id,
|
||||
fromSlot: step.fromSlot,
|
||||
toNode,
|
||||
toSlot: step.toSlot
|
||||
}
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function restoreClearedLink() {
|
||||
const link = clearedLink.value
|
||||
if (!link) return
|
||||
const fromNode = app.canvas?.graph?.getNodeById(link.fromNodeId)
|
||||
if (fromNode && !link.toNode.getInputNode(link.toSlot)) {
|
||||
fromNode.connect(link.fromSlot, link.toNode, link.toSlot)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
clearedLink.value = null
|
||||
}
|
||||
|
||||
function listenForCompletion(step: TourStep): () => void {
|
||||
// The prompt step lets the user type freely and move on with Next — typing
|
||||
// is continuous, so keystrokes must not auto-advance mid-thought.
|
||||
|
||||
if (step.kind === 'connect') {
|
||||
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
|
||||
// Only advance once the input goes from empty (we pre-cleared it) to linked
|
||||
// again — guards against a stale/undetected pre-clear firing instantly.
|
||||
const poll = window.setInterval(() => {
|
||||
if (!clearedLink.value) return
|
||||
if (toNode?.getInputNode(step.toSlot)) {
|
||||
clearedLink.value = null
|
||||
next()
|
||||
}
|
||||
}, 200)
|
||||
return () => window.clearInterval(poll)
|
||||
}
|
||||
|
||||
if (step.kind === 'run') {
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if ((event.target as Element | null)?.closest(step.domTarget)) next()
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}
|
||||
|
||||
if (step.kind === 'generating') {
|
||||
const onSuccess = () => {
|
||||
captureResultImage()
|
||||
next()
|
||||
}
|
||||
api.addEventListener('execution_success', onSuccess)
|
||||
return () => api.removeEventListener('execution_success', onSuccess)
|
||||
}
|
||||
|
||||
// reveal / result wait for the user to press Next / the explore CTA.
|
||||
return () => {}
|
||||
}
|
||||
|
||||
/** The result step's CTA: open the template library, then end the tour. */
|
||||
function openExplore() {
|
||||
templateDialog.show('command', { initialCategory: 'all' })
|
||||
stop()
|
||||
}
|
||||
|
||||
/** The finished image lands on the Save node — capture it for the result step. */
|
||||
function captureResultImage() {
|
||||
const saveNode = resolveNode('SaveImage', 0)
|
||||
if (!saveNode) return
|
||||
resultImageUrl.value =
|
||||
nodeOutputStore.getNodeImageUrls(saveNode)?.[0] ?? null
|
||||
}
|
||||
|
||||
function focusTypeInput(step: TourStep) {
|
||||
if (step.kind !== 'type') return
|
||||
const node = stepTargetNode(step)
|
||||
if (!node) return
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement | HTMLInputElement>(
|
||||
`[data-node-id="${node.id}"] textarea, [data-node-id="${node.id}"] input`
|
||||
)
|
||||
?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function enterStep() {
|
||||
detachStep?.()
|
||||
detachStep = null
|
||||
|
||||
const step = currentStep.value
|
||||
if (!step) return
|
||||
|
||||
if (step.kind === 'connect') preClearConnectLink(step)
|
||||
focusTypeInput(step)
|
||||
app.canvas?.setDirty(true, true)
|
||||
|
||||
autoStepStalled.value = false
|
||||
const graceTimer = step.auto
|
||||
? window.setTimeout(() => (autoStepStalled.value = true), 8000)
|
||||
: undefined
|
||||
const detachCompletion = listenForCompletion(step)
|
||||
detachStep = () => {
|
||||
window.clearTimeout(graceTimer)
|
||||
detachCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (!isActive.value) return
|
||||
if (isLast.value) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stepIndex.value += 1
|
||||
enterStep()
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (!isActive.value || stepIndex.value === 0) return
|
||||
detachStep?.()
|
||||
detachStep = null
|
||||
restoreClearedLink()
|
||||
stepIndex.value -= 1
|
||||
enterStep()
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the loaded template down to a clean teaching line: keep the first
|
||||
* LoadImage, the edit engine, and SaveImage; drop notes, the duplicate
|
||||
* LoadImage, and any stray node, then lay the three out evenly. A newcomer
|
||||
* sees three clear boxes, not a busy production graph.
|
||||
*/
|
||||
function simplifyGraph() {
|
||||
const graph = canvasStore.currentGraph
|
||||
if (!graph) return
|
||||
const keep = new Set<LGraphNode>()
|
||||
const load = resolveNode('LoadImage', 0)
|
||||
const edit = resolveNode(EDIT_ENGINE_TYPE, 0)
|
||||
const save = resolveNode('SaveImage', 0)
|
||||
for (const node of [load, edit, save]) if (node) keep.add(node)
|
||||
|
||||
for (const node of [...graph.nodes]) {
|
||||
if (!keep.has(node)) graph.remove(node)
|
||||
}
|
||||
|
||||
// Balance the row: cap the very wide Save node so nothing dominates the frame.
|
||||
const row = [load, edit, save].filter((n): n is LGraphNode => !!n)
|
||||
let x = 0
|
||||
for (const node of row) {
|
||||
node.size = [Math.min(node.size[0], 320), node.size[1]]
|
||||
node.pos = [x, 0]
|
||||
x += node.size[0] + 80
|
||||
}
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Frame the whole small workflow once, gently — no per-node zoom. */
|
||||
function frameWorkflow() {
|
||||
const canvas = app.canvas
|
||||
const nodes = canvas?.graph?.nodes ?? []
|
||||
if (!canvas || !nodes.length) return
|
||||
for (const node of nodes) {
|
||||
node.updateArea()
|
||||
}
|
||||
const bounds = createBounds(nodes)
|
||||
if (bounds) canvas.ds.fitToBounds(bounds, { zoom: 0.85 })
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (isActive.value) return
|
||||
await templateWorkflows.loadTemplates()
|
||||
await templateWorkflows.loadWorkflowTemplate(
|
||||
TOUR_TEMPLATE_ID,
|
||||
TOUR_TEMPLATE_SOURCE
|
||||
)
|
||||
|
||||
simplifyGraph()
|
||||
frameWorkflow()
|
||||
|
||||
isActive.value = true
|
||||
stepIndex.value = 0
|
||||
enterStep()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
detachStep?.()
|
||||
detachStep = null
|
||||
restoreClearedLink()
|
||||
app.canvas?.setDirty(true, true)
|
||||
isActive.value = false
|
||||
stepIndex.value = 0
|
||||
resultImageUrl.value = null
|
||||
}
|
||||
|
||||
const executionProgress = computed(() =>
|
||||
Math.round(executionStore.executionProgress * 100)
|
||||
)
|
||||
|
||||
return {
|
||||
isActive,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
currentStep,
|
||||
isLast,
|
||||
focusNode,
|
||||
litNodes,
|
||||
domTargetSelector,
|
||||
canGoNext,
|
||||
canGoBack,
|
||||
resultImageUrl,
|
||||
executionProgress,
|
||||
resolveNode,
|
||||
start,
|
||||
next,
|
||||
back,
|
||||
stop,
|
||||
openExplore
|
||||
}
|
||||
}
|
||||
|
||||
export const useOnboardingTour = createSharedComposable(useOnboardingTourImpl)
|
||||
@@ -20,6 +20,7 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<OnboardingTourOverlay />
|
||||
<GlobalToast />
|
||||
<InviteAcceptedToast />
|
||||
<RerouteMigrationToast />
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
import OnboardingTourOverlay from '@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue'
|
||||
import { useOnboardingTour } from '@/renderer/extensions/onboardingTour/useOnboardingTour'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
|
||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
@@ -293,7 +296,11 @@ void nextTick(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const onboardingTour = useOnboardingTour()
|
||||
|
||||
const onGraphReady = () => {
|
||||
void onboardingTour.start()
|
||||
|
||||
runWhenGlobalIdle(() => {
|
||||
// Track user login when app is ready in graph view (cloud only)
|
||||
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {
|
||||
|
||||
Reference in New Issue
Block a user