diff --git a/eslint-suppressions.json b/eslint-suppressions.json index fde5850496..dafa2bdd87 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index e381d61a3d..da2de72770 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -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 = ({ diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx index 4af00aa704..e97fc19f71 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx @@ -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( + , + ) + + 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() diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index cb1766b311..e5393ee387 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -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 = ({ }, [handleSave]) return ( -
-
+
+
{t(`${i18nPrefix}.title`, { ns: 'workflow' })}
+
+
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })} @@ -322,7 +324,7 @@ const InputField: React.FC = ({
)}
-
+
{isEdit diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx index e40d597ac4..d5fd9b149e 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/__tests__/index.spec.tsx @@ -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 = ({ withContainer = true, hotkey, + displayMode, children, className, onOpen, @@ -83,6 +85,7 @@ const MinimalEditor: React.FC = ({ { 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() + 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() + 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() diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx index c36f97a6d5..90db634601 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx @@ -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 = (command: LexicalCommand, 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(null) const lastSelectionRef = useRef(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(
{ 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', + }} > -
+
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
, diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx index 16dff0a6ee..ac2385fa34 100644 --- a/web/app/components/base/prompt-editor/prompt-editor-content.tsx +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -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 = ({ )} ErrorBoundary={LexicalErrorBoundary} /> - {shortcutPopups.map(({ hotkey, Popup }, idx) => ( - + {shortcutPopups.map(({ hotkey, displayMode, Popup }, idx) => ( + {(closePortal, onInsert) => } ))} diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx index 1b7fd40063..9594b4ae45 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx @@ -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' }), diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx index 7bfbe1492a..3f9e4c9481 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx @@ -132,6 +132,7 @@ const FormContent: FC = ({ 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 }: { diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 3b54583d1c..d6f537d087 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -135,6 +135,7 @@ const Panel: FC = ({ return (