diff --git a/browser_tests/fixtures/helpers/AppModeHelper.ts b/browser_tests/fixtures/helpers/AppModeHelper.ts index 8fb2d87161..b7559bab0e 100644 --- a/browser_tests/fixtures/helpers/AppModeHelper.ts +++ b/browser_tests/fixtures/helpers/AppModeHelper.ts @@ -68,6 +68,41 @@ export class AppModeHelper { await this.comfyPage.nextFrame() } + /** + * Inject linearData into the current graph and enter app mode. + * + * Serializes the graph, injects linearData with the given inputs and + * auto-detected output node IDs, then reloads so the appModeStore + * picks up the data via its activeWorkflow watcher. + * + * @param inputs - Widget selections as [nodeId, widgetName] tuples + */ + async enterAppModeWithInputs(inputs: [string, string][]) { + await this.page.evaluate(async (inputTuples) => { + const graph = window.app!.graph + if (!graph) return + + const outputNodeIds = graph.nodes + .filter( + (n: { type?: string }) => + n.type === 'SaveImage' || n.type === 'PreviewImage' + ) + .map((n: { id: number | string }) => String(n.id)) + + const workflow = graph.serialize() as unknown as Record + const extra = (workflow.extra ?? {}) as Record + extra.linearData = { inputs: inputTuples, outputs: outputNodeIds } + workflow.extra = extra + await window.app!.loadGraphData( + workflow as unknown as Parameters< + NonNullable['loadGraphData'] + >[0] + ) + }, inputs) + await this.comfyPage.nextFrame() + await this.toggleAppMode() + } + /** The linear-mode widget list container (visible in app mode). */ get linearWidgets(): Locator { return this.page.locator('[data-testid="linear-widgets"]') diff --git a/browser_tests/tests/appModeDropdownClipping.spec.ts b/browser_tests/tests/appModeDropdownClipping.spec.ts new file mode 100644 index 0000000000..f01c3853c0 --- /dev/null +++ b/browser_tests/tests/appModeDropdownClipping.spec.ts @@ -0,0 +1,168 @@ +import type { Page } from '@playwright/test' + +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' + +/** + * Default workflow widget inputs as [nodeId, widgetName] tuples. + * All widgets from the default graph are selected so the panel scrolls, + * pushing the last widget's dropdown to the clipping boundary. + */ +const DEFAULT_INPUTS: [string, string][] = [ + ['4', 'ckpt_name'], + ['6', 'text'], + ['7', 'text'], + ['5', 'width'], + ['5', 'height'], + ['5', 'batch_size'], + ['3', 'seed'], + ['3', 'steps'], + ['3', 'cfg'], + ['3', 'sampler_name'], + ['3', 'scheduler'], + ['3', 'denoise'], + ['9', 'filename_prefix'] +] + +function isClippedByAnyAncestor(el: Element): boolean { + const child = el.getBoundingClientRect() + let parent = el.parentElement + + while (parent) { + const overflow = getComputedStyle(parent).overflow + if (overflow !== 'visible') { + const p = parent.getBoundingClientRect() + if ( + child.top < p.top || + child.bottom > p.bottom || + child.left < p.left || + child.right > p.right + ) { + return true + } + } + parent = parent.parentElement + } + return false +} + +/** Add a node to the graph by type and return its ID. */ +async function addNode(page: Page, nodeType: string): Promise { + return page.evaluate((type) => { + const node = window.app!.graph.add( + window.LiteGraph!.createNode(type, undefined, {}) + ) + return String(node!.id) + }, nodeType) +} + +test.describe('App mode dropdown clipping', { tag: '@ui' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page.evaluate(() => { + window.app!.api.serverFeatureFlags.value = { + ...window.app!.api.serverFeatureFlags.value, + linear_toggle_enabled: true + } + }) + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + }) + + test('Select dropdown is not clipped in app mode panel', async ({ + comfyPage + }) => { + const saveVideoId = await addNode(comfyPage.page, 'SaveVideo') + await comfyPage.nextFrame() + + const inputs: [string, string][] = [ + ...DEFAULT_INPUTS, + [saveVideoId, 'codec'] + ] + await comfyPage.appMode.enterAppModeWithInputs(inputs) + + await expect(comfyPage.appMode.linearWidgets).toBeVisible({ + timeout: 5000 + }) + + // Scroll to bottom so the codec widget is at the clipping edge + const widgetList = comfyPage.appMode.linearWidgets + await widgetList.evaluate((el) => + el.scrollTo({ top: el.scrollHeight, behavior: 'instant' }) + ) + + // Click the codec select (combobox role with aria-label from WidgetSelectDefault) + const codecSelect = widgetList.getByRole('combobox', { name: 'codec' }) + await codecSelect.click() + + const overlay = comfyPage.page.locator('.p-select-overlay').first() + await expect(overlay).toBeVisible({ timeout: 5000 }) + + const isInViewport = await overlay.evaluate((el) => { + const rect = el.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ) + }) + expect(isInViewport).toBe(true) + + const isClipped = await overlay.evaluate(isClippedByAnyAncestor) + expect(isClipped).toBe(false) + }) + + test('FormDropdown popup is not clipped in app mode panel', async ({ + comfyPage + }) => { + const loadImageId = await addNode(comfyPage.page, 'LoadImage') + await comfyPage.nextFrame() + + const inputs: [string, string][] = [ + ...DEFAULT_INPUTS, + [loadImageId, 'image'] + ] + await comfyPage.appMode.enterAppModeWithInputs(inputs) + + await expect(comfyPage.appMode.linearWidgets).toBeVisible({ + timeout: 5000 + }) + + // Scroll to bottom so the image widget is at the clipping edge + const widgetList = comfyPage.appMode.linearWidgets + await widgetList.evaluate((el) => + el.scrollTo({ top: el.scrollHeight, behavior: 'instant' }) + ) + + // Click the FormDropdown trigger button for the image widget. + // The button emits 'select-click' which toggles the Popover. + const imageRow = widgetList.locator( + 'div:has(> div > span:text-is("image"))' + ) + const dropdownButton = imageRow.locator('button:has(> span)').first() + await dropdownButton.click() + + // The unstyled PrimeVue Popover renders with role="dialog". + // Locate the one containing the image grid (filter buttons like "All", "Inputs"). + const popover = comfyPage.page + .getByRole('dialog') + .filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) }) + .first() + await expect(popover).toBeVisible({ timeout: 5000 }) + + const isInViewport = await popover.evaluate((el) => { + const rect = el.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ) + }) + expect(isInViewport).toBe(true) + + const isClipped = await popover.evaluate(isClippedByAnyAncestor) + expect(isClipped).toBe(false) + }) +}) diff --git a/browser_tests/tests/linearMode.spec.ts b/browser_tests/tests/linearMode.spec.ts index 887002718b..7551e31bbc 100644 --- a/browser_tests/tests/linearMode.spec.ts +++ b/browser_tests/tests/linearMode.spec.ts @@ -1,5 +1,3 @@ -import type { Page } from '@playwright/test' - import { comfyPageFixture as test, comfyExpect as expect @@ -11,58 +9,10 @@ test.describe('Linear Mode', { tag: '@ui' }, () => { await comfyPage.setup() }) - async function enterAppMode(comfyPage: { - page: Page - nextFrame: () => Promise - }) { - // LinearControls requires hasOutputs to be true. Serialize the current - // graph, inject linearData with output node IDs, then reload so the - // appModeStore picks up the outputs via its activeWorkflow watcher. - await comfyPage.page.evaluate(async () => { - const graph = window.app!.graph - if (!graph) return - - const outputNodeIds = graph.nodes - .filter( - (n: { type?: string }) => - n.type === 'SaveImage' || n.type === 'PreviewImage' - ) - .map((n: { id: number | string }) => String(n.id)) - - // Serialize, inject linearData, and reload to sync stores - const workflow = graph.serialize() as unknown as Record - const extra = (workflow.extra ?? {}) as Record - extra.linearData = { inputs: [], outputs: outputNodeIds } - workflow.extra = extra - await window.app!.loadGraphData( - workflow as unknown as Parameters< - NonNullable['loadGraphData'] - >[0] - ) - }) - await comfyPage.nextFrame() - - // Toggle to app mode via the command which sets canvasStore.linearMode - await comfyPage.page.evaluate(() => { - window.app!.extensionManager.command.execute('Comfy.ToggleLinear') - }) - await comfyPage.nextFrame() - } - - async function enterGraphMode(comfyPage: { - page: Page - nextFrame: () => Promise - }) { - await comfyPage.page.evaluate(() => { - window.app!.extensionManager.command.execute('Comfy.ToggleLinear') - }) - await comfyPage.nextFrame() - } - test('Displays linear controls when app mode active', async ({ comfyPage }) => { - await enterAppMode(comfyPage) + await comfyPage.appMode.enterAppModeWithInputs([]) await expect( comfyPage.page.locator('[data-testid="linear-widgets"]') @@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => { }) test('Run button visible in linear mode', async ({ comfyPage }) => { - await enterAppMode(comfyPage) + await comfyPage.appMode.enterAppModeWithInputs([]) await expect( comfyPage.page.locator('[data-testid="linear-run-button"]') @@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => { }) test('Workflow info section visible', async ({ comfyPage }) => { - await enterAppMode(comfyPage) + await comfyPage.appMode.enterAppModeWithInputs([]) await expect( comfyPage.page.locator('[data-testid="linear-workflow-info"]') @@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => { }) test('Returns to graph mode', async ({ comfyPage }) => { - await enterAppMode(comfyPage) + await comfyPage.appMode.enterAppModeWithInputs([]) await expect( comfyPage.page.locator('[data-testid="linear-widgets"]') ).toBeVisible({ timeout: 5000 }) - await enterGraphMode(comfyPage) + await comfyPage.appMode.toggleAppMode() await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 }) await expect( @@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => { }) test('Canvas not visible in app mode', async ({ comfyPage }) => { - await enterAppMode(comfyPage) + await comfyPage.appMode.enterAppModeWithInputs([]) await expect( comfyPage.page.locator('[data-testid="linear-widgets"]') diff --git a/src/components/builder/AppModeWidgetList.vue b/src/components/builder/AppModeWidgetList.vue index 1ef3a2166b..75a9106a18 100644 --- a/src/components/builder/AppModeWidgetList.vue +++ b/src/components/builder/AppModeWidgetList.vue @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n' import Popover from '@/components/ui/Popover.vue' import Button from '@/components/ui/button/Button.vue' import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager' +import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' @@ -44,6 +45,7 @@ const appModeStore = useAppModeStore() const maskEditor = useMaskEditor() provide(HideLayoutFieldKey, true) +provide(OverlayAppendToKey, 'body') const graphNodes = shallowRef(app.rootGraph.nodes) useEventListener( diff --git a/src/composables/useTransformCompatOverlayProps.ts b/src/composables/useTransformCompatOverlayProps.ts index f6a7e66723..07c501f0bd 100644 --- a/src/composables/useTransformCompatOverlayProps.ts +++ b/src/composables/useTransformCompatOverlayProps.ts @@ -1,5 +1,6 @@ import type { HintedString } from '@primevue/core' -import { computed } from 'vue' +import type { InjectionKey } from 'vue' +import { computed, inject } from 'vue' /** * Options for configuring transform-compatible overlay props @@ -15,6 +16,10 @@ interface TransformCompatOverlayOptions { // autoZIndex?: boolean } +export const OverlayAppendToKey: InjectionKey< + HintedString<'body' | 'self'> | undefined | HTMLElement +> = Symbol('OverlayAppendTo') + /** * Composable that provides props to make PrimeVue overlay components * compatible with CSS-transformed parent elements. @@ -41,8 +46,10 @@ interface TransformCompatOverlayOptions { export function useTransformCompatOverlayProps( overrides: TransformCompatOverlayOptions = {} ) { + const injectedAppendTo = inject(OverlayAppendToKey, undefined) + return computed(() => ({ - appendTo: 'self' as const, + appendTo: injectedAppendTo ?? ('self' as const), ...overrides })) } diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue index 9a5041b97c..10b10352ff 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -4,6 +4,7 @@ import Popover from 'primevue/popover' import { computed, ref, useTemplateRef } from 'vue' import { useI18n } from 'vue-i18n' +import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps' import { useToastStore } from '@/platform/updates/common/toastStore' import type { @@ -50,6 +51,7 @@ interface Props { } const { t } = useI18n() +const overlayProps = useTransformCompatOverlayProps() const { placeholder, @@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) { ref="popoverRef" :dismissable="true" :close-on-escape="true" + :append-to="overlayProps.appendTo" unstyled :pt="{ root: { diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue index 80c8095f02..7b2bbcd08e 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue @@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue' import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' +import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps' import type { FilterOption, OwnershipFilterOption, @@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue' import type { LayoutMode, SortOption } from './types' const { t } = useI18n() +const overlayProps = useTransformCompatOverlayProps() defineProps<{ sortOptions: SortOption[] @@ -132,6 +134,7 @@ function toggleBaseModelSelection(item: FilterOption) { ref="sortPopoverRef" :dismissable="true" :close-on-escape="true" + :append-to="overlayProps.appendTo" unstyled :pt="{ root: { @@ -194,6 +197,7 @@ function toggleBaseModelSelection(item: FilterOption) { ref="ownershipPopoverRef" :dismissable="true" :close-on-escape="true" + :append-to="overlayProps.appendTo" unstyled :pt="{ root: { @@ -256,6 +260,7 @@ function toggleBaseModelSelection(item: FilterOption) { ref="baseModelPopoverRef" :dismissable="true" :close-on-escape="true" + :append-to="overlayProps.appendTo" unstyled :pt="{ root: {