mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-11 08:20:53 +00:00
## Summary Phase 1 of the dialog migration kicked off in #11719. Migrates the two simplest production dialogs — `PromptDialogContent` and `ConfirmationDialogContent` — from PrimeVue `Dialog` onto the Reka-UI primitives landed in Phase 0. Public API of `useDialogService` / `dialogStore` is unchanged. Parent: [FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent) This phase: [FE-573](https://linear.app/comfyorg/issue/FE-573/phase-1-migrate-promptdialog-confirmationdialog-closes-11688) Predecessor: #11719 (merged at `0788e7139`) Refs #11688 (closed manually after Phase 0; the actual user-visible max-width fix ships in this PR) ## Changes ### `src/services/dialogService.ts` | Call site | Renderer | Size | Width override | | --- | --- | --- | --- | | `prompt()` | `'reka'` | `md` | — | | `confirm()` | `'reka'` | `md` | — | | `showBillingComingSoonDialog()` | `'reka'` | `sm` | `contentClass: 'max-w-[360px]'` | ### `src/components/dialog/content/ConfirmationDialogContent.vue` - Drops `import Message from 'primevue/message'` — the only PrimeVue dependency in the component - Replaces `<Message>` with a Tailwind `role="status"` alert keeping the `pi pi-info-circle` icon and muted-foreground severity ### `src/stores/dialogStore.ts` + `src/components/dialog/GlobalDialog.vue` - Adds `contentClass?: HTMLAttributes['class']` on `CustomDialogComponentProps` - Forwards it to `<DialogContent :class="...">` on the Reka branch (PrimeVue path keeps using `pt`) ## Why this scope 1. **Smallest content surface** — `PromptDialogContent` is 43 LOC; the only PrimeVue dependency in `ConfirmationDialogContent` is the `<Message>` info banner. 2. **Closes #11688 ergonomics** — Reka's `md` size = `max-w-xl` (576px / 36rem), exactly the max-width the issue reporter asked for. 3. **Three known callers** — all in `dialogService.ts`. No other callers needed to change. 4. **Renderer branch is already proven by Phase 0**; this PR just flips the flag. ## Visual proof Verified live in Storybook (`Components / Dialog / Dialog → Default` and `… → All Sizes`) at viewport `1920×1080`. DOM inspection confirms the rendered widths match the design intent: | Story | size | Rendered width | Computed `max-width` | | --- | --- | --- | --- | | `Default` | `md` | **576 px** | **576 px (= 36rem)** | | `All Sizes` (sm slot) | `sm` | 384 px | 384 px (= 24rem) | The `md` measurement directly answers the #11688 reporter screenshot (1558 px wide PrimeVue dialog → 576 px Reka dialog on the same display). Local screenshot artifacts (not committed): `temp/screenshots/phase1-md-576px-1920w.png`, `temp/screenshots/phase1-md-allsizes-1920w.png`, `temp/screenshots/phase1-sm-384px-1920w.png` — drag-drop into the PR body before marking ready for review. ## Quality gates - [x] `pnpm typecheck` — clean - [x] `pnpm lint` — clean - [x] `pnpm format` — applied (oxfmt) - [x] `pnpm test:unit` (touched files): **26/26 passed** - `ConfirmationDialogContent.test.ts` (9 tests, no longer needs PrimeVue plugin) - `PromptDialogContent.test.ts` (5 tests, unchanged) - `GlobalDialog.test.ts` (9 tests, Phase 0 coverage still passes after the contentClass forwarder addition) - `dialogService.renderer.test.ts` **new** — 3 tests asserting each call site sets `renderer: 'reka'` (regression net) - [ ] `pnpm test:browser:local --grep "@mobile confirm dialog"` — **could not run locally** (no ComfyUI Python backend on `localhost:8188` in this session); CI will gate the existing fixture, which is already renderer-agnostic (`getByRole('dialog')` + `getByRole('button', ...)` in `browser_tests/fixtures/components/ConfirmDialog.ts`). ## Public API impact None. `useDialogService().prompt(...)` / `confirm(...)` / `showBillingComingSoonDialog(...)` keep their existing signatures. Custom-node extensions calling `app.extensionManager.dialog.*` continue to work. ## Out of scope (later phases) - `ErrorDialogContent`, `NodeSearchBox`, `SecretFormDialog`, `VideoHelpDialog`, `CustomizationDialog` — Phase 2 (FE-574) - Settings dialog — Phase 3 (FE-575) - Manager dialog — Phase 4 (FE-576) - `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) — Phase 5 (FE-577) - Removing PrimeVue `Dialog` imports + `<style>` cleanup in `GlobalDialog.vue` — Phase 6 (FE-578) - Legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`) - Deduplicating `Dialogue.vue` / `ImageLightbox.vue` ## Screenshot <img width="865" height="497" alt="Screenshot 2026-05-08 at 4 35 45 PM" src="https://github.com/user-attachments/assets/6aead2ad-2e0b-478a-9154-bb632a6bf3d1" /> <img width="1363" height="964" alt="Screenshot 2026-05-08 at 4 38 16 PM" src="https://github.com/user-attachments/assets/10647752-a063-4901-a206-842799cc5d7a" /> <img width="889" height="486" alt="Screenshot 2026-05-08 at 4 46 57 PM" src="https://github.com/user-attachments/assets/81899a81-205a-46f2-bddd-7639624607f6" /> ## Test plan - [x] Unit: 26/26 pass on touched files - [ ] CI: `@mobile confirm dialog` spec on the migrated path - [ ] Manual (post-CI on a real backend): open prompt and confirm dialogs on 1920×1080 viewport, verify ≤ 36rem max-width, ESC closes, backdrop click closes, Enter submits prompt, focus trap holds - [ ] Manual: open Billing Coming Soon dialog — verify it stays at the existing `max-w-[360px]` width
282 lines
7.9 KiB
TypeScript
282 lines
7.9 KiB
TypeScript
// We should consider moving to https://primevue.org/dynamicdialog/ once everything is in Vue.
|
|
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
|
|
import { merge } from 'es-toolkit/compat'
|
|
import { defineStore } from 'pinia'
|
|
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
|
import { markRaw, ref } from 'vue'
|
|
import type { Component, HTMLAttributes } from 'vue'
|
|
|
|
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
|
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
|
|
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
|
|
|
type DialogPosition =
|
|
| 'center'
|
|
| 'top'
|
|
| 'bottom'
|
|
| 'left'
|
|
| 'right'
|
|
| 'topleft'
|
|
| 'topright'
|
|
| 'bottomleft'
|
|
| 'bottomright'
|
|
|
|
/**
|
|
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
|
|
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
|
|
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
|
|
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
|
|
*/
|
|
type DialogRenderer = 'primevue' | 'reka'
|
|
|
|
interface CustomDialogComponentProps {
|
|
maximizable?: boolean
|
|
maximized?: boolean
|
|
onClose?: () => void
|
|
closable?: boolean
|
|
modal?: boolean
|
|
position?: DialogPosition
|
|
pt?: DialogPassThroughOptions
|
|
closeOnEscape?: boolean
|
|
dismissableMask?: boolean
|
|
unstyled?: boolean
|
|
headless?: boolean
|
|
renderer?: DialogRenderer
|
|
size?: DialogContentSize
|
|
/**
|
|
* Class applied to the Reka-UI `DialogContent` element. Ignored on the
|
|
* PrimeVue path — use `pt` for that renderer.
|
|
*/
|
|
contentClass?: HTMLAttributes['class']
|
|
}
|
|
|
|
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
|
|
CustomDialogComponentProps
|
|
|
|
export interface DialogInstance<
|
|
H extends Component = Component,
|
|
B extends Component = Component,
|
|
F extends Component = Component
|
|
> {
|
|
key: string
|
|
visible: boolean
|
|
title?: string
|
|
headerComponent?: H
|
|
headerProps?: ComponentAttrs<H>
|
|
component: B
|
|
contentProps: ComponentAttrs<B>
|
|
footerComponent?: F
|
|
footerProps?: ComponentAttrs<F>
|
|
dialogComponentProps: DialogComponentProps
|
|
priority: number
|
|
}
|
|
|
|
export interface ShowDialogOptions<
|
|
H extends Component = Component,
|
|
B extends Component = Component,
|
|
F extends Component = Component
|
|
> {
|
|
key?: string
|
|
title?: string
|
|
headerComponent?: H
|
|
footerComponent?: F
|
|
component: B
|
|
props?: ComponentAttrs<B>
|
|
headerProps?: ComponentAttrs<H>
|
|
footerProps?: ComponentAttrs<F>
|
|
dialogComponentProps?: DialogComponentProps
|
|
/**
|
|
* Optional priority for dialog stacking.
|
|
* A dialog will never be shown above a dialog with a higher priority.
|
|
* @default 1
|
|
*/
|
|
priority?: number
|
|
}
|
|
|
|
export const useDialogStore = defineStore('dialog', () => {
|
|
const dialogStack = ref<DialogInstance[]>([])
|
|
|
|
/**
|
|
* The key of the currently active (top-most) dialog.
|
|
* Only the active dialog can be closed with the ESC key.
|
|
*/
|
|
const activeKey = ref<string | null>(null)
|
|
|
|
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
|
|
|
|
/**
|
|
* Inserts a dialog into the stack at the correct position based on priority.
|
|
* Higher priority dialogs are placed before lower priority ones.
|
|
*/
|
|
function insertDialogByPriority(dialog: DialogInstance) {
|
|
const insertIndex = dialogStack.value.findIndex(
|
|
(d) => d.priority <= dialog.priority
|
|
)
|
|
|
|
dialogStack.value.splice(
|
|
insertIndex === -1 ? dialogStack.value.length : insertIndex,
|
|
0,
|
|
dialog
|
|
)
|
|
}
|
|
|
|
function riseDialog(options: { key: string }) {
|
|
const dialogKey = options.key
|
|
|
|
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
|
|
if (index !== -1) {
|
|
const [dialog] = dialogStack.value.splice(index, 1)
|
|
insertDialogByPriority(dialog)
|
|
activeKey.value = dialogKey
|
|
updateCloseOnEscapeStates()
|
|
}
|
|
}
|
|
|
|
function closeDialog(options?: { key: string }) {
|
|
const targetDialog = options
|
|
? dialogStack.value.find((d) => d.key === options.key)
|
|
: dialogStack.value.find((d) => d.key === activeKey.value)
|
|
if (!targetDialog) return
|
|
|
|
targetDialog.dialogComponentProps?.onClose?.()
|
|
const index = dialogStack.value.indexOf(targetDialog)
|
|
dialogStack.value.splice(index, 1)
|
|
|
|
activeKey.value =
|
|
dialogStack.value.length > 0
|
|
? dialogStack.value[dialogStack.value.length - 1].key
|
|
: null
|
|
|
|
updateCloseOnEscapeStates()
|
|
}
|
|
|
|
function createDialog<
|
|
H extends Component = Component,
|
|
B extends Component = Component,
|
|
F extends Component = Component
|
|
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
|
|
if (dialogStack.value.length >= 10) {
|
|
dialogStack.value.shift()
|
|
}
|
|
|
|
const dialog = {
|
|
key: options.key,
|
|
visible: true,
|
|
title: options.title,
|
|
headerComponent: options.headerComponent
|
|
? markRaw(options.headerComponent)
|
|
: undefined,
|
|
footerComponent: options.footerComponent
|
|
? markRaw(options.footerComponent)
|
|
: undefined,
|
|
component: markRaw(options.component),
|
|
headerProps: { ...options.headerProps },
|
|
contentProps: { ...options.props },
|
|
footerProps: { ...options.footerProps },
|
|
priority: options.priority ?? 1,
|
|
dialogComponentProps: {
|
|
maximizable: false,
|
|
modal: true,
|
|
closable: true,
|
|
closeOnEscape: true,
|
|
dismissableMask: true,
|
|
...options.dialogComponentProps,
|
|
maximized: false,
|
|
onMaximize: () => {
|
|
dialog.dialogComponentProps.maximized = true
|
|
},
|
|
onUnmaximize: () => {
|
|
dialog.dialogComponentProps.maximized = false
|
|
},
|
|
onAfterHide: () => {
|
|
closeDialog(dialog)
|
|
},
|
|
pt: merge(options.dialogComponentProps?.pt || {}, {
|
|
root: {
|
|
onMousedown: () => {
|
|
riseDialog(dialog)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
insertDialogByPriority(dialog)
|
|
activeKey.value = options.key
|
|
updateCloseOnEscapeStates()
|
|
|
|
return dialog
|
|
}
|
|
|
|
/**
|
|
* Ensures only the top-most dialog in the stack can be closed with the Escape key.
|
|
* This is necessary because PrimeVue Dialogs do not handle `closeOnEscape` prop
|
|
* correctly when multiple dialogs are open.
|
|
*/
|
|
function updateCloseOnEscapeStates() {
|
|
const topDialog = dialogStack.value.find((d) => d.key === activeKey.value)
|
|
const topClosable = topDialog?.dialogComponentProps.closable
|
|
|
|
dialogStack.value.forEach((dialog) => {
|
|
dialog.dialogComponentProps = {
|
|
...dialog.dialogComponentProps,
|
|
closeOnEscape: dialog === topDialog && !!topClosable
|
|
}
|
|
})
|
|
}
|
|
|
|
function showDialog<
|
|
H extends Component = Component,
|
|
B extends Component = Component,
|
|
F extends Component = Component
|
|
>(options: ShowDialogOptions<H, B, F>) {
|
|
const dialogKey = options.key || genDialogKey()
|
|
|
|
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
|
|
|
|
if (dialog) {
|
|
dialog.visible = true
|
|
riseDialog(dialog)
|
|
} else {
|
|
dialog = createDialog({ ...options, key: dialogKey })
|
|
}
|
|
return dialog
|
|
}
|
|
|
|
/**
|
|
* Shows a dialog from a third party extension.
|
|
* Explicitly keys extension dialogs with `extension-` prefix,
|
|
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
|
|
*/
|
|
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
|
|
const { key } = options
|
|
if (!key) {
|
|
console.error('Extension dialog key is required')
|
|
return
|
|
}
|
|
|
|
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
|
|
|
|
const dialog = dialogStack.value.find((d) => d.key === extKey)
|
|
if (!dialog) return createDialog({ ...options, key: extKey })
|
|
|
|
dialog.visible = true
|
|
riseDialog(dialog)
|
|
return dialog
|
|
}
|
|
|
|
function isDialogOpen(key: string) {
|
|
return dialogStack.value.some((d) => d.key === key)
|
|
}
|
|
|
|
return {
|
|
dialogStack,
|
|
riseDialog,
|
|
showDialog,
|
|
closeDialog,
|
|
showExtensionDialog,
|
|
isDialogOpen,
|
|
activeKey
|
|
}
|
|
})
|