fix(web): style issue of add input field panel in human input form co… (#37102)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
KVOJJJin 2026-06-05 16:40:20 +08:00 committed by GitHub
parent d16a012575
commit edeaac5d4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 227 additions and 24 deletions

View File

@ -1666,11 +1666,6 @@
"count": 2
}
},
"web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3

View File

@ -5,7 +5,7 @@ import type {
EditorState,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
@ -131,7 +131,11 @@ export type PromptEditorProps = {
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
shortcutPopups?: Array<{
hotkey: Hotkey
displayMode?: ShortcutPopupDisplayMode
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
}>
}
const PromptEditor: FC<PromptEditorProps> = ({

View File

@ -91,6 +91,29 @@ describe('InputField', () => {
lastVarReferencePickerProps = undefined
})
it('should keep the header and actions visible while the field content scrolls internally', () => {
const { container } = render(
<InputField
nodeId="node-layout"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={vi.fn()}
/>,
)
const panel = container.firstElementChild
const header = panel?.children[0]
const scrollBody = panel?.children[1]
const footer = panel?.lastElementChild
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
expect(header).toHaveClass('shrink-0', 'pb-2')
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
expect(footer).not.toHaveClass('border-t')
})
it('should disable save and show validation error when variable name is invalid', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -3,6 +3,7 @@ import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { produce } from 'immer'
@ -11,7 +12,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import {
@ -206,9 +206,11 @@ const InputField: React.FC<InputFieldProps> = ({
}, [handleSave])
return (
<div className="flex max-h-[540px] w-[372px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-h-0 flex-1 overflow-y-auto p-3 pb-0">
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="shrink-0 p-3 pb-2">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 pt-0 pb-0">
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })}
@ -322,7 +324,7 @@ const InputField: React.FC<InputFieldProps> = ({
</div>
)}
</div>
<div className="shrink-0 p-3">
<div className="shrink-0 bg-components-panel-bg p-3">
<div className="flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit

View File

@ -1,4 +1,4 @@
import type { ShortcutPopupInsertHandler } from '../index'
import type { ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from '../index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
@ -50,6 +50,7 @@ const CONTENT_EDITABLE_ID = 'ce'
type MinimalEditorProps = {
withContainer?: boolean
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
displayMode?: ShortcutPopupDisplayMode
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
onOpen?: () => void
@ -59,6 +60,7 @@ type MinimalEditorProps = {
const MinimalEditor: React.FC<MinimalEditorProps> = ({
withContainer = true,
hotkey,
displayMode,
children,
className,
onOpen,
@ -83,6 +85,7 @@ const MinimalEditor: React.FC<MinimalEditorProps> = ({
<ShortcutsPopupPlugin
container={withContainer ? containerEl : undefined}
hotkey={hotkey}
displayMode={displayMode}
className={className}
onOpen={onOpen}
onClose={onClose}
@ -179,7 +182,9 @@ describe('ShortcutsPopupPlugin', () => {
const host = screen.getByTestId(CONTAINER_ID)
focusAndTriggerHotkey('/')
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(host).toContainElement(portalContent)
expect(floatingDiv).toHaveStyle({ position: 'absolute' })
})
it('falls back to document.body when container is not provided', async () => {
@ -188,10 +193,65 @@ describe('ShortcutsPopupPlugin', () => {
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(document.body).toContainElement(portalContent)
expect(floatingDiv).toHaveStyle({ position: 'fixed' })
expect(floatingDiv).toHaveStyle({ zIndex: '50' })
expect(floatingDiv).toHaveStyle({ overflow: 'visible' })
})
it('clips the popup viewport so child popups own their internal scrolling', async () => {
render(<MinimalEditor />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(floatingDiv.firstElementChild).toHaveClass('overflow-hidden')
})
it('can render fixed next to the workflow panel instead of following the cursor', async () => {
const originalInnerWidth = window.innerWidth
const originalInnerHeight = window.innerHeight
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
const rightPanel = document.createElement('div')
rightPanel.setAttribute('data-workflow-right-panel', '')
rightPanel.getBoundingClientRect = vi.fn(() => ({
x: 800,
y: 56,
width: 400,
height: 840,
top: 56,
right: 1200,
bottom: 896,
left: 800,
toJSON: () => ({}),
} as DOMRect))
document.body.appendChild(rightPanel)
try {
render(<MinimalEditor withContainer={false} displayMode="workflow-panel-adjacent-center" />)
focusAndTriggerHotkey('/')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
await waitFor(() => {
expect(floatingDiv).toHaveStyle({
position: 'fixed',
right: '404px',
top: '474px',
transform: 'translateY(-50%)',
})
})
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-width')).toBe('400px')
expect(floatingDiv.style.getPropertyValue('--shortcut-popup-max-height')).toBe('836px')
}
finally {
rightPanel.remove()
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
}
})
// ─── matchHotkey: string hotkey ───
it('matches a string hotkey like "mod+/"', async () => {
render(<MinimalEditor hotkey="mod+/" />)

View File

@ -1,4 +1,5 @@
import type { LexicalCommand } from 'lexical'
import type { CSSProperties } from 'react'
import {
autoUpdate,
flip,
@ -19,11 +20,13 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { createPortal } from 'react-dom'
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void
export type ShortcutPopupDisplayMode = 'selection' | 'workflow-panel-adjacent-center'
// Hotkey can be:
// - string: 'mod+/'
@ -37,10 +40,85 @@ type ShortcutPopupPluginProps = {
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
container?: Element | null
displayMode?: ShortcutPopupDisplayMode
onOpen?: () => void
onClose?: () => void
}
const VIEWPORT_PADDING = 8
const PANEL_GAP = 4
const POPUP_MAX_WIDTH = 400
type FixedPlacementState = {
right: number
top: number
availableWidth: number
availableHeight: number
}
function getWorkflowPanelAdjacentPlacement(): FixedPlacementState {
const rightPanel = document.querySelector('[data-workflow-right-panel]') as HTMLElement | null
const rightPanelRect = rightPanel?.getBoundingClientRect()
const rightBoundary = rightPanelRect && rightPanelRect.left > 0
? rightPanelRect.left
: window.innerWidth
const topBoundary = rightPanelRect?.top ?? 56
const bottomBoundary = window.innerHeight - VIEWPORT_PADDING
const availableWidth = Math.max(0, rightBoundary - VIEWPORT_PADDING * 2)
const availableHeight = Math.max(0, bottomBoundary - topBoundary)
return {
right: Math.max(VIEWPORT_PADDING, window.innerWidth - rightBoundary + PANEL_GAP),
top: topBoundary + availableHeight / 2,
availableWidth,
availableHeight,
}
}
function getWorkflowPanelAdjacentPlacementSnapshot() {
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
if (typeof window === 'undefined' || typeof document === 'undefined')
return '0|0|0|0'
const placement = getWorkflowPanelAdjacentPlacement()
return [
placement.right,
placement.top,
placement.availableWidth,
placement.availableHeight,
].join('|')
}
function parseWorkflowPanelAdjacentPlacement(snapshot: string): FixedPlacementState {
const [right = '0', top = '0', availableWidth = '0', availableHeight = '0'] = snapshot.split('|')
return {
right: Number(right),
top: Number(top),
availableWidth: Number(availableWidth),
availableHeight: Number(availableHeight),
}
}
function subscribeWorkflowPanelAdjacentPlacement(callback: () => void) {
/* v8 ignore next 2 -- server/non-browser fallback for a client-only positioning branch. @preserve */
if (typeof window === 'undefined' || typeof document === 'undefined')
return () => {}
window.addEventListener('resize', callback)
const rightPanel = document.querySelector('[data-workflow-right-panel]')
const resizeObserver = rightPanel && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(callback)
: null
if (rightPanel)
resizeObserver?.observe(rightPanel)
return () => {
window.removeEventListener('resize', callback)
resizeObserver?.disconnect()
}
}
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
const CTRL_ALIASES = new Set(['ctrl'])
const ALT_ALIASES = new Set(['alt', 'option'])
@ -134,11 +212,17 @@ export default function ShortcutsPopupPlugin({
children,
className,
container,
displayMode = 'selection',
onOpen,
onClose,
}: ShortcutPopupPluginProps): React.ReactPortal | null {
const [editor] = useLexicalComposerContext()
const [open, setOpen] = useState(false)
const workflowPanelAdjacentPlacementSnapshot = useSyncExternalStore(
subscribeWorkflowPanelAdjacentPlacement,
getWorkflowPanelAdjacentPlacementSnapshot,
() => '0|0|0|0',
)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
@ -148,6 +232,7 @@ export default function ShortcutsPopupPlugin({
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
strategy: useContainer ? 'absolute' : 'fixed',
middleware: [
offset(0), // fix hide cursor
shift({
@ -192,6 +277,12 @@ export default function ShortcutsPopupPlugin({
}, [editor])
const openPortal = useCallback(() => {
if (displayMode !== 'selection') {
setOpen(true)
onOpen?.()
return
}
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
@ -237,7 +328,7 @@ export default function ShortcutsPopupPlugin({
setOpen(true)
onOpen?.()
}, [editor, onOpen, refs])
}, [displayMode, editor, onOpen, refs])
const closePortal = useCallback(() => {
setOpen(false)
@ -292,25 +383,44 @@ export default function ShortcutsPopupPlugin({
if (!open || !containerEl)
return null
const isFixedPanelAdjacent = displayMode === 'workflow-panel-adjacent-center'
const fixedPlacementState = parseWorkflowPanelAdjacentPlacement(workflowPanelAdjacentPlacementSnapshot)
const fixedPanelAdjacentStyles: CSSProperties = isFixedPanelAdjacent
? {
position: 'fixed',
right: fixedPlacementState.right,
top: fixedPlacementState.top,
transform: 'translateY(-50%)',
zIndex: 50,
overflow: 'visible',
visibility: 'visible',
['--shortcut-popup-max-width' as string]: `${Math.min(POPUP_MAX_WIDTH, fixedPlacementState.availableWidth)}px`,
['--shortcut-popup-max-height' as string]: `${fixedPlacementState.availableHeight}px`,
} as CSSProperties
: {}
return createPortal(
<div
data-testid="shortcuts-popup"
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
if (!isFixedPanelAdjacent)
refs.setFloating(node)
}}
className={cn(
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
className,
)}
style={{
...floatingStyles,
zIndex: useContainer ? undefined : 50,
overflow: 'visible',
visibility: isPositioned ? 'visible' : 'hidden',
}}
style={isFixedPanelAdjacent
? fixedPanelAdjacentStyles
: {
...floatingStyles,
zIndex: useContainer ? undefined : 50,
overflow: 'visible',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-x-hidden overflow-y-auto rounded-xl">
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-hidden rounded-xl">
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
</div>
</div>,

View File

@ -1,6 +1,6 @@
import type { EditorState } from 'lexical'
import type { FC } from 'react'
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type { Hotkey, ShortcutPopupDisplayMode, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
@ -68,6 +68,7 @@ import {
type ShortcutPopup = {
hotkey: Hotkey
displayMode?: ShortcutPopupDisplayMode
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
}
@ -144,8 +145,8 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
)}
ErrorBoundary={LexicalErrorBoundary}
/>
{shortcutPopups.map(({ hotkey, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
{shortcutPopups.map(({ hotkey, displayMode, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey} displayMode={displayMode}>
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
</ShortcutsPopupPlugin>
))}

View File

@ -165,6 +165,12 @@ describe('FormContent', () => {
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: true,
shortcutPopups: [
expect.objectContaining({
hotkey: ['mod', '/'],
displayMode: 'workflow-panel-adjacent-center',
}),
],
hitlInputBlock: expect.objectContaining({
workflowNodesMap: expect.objectContaining({
'node-1': expect.objectContaining({ title: 'Start' }),

View File

@ -132,6 +132,7 @@ const FormContent: FC<FormContentProps> = ({
return [{
hotkey: ['mod', '/'],
displayMode: 'workflow-panel-adjacent-center' as const,
// Keep this component type stable while the popup is open; it reads fresh props from a ref.
// eslint-disable-next-line react/no-nested-component-definitions
Popup: ({ onClose, onInsert }: {

View File

@ -135,6 +135,7 @@ const Panel: FC<PanelProps> = ({
return (
<div
ref={rightPanelRef}
data-workflow-right-panel
tabIndex={-1}
className={cn('absolute top-14 right-0 bottom-1 z-10 flex outline-hidden')}
key={`${isRestoring}`}